Skip to content

Commit 38d6898

Browse files
authored
codegen: generate OneOf unions in views package (#3884)
View-projected types can reference OneOf (union) helpers but the views generator was not emitting the corresponding union types into the views package. This can lead to undefined discriminator types in generated example clients. Add view-local union collection/generation, include required imports, and add a regression test covering OneOf in a ResultType (goa issue #3882).
1 parent 22ff22f commit 38d6898

5 files changed

Lines changed: 128 additions & 4 deletions

File tree

codegen/service/service_data.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1117,6 +1117,73 @@ func buildUnionTypeData(u *expr.Union, scope *codegen.NameScope, loc *codegen.Lo
11171117
}
11181118
}
11191119

1120+
// collectViewUnionTypes traverses the attribute to gather all union sum-type
1121+
// definitions referenced by view-projected types. It always uses the provided
1122+
// location for all nested user types so that unions are generated in the views
1123+
// package and refer to view-local types (preventing import cycles).
1124+
func collectViewUnionTypes(att *expr.AttributeExpr, scope *codegen.NameScope, loc *codegen.Location, unions map[string]*UnionTypeData, seen map[string]struct{}) {
1125+
if att == nil || att.Type == expr.Empty {
1126+
return
1127+
}
1128+
switch dt := att.Type.(type) {
1129+
case expr.UserType:
1130+
if _, ok := seen[dt.ID()]; ok {
1131+
return
1132+
}
1133+
seen[dt.ID()] = struct{}{}
1134+
collectViewUnionTypes(dt.Attribute(), scope, loc, unions, seen)
1135+
case *expr.Object:
1136+
for _, nat := range *dt {
1137+
collectViewUnionTypes(nat.Attribute, scope, loc, unions, seen)
1138+
}
1139+
case *expr.Array:
1140+
collectViewUnionTypes(dt.ElemType, scope, loc, unions, seen)
1141+
case *expr.Map:
1142+
collectViewUnionTypes(dt.KeyType, scope, loc, unions, seen)
1143+
collectViewUnionTypes(dt.ElemType, scope, loc, unions, seen)
1144+
case *expr.Union:
1145+
hash := dt.Hash()
1146+
if _, ok := unions[hash]; !ok {
1147+
unions[hash] = buildViewUnionTypeData(dt, scope, loc)
1148+
}
1149+
for _, nat := range dt.Values {
1150+
collectViewUnionTypes(nat.Attribute, scope, loc, unions, seen)
1151+
}
1152+
}
1153+
}
1154+
1155+
// buildViewUnionTypeData creates the data needed to generate a sum-type union
1156+
// in the views package. Field types are computed using the view scope and are
1157+
// always emitted unqualified so they refer to the view-local projected types.
1158+
func buildViewUnionTypeData(u *expr.Union, scope *codegen.NameScope, loc *codegen.Location) *UnionTypeData {
1159+
att := &expr.AttributeExpr{Type: u}
1160+
name := scope.GoTypeName(att)
1161+
kindName := scope.Unique(name + "Kind")
1162+
1163+
fields := make([]*UnionFieldData, len(u.Values))
1164+
for i, nat := range u.Values {
1165+
fieldName := codegen.Goify(nat.Name, true)
1166+
fieldType := scope.GoTypeRef(nat.Attribute)
1167+
kindConst := kindName + codegen.Goify(nat.Name, true)
1168+
fields[i] = &UnionFieldData{
1169+
Name: nat.Name,
1170+
KindConst: kindConst,
1171+
FieldName: fieldName,
1172+
FieldType: fieldType,
1173+
TypeTag: nat.Name,
1174+
}
1175+
}
1176+
1177+
return &UnionTypeData{
1178+
Name: name,
1179+
KindName: kindName,
1180+
Fields: fields,
1181+
Loc: loc,
1182+
TypeKey: u.GetTypeKey(),
1183+
ValueKey: u.GetValueKey(),
1184+
}
1185+
}
1186+
11201187
// buildErrorInitData creates the data needed to generate code around endpoint error return values.
11211188
func buildErrorInitData(er *expr.ErrorExpr, scope *codegen.NameScope) *ErrorInitData {
11221189
_, temporary := er.Meta["goa:error:temporary"]

codegen/service/testdata/views_code.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -860,3 +860,5 @@ func ValidateUserTypeView(result *UserTypeView) (err error) {
860860
return
861861
}
862862
`
863+
864+
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 a 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 a 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\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\treturn fmt.Errorf(\"unexpected Flag type %q\", raw.Type)\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"

codegen/service/testdata/views_dsls.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,3 +276,22 @@ var ResultWithCustomFieldsDSL = func() {
276276
})
277277
})
278278
}
279+
280+
var ResultWithOneOfInResultTypeDSL = func() {
281+
var OneOfValue = Type("OneOfValue", func() {
282+
OneOf("flag", func() {
283+
Attribute("astring", String, "String data")
284+
Attribute("aint", Int64, "Int data")
285+
})
286+
})
287+
var OneOfResource = ResultType("application/vnd.oneof.resource", func() {
288+
TypeName("OneOfResource")
289+
Attribute("data", OneOfValue, "Data (type depends on flag)")
290+
Required("data")
291+
})
292+
Service("ResultWithOneOfInResultType", func() {
293+
Method("List", func() {
294+
Result(OneOfResource)
295+
})
296+
})
297+
}

codegen/service/views.go

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package service
22

33
import (
44
"path/filepath"
5+
"sort"
56

67
"goa.design/goa/v3/codegen"
78
"goa.design/goa/v3/expr"
@@ -21,12 +22,39 @@ func ViewsFile(_ string, service *expr.ServiceExpr, services *ServicesData) *cod
2122
if len(svc.projectedTypes) == 0 {
2223
return nil
2324
}
25+
26+
// Collect union sum-type definitions for the views package.
27+
//
28+
// View-projected types cannot import the service package (which already
29+
// depends on views), therefore unions must be generated in the views package
30+
// when referenced by projected types.
31+
unionByHash := make(map[string]*UnionTypeData)
32+
seenUnions := make(map[string]struct{})
33+
viewLoc := &codegen.Location{RelImportPath: "views"}
34+
for _, t := range svc.projectedTypes {
35+
collectViewUnionTypes(&expr.AttributeExpr{Type: t.Type}, svc.ViewScope, viewLoc, unionByHash, seenUnions)
36+
}
37+
unions := make([]*UnionTypeData, 0, len(unionByHash))
38+
for _, u := range unionByHash {
39+
unions = append(unions, u)
40+
}
41+
sort.Slice(unions, func(i, j int) bool {
42+
return unions[i].Name < unions[j].Name
43+
})
44+
2445
path := filepath.Join(codegen.Gendir, svc.PathName, "views", "view.go")
46+
imports := []*codegen.ImportSpec{
47+
codegen.GoaImport(""),
48+
{Path: "unicode/utf8"},
49+
}
50+
if len(unions) > 0 {
51+
imports = append(imports,
52+
codegen.SimpleImport("encoding/json"),
53+
codegen.SimpleImport("fmt"),
54+
)
55+
}
2556
header := codegen.Header(service.Name+" views", "views",
26-
[]*codegen.ImportSpec{
27-
codegen.GoaImport(""),
28-
{Path: "unicode/utf8"},
29-
})
57+
imports)
3058
sections := []*codegen.SectionTemplate{header}
3159

3260
// type definitions
@@ -44,6 +72,13 @@ func ViewsFile(_ string, service *expr.ServiceExpr, services *ServicesData) *cod
4472
Data: t.UserTypeData,
4573
})
4674
}
75+
for _, u := range unions {
76+
sections = append(sections, &codegen.SectionTemplate{
77+
Name: "projected-union-type",
78+
Source: serviceTemplates.Read(unionTypeT),
79+
Data: u,
80+
})
81+
}
4782

4883
// generate a map for result types with view name as key and the fields
4984
// rendered in the view as value.

codegen/service/views_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ func TestViews(t *testing.T) {
2929
{"result-with-multiple-methods", testdata.ResultWithMultipleMethodsDSL, testdata.ResultWithMultipleMethodsCode},
3030
{"result-with-enum-type", testdata.ResultWithEnumTypeDSL, testdata.ResultWithEnumType},
3131
{"result-with-pkg-path", testdata.ResultWithPkgPathDSL, testdata.ResultWithPkgPathCode},
32+
{"result-with-oneof-in-result-type", testdata.ResultWithOneOfInResultTypeDSL, testdata.ResultWithOneOfInResultTypeCode},
3233
}
3334
for _, c := range cases {
3435
t.Run(c.Name, func(t *testing.T) {

0 commit comments

Comments
 (0)