diff --git a/configuration-schema.json b/configuration-schema.json index 8f902ca708..c471f2d2e9 100644 --- a/configuration-schema.json +++ b/configuration-schema.json @@ -277,6 +277,11 @@ "description": "When set to true, automatically renames types that collide across different OpenAPI component sections (schemas, parameters, requestBodies, responses, headers) by appending a suffix based on the component section. Also detects collisions between component types and client response wrapper types. Without this, the codegen will error on duplicate type names, requiring manual resolution via x-go-name.", "default": false }, + "generate-types-for-anonymous-schemas": { + "type": "boolean", + "description": "When true, every inline schema that would otherwise generate as an anonymous Go struct is instead emitted as a named type with a path-derived name (e.g. `GetRolesIdResponseBody_Data`). Equivalent to adding `x-go-type-name` to every inline schema; when both are present at the same site, `x-go-type-name` wins. Default false. See https://github.com/oapi-codegen/oapi-codegen/issues/1139", + "default": false + }, "type-mapping": { "type": "object", "additionalProperties": false, diff --git a/internal/test/anonymous_inner_hoisting/global/cfg.yaml b/internal/test/anonymous_inner_hoisting/global/cfg.yaml new file mode 100644 index 0000000000..2f59ebd5e5 --- /dev/null +++ b/internal/test/anonymous_inner_hoisting/global/cfg.yaml @@ -0,0 +1,7 @@ +package: global +output: client.gen.go +generate: + models: true + client: true +output-options: + generate-types-for-anonymous-schemas: true diff --git a/internal/test/anonymous_inner_hoisting/global/client.gen.go b/internal/test/anonymous_inner_hoisting/global/client.gen.go new file mode 100644 index 0000000000..8b11b6c1f9 --- /dev/null +++ b/internal/test/anonymous_inner_hoisting/global/client.gen.go @@ -0,0 +1,279 @@ +// Package global provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.0.0-00010101000000-000000000000 DO NOT EDIT. +package global + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "github.com/oapi-codegen/runtime" +) + +// Role defines model for Role. +type Role struct { + Id int `json:"id"` + Name string `json:"name"` +} + +// SuccessfulResponse defines model for SuccessfulResponse. +type SuccessfulResponse struct { + // Data If successful, response from api + Data map[string]interface{} `json:"data"` + + // Ok Indicated whether the response is successful. + Ok bool `json:"ok"` +} + +// GetRolesId200JSONResponseBody_Data defines parameters for GetRolesId. +type GetRolesId200JSONResponseBody_Data struct { + Role Role `json:"role"` +} + +// GetRolesId200JSONResponseBody defines parameters for GetRolesId. +type GetRolesId200JSONResponseBody struct { + Data GetRolesId200JSONResponseBody_Data `json:"data"` + + // Ok Indicated whether the response is successful. + Ok bool `json:"ok"` +} + +// RequestEditorFn is the function signature for the RequestEditor callback function +type RequestEditorFn func(ctx context.Context, req *http.Request) error + +// Doer performs HTTP requests. +// +// The standard http.Client implements this interface. +type HttpRequestDoer interface { + Do(req *http.Request) (*http.Response, error) +} + +// Client which conforms to the OpenAPI3 specification for this service. +type Client struct { + // The endpoint of the server conforming to this interface, with scheme, + // https://api.deepmap.com for example. This can contain a path relative + // to the server, such as https://api.deepmap.com/dev-test, and all the + // paths in the swagger spec will be appended to the server. + Server string + + // Doer for performing requests, typically a *http.Client with any + // customized settings, such as certificate chains. + Client HttpRequestDoer + + // A list of callbacks for modifying requests which are generated before sending over + // the network. + RequestEditors []RequestEditorFn +} + +// ClientOption allows setting custom parameters during construction +type ClientOption func(*Client) error + +// Creates a new Client, with reasonable defaults +func NewClient(server string, opts ...ClientOption) (*Client, error) { + // create a client with sane default values + client := Client{ + Server: server, + } + // mutate client and add all optional params + for _, o := range opts { + if err := o(&client); err != nil { + return nil, err + } + } + // ensure the server URL always has a trailing slash + if !strings.HasSuffix(client.Server, "/") { + client.Server += "/" + } + // create httpClient, if not already present + if client.Client == nil { + client.Client = &http.Client{} + } + return &client, nil +} + +// WithHTTPClient allows overriding the default Doer, which is +// automatically created using http.Client. This is useful for tests. +func WithHTTPClient(doer HttpRequestDoer) ClientOption { + return func(c *Client) error { + c.Client = doer + return nil + } +} + +// WithRequestEditorFn allows setting up a callback function, which will be +// called right before sending the request. This can be used to mutate the request. +func WithRequestEditorFn(fn RequestEditorFn) ClientOption { + return func(c *Client) error { + c.RequestEditors = append(c.RequestEditors, fn) + return nil + } +} + +// The interface specification for the client above. +type ClientInterface interface { + // GetRolesId request + GetRolesId(ctx context.Context, id int, reqEditors ...RequestEditorFn) (*http.Response, error) +} + +func (c *Client) GetRolesId(ctx context.Context, id int, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetRolesIdRequest(c.Server, id) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +// NewGetRolesIdRequest generates requests for GetRolesId +func NewGetRolesIdRequest(server string, id int) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "id", id, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "integer", Format: ""}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/roles/%s", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +func (c *Client) applyEditors(ctx context.Context, req *http.Request, additionalEditors []RequestEditorFn) error { + for _, r := range c.RequestEditors { + if err := r(ctx, req); err != nil { + return err + } + } + for _, r := range additionalEditors { + if err := r(ctx, req); err != nil { + return err + } + } + return nil +} + +// ClientWithResponses builds on ClientInterface to offer response payloads +type ClientWithResponses struct { + ClientInterface +} + +// NewClientWithResponses creates a new ClientWithResponses, which wraps +// Client with return type handling +func NewClientWithResponses(server string, opts ...ClientOption) (*ClientWithResponses, error) { + client, err := NewClient(server, opts...) + if err != nil { + return nil, err + } + return &ClientWithResponses{client}, nil +} + +// WithBaseURL overrides the baseURL. +func WithBaseURL(baseURL string) ClientOption { + return func(c *Client) error { + newBaseURL, err := url.Parse(baseURL) + if err != nil { + return err + } + c.Server = newBaseURL.String() + return nil + } +} + +// ClientWithResponsesInterface is the interface specification for the client with responses above. +type ClientWithResponsesInterface interface { + // GetRolesIdWithResponse request + GetRolesIdWithResponse(ctx context.Context, id int, reqEditors ...RequestEditorFn) (*GetRolesIdResponse, error) +} + +type GetRolesIdResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *GetRolesId200JSONResponseBody +} + +// Status returns HTTPResponse.Status +func (r GetRolesIdResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetRolesIdResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r GetRolesIdResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +// GetRolesIdWithResponse request returning *GetRolesIdResponse +func (c *ClientWithResponses) GetRolesIdWithResponse(ctx context.Context, id int, reqEditors ...RequestEditorFn) (*GetRolesIdResponse, error) { + rsp, err := c.GetRolesId(ctx, id, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetRolesIdResponse(rsp) +} + +// ParseGetRolesIdResponse parses an HTTP response from a GetRolesIdWithResponse call +func ParseGetRolesIdResponse(rsp *http.Response) (*GetRolesIdResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetRolesIdResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest GetRolesId200JSONResponseBody + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + } + + return response, nil +} diff --git a/internal/test/anonymous_inner_hoisting/global/client_test.go b/internal/test/anonymous_inner_hoisting/global/client_test.go new file mode 100644 index 0000000000..b6cf9fb9af --- /dev/null +++ b/internal/test/anonymous_inner_hoisting/global/client_test.go @@ -0,0 +1,44 @@ +package global + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestHoistedTypesExist verifies that with output-options.generate-types-for- +// anonymous-schemas enabled, the inline schemas in spec.yaml become named +// Go types we can reference directly. The spec is the canonical issue #1139 +// shape: a response body using `allOf` to merge a $ref with sibling +// `properties:` containing an inline `data` object. +func TestHoistedTypesExist(t *testing.T) { + // Both the response root and the nested inline `data` schema should be + // emitted as named types — assigning a typed zero value would not + // compile if either were still anonymous structs. + var responseBody GetRolesId200JSONResponseBody + var dataField GetRolesId200JSONResponseBody_Data + + // Field-level type identity: GetRolesId200JSONResponseBody.Data must be + // of the hoisted GetRolesId200JSONResponseBody_Data type. This + // assignment fails to compile if Data is still an anonymous struct. + responseBody.Data = dataField + _ = responseBody +} + +func TestHoistedTypesRoundTrip(t *testing.T) { + body := GetRolesId200JSONResponseBody{ + Data: GetRolesId200JSONResponseBody_Data{ + Role: Role{Id: 7, Name: "admin"}, + }, + Ok: true, + } + + encoded, err := json.Marshal(body) + require.NoError(t, err) + + var decoded GetRolesId200JSONResponseBody + require.NoError(t, json.Unmarshal(encoded, &decoded)) + assert.Equal(t, body, decoded) +} diff --git a/internal/test/anonymous_inner_hoisting/generate.go b/internal/test/anonymous_inner_hoisting/global/generate.go similarity index 76% rename from internal/test/anonymous_inner_hoisting/generate.go rename to internal/test/anonymous_inner_hoisting/global/generate.go index 19ff037202..50562013f5 100644 --- a/internal/test/anonymous_inner_hoisting/generate.go +++ b/internal/test/anonymous_inner_hoisting/global/generate.go @@ -1,3 +1,3 @@ -package anonymous_inner_hoisting +package global //go:generate go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen --config=cfg.yaml spec.yaml diff --git a/internal/test/anonymous_inner_hoisting/global/spec.yaml b/internal/test/anonymous_inner_hoisting/global/spec.yaml new file mode 100644 index 0000000000..884f9369f3 --- /dev/null +++ b/internal/test/anonymous_inner_hoisting/global/spec.yaml @@ -0,0 +1,59 @@ +openapi: "3.0.0" +info: + title: Test inline struct hoisting + version: "1.0" +paths: + /roles/{id}: + summary: Get role by id + get: + operationId: GetRolesId + tags: + - role + parameters: + - name: id + in: path + description: Role ID + required: true + schema: + type: integer + responses: + '200': + description: Role + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/SuccessfulResponse' + - properties: + data: + type: object + properties: + role: + $ref: '#/components/schemas/Role' + required: [ role ] + +components: + schemas: + SuccessfulResponse: + type: object + properties: + ok: + type: boolean + default: true + description: Indicated whether the response is successful. + data: + type: object + description: If successful, response from api + required: + - ok + - data + Role: + type: object + properties: + id: + type: integer + name: + type: string + required: + - id + - name diff --git a/internal/test/anonymous_inner_hoisting/cfg.yaml b/internal/test/anonymous_inner_hoisting/implicit/cfg.yaml similarity index 64% rename from internal/test/anonymous_inner_hoisting/cfg.yaml rename to internal/test/anonymous_inner_hoisting/implicit/cfg.yaml index bbdee926a3..b598a0f7c2 100644 --- a/internal/test/anonymous_inner_hoisting/cfg.yaml +++ b/internal/test/anonymous_inner_hoisting/implicit/cfg.yaml @@ -1,4 +1,4 @@ -package: anonymous_inner_hoisting +package: implicit output: client.gen.go generate: models: true diff --git a/internal/test/anonymous_inner_hoisting/client.gen.go b/internal/test/anonymous_inner_hoisting/implicit/client.gen.go similarity index 99% rename from internal/test/anonymous_inner_hoisting/client.gen.go rename to internal/test/anonymous_inner_hoisting/implicit/client.gen.go index 74fe6907c2..23d2b5fb93 100644 --- a/internal/test/anonymous_inner_hoisting/client.gen.go +++ b/internal/test/anonymous_inner_hoisting/implicit/client.gen.go @@ -1,7 +1,7 @@ -// Package anonymous_inner_hoisting provides primitives to interact with the openapi HTTP API. +// Package implicit provides primitives to interact with the openapi HTTP API. // // Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.0.0-00010101000000-000000000000 DO NOT EDIT. -package anonymous_inner_hoisting +package implicit import ( "bytes" diff --git a/internal/test/anonymous_inner_hoisting/client_test.go b/internal/test/anonymous_inner_hoisting/implicit/client_test.go similarity index 99% rename from internal/test/anonymous_inner_hoisting/client_test.go rename to internal/test/anonymous_inner_hoisting/implicit/client_test.go index abdf532440..9090a5880a 100644 --- a/internal/test/anonymous_inner_hoisting/client_test.go +++ b/internal/test/anonymous_inner_hoisting/implicit/client_test.go @@ -1,4 +1,4 @@ -package anonymous_inner_hoisting +package implicit import ( "encoding/json" diff --git a/internal/test/anonymous_inner_hoisting/implicit/generate.go b/internal/test/anonymous_inner_hoisting/implicit/generate.go new file mode 100644 index 0000000000..6dab4e3b38 --- /dev/null +++ b/internal/test/anonymous_inner_hoisting/implicit/generate.go @@ -0,0 +1,3 @@ +package implicit + +//go:generate go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen --config=cfg.yaml spec.yaml diff --git a/internal/test/anonymous_inner_hoisting/spec.yaml b/internal/test/anonymous_inner_hoisting/implicit/spec.yaml similarity index 100% rename from internal/test/anonymous_inner_hoisting/spec.yaml rename to internal/test/anonymous_inner_hoisting/implicit/spec.yaml diff --git a/pkg/codegen/configuration.go b/pkg/codegen/configuration.go index f2cc4c0162..108cc2b440 100644 --- a/pkg/codegen/configuration.go +++ b/pkg/codegen/configuration.go @@ -389,6 +389,15 @@ type OutputOptions struct { // via x-go-name. ResolveTypeNameCollisions bool `yaml:"resolve-type-name-collisions,omitempty"` + // GenerateTypesForAnonymousSchemas, when true, causes oapi-codegen to + // emit a named Go type for every inline schema that would otherwise + // generate as an anonymous `struct { ... }`. The type's name is derived + // from the schema path (e.g. `GetRolesIdResponseBody_Data`). Default + // false. Equivalent to adding `x-go-type-name` to every inline schema; + // when both are present at the same site, `x-go-type-name` wins. + // See https://github.com/oapi-codegen/oapi-codegen/issues/1139 + GenerateTypesForAnonymousSchemas bool `yaml:"generate-types-for-anonymous-schemas,omitempty"` + // TypeMapping allows customizing OpenAPI type/format to Go type mappings. // User-specified mappings are merged on top of the defaults. TypeMapping *TypeMapping `yaml:"type-mapping,omitempty"` diff --git a/pkg/codegen/operations.go b/pkg/codegen/operations.go index 94b6911c71..e3a869d13a 100644 --- a/pkg/codegen/operations.go +++ b/pkg/codegen/operations.go @@ -477,7 +477,8 @@ func (o *OperationDefinition) GetResponseTypeDefinitions() ([]ResponseTypeDefini // equivalent block in GenerateResponseDefinitions for // rationale. if !IsGoTypeReference(responseRef.Ref) && responseSchema.RefType == "" && - (len(responseSchema.UnionElements) != 0 || responseSchema.HasAdditionalProperties) { + (len(responseSchema.UnionElements) != 0 || responseSchema.HasAdditionalProperties || + (globalState.options.OutputOptions.GenerateTypesForAnonymousSchemas && len(responseSchema.Properties) > 0)) { if externalPkg := externalPackageFor(o.PathItemRef); externalPkg != "" { responseSchema.RefType = fmt.Sprintf("%s.%s", externalPkg, responseBodyTypeName) } else { @@ -1092,7 +1093,8 @@ func GenerateResponseDefinitions(operationID string, responses map[string]*opena // the imported package generated the same hoisted name, so we // reference it instead of redeclaring locally. if !IsGoTypeReference(responseOrRef.Ref) && contentSchema.RefType == "" && - (len(contentSchema.UnionElements) != 0 || contentSchema.HasAdditionalProperties) { + (len(contentSchema.UnionElements) != 0 || contentSchema.HasAdditionalProperties || + (globalState.options.OutputOptions.GenerateTypesForAnonymousSchemas && len(contentSchema.Properties) > 0)) { if externalPkg != "" { contentSchema.RefType = fmt.Sprintf("%s.%s", externalPkg, responseBodyTypeName) } else { diff --git a/pkg/codegen/schema.go b/pkg/codegen/schema.go index 2e216537f0..f9c3df3c3d 100644 --- a/pkg/codegen/schema.go +++ b/pkg/codegen/schema.go @@ -621,6 +621,33 @@ func GenerateGoSchema(sref *openapi3.SchemaRef, path []string) (Schema, error) { } } + // Auto-hoist anonymous inline schemas to a named type when the + // output-options.generate-types-for-anonymous-schemas option is set. + // Mirrors the x-go-type-name path above but with a path-derived name + // and a distinct named type (DefineViaAlias=false). Only fires for + // schemas nested inside another schema (len(path) > 1) — top-level + // component schemas already have their own names. Skipped if the + // schema is already a named ref or has just been wrapped by + // x-go-type-name (DefineViaAlias=true above). + // See https://github.com/oapi-codegen/oapi-codegen/issues/1139 + if globalState.options.OutputOptions.GenerateTypesForAnonymousSchemas && + len(path) > 1 && + outSchema.RefType == "" && + !outSchema.DefineViaAlias && + hasInlineStructuralContent(&outSchema) { + typeName := PathToTypeName(path) + typeDef := TypeDefinition{ + TypeName: typeName, + JsonName: strings.Join(path, "."), + Schema: outSchema, + } + outSchema = Schema{ + Description: typeDef.Schema.Description, + GoType: typeName, + AdditionalTypes: append(outSchema.AdditionalTypes, typeDef), + } + } + return outSchema, nil } else if len(schema.Enum) > 0 { err := oapiSchemaToGoType(schema, path, &outSchema) @@ -1068,3 +1095,17 @@ func hasStructuralSiblings(s *openapi3.Schema) bool { s.AdditionalProperties.Has != nil || s.AdditionalProperties.Schema != nil } + +// hasInlineStructuralContent reports whether a generated Schema is an +// anonymous inline that the generate-types-for-anonymous-schemas option +// should turn into a named type. Pure scalars and aliases-to-refs are +// excluded; objects with properties, schemas with additional-properties, +// and union schemas all qualify. +func hasInlineStructuralContent(s *Schema) bool { + if s == nil { + return false + } + return len(s.Properties) > 0 || + s.HasAdditionalProperties || + len(s.UnionElements) > 0 +}