From b68176f013845068ec686ff63a7430d83c105562 Mon Sep 17 00:00:00 2001 From: Raphael Simon Date: Wed, 22 Apr 2026 08:13:43 -0700 Subject: [PATCH] openapi: map parameter and header aliases to base schema types Use the same primitive mapping for v2 parameters, headers, and array items that body schemas already use so generated specs stay valid for named aliases and int64/date-time formats. Made-with: Cursor --- http/codegen/openapi/v2/builder.go | 89 +++++++++++-------- http/codegen/openapi/v2/files_test.go | 66 ++++++++++++++ .../TestSections/headers_file0.golden | 2 + .../TestSections/headers_file1.golden | 2 + ...h-multiple-explicit-wildcards_file0.golden | 2 + ...h-multiple-explicit-wildcards_file1.golden | 2 + .../path-with-multiple-wildcards_file0.golden | 2 + .../path-with-multiple-wildcards_file1.golden | 2 + .../path-with-wildcards_file0.golden | 1 + .../path-with-wildcards_file1.golden | 1 + 10 files changed, 134 insertions(+), 35 deletions(-) diff --git a/http/codegen/openapi/v2/builder.go b/http/codegen/openapi/v2/builder.go index 0fa7735295..26921dcdd9 100644 --- a/http/codegen/openapi/v2/builder.go +++ b/http/codegen/openapi/v2/builder.go @@ -311,56 +311,31 @@ func paramsFromHeaders(endpoint *expr.HTTPEndpointExpr) []*Parameter { func paramFor(at *expr.AttributeExpr, name, in string, required bool) *Parameter { alias := at - if expr.IsAlias(at.Type) { - at = at.Type.(expr.UserType).Attribute() - } + at = resolvedAliasAttribute(at) p := &Parameter{ In: in, Name: name, Default: openapi.ToStringMap(at.DefaultValue), Description: at.Description, Required: required, - Type: at.Type.Name(), } + p.Type, p.Format = openAPITypeFormat(at) if expr.IsArray(at.Type) { p.Items = itemsFromExpr(expr.AsArray(at.Type).ElemType) p.CollectionFormat = "multi" } - switch at.Type { - case expr.Int, expr.UInt, expr.UInt32, expr.UInt64: - p.Type = "integer" - case expr.Int32, expr.Int64: - p.Type = "integer" - p.Format = at.Type.Name() - case expr.Float32: - p.Type = "number" - p.Format = "float" - case expr.Float64: - p.Type = "number" - p.Format = "double" - case expr.Bytes: - p.Type = "string" - p.Format = "byte" - } p.Extensions = openapi.ExtensionsFromExpr(at.Meta) - initValidations(alias, p) + initAttributeValidations(alias, p) return p } func itemsFromExpr(at *expr.AttributeExpr) *Items { - items := &Items{Type: at.Type.Name()} - p, ok := at.Type.(expr.Primitive) - if ok { - switch p.Kind() { - case expr.IntKind, expr.Int64Kind, expr.UIntKind, expr.UInt64Kind, expr.Int32Kind, expr.UInt32Kind: - items.Type = "integer" - case expr.Float32Kind, expr.Float64Kind: - items.Type = "number" - case expr.BytesKind: - items.Type = "string" - } + itemType, itemFormat := openAPITypeFormat(at) + items := &Items{ + Type: itemType, + Format: itemFormat, } - initValidations(at, items) + initAttributeValidations(at, items) if expr.IsArray(at.Type) { items.Items = itemsFromExpr(expr.AsArray(at.Type).ElemType) } @@ -401,12 +376,17 @@ func headersFromExpr(headers *expr.MappedAttributeExpr) map[string]*Header { } res := make(map[string]*Header) codegen.WalkMappedAttr(headers, func(_, n string, _ bool, at *expr.AttributeExpr) error { // nolint: errcheck + headerType, headerFormat := openAPITypeFormat(at) header := &Header{ Default: at.DefaultValue, Description: at.Description, - Type: at.Type.Name(), + Type: headerType, + Format: headerFormat, } - initValidations(at, header) + if expr.IsArray(at.Type) { + header.Items = itemsFromExpr(expr.AsArray(at.Type).ElemType) + } + initAttributeValidations(at, header) res[n] = header return nil }) @@ -416,6 +396,45 @@ func headersFromExpr(headers *expr.MappedAttributeExpr) map[string]*Header { return res } +func openAPITypeFormat(at *expr.AttributeExpr) (string, string) { + at = resolvedAliasAttribute(at) + p, ok := at.Type.(expr.Primitive) + if !ok { + return at.Type.Name(), "" + } + switch p.Kind() { + case expr.IntKind, expr.UIntKind, expr.Int64Kind, expr.UInt64Kind: + return "integer", "int64" + case expr.Int32Kind, expr.UInt32Kind: + return "integer", "int32" + case expr.Float32Kind: + return "number", "float" + case expr.Float64Kind: + return "number", "double" + case expr.BytesKind: + return "string", "byte" + case expr.AnyKind: + return "", "" + default: + return p.Name(), "" + } +} + +func resolvedAliasAttribute(at *expr.AttributeExpr) *expr.AttributeExpr { + if expr.IsAlias(at.Type) { + return at.Type.(expr.UserType).Attribute() + } + return at +} + +func initAttributeValidations(at *expr.AttributeExpr, def any) { + resolved := resolvedAliasAttribute(at) + if resolved != at { + initValidations(resolved, def) + } + initValidations(at, def) +} + func buildPathFromFileServer(s *V2, root *expr.RootExpr, fs *expr.HTTPFileServerExpr) { for _, path := range fs.RequestPaths { wcs := expr.ExtractHTTPWildcards(path) diff --git a/http/codegen/openapi/v2/files_test.go b/http/codegen/openapi/v2/files_test.go index 9f13d5eb40..256719c53c 100644 --- a/http/codegen/openapi/v2/files_test.go +++ b/http/codegen/openapi/v2/files_test.go @@ -12,6 +12,7 @@ import ( "github.com/stretchr/testify/require" "goa.design/goa/v3/codegen/testutil" + "goa.design/goa/v3/dsl" httpgen "goa.design/goa/v3/http/codegen" "goa.design/goa/v3/http/codegen/openapi" openapiv2 "goa.design/goa/v3/http/codegen/openapi/v2" @@ -187,6 +188,71 @@ func TestExtensions(t *testing.T) { } } +func TestNamedPrimitiveParamsAndHeadersUseOpenAPIBaseTypes(t *testing.T) { + // Reset global variables + openapi.Definitions = make(map[string]*openapi.Schema) + + root := httpgen.RunHTTPDSL(t, func() { + var UUID = dsl.Type("UUID", dsl.String, func() { + dsl.Format(dsl.FormatUUID) + }) + var Time = dsl.Type("Time", dsl.String, func() { + dsl.Format(dsl.FormatDateTime) + }) + var UploadStatus = dsl.ResultType("application/vnd.upload-status", func() { + dsl.Attributes(func() { + dsl.Attribute("expires", Time, "RFC3339 expiration timestamp.") + dsl.Attribute("offset", dsl.Int64, "Current upload offset in bytes.") + }) + }) + + dsl.API("test", func() { + dsl.Server("test", func() { + dsl.Host("localhost", func() { + dsl.URI("http://localhost:80") + }) + }) + }) + + dsl.Service("repro", func() { + dsl.Method("show", func() { + dsl.Payload(func() { + dsl.Attribute("ids", dsl.ArrayOf(UUID), "UUID filter values.") + }) + dsl.Result(UploadStatus) + dsl.HTTP(func() { + dsl.GET("/repro") + dsl.Param("ids") + dsl.Response(dsl.StatusOK, func() { + dsl.Header("expires:Upload-Expires") + dsl.Header("offset:Upload-Offset") + dsl.Body(dsl.Empty) + }) + }) + }) + }) + }) + + spec, err := openapiv2.NewV2(root, root.API.Servers[0].Hosts[0]) + require.NoError(t, err) + + path, ok := spec.Paths["/repro"] + require.True(t, ok) + + get := path.(*openapiv2.Path).Get + require.Len(t, get.Parameters, 1) + require.Equal(t, "array", get.Parameters[0].Type) + require.NotNil(t, get.Parameters[0].Items) + require.Equal(t, "string", get.Parameters[0].Items.Type) + require.Equal(t, "uuid", get.Parameters[0].Items.Format) + + headers := get.Responses["200"].Headers + require.Equal(t, "string", headers["Upload-Expires"].Type) + require.Equal(t, "date-time", headers["Upload-Expires"].Format) + require.Equal(t, "integer", headers["Upload-Offset"].Type) + require.Equal(t, "int64", headers["Upload-Offset"].Format) +} + // validateSwagger asserts that the given bytes contain a valid Swagger spec. func validateSwagger(b []byte) error { doc := &openapi2.T{} diff --git a/http/codegen/openapi/v2/testdata/TestSections/headers_file0.golden b/http/codegen/openapi/v2/testdata/TestSections/headers_file0.golden index e870f8cc3b..3b301effec 100644 --- a/http/codegen/openapi/v2/testdata/TestSections/headers_file0.golden +++ b/http/codegen/openapi/v2/testdata/TestSections/headers_file0.golden @@ -15,12 +15,14 @@ "operationId": "test service#test endpoint", "parameters": [ { + "format": "int64", "in": "header", "name": "foo", "required": false, "type": "integer" }, { + "format": "int64", "in": "header", "name": "bar", "required": false, diff --git a/http/codegen/openapi/v2/testdata/TestSections/headers_file1.golden b/http/codegen/openapi/v2/testdata/TestSections/headers_file1.golden index d26977bdf2..50deb7f40d 100644 --- a/http/codegen/openapi/v2/testdata/TestSections/headers_file1.golden +++ b/http/codegen/openapi/v2/testdata/TestSections/headers_file1.golden @@ -23,10 +23,12 @@ paths: in: header required: false type: integer + format: int64 - name: bar in: header required: false type: integer + format: int64 responses: "204": description: No Content response. diff --git a/http/codegen/openapi/v2/testdata/TestSections/path-with-multiple-explicit-wildcards_file0.golden b/http/codegen/openapi/v2/testdata/TestSections/path-with-multiple-explicit-wildcards_file0.golden index b6fba2b823..1453e7a59d 100644 --- a/http/codegen/openapi/v2/testdata/TestSections/path-with-multiple-explicit-wildcards_file0.golden +++ b/http/codegen/openapi/v2/testdata/TestSections/path-with-multiple-explicit-wildcards_file0.golden @@ -15,12 +15,14 @@ "operationId": "test service#test endpoint", "parameters": [ { + "format": "int64", "in": "path", "name": "foo", "required": true, "type": "integer" }, { + "format": "int64", "in": "path", "name": "bar", "required": true, diff --git a/http/codegen/openapi/v2/testdata/TestSections/path-with-multiple-explicit-wildcards_file1.golden b/http/codegen/openapi/v2/testdata/TestSections/path-with-multiple-explicit-wildcards_file1.golden index c0b317c33f..74b38f830a 100644 --- a/http/codegen/openapi/v2/testdata/TestSections/path-with-multiple-explicit-wildcards_file1.golden +++ b/http/codegen/openapi/v2/testdata/TestSections/path-with-multiple-explicit-wildcards_file1.golden @@ -23,10 +23,12 @@ paths: in: path required: true type: integer + format: int64 - name: bar in: path required: true type: integer + format: int64 responses: "204": description: No Content response. diff --git a/http/codegen/openapi/v2/testdata/TestSections/path-with-multiple-wildcards_file0.golden b/http/codegen/openapi/v2/testdata/TestSections/path-with-multiple-wildcards_file0.golden index b6fba2b823..1453e7a59d 100644 --- a/http/codegen/openapi/v2/testdata/TestSections/path-with-multiple-wildcards_file0.golden +++ b/http/codegen/openapi/v2/testdata/TestSections/path-with-multiple-wildcards_file0.golden @@ -15,12 +15,14 @@ "operationId": "test service#test endpoint", "parameters": [ { + "format": "int64", "in": "path", "name": "foo", "required": true, "type": "integer" }, { + "format": "int64", "in": "path", "name": "bar", "required": true, diff --git a/http/codegen/openapi/v2/testdata/TestSections/path-with-multiple-wildcards_file1.golden b/http/codegen/openapi/v2/testdata/TestSections/path-with-multiple-wildcards_file1.golden index c0b317c33f..74b38f830a 100644 --- a/http/codegen/openapi/v2/testdata/TestSections/path-with-multiple-wildcards_file1.golden +++ b/http/codegen/openapi/v2/testdata/TestSections/path-with-multiple-wildcards_file1.golden @@ -23,10 +23,12 @@ paths: in: path required: true type: integer + format: int64 - name: bar in: path required: true type: integer + format: int64 responses: "204": description: No Content response. diff --git a/http/codegen/openapi/v2/testdata/TestSections/path-with-wildcards_file0.golden b/http/codegen/openapi/v2/testdata/TestSections/path-with-wildcards_file0.golden index cb6de81c95..6918e8bcf5 100644 --- a/http/codegen/openapi/v2/testdata/TestSections/path-with-wildcards_file0.golden +++ b/http/codegen/openapi/v2/testdata/TestSections/path-with-wildcards_file0.golden @@ -15,6 +15,7 @@ "operationId": "test service#test endpoint", "parameters": [ { + "format": "int64", "in": "path", "name": "int_map", "required": true, diff --git a/http/codegen/openapi/v2/testdata/TestSections/path-with-wildcards_file1.golden b/http/codegen/openapi/v2/testdata/TestSections/path-with-wildcards_file1.golden index 9d48ad686c..800a66627a 100644 --- a/http/codegen/openapi/v2/testdata/TestSections/path-with-wildcards_file1.golden +++ b/http/codegen/openapi/v2/testdata/TestSections/path-with-wildcards_file1.golden @@ -23,6 +23,7 @@ paths: in: path required: true type: integer + format: int64 responses: "204": description: No Content response.