From 567b8b6d115fa7d4172d06b931396a68d1f78fb7 Mon Sep 17 00:00:00 2001 From: Hittrich Date: Mon, 19 Oct 2026 12:35:45 +0200 Subject: [PATCH 1/3] Name inline union parameters so they reach the request --- examples/union/anyof-query-params/README.md | 34 ++ examples/union/anyof-query-params/api.yaml | 48 ++ examples/union/anyof-query-params/cfg.yaml | 3 + examples/union/anyof-query-params/gen.go | 538 ++++++++++++++++++ examples/union/anyof-query-params/gen_test.go | 57 ++ examples/union/anyof-query-params/generate.go | 3 + pkg/codegen/testdata/union-params.yml | 52 ++ pkg/codegen/typedef_parameters.go | 29 + pkg/codegen/typedef_parameters_test.go | 83 +++ 9 files changed, 847 insertions(+) create mode 100644 examples/union/anyof-query-params/README.md create mode 100644 examples/union/anyof-query-params/api.yaml create mode 100644 examples/union/anyof-query-params/cfg.yaml create mode 100644 examples/union/anyof-query-params/gen.go create mode 100644 examples/union/anyof-query-params/gen_test.go create mode 100644 examples/union/anyof-query-params/generate.go create mode 100644 pkg/codegen/testdata/union-params.yml diff --git a/examples/union/anyof-query-params/README.md b/examples/union/anyof-query-params/README.md new file mode 100644 index 000000000..581f0ad79 --- /dev/null +++ b/examples/union/anyof-query-params/README.md @@ -0,0 +1,34 @@ +# Union Query Parameters Example + +This example demonstrates parameters whose schema is a union with two or more non-null variants. + +## Behavior + +A parameter union is generated as a named type with its own marshaler, so the value reaches the request. A single non-null variant next to `null` still collapses to a plain pointer. + +```yaml +# two non-null variants and null +- name: parentID + in: query + schema: + anyOf: + - $ref: '#/components/schemas/WidgetID' + - type: string + enum: ["null"] + - type: "null" +``` +Generates: `ParentID *ListGadgetsQuery_ParentID`, which wraps the `anyOf` and encodes as `parentID=w1`. + +```yaml +# one non-null variant and null +- name: order + in: query + schema: + anyOf: + - type: string + enum: ["asc", "desc"] + - type: "null" +``` +Generates: `Order *ListGizmosQueryOrderAnyOf` (no wrapper). + +The same applies to path and header parameters. diff --git a/examples/union/anyof-query-params/api.yaml b/examples/union/anyof-query-params/api.yaml new file mode 100644 index 000000000..be8004b00 --- /dev/null +++ b/examples/union/anyof-query-params/api.yaml @@ -0,0 +1,48 @@ +openapi: 3.1.0 +info: {title: t, version: 0.0.0} +paths: + /widgets: + get: + operationId: listWidgets + parameters: + - name: parentID + in: query + schema: + anyOf: + - anyOf: + - $ref: '#/components/schemas/WidgetID' + - type: string + enum: ["null"] + - type: "null" + responses: + "200": {description: OK, content: {application/json: {schema: {type: array, items: {type: string}}}}} + /gadgets: + get: + operationId: listGadgets + parameters: + - name: parentID + in: query + schema: + anyOf: + - $ref: '#/components/schemas/WidgetID' + - type: string + enum: ["null"] + - type: "null" + responses: + "200": {description: OK, content: {application/json: {schema: {type: array, items: {type: string}}}}} + /gizmos: + get: + operationId: listGizmos + parameters: + - name: order + in: query + schema: + anyOf: + - type: string + enum: ["asc", "desc"] + - type: "null" + responses: + "200": {description: OK, content: {application/json: {schema: {type: array, items: {type: string}}}}} +components: + schemas: + WidgetID: {type: string} diff --git a/examples/union/anyof-query-params/cfg.yaml b/examples/union/anyof-query-params/cfg.yaml new file mode 100644 index 000000000..eddd333a5 --- /dev/null +++ b/examples/union/anyof-query-params/cfg.yaml @@ -0,0 +1,3 @@ +package: gen +generate: + client: true diff --git a/examples/union/anyof-query-params/gen.go b/examples/union/anyof-query-params/gen.go new file mode 100644 index 000000000..a55c2b01c --- /dev/null +++ b/examples/union/anyof-query-params/gen.go @@ -0,0 +1,538 @@ +// Code generated by oapi-codegen. DO NOT EDIT. + +package gen + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + + "github.com/doordash-oss/oapi-codegen-dd/v3/pkg/runtime" + "github.com/go-playground/validator/v10" +) + +// Client is the client for the API implementing the Client interface. +type Client struct { + apiClient runtime.APIClient +} + +// NewClient creates a new instance of the Client client. +func NewClient(apiClient runtime.APIClient) *Client { + return &Client{apiClient: apiClient} +} + +// NewDefaultClient creates a new instance of the Client client with default api client. +func NewDefaultClient(baseURL string, opts ...runtime.APIClientOption) (*Client, error) { + apiClient, err := runtime.NewAPIClient(baseURL, opts...) + if err != nil { + return nil, fmt.Errorf("error creating API client: %w", err) + } + return &Client{apiClient: apiClient}, nil +} + +// ClientInterface is the interface for the API client. +type ClientInterface interface { + ListWidgets(ctx context.Context, options *ListWidgetsRequestOptions, reqEditors ...runtime.RequestEditorFn) (*ListWidgetsResponse, error) + + ListGadgets(ctx context.Context, options *ListGadgetsRequestOptions, reqEditors ...runtime.RequestEditorFn) (*ListGadgetsResponse, error) + + ListGizmos(ctx context.Context, options *ListGizmosRequestOptions, reqEditors ...runtime.RequestEditorFn) (*ListGizmosResponse, error) +} + +func (c *Client) ListWidgets(ctx context.Context, options *ListWidgetsRequestOptions, reqEditors ...runtime.RequestEditorFn) (*ListWidgetsResponse, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/widgets", + Method: "GET", + Options: options, + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + responseParser := func(ctx context.Context, resp *runtime.Response) (*ListWidgetsResponse, error) { + bodyBytes := resp.Content + if resp.StatusCode != 200 { + return nil, runtime.NewClientAPIError(fmt.Errorf("unexpected status code: %d", resp.StatusCode), + runtime.WithStatusCode(resp.StatusCode)) + } + target := new(ListWidgetsResponse) + // Handle empty response body gracefully + if len(bodyBytes) == 0 { + return target, nil + } + if err = json.Unmarshal(bodyBytes, target); err != nil { + return nil, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "ListWidgetsResponse", + Body: bodyBytes, + Err: err, + } + } + return target, nil + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/widgets") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + return responseParser(ctx, resp) +} + +func (c *Client) ListGadgets(ctx context.Context, options *ListGadgetsRequestOptions, reqEditors ...runtime.RequestEditorFn) (*ListGadgetsResponse, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/gadgets", + Method: "GET", + Options: options, + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + responseParser := func(ctx context.Context, resp *runtime.Response) (*ListGadgetsResponse, error) { + bodyBytes := resp.Content + if resp.StatusCode != 200 { + return nil, runtime.NewClientAPIError(fmt.Errorf("unexpected status code: %d", resp.StatusCode), + runtime.WithStatusCode(resp.StatusCode)) + } + target := new(ListGadgetsResponse) + // Handle empty response body gracefully + if len(bodyBytes) == 0 { + return target, nil + } + if err = json.Unmarshal(bodyBytes, target); err != nil { + return nil, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "ListGadgetsResponse", + Body: bodyBytes, + Err: err, + } + } + return target, nil + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/gadgets") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + return responseParser(ctx, resp) +} + +func (c *Client) ListGizmos(ctx context.Context, options *ListGizmosRequestOptions, reqEditors ...runtime.RequestEditorFn) (*ListGizmosResponse, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/gizmos", + Method: "GET", + Options: options, + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + responseParser := func(ctx context.Context, resp *runtime.Response) (*ListGizmosResponse, error) { + bodyBytes := resp.Content + if resp.StatusCode != 200 { + return nil, runtime.NewClientAPIError(fmt.Errorf("unexpected status code: %d", resp.StatusCode), + runtime.WithStatusCode(resp.StatusCode)) + } + target := new(ListGizmosResponse) + // Handle empty response body gracefully + if len(bodyBytes) == 0 { + return target, nil + } + if err = json.Unmarshal(bodyBytes, target); err != nil { + return nil, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "ListGizmosResponse", + Body: bodyBytes, + Err: err, + } + } + return target, nil + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/gizmos") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + return responseParser(ctx, resp) +} + +var _ ClientInterface = (*Client)(nil) + +// ListWidgetsRequestOptions is the options needed to make a request to ListWidgets. +type ListWidgetsRequestOptions struct { + Query *ListWidgetsQuery +} + +// Validate validates all the fields in the options. +// Use it if fields validation was not run. +func (o *ListWidgetsRequestOptions) Validate() error { + var errors runtime.ValidationErrors + + if o.Query != nil { + if v, ok := any(o.Query).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Query", err) + } + } + } + if len(errors) == 0 { + return nil + } + + return errors +} + +// GetPathParams returns the path params as a map. +func (o *ListWidgetsRequestOptions) GetPathParams() (map[string]any, error) { + return nil, nil +} + +// GetQuery returns the query params as a map. +func (o *ListWidgetsRequestOptions) GetQuery() (map[string]any, error) { + return runtime.AsMap[any](o.Query) +} + +// GetBody returns the payload in any type that can be marshalled to JSON by the client. +func (o *ListWidgetsRequestOptions) GetBody() any { + return nil +} + +// GetHeader returns the headers as a map. +func (o *ListWidgetsRequestOptions) GetHeader() (map[string]string, error) { + return nil, nil +} + +// ListGadgetsRequestOptions is the options needed to make a request to ListGadgets. +type ListGadgetsRequestOptions struct { + Query *ListGadgetsQuery +} + +// Validate validates all the fields in the options. +// Use it if fields validation was not run. +func (o *ListGadgetsRequestOptions) Validate() error { + var errors runtime.ValidationErrors + + if o.Query != nil { + if v, ok := any(o.Query).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Query", err) + } + } + } + if len(errors) == 0 { + return nil + } + + return errors +} + +// GetPathParams returns the path params as a map. +func (o *ListGadgetsRequestOptions) GetPathParams() (map[string]any, error) { + return nil, nil +} + +// GetQuery returns the query params as a map. +func (o *ListGadgetsRequestOptions) GetQuery() (map[string]any, error) { + return runtime.AsMap[any](o.Query) +} + +// GetBody returns the payload in any type that can be marshalled to JSON by the client. +func (o *ListGadgetsRequestOptions) GetBody() any { + return nil +} + +// GetHeader returns the headers as a map. +func (o *ListGadgetsRequestOptions) GetHeader() (map[string]string, error) { + return nil, nil +} + +// ListGizmosRequestOptions is the options needed to make a request to ListGizmos. +type ListGizmosRequestOptions struct { + Query *ListGizmosQuery +} + +// Validate validates all the fields in the options. +// Use it if fields validation was not run. +func (o *ListGizmosRequestOptions) Validate() error { + var errors runtime.ValidationErrors + + if o.Query != nil { + if v, ok := any(o.Query).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Query", err) + } + } + } + if len(errors) == 0 { + return nil + } + + return errors +} + +// GetPathParams returns the path params as a map. +func (o *ListGizmosRequestOptions) GetPathParams() (map[string]any, error) { + return nil, nil +} + +// GetQuery returns the query params as a map. +func (o *ListGizmosRequestOptions) GetQuery() (map[string]any, error) { + return runtime.AsMap[any](o.Query) +} + +// GetBody returns the payload in any type that can be marshalled to JSON by the client. +func (o *ListGizmosRequestOptions) GetBody() any { + return nil +} + +// GetHeader returns the headers as a map. +func (o *ListGizmosRequestOptions) GetHeader() (map[string]string, error) { + return nil, nil +} + +type ListGizmosQueryOrderAnyOf string + +const ( + Asc ListGizmosQueryOrderAnyOf = "asc" + Desc ListGizmosQueryOrderAnyOf = "desc" +) + +// Validate checks if the ListGizmosQueryOrderAnyOf value is valid +func (l ListGizmosQueryOrderAnyOf) Validate() error { + switch l { + case Asc, Desc: + return nil + default: + return runtime.NewValidationErrorsFromString("Enum", fmt.Sprintf("must be a valid ListGizmosQueryOrderAnyOf value, got: %v", l)) + } +} + +type ListWidgetsQuery_ParentID struct { + ListWidgets_Query_ParentID_AnyOf_AnyOf *ListWidgets_Query_ParentID_AnyOf_AnyOf `json:"-"` +} + +func (l ListWidgetsQuery_ParentID) Validate() error { + var errors runtime.ValidationErrors + if l.ListWidgets_Query_ParentID_AnyOf_AnyOf != nil { + if v, ok := any(l.ListWidgets_Query_ParentID_AnyOf_AnyOf).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("ListWidgets_Query_ParentID_AnyOf_AnyOf", err) + } + } + } + if len(errors) == 0 { + return nil + } + return errors +} + +func (l ListWidgetsQuery_ParentID) MarshalJSON() ([]byte, error) { + var parts []json.RawMessage + + { + b, err := runtime.MarshalJSON(l.ListWidgets_Query_ParentID_AnyOf_AnyOf) + if err != nil { + return nil, fmt.Errorf("ListWidgets_Query_ParentID_AnyOf_AnyOf marshal: %w", err) + } + parts = append(parts, b) + } + + return runtime.CoalesceOrMerge(parts...) +} + +func (l *ListWidgetsQuery_ParentID) UnmarshalJSON(data []byte) error { + trim := bytes.TrimSpace(data) + if bytes.Equal(trim, []byte("null")) { + return nil + } + if len(trim) == 0 { + return fmt.Errorf("empty JSON input") + } + + if l.ListWidgets_Query_ParentID_AnyOf_AnyOf == nil { + l.ListWidgets_Query_ParentID_AnyOf_AnyOf = &ListWidgets_Query_ParentID_AnyOf_AnyOf{} + } + + if err := runtime.UnmarshalJSON(data, l.ListWidgets_Query_ParentID_AnyOf_AnyOf); err != nil { + return fmt.Errorf("ListWidgets_Query_ParentID_AnyOf_AnyOf unmarshal: %w", err) + } + + return nil +} + +type ListWidgetsQuery struct { + ParentID *ListWidgetsQuery_ParentID `json:"parentID,omitempty"` +} + +func (l ListWidgetsQuery) Validate() error { + var errors runtime.ValidationErrors + if l.ParentID != nil { + if v, ok := any(l.ParentID).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("ParentID", err) + } + } + } + if len(errors) == 0 { + return nil + } + return errors +} + +type ListGadgetsQuery_ParentID struct { + ListGadgets_Query_ParentID_AnyOf *ListGadgets_Query_ParentID_AnyOf `json:"-"` +} + +func (l ListGadgetsQuery_ParentID) Validate() error { + var errors runtime.ValidationErrors + if l.ListGadgets_Query_ParentID_AnyOf != nil { + if v, ok := any(l.ListGadgets_Query_ParentID_AnyOf).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("ListGadgets_Query_ParentID_AnyOf", err) + } + } + } + if len(errors) == 0 { + return nil + } + return errors +} + +func (l ListGadgetsQuery_ParentID) MarshalJSON() ([]byte, error) { + var parts []json.RawMessage + + { + b, err := runtime.MarshalJSON(l.ListGadgets_Query_ParentID_AnyOf) + if err != nil { + return nil, fmt.Errorf("ListGadgets_Query_ParentID_AnyOf marshal: %w", err) + } + parts = append(parts, b) + } + + return runtime.CoalesceOrMerge(parts...) +} + +func (l *ListGadgetsQuery_ParentID) UnmarshalJSON(data []byte) error { + trim := bytes.TrimSpace(data) + if bytes.Equal(trim, []byte("null")) { + return nil + } + if len(trim) == 0 { + return fmt.Errorf("empty JSON input") + } + + if l.ListGadgets_Query_ParentID_AnyOf == nil { + l.ListGadgets_Query_ParentID_AnyOf = &ListGadgets_Query_ParentID_AnyOf{} + } + + if err := runtime.UnmarshalJSON(data, l.ListGadgets_Query_ParentID_AnyOf); err != nil { + return fmt.Errorf("ListGadgets_Query_ParentID_AnyOf unmarshal: %w", err) + } + + return nil +} + +type ListGadgetsQuery struct { + ParentID *ListGadgetsQuery_ParentID `json:"parentID,omitempty"` +} + +func (l ListGadgetsQuery) Validate() error { + var errors runtime.ValidationErrors + if l.ParentID != nil { + if v, ok := any(l.ParentID).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("ParentID", err) + } + } + } + if len(errors) == 0 { + return nil + } + return errors +} + +type ListGizmosQuery struct { + Order *ListGizmosQueryOrderAnyOf `json:"order,omitempty"` +} + +func (l ListGizmosQuery) Validate() error { + var errors runtime.ValidationErrors + if l.Order != nil { + if v, ok := any(l.Order).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Order", err) + } + } + } + if len(errors) == 0 { + return nil + } + return errors +} + +type ListWidgetsResponse []string + +type ListGadgetsResponse []string + +type ListGizmosResponse []string + +type WidgetID = string + +type ListWidgets_Query_ParentID_AnyOf_AnyOf struct { + runtime.Either[WidgetID, string] +} + +func (l *ListWidgets_Query_ParentID_AnyOf_AnyOf) Validate() error { + if l.IsA() { + if v, ok := any(l.A).(runtime.Validator); ok { + return v.Validate() + } + } + if l.IsB() { + if v, ok := any(l.B).(runtime.Validator); ok { + return v.Validate() + } + } + return nil +} + +type ListGadgets_Query_ParentID_AnyOf struct { + runtime.Either[WidgetID, string] +} + +func (l *ListGadgets_Query_ParentID_AnyOf) Validate() error { + if l.IsA() { + if v, ok := any(l.A).(runtime.Validator); ok { + return v.Validate() + } + } + if l.IsB() { + if v, ok := any(l.B).(runtime.Validator); ok { + return v.Validate() + } + } + return nil +} + +var typesValidator *validator.Validate + +func init() { + typesValidator = validator.New(validator.WithRequiredStructEnabled()) + runtime.RegisterCustomTypeFunc(typesValidator) +} diff --git a/examples/union/anyof-query-params/gen_test.go b/examples/union/anyof-query-params/gen_test.go new file mode 100644 index 000000000..99be5475a --- /dev/null +++ b/examples/union/anyof-query-params/gen_test.go @@ -0,0 +1,57 @@ +// Copyright 2026 DoorDash, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + +package gen + +import ( + "testing" + + "github.com/doordash-oss/oapi-codegen-dd/v3/pkg/runtime" +) + +// A multi-variant union query parameter must reach the query string +// instead of marshaling to an empty object. +func TestUnionQueryParamIsEncoded(t *testing.T) { + id := WidgetID("w1") + + t.Run("flattened union", func(t *testing.T) { + opts := &ListGadgetsRequestOptions{Query: &ListGadgetsQuery{ + ParentID: &ListGadgetsQuery_ParentID{ + ListGadgets_Query_ParentID_AnyOf: &ListGadgets_Query_ParentID_AnyOf{ + Either: runtime.NewEitherFromA[WidgetID, string](id), + }, + }, + }} + m, err := opts.GetQuery() + if err != nil { + t.Fatal(err) + } + if got := m["parentID"]; got != "w1" { + t.Fatalf("parentID = %#v, want \"w1\"", got) + } + }) + + t.Run("nested union", func(t *testing.T) { + opts := &ListWidgetsRequestOptions{Query: &ListWidgetsQuery{ + ParentID: &ListWidgetsQuery_ParentID{ + ListWidgets_Query_ParentID_AnyOf_AnyOf: &ListWidgets_Query_ParentID_AnyOf_AnyOf{ + Either: runtime.NewEitherFromB[WidgetID, string]("null"), + }, + }, + }} + m, err := opts.GetQuery() + if err != nil { + t.Fatal(err) + } + if got := m["parentID"]; got != "null" { + t.Fatalf("parentID = %#v, want \"null\"", got) + } + }) +} diff --git a/examples/union/anyof-query-params/generate.go b/examples/union/anyof-query-params/generate.go new file mode 100644 index 000000000..97b041b0b --- /dev/null +++ b/examples/union/anyof-query-params/generate.go @@ -0,0 +1,3 @@ +package gen + +//go:generate go run ../../../cmd/oapi-codegen -config cfg.yaml api.yaml diff --git a/pkg/codegen/testdata/union-params.yml b/pkg/codegen/testdata/union-params.yml new file mode 100644 index 000000000..9b406978a --- /dev/null +++ b/pkg/codegen/testdata/union-params.yml @@ -0,0 +1,52 @@ +openapi: 3.1.0 +info: + title: Union Parameters Test + version: 1.0.0 + +paths: + /widgets: + get: + operationId: listWidgets + parameters: + - name: parentID + in: query + schema: + anyOf: + - $ref: '#/components/schemas/WidgetID' + - type: string + enum: ["null"] + - type: "null" + - name: X-Owner + in: header + schema: + anyOf: + - type: integer + - type: string + - name: order + in: query + schema: + anyOf: + - type: string + enum: [asc, desc] + - type: "null" + responses: + "200": + description: OK + /gadgets: + get: + operationId: listGadgets + parameters: + - name: parentID + in: query + schema: + anyOf: + - $ref: '#/components/schemas/WidgetID' + - type: integer + responses: + "200": + description: OK + +components: + schemas: + WidgetID: + type: string diff --git a/pkg/codegen/typedef_parameters.go b/pkg/codegen/typedef_parameters.go index 35caf4dc6..3373c016c 100644 --- a/pkg/codegen/typedef_parameters.go +++ b/pkg/codegen/typedef_parameters.go @@ -347,6 +347,14 @@ func generateParamsTypes(objectParams []ParameterDefinition, typeName string, op options.typeTracker.register(td, "") } + // An inline union wrapper holds nameless (json:"-") fields that only a + // generated MarshalJSON merges back. An anonymous struct cannot have + // one, so the field would marshal to {} and the parameter would be + // silently dropped from requests. Give it a named type instead. + if pSchema.RefType == "" && !pSchema.HasAdditionalProperties && needsMarshaler(pSchema) { + pSchema = nameParamSchema(pSchema, strings.Join([]string{typeName, param.GoName()}, "_"), specLocation, options) + } + typeDefs = append(typeDefs, pSchema.AdditionalTypes...) exts := extractExtensions(param.Spec.Extensions) @@ -414,6 +422,27 @@ func generateParamsTypes(objectParams []ParameterDefinition, typeName string, op return res, append(typeDefs, td), imports } +// nameParamSchema registers an inline parameter schema as a named type with a +// generated marshaler and returns a reference to it. +func nameParamSchema(src GoSchema, name string, specLocation SpecLocation, options ParseOptions) GoSchema { + if options.typeTracker.Exists(name) { + name = options.typeTracker.generateUniqueName(name) + } + + td := TypeDefinition{ + Name: name, + Schema: src, + SpecLocation: specLocation, + NeedsMarshaler: true, + } + options.typeTracker.register(td, "") + + return GoSchema{ + RefType: name, + AdditionalTypes: append(append([]TypeDefinition{}, src.AdditionalTypes...), td), + } +} + // This constructs a Go type for a parameter, looking at either the schema or // the content, whichever is available func paramToGoType(param *v3high.Parameter, options ParseOptions) (GoSchema, error) { diff --git a/pkg/codegen/typedef_parameters_test.go b/pkg/codegen/typedef_parameters_test.go index d5069cb89..d6d884d33 100644 --- a/pkg/codegen/typedef_parameters_test.go +++ b/pkg/codegen/typedef_parameters_test.go @@ -11,9 +11,12 @@ package codegen import ( + "go/format" + "os" "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestSliceTypeDefinition(t *testing.T) { @@ -47,3 +50,83 @@ func TestSliceTypeDefinition(t *testing.T) { }) } } + +func TestUnionParametersAreNamed(t *testing.T) { + docContents, err := os.ReadFile("testdata/union-params.yml") + require.NoError(t, err) + + codes, err := Generate(docContents, Configuration{ + PackageName: "api", + Output: &Output{UseSingleFile: true}, + }) + require.NoError(t, err) + code := codes.GetCombined() + + _, err = format.Source([]byte(code)) + require.NoError(t, err) + + // An anonymous struct cannot carry the marshaler that merges the nameless + // union field, so the parameter would be dropped from the request. + assert.NotContains(t, code, "*struct {") + + t.Run("query union is a named type with a marshaler", func(t *testing.T) { + assert.Contains(t, code, "ParentID *ListWidgetsQuery_ParentID ") + assert.Contains(t, code, "func (l ListWidgetsQuery_ParentID) MarshalJSON()") + }) + + t.Run("header union is named too", func(t *testing.T) { + assert.Contains(t, code, "func (l ListWidgetsHeaders_XOwner) MarshalJSON()") + }) + + t.Run("second operation gets its own type", func(t *testing.T) { + assert.Contains(t, code, "func (l ListGadgetsQuery_ParentID) MarshalJSON()") + }) + + t.Run("single variant next to null stays a plain pointer", func(t *testing.T) { + assert.NotContains(t, code, "ListWidgetsQuery_Order") + }) +} + +func TestNameParamSchema(t *testing.T) { + wrapper := GoSchema{ + Properties: []Property{{GoName: "Union", Schema: GoSchema{RefType: "Union"}}}, + } + + t.Run("registers a named type with a marshaler", func(t *testing.T) { + options := ParseOptions{typeTracker: newTypeTracker()} + + got := nameParamSchema(wrapper, "ListQuery_ID", SpecLocationQuery, options) + + assert.Equal(t, "ListQuery_ID", got.RefType) + require.Len(t, got.AdditionalTypes, 1) + td := got.AdditionalTypes[0] + assert.Equal(t, "ListQuery_ID", td.Name) + assert.Equal(t, SpecLocationQuery, td.SpecLocation) + assert.True(t, td.NeedsMarshaler) + assert.True(t, options.typeTracker.Exists("ListQuery_ID")) + }) + + t.Run("name clash gets a unique name", func(t *testing.T) { + options := ParseOptions{typeTracker: newTypeTracker()} + options.typeTracker.register(TypeDefinition{Name: "ListQuery_ID"}, "") + + got := nameParamSchema(wrapper, "ListQuery_ID", SpecLocationQuery, options) + + assert.NotEqual(t, "ListQuery_ID", got.RefType) + assert.Equal(t, got.RefType, got.AdditionalTypes[0].Name) + assert.True(t, options.typeTracker.Exists(got.RefType)) + }) + + t.Run("keeps nested types of the source schema", func(t *testing.T) { + options := ParseOptions{typeTracker: newTypeTracker()} + src := wrapper + src.AdditionalTypes = []TypeDefinition{{Name: "Nested"}} + + got := nameParamSchema(src, "ListQuery_ID", SpecLocationQuery, options) + + require.Len(t, got.AdditionalTypes, 2) + assert.Equal(t, "Nested", got.AdditionalTypes[0].Name) + assert.Equal(t, "ListQuery_ID", got.AdditionalTypes[1].Name) + assert.Len(t, src.AdditionalTypes, 1, "source schema must not be modified") + }) +} From 026b4c0ad9be96c10fdce1de0909aaaee9537622 Mon Sep 17 00:00:00 2001 From: Hittrich Date: Thu, 29 Oct 2026 09:58:51 +0100 Subject: [PATCH 2/3] assign schema --- examples/union/anyof-query-params/README.md | 2 + examples/union/anyof-query-params/cfg.yaml | 2 + examples/union/anyof-query-params/gen.go | 521 ++++++++++++++++++++ pkg/codegen/typedef_parameters.go | 1 + 4 files changed, 526 insertions(+) diff --git a/examples/union/anyof-query-params/README.md b/examples/union/anyof-query-params/README.md index 581f0ad79..7d1c63218 100644 --- a/examples/union/anyof-query-params/README.md +++ b/examples/union/anyof-query-params/README.md @@ -32,3 +32,5 @@ Generates: `ParentID *ListGadgetsQuery_ParentID`, which wraps the `anyOf` and en Generates: `Order *ListGizmosQueryOrderAnyOf` (no wrapper). The same applies to path and header parameters. + +The config also generates a `std-http` handler, so the request adapter has to parse into the same named type; the example would not compile otherwise. diff --git a/examples/union/anyof-query-params/cfg.yaml b/examples/union/anyof-query-params/cfg.yaml index eddd333a5..e1f935c64 100644 --- a/examples/union/anyof-query-params/cfg.yaml +++ b/examples/union/anyof-query-params/cfg.yaml @@ -1,3 +1,5 @@ package: gen generate: client: true + handler: + kind: std-http diff --git a/examples/union/anyof-query-params/gen.go b/examples/union/anyof-query-params/gen.go index a55c2b01c..466b1d6c9 100644 --- a/examples/union/anyof-query-params/gen.go +++ b/examples/union/anyof-query-params/gen.go @@ -7,6 +7,7 @@ import ( "context" "encoding/json" "fmt" + "net/http" "github.com/doordash-oss/oapi-codegen-dd/v3/pkg/runtime" "github.com/go-playground/validator/v10" @@ -323,6 +324,349 @@ func (l ListGizmosQueryOrderAnyOf) Validate() error { } } +// OapiErrorKind represents the type of error that occurred during request processing. +type OapiErrorKind int + +const ( + // OapiErrorKindParse indicates a parameter parsing error (invalid path/query/header parameter). + OapiErrorKindParse OapiErrorKind = iota + + // OapiErrorKindDecode indicates a request body decoding error (invalid JSON, form data, etc.). + OapiErrorKindDecode + + // OapiErrorKindValidation indicates a request validation error (failed schema validation). + OapiErrorKindValidation + + // OapiErrorKindService indicates a service/business logic error returned by the service implementation. + OapiErrorKindService +) + +// OapiHandlerError represents an error that occurred during request handling (parse, decode, validation). +// When no typed error response is configured in the OpenAPI spec, this error type is used. +// Custom error handlers can type-assert to this type to access error details. +type OapiHandlerError struct { + Kind OapiErrorKind + OperationID string + Message string + ParamName string + ParamLocation string + + // Err is the error that caused this handler error. + Err error `json:"-"` +} + +func (e OapiHandlerError) Error() string { + return e.Message +} + +// Unwrap returns the underlying error, enabling errors.Is and errors.As. +func (e OapiHandlerError) Unwrap() error { + return e.Err +} + +// OapiErrorResponse is the default JSON error response structure used by OapiDefaultErrorHandler. +type OapiErrorResponse struct { + Error string `json:"error"` + OperationID string `json:"operation_id,omitempty"` + ParamName string `json:"param_name,omitempty"` + ParamLocation string `json:"param_location,omitempty"` +} + +// OapiErrorHandler handles errors that occur during request processing. +// Implement this interface to customize error responses, logging, and metrics. +type OapiErrorHandler interface { + // HandleError writes an error response to w with the given status code. + // The err is either an OapiHandlerError (for parse/decode/validation errors) + // or a typed error matching the OpenAPI spec's error response schema. + HandleError(w http.ResponseWriter, r *http.Request, statusCode int, err error) +} + +// OapiDefaultErrorHandler provides the default error handling behavior. +// It writes JSON error responses. For OapiHandlerError, it uses OapiErrorResponse. +// For typed errors (from OpenAPI spec), it encodes them directly. +type OapiDefaultErrorHandler struct{} + +// HandleError implements OapiErrorHandler with default JSON error responses. +func (h *OapiDefaultErrorHandler) HandleError(w http.ResponseWriter, r *http.Request, statusCode int, err error) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + + if handlerErr, ok := err.(OapiHandlerError); ok { + _ = json.NewEncoder(w).Encode(OapiErrorResponse{ + Error: handlerErr.Message, + OperationID: handlerErr.OperationID, + ParamName: handlerErr.ParamName, + ParamLocation: handlerErr.ParamLocation, + }) + return + } + + // Typed error from OpenAPI spec - encode directly + _ = json.NewEncoder(w).Encode(err) +} + +// ServiceInterface defines the service interface for business logic. +type ServiceInterface interface { + ListWidgets(ctx context.Context, opts *ListWidgetsServiceRequestOptions) (*ListWidgetsResponseData, error) + + ListGadgets(ctx context.Context, opts *ListGadgetsServiceRequestOptions) (*ListGadgetsResponseData, error) + + ListGizmos(ctx context.Context, opts *ListGizmosServiceRequestOptions) (*ListGizmosResponseData, error) +} + +// HTTPAdapter adapts the ServiceInterface to HTTP handlers. +// This struct is generated and should not be modified. +type HTTPAdapter struct { + svc ServiceInterface + errHandler OapiErrorHandler + jsonBodyDecoder runtime.JSONBodyDecoderFunc +} + +// NewHTTPAdapter creates a new HTTPAdapter wrapping the given service. +// If errHandler is nil, OapiDefaultErrorHandler is used. +func NewHTTPAdapter(svc ServiceInterface, errHandler OapiErrorHandler) *HTTPAdapter { + if errHandler == nil { + errHandler = &OapiDefaultErrorHandler{} + } + return &HTTPAdapter{svc: svc, errHandler: errHandler, jsonBodyDecoder: runtime.DecodeJSONBody} +} + +// ListWidgets handles GET /widgets +func (a *HTTPAdapter) ListWidgets(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + opts := &ListWidgetsServiceRequestOptions{} + opts.RawRequest = r + + // Parse query parameters + queryParams := &ListWidgetsQuery{} + query := r.URL.Query() + if queryParamParentIDStr := query.Get("parentID"); queryParamParentIDStr != "" { + queryParamParentID, err := runtime.ParseString[ListWidgetsQuery_ParentID](queryParamParentIDStr) + if err != nil { + a.errHandler.HandleError(w, r, http.StatusBadRequest, OapiHandlerError{ + Kind: OapiErrorKindParse, + OperationID: "ListWidgets", + Message: err.Error(), + ParamName: "parentID", + ParamLocation: "query", + Err: err, + }) + return + } + queryParams.ParentID = &queryParamParentID + } + opts.Query = queryParams + + // Call business logic + resp, err := a.svc.ListWidgets(ctx, opts) + if err != nil { + code := http.StatusInternalServerError + if resp != nil && resp.Status != 0 { + code = resp.Status + } + a.errHandler.HandleError(w, r, code, err) + return + } + + // Apply custom headers from response + if resp != nil && resp.Headers != nil { + for k, v := range resp.Headers { + for _, val := range v { + w.Header().Add(k, val) + } + } + } + + // Determine status code + status := 200 + if resp != nil && resp.Status != 0 { + status = resp.Status + } + if w.Header().Get("Content-Type") == "" { + w.Header().Set("Content-Type", "application/json") + } + w.WriteHeader(status) + if resp != nil { + if payload := resp.Payload(); payload != nil { + _ = json.NewEncoder(w).Encode(payload) + } + } +} + +// ListGadgets handles GET /gadgets +func (a *HTTPAdapter) ListGadgets(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + opts := &ListGadgetsServiceRequestOptions{} + opts.RawRequest = r + + // Parse query parameters + queryParams := &ListGadgetsQuery{} + query := r.URL.Query() + if queryParamParentIDStr := query.Get("parentID"); queryParamParentIDStr != "" { + queryParamParentID, err := runtime.ParseString[ListGadgetsQuery_ParentID](queryParamParentIDStr) + if err != nil { + a.errHandler.HandleError(w, r, http.StatusBadRequest, OapiHandlerError{ + Kind: OapiErrorKindParse, + OperationID: "ListGadgets", + Message: err.Error(), + ParamName: "parentID", + ParamLocation: "query", + Err: err, + }) + return + } + queryParams.ParentID = &queryParamParentID + } + opts.Query = queryParams + + // Call business logic + resp, err := a.svc.ListGadgets(ctx, opts) + if err != nil { + code := http.StatusInternalServerError + if resp != nil && resp.Status != 0 { + code = resp.Status + } + a.errHandler.HandleError(w, r, code, err) + return + } + + // Apply custom headers from response + if resp != nil && resp.Headers != nil { + for k, v := range resp.Headers { + for _, val := range v { + w.Header().Add(k, val) + } + } + } + + // Determine status code + status := 200 + if resp != nil && resp.Status != 0 { + status = resp.Status + } + if w.Header().Get("Content-Type") == "" { + w.Header().Set("Content-Type", "application/json") + } + w.WriteHeader(status) + if resp != nil { + if payload := resp.Payload(); payload != nil { + _ = json.NewEncoder(w).Encode(payload) + } + } +} + +// ListGizmos handles GET /gizmos +func (a *HTTPAdapter) ListGizmos(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + opts := &ListGizmosServiceRequestOptions{} + opts.RawRequest = r + + // Parse query parameters + queryParams := &ListGizmosQuery{} + query := r.URL.Query() + if queryParamOrderStr := query.Get("order"); queryParamOrderStr != "" { + + queryParamOrder := ListGizmosQueryOrderAnyOf(queryParamOrderStr) + queryParams.Order = &queryParamOrder + } + opts.Query = queryParams + + // Call business logic + resp, err := a.svc.ListGizmos(ctx, opts) + if err != nil { + code := http.StatusInternalServerError + if resp != nil && resp.Status != 0 { + code = resp.Status + } + a.errHandler.HandleError(w, r, code, err) + return + } + + // Apply custom headers from response + if resp != nil && resp.Headers != nil { + for k, v := range resp.Headers { + for _, val := range v { + w.Header().Add(k, val) + } + } + } + + // Determine status code + status := 200 + if resp != nil && resp.Status != 0 { + status = resp.Status + } + if w.Header().Get("Content-Type") == "" { + w.Header().Set("Content-Type", "application/json") + } + w.WriteHeader(status) + if resp != nil { + if payload := resp.Payload(); payload != nil { + _ = json.NewEncoder(w).Encode(payload) + } + } +} + +// RouterOption is a function that configures the router. +type RouterOption func(*routerConfig) + +type routerConfig struct { + middlewares []func(http.Handler) http.Handler + errHandler OapiErrorHandler + jsonBodyDecoder runtime.JSONBodyDecoderFunc +} + +// WithMiddleware adds middleware to the router. +func WithMiddleware(mw func(http.Handler) http.Handler) RouterOption { + return func(cfg *routerConfig) { + cfg.middlewares = append(cfg.middlewares, mw) + } +} + +// WithErrorHandler sets a custom error handler for the router. +// If not set, OapiDefaultErrorHandler is used. +func WithErrorHandler(h OapiErrorHandler) RouterOption { + return func(cfg *routerConfig) { + cfg.errHandler = h + } +} + +// WithJSONBodyDecoder sets a custom function for decoding JSON request bodies. +// If not set, runtime.DecodeJSONBody is used. +func WithJSONBodyDecoder(fn runtime.JSONBodyDecoderFunc) RouterOption { + return func(cfg *routerConfig) { + cfg.jsonBodyDecoder = fn + } +} + +// NewRouter creates a new http.ServeMux with the given service implementation. +func NewRouter(svc ServiceInterface, opts ...RouterOption) *http.ServeMux { + cfg := &routerConfig{} + for _, opt := range opts { + opt(cfg) + } + + mux := http.NewServeMux() + + adapter := NewHTTPAdapter(svc, cfg.errHandler) + if cfg.jsonBodyDecoder != nil { + adapter.jsonBodyDecoder = cfg.jsonBodyDecoder + } + mux.HandleFunc("GET /widgets", applyMiddleware(http.HandlerFunc(adapter.ListWidgets), cfg.middlewares...)) + mux.HandleFunc("GET /gadgets", applyMiddleware(http.HandlerFunc(adapter.ListGadgets), cfg.middlewares...)) + mux.HandleFunc("GET /gizmos", applyMiddleware(http.HandlerFunc(adapter.ListGizmos), cfg.middlewares...)) + + return mux +} + +// applyMiddleware wraps a handler with the given middleware chain. +func applyMiddleware(h http.Handler, middlewares ...func(http.Handler) http.Handler) http.HandlerFunc { + for i := len(middlewares) - 1; i >= 0; i-- { + h = middlewares[i](h) + } + return h.ServeHTTP +} + type ListWidgetsQuery_ParentID struct { ListWidgets_Query_ParentID_AnyOf_AnyOf *ListWidgets_Query_ParentID_AnyOf_AnyOf `json:"-"` } @@ -486,12 +830,189 @@ func (l ListGizmosQuery) Validate() error { return errors } +// ListWidgetsResponseData wraps the success response with optional headers and status override. +type ListWidgetsResponseData struct { + Body *ListWidgetsResponse + Headers http.Header + Status int // 0 = use default (200) +} + +// NewListWidgetsResponseData creates a new ListWidgetsResponseData with the given body. +func NewListWidgetsResponseData(body *ListWidgetsResponse) *ListWidgetsResponseData { + return &ListWidgetsResponseData{Body: body} +} + +// WithHeaders sets custom headers on the response. +func (r *ListWidgetsResponseData) WithHeaders(h http.Header) *ListWidgetsResponseData { + r.Headers = h + return r +} + +// WithStatus overrides the default status code. +func (r *ListWidgetsResponseData) WithStatus(code int) *ListWidgetsResponseData { + r.Status = code + return r +} + +// Payload returns the value to be JSON-encoded as the response body. Override +// this template to customize the encoded shape (e.g. to wrap it in an envelope) +// without needing to change the adapter template. +func (r *ListWidgetsResponseData) Payload() any { + if r.Body == nil { + return nil + } + return r.Body +} + +// ListGadgetsResponseData wraps the success response with optional headers and status override. +type ListGadgetsResponseData struct { + Body *ListGadgetsResponse + Headers http.Header + Status int // 0 = use default (200) +} + +// NewListGadgetsResponseData creates a new ListGadgetsResponseData with the given body. +func NewListGadgetsResponseData(body *ListGadgetsResponse) *ListGadgetsResponseData { + return &ListGadgetsResponseData{Body: body} +} + +// WithHeaders sets custom headers on the response. +func (r *ListGadgetsResponseData) WithHeaders(h http.Header) *ListGadgetsResponseData { + r.Headers = h + return r +} + +// WithStatus overrides the default status code. +func (r *ListGadgetsResponseData) WithStatus(code int) *ListGadgetsResponseData { + r.Status = code + return r +} + +// Payload returns the value to be JSON-encoded as the response body. Override +// this template to customize the encoded shape (e.g. to wrap it in an envelope) +// without needing to change the adapter template. +func (r *ListGadgetsResponseData) Payload() any { + if r.Body == nil { + return nil + } + return r.Body +} + +// ListGizmosResponseData wraps the success response with optional headers and status override. +type ListGizmosResponseData struct { + Body *ListGizmosResponse + Headers http.Header + Status int // 0 = use default (200) +} + +// NewListGizmosResponseData creates a new ListGizmosResponseData with the given body. +func NewListGizmosResponseData(body *ListGizmosResponse) *ListGizmosResponseData { + return &ListGizmosResponseData{Body: body} +} + +// WithHeaders sets custom headers on the response. +func (r *ListGizmosResponseData) WithHeaders(h http.Header) *ListGizmosResponseData { + r.Headers = h + return r +} + +// WithStatus overrides the default status code. +func (r *ListGizmosResponseData) WithStatus(code int) *ListGizmosResponseData { + r.Status = code + return r +} + +// Payload returns the value to be JSON-encoded as the response body. Override +// this template to customize the encoded shape (e.g. to wrap it in an envelope) +// without needing to change the adapter template. +func (r *ListGizmosResponseData) Payload() any { + if r.Body == nil { + return nil + } + return r.Body +} + type ListWidgetsResponse []string type ListGadgetsResponse []string type ListGizmosResponse []string +// ListWidgetsServiceRequestOptions holds all parameters for the ListWidgets operation. +type ListWidgetsServiceRequestOptions struct { + Query *ListWidgetsQuery + // RawRequest provides access to the underlying HTTP request for custom content type handling. + RawRequest *http.Request +} + +// Validate validates all the fields in the options. +func (o *ListWidgetsServiceRequestOptions) Validate() error { + var errors runtime.ValidationErrors + + if o.Query != nil { + if v, ok := any(o.Query).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Query", err) + } + } + } + if len(errors) == 0 { + return nil + } + + return errors +} + +// ListGadgetsServiceRequestOptions holds all parameters for the ListGadgets operation. +type ListGadgetsServiceRequestOptions struct { + Query *ListGadgetsQuery + // RawRequest provides access to the underlying HTTP request for custom content type handling. + RawRequest *http.Request +} + +// Validate validates all the fields in the options. +func (o *ListGadgetsServiceRequestOptions) Validate() error { + var errors runtime.ValidationErrors + + if o.Query != nil { + if v, ok := any(o.Query).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Query", err) + } + } + } + if len(errors) == 0 { + return nil + } + + return errors +} + +// ListGizmosServiceRequestOptions holds all parameters for the ListGizmos operation. +type ListGizmosServiceRequestOptions struct { + Query *ListGizmosQuery + // RawRequest provides access to the underlying HTTP request for custom content type handling. + RawRequest *http.Request +} + +// Validate validates all the fields in the options. +func (o *ListGizmosServiceRequestOptions) Validate() error { + var errors runtime.ValidationErrors + + if o.Query != nil { + if v, ok := any(o.Query).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Query", err) + } + } + } + if len(errors) == 0 { + return nil + } + + return errors +} + type WidgetID = string type ListWidgets_Query_ParentID_AnyOf_AnyOf struct { diff --git a/pkg/codegen/typedef_parameters.go b/pkg/codegen/typedef_parameters.go index 3373c016c..98ed06aca 100644 --- a/pkg/codegen/typedef_parameters.go +++ b/pkg/codegen/typedef_parameters.go @@ -353,6 +353,7 @@ func generateParamsTypes(objectParams []ParameterDefinition, typeName string, op // silently dropped from requests. Give it a named type instead. if pSchema.RefType == "" && !pSchema.HasAdditionalProperties && needsMarshaler(pSchema) { pSchema = nameParamSchema(pSchema, strings.Join([]string{typeName, param.GoName()}, "_"), specLocation, options) + param.Schema = pSchema } typeDefs = append(typeDefs, pSchema.AdditionalTypes...) From 97c2f93979040d01153255e0a4edf6e4e29be1b0 Mon Sep 17 00:00:00 2001 From: Hittrich Date: Thu, 29 Oct 2026 10:32:08 +0100 Subject: [PATCH 3/3] fix inttests --- examples/union/anyof-query-params/api.yaml | 9 ++ examples/union/anyof-query-params/gen.go | 97 +++++++++++++++++++++- pkg/codegen/testdata/union-params.yml | 9 ++ pkg/codegen/typedef_parameters.go | 3 +- pkg/codegen/typedef_parameters_test.go | 5 ++ 5 files changed, 121 insertions(+), 2 deletions(-) diff --git a/examples/union/anyof-query-params/api.yaml b/examples/union/anyof-query-params/api.yaml index be8004b00..285519ae2 100644 --- a/examples/union/anyof-query-params/api.yaml +++ b/examples/union/anyof-query-params/api.yaml @@ -41,6 +41,15 @@ paths: - type: string enum: ["asc", "desc"] - type: "null" + - name: statuses + in: query + schema: + type: array + items: + oneOf: + - $ref: '#/components/schemas/WidgetID' + - type: string + enum: ["unpublished"] responses: "200": {description: OK, content: {application/json: {schema: {type: array, items: {type: string}}}}} components: diff --git a/examples/union/anyof-query-params/gen.go b/examples/union/anyof-query-params/gen.go index 466b1d6c9..f9df481c7 100644 --- a/examples/union/anyof-query-params/gen.go +++ b/examples/union/anyof-query-params/gen.go @@ -569,6 +569,22 @@ func (a *HTTPAdapter) ListGizmos(w http.ResponseWriter, r *http.Request) { queryParamOrder := ListGizmosQueryOrderAnyOf(queryParamOrderStr) queryParams.Order = &queryParamOrder } + + if values, ok := query["statuses"]; ok { + parsed, err := runtime.ParseStringSlice[ListGizmos_Query_Statuses_Item](values) + if err != nil { + a.errHandler.HandleError(w, r, http.StatusBadRequest, OapiHandlerError{ + Kind: OapiErrorKindParse, + OperationID: "ListGizmos", + Message: err.Error(), + ParamName: "statuses", + ParamLocation: "query", + Err: err, + }) + return + } + queryParams.Statuses = parsed + } opts.Query = queryParams // Call business logic @@ -812,7 +828,8 @@ func (l ListGadgetsQuery) Validate() error { } type ListGizmosQuery struct { - Order *ListGizmosQueryOrderAnyOf `json:"order,omitempty"` + Order *ListGizmosQueryOrderAnyOf `json:"order,omitempty"` + Statuses []ListGizmos_Query_Statuses_Item `json:"statuses,omitempty"` } func (l ListGizmosQuery) Validate() error { @@ -824,6 +841,13 @@ func (l ListGizmosQuery) Validate() error { } } } + for i, item := range l.Statuses { + if v, ok := any(item).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append(fmt.Sprintf("Statuses[%d]", i), err) + } + } + } if len(errors) == 0 { return nil } @@ -1015,6 +1039,59 @@ func (o *ListGizmosServiceRequestOptions) Validate() error { type WidgetID = string +type ListGizmos_Query_Statuses_Item struct { + ListGizmos_Query_Statuses_OneOf *ListGizmos_Query_Statuses_OneOf `json:"-"` +} + +func (l ListGizmos_Query_Statuses_Item) Validate() error { + var errors runtime.ValidationErrors + if l.ListGizmos_Query_Statuses_OneOf != nil { + if v, ok := any(l.ListGizmos_Query_Statuses_OneOf).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("ListGizmos_Query_Statuses_OneOf", err) + } + } + } + if len(errors) == 0 { + return nil + } + return errors +} + +func (l ListGizmos_Query_Statuses_Item) MarshalJSON() ([]byte, error) { + var parts []json.RawMessage + + { + b, err := runtime.MarshalJSON(l.ListGizmos_Query_Statuses_OneOf) + if err != nil { + return nil, fmt.Errorf("ListGizmos_Query_Statuses_OneOf marshal: %w", err) + } + parts = append(parts, b) + } + + return runtime.CoalesceOrMerge(parts...) +} + +func (l *ListGizmos_Query_Statuses_Item) UnmarshalJSON(data []byte) error { + trim := bytes.TrimSpace(data) + if bytes.Equal(trim, []byte("null")) { + return nil + } + if len(trim) == 0 { + return fmt.Errorf("empty JSON input") + } + + if l.ListGizmos_Query_Statuses_OneOf == nil { + l.ListGizmos_Query_Statuses_OneOf = &ListGizmos_Query_Statuses_OneOf{} + } + + if err := runtime.UnmarshalJSON(data, l.ListGizmos_Query_Statuses_OneOf); err != nil { + return fmt.Errorf("ListGizmos_Query_Statuses_OneOf unmarshal: %w", err) + } + + return nil +} + type ListWidgets_Query_ParentID_AnyOf_AnyOf struct { runtime.Either[WidgetID, string] } @@ -1051,6 +1128,24 @@ func (l *ListGadgets_Query_ParentID_AnyOf) Validate() error { return nil } +type ListGizmos_Query_Statuses_OneOf struct { + runtime.Either[WidgetID, string] +} + +func (l *ListGizmos_Query_Statuses_OneOf) Validate() error { + if l.IsA() { + if v, ok := any(l.A).(runtime.Validator); ok { + return v.Validate() + } + } + if l.IsB() { + if v, ok := any(l.B).(runtime.Validator); ok { + return v.Validate() + } + } + return nil +} + var typesValidator *validator.Validate func init() { diff --git a/pkg/codegen/testdata/union-params.yml b/pkg/codegen/testdata/union-params.yml index 9b406978a..ef985664b 100644 --- a/pkg/codegen/testdata/union-params.yml +++ b/pkg/codegen/testdata/union-params.yml @@ -42,6 +42,15 @@ paths: anyOf: - $ref: '#/components/schemas/WidgetID' - type: integer + - name: statuses + in: query + schema: + type: array + items: + oneOf: + - $ref: '#/components/schemas/WidgetID' + - type: string + enum: [unpublished] responses: "200": description: OK diff --git a/pkg/codegen/typedef_parameters.go b/pkg/codegen/typedef_parameters.go index 98ed06aca..b82434cb4 100644 --- a/pkg/codegen/typedef_parameters.go +++ b/pkg/codegen/typedef_parameters.go @@ -351,7 +351,8 @@ func generateParamsTypes(objectParams []ParameterDefinition, typeName string, op // generated MarshalJSON merges back. An anonymous struct cannot have // one, so the field would marshal to {} and the parameter would be // silently dropped from requests. Give it a named type instead. - if pSchema.RefType == "" && !pSchema.HasAdditionalProperties && needsMarshaler(pSchema) { + // A slice is not such a wrapper, it marshals on its own. + if pSchema.RefType == "" && !pSchema.HasAdditionalProperties && pSchema.ArrayType == nil && needsMarshaler(pSchema) { pSchema = nameParamSchema(pSchema, strings.Join([]string{typeName, param.GoName()}, "_"), specLocation, options) param.Schema = pSchema } diff --git a/pkg/codegen/typedef_parameters_test.go b/pkg/codegen/typedef_parameters_test.go index d6d884d33..6c4cc03ea 100644 --- a/pkg/codegen/typedef_parameters_test.go +++ b/pkg/codegen/typedef_parameters_test.go @@ -85,6 +85,11 @@ func TestUnionParametersAreNamed(t *testing.T) { t.Run("single variant next to null stays a plain pointer", func(t *testing.T) { assert.NotContains(t, code, "ListWidgetsQuery_Order") }) + + t.Run("array of unions stays a slice", func(t *testing.T) { + assert.Regexp(t, `Statuses\s+\[\]ListGadgets_Query_Statuses_Item `, code) + assert.NotContains(t, code, "ListGadgetsQuery_Statuses") + }) } func TestNameParamSchema(t *testing.T) {