From a07c24e96ad90f6ae8efbe8c10cb6a63cda4df6a Mon Sep 17 00:00:00 2001 From: Max Collier Date: Fri, 17 Oct 2025 20:53:55 +1100 Subject: [PATCH 1/2] addressing issues with upstream oapi-codegen --- .gitignore | 1 + README.md | 28 ++ examples/go.mod | 2 + examples/overlap/generated.go | 601 ++++++++++++++++++++++++++++++++++ examples/overlap/readme.md | 7 + examples/overlap/schema.yaml | 86 +++++ pkg/codegen/merge_schemas.go | 19 +- 7 files changed, 742 insertions(+), 2 deletions(-) create mode 100644 examples/overlap/generated.go create mode 100644 examples/overlap/readme.md create mode 100644 examples/overlap/schema.yaml diff --git a/.gitignore b/.gitignore index 74762c247e..6751ed6e91 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ .DS_Store .vscode/ bin/ +*~ \ No newline at end of file diff --git a/README.md b/README.md index 06bcf667ba..dce2708f39 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,33 @@ # `oapi-codegen` +## Max's version + +Traditional merging by `oapi-codegen` is fairly strict and fails on legal declarations of types following the OpenAPI 3.0 spec. + +Consider: + +```yaml +components: + schemas: + Match: + additionalProperties: false + deprecated: false + properties: + begin_at: + allOf: + - nullable: true + - $ref: '#/components/schemas/MatchBeginAt' +``` + +For example, the type _begin_at_ with `allOf`of nullable and ref should, in Go, become `*MatchBeginAt`. The current library has strict rules regarding conflicts across types regarding formats. In this fork, I have addressed the issue which I am to merge upstream. + +### Other issues to consider + +* Global namespacing between, funcs, consts, types, enums not handled upstream. +* More lenient merging of `openapi3.Schemas` to allow for merges across fields other than nullable. + +## DOCUMENTATION + [![OpenSSF Best Practices](https://www.bestpractices.dev/projects/9450/badge)](https://www.bestpractices.dev/projects/9450) `oapi-codegen` is a command-line tool and library to convert OpenAPI specifications to Go code, be it [server-side implementations](#generating-server-side-boilerplate), [API clients](#generating-api-clients), or simply [HTTP models](#generating-api-models). diff --git a/examples/go.mod b/examples/go.mod index 70e2872aec..4318a2b350 100644 --- a/examples/go.mod +++ b/examples/go.mod @@ -120,3 +120,5 @@ require ( gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) + +tool github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen diff --git a/examples/overlap/generated.go b/examples/overlap/generated.go new file mode 100644 index 0000000000..e3b3778d77 --- /dev/null +++ b/examples/overlap/generated.go @@ -0,0 +1,601 @@ +// Package schema 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 schema + +import ( + "bytes" + "compress/gzip" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "path" + "strings" + "time" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/labstack/echo/v4" + "github.com/oapi-codegen/runtime" +) + +// Defines values for OpponentType. +const ( + OpponentTypePlayer OpponentType = "Player" + OpponentTypeTeam OpponentType = "Team" +) + +// Defines values for OpponentTypePlayer. +const ( + OpponentTypePlayerPlayer OpponentTypePlayer = "Player" +) + +// Match defines model for Match. +type Match struct { + BeginAt *MatchBeginAt `json:"begin_at,omitempty"` +} + +// MatchBeginAt defines model for MatchBeginAt. +type MatchBeginAt = time.Time + +// MatchPlayerOpponentsObject defines model for MatchPlayerOpponentsObject. +type MatchPlayerOpponentsObject struct { + OpponentType *OpponentTypePlayer `json:"opponent_type,omitempty"` +} + +// Matches defines model for Matches. +type Matches = []Match + +// OpponentType defines model for OpponentType. +type OpponentType string + +// OpponentTypePlayer defines model for OpponentTypePlayer. +type OpponentTypePlayer string + +// GetMatchesParams defines parameters for GetMatches. +type GetMatchesParams struct { + Filter *MatchPlayerOpponentsObject `json:"filter,omitempty"` +} + +// GetOpponentsParams defines parameters for GetOpponents. +type GetOpponentsParams struct { + OpponentType *OpponentType `json:"opponent_type,omitempty"` +} + +// 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 { + // GetMatches request + GetMatches(ctx context.Context, params *GetMatchesParams, reqEditors ...RequestEditorFn) (*http.Response, error) + + // GetOpponents request + GetOpponents(ctx context.Context, params *GetOpponentsParams, reqEditors ...RequestEditorFn) (*http.Response, error) +} + +func (c *Client) GetMatches(ctx context.Context, params *GetMatchesParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetMatchesRequest(c.Server, params) + 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) +} + +func (c *Client) GetOpponents(ctx context.Context, params *GetOpponentsParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetOpponentsRequest(c.Server, params) + 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) +} + +// NewGetMatchesRequest generates requests for GetMatches +func NewGetMatchesRequest(server string, params *GetMatchesParams) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/matches") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + if params != nil { + queryValues := queryURL.Query() + + if params.Filter != nil { + + if queryFrag, err := runtime.StyleParamWithLocation("deepObject", true, "filter", runtime.ParamLocationQuery, *params.Filter); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + } + + queryURL.RawQuery = queryValues.Encode() + } + + req, err := http.NewRequest("GET", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewGetOpponentsRequest generates requests for GetOpponents +func NewGetOpponentsRequest(server string, params *GetOpponentsParams) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/matches/opponents") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + if params != nil { + queryValues := queryURL.Query() + + if params.OpponentType != nil { + + if queryFrag, err := runtime.StyleParamWithLocation("deepObject", true, "opponent_type", runtime.ParamLocationQuery, *params.OpponentType); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + } + + queryURL.RawQuery = queryValues.Encode() + } + + req, err := http.NewRequest("GET", 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 { + // GetMatchesWithResponse request + GetMatchesWithResponse(ctx context.Context, params *GetMatchesParams, reqEditors ...RequestEditorFn) (*GetMatchesResponse, error) + + // GetOpponentsWithResponse request + GetOpponentsWithResponse(ctx context.Context, params *GetOpponentsParams, reqEditors ...RequestEditorFn) (*GetOpponentsResponse, error) +} + +type GetMatchesResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *Matches +} + +// Status returns HTTPResponse.Status +func (r GetMatchesResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetMatchesResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type GetOpponentsResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *Matches +} + +// Status returns HTTPResponse.Status +func (r GetOpponentsResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetOpponentsResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// GetMatchesWithResponse request returning *GetMatchesResponse +func (c *ClientWithResponses) GetMatchesWithResponse(ctx context.Context, params *GetMatchesParams, reqEditors ...RequestEditorFn) (*GetMatchesResponse, error) { + rsp, err := c.GetMatches(ctx, params, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetMatchesResponse(rsp) +} + +// GetOpponentsWithResponse request returning *GetOpponentsResponse +func (c *ClientWithResponses) GetOpponentsWithResponse(ctx context.Context, params *GetOpponentsParams, reqEditors ...RequestEditorFn) (*GetOpponentsResponse, error) { + rsp, err := c.GetOpponents(ctx, params, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetOpponentsResponse(rsp) +} + +// ParseGetMatchesResponse parses an HTTP response from a GetMatchesWithResponse call +func ParseGetMatchesResponse(rsp *http.Response) (*GetMatchesResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetMatchesResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest Matches + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + } + + return response, nil +} + +// ParseGetOpponentsResponse parses an HTTP response from a GetOpponentsWithResponse call +func ParseGetOpponentsResponse(rsp *http.Response) (*GetOpponentsResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetOpponentsResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest Matches + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + } + + return response, nil +} + +// ServerInterface represents all server handlers. +type ServerInterface interface { + // List matches + // (GET /matches) + GetMatches(ctx echo.Context, params GetMatchesParams) error + // List matches + // (GET /matches/opponents) + GetOpponents(ctx echo.Context, params GetOpponentsParams) error +} + +// ServerInterfaceWrapper converts echo contexts to parameters. +type ServerInterfaceWrapper struct { + Handler ServerInterface +} + +// GetMatches converts echo context to params. +func (w *ServerInterfaceWrapper) GetMatches(ctx echo.Context) error { + var err error + + // Parameter object where we will unmarshal all parameters from the context + var params GetMatchesParams + // ------------- Optional query parameter "filter" ------------- + + err = runtime.BindQueryParameter("deepObject", true, false, "filter", ctx.QueryParams(), ¶ms.Filter) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter filter: %s", err)) + } + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.GetMatches(ctx, params) + return err +} + +// GetOpponents converts echo context to params. +func (w *ServerInterfaceWrapper) GetOpponents(ctx echo.Context) error { + var err error + + // Parameter object where we will unmarshal all parameters from the context + var params GetOpponentsParams + // ------------- Optional query parameter "opponent_type" ------------- + + err = runtime.BindQueryParameter("deepObject", true, false, "opponent_type", ctx.QueryParams(), ¶ms.OpponentType) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter opponent_type: %s", err)) + } + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.GetOpponents(ctx, params) + return err +} + +// This is a simple interface which specifies echo.Route addition functions which +// are present on both echo.Echo and echo.Group, since we want to allow using +// either of them for path registration +type EchoRouter interface { + CONNECT(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route + DELETE(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route + GET(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route + HEAD(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route + OPTIONS(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route + PATCH(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route + POST(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route + PUT(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route + TRACE(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route +} + +// RegisterHandlers adds each server route to the EchoRouter. +func RegisterHandlers(router EchoRouter, si ServerInterface) { + RegisterHandlersWithBaseURL(router, si, "") +} + +// Registers handlers, and prepends BaseURL to the paths, so that the paths +// can be served under a prefix. +func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL string) { + + wrapper := ServerInterfaceWrapper{ + Handler: si, + } + + router.GET(baseURL+"/matches", wrapper.GetMatches) + router.GET(baseURL+"/matches/opponents", wrapper.GetOpponents) + +} + +// Base64 encoded, gzipped, json marshaled Swagger object +var swaggerSpec = []string{ + + "H4sIAAAAAAAC/8xTTW/TQBD9K+jBcZELx73BBSEVpYfeqqja2mNnq/1idyxhRfnvaOw4jmkKzY2cNuOZ", + "9+a9mdmjjj7FQIEL9B6ZSoqh0Pjnh+F6Nz3rGJgCy9Ok5Gxt2MZQPZcYJFbqHXkjrw+ZWmi8rxbcavpa", + "qhnvID91LFqYRvCmsYJs3F2OiTJb4W+NK6SQzkJ7PFFnw6OZenJu00I/vIH/q5R9YRy2YxOrmN6jjdkL", + "JhrD9JGtJyh4G24pdLyD/qTAlh1Br0sVeEgSLZxt6DBD3zkzUN6kYy+bp2eq+Uql8Vj9OFH8XeRMdT8k", + "msixCJ3wLJMvbxqWyFjJpbIoNTmbQTLOKQWWQu+hH3CkV7gn47FdoFYFF5y7oOEl7Ct4J9I/UMUDG9oI", + "HXrnFGKiYJKFBhSS4d3oSOUXmzoaB9VQqbNNMito3NrC7/zJChnTeArfG2h8I15cSiYbT0y5jItJv5KL", + "DUFz7klagcbPnvIAhWC8tNpax2PrV5zT5f2S6+Jh9KYhSnN0q9YH/vnm5jWSU97Z1SqU3nuTh5c+sOlE", + "5mlJtpI9m1nNC/wPW5e0C8Zuzj5eb+36hN7q8GpP/wdPD4ffAQAA///fjXrlsQUAAA==", +} + +// GetSwagger returns the content of the embedded swagger specification file +// or error if failed to decode +func decodeSpec() ([]byte, error) { + zipped, err := base64.StdEncoding.DecodeString(strings.Join(swaggerSpec, "")) + if err != nil { + return nil, fmt.Errorf("error base64 decoding spec: %w", err) + } + zr, err := gzip.NewReader(bytes.NewReader(zipped)) + if err != nil { + return nil, fmt.Errorf("error decompressing spec: %w", err) + } + var buf bytes.Buffer + _, err = buf.ReadFrom(zr) + if err != nil { + return nil, fmt.Errorf("error decompressing spec: %w", err) + } + + return buf.Bytes(), nil +} + +var rawSpec = decodeSpecCached() + +// a naive cached of a decoded swagger spec +func decodeSpecCached() func() ([]byte, error) { + data, err := decodeSpec() + return func() ([]byte, error) { + return data, err + } +} + +// Constructs a synthetic filesystem for resolving external references when loading openapi specifications. +func PathToRawSpec(pathToFile string) map[string]func() ([]byte, error) { + res := make(map[string]func() ([]byte, error)) + if len(pathToFile) > 0 { + res[pathToFile] = rawSpec + } + + return res +} + +// GetSwagger returns the Swagger specification corresponding to the generated code +// in this file. The external references of Swagger specification are resolved. +// The logic of resolving external references is tightly connected to "import-mapping" feature. +// Externally referenced files must be embedded in the corresponding golang packages. +// Urls can be supported but this task was out of the scope. +func GetSwagger() (swagger *openapi3.T, err error) { + resolvePath := PathToRawSpec("") + + loader := openapi3.NewLoader() + loader.IsExternalRefsAllowed = true + loader.ReadFromURIFunc = func(loader *openapi3.Loader, url *url.URL) ([]byte, error) { + pathToFile := url.String() + pathToFile = path.Clean(pathToFile) + getSpec, ok := resolvePath[pathToFile] + if !ok { + err1 := fmt.Errorf("path not found: %s", pathToFile) + return nil, err1 + } + return getSpec() + } + var specData []byte + specData, err = rawSpec() + if err != nil { + return + } + swagger, err = loader.LoadFromData(specData) + if err != nil { + return + } + return +} diff --git a/examples/overlap/readme.md b/examples/overlap/readme.md new file mode 100644 index 0000000000..d18556227a --- /dev/null +++ b/examples/overlap/readme.md @@ -0,0 +1,7 @@ +# Jounery + +To run locally: + +```bash +$ go run ../../cmd/oapi-codegen ./schema.yaml > generated.go +``` \ No newline at end of file diff --git a/examples/overlap/schema.yaml b/examples/overlap/schema.yaml new file mode 100644 index 0000000000..031f6f18d8 --- /dev/null +++ b/examples/overlap/schema.yaml @@ -0,0 +1,86 @@ +components: + responses: + Matches: + content: + application/json: + schema: + $ref: '#/components/schemas/Matches' + schemas: + Matches: + items: + $ref: '#/components/schemas/Match' + title: Matches + type: array + Match: + additionalProperties: false + deprecated: false + properties: + begin_at: + allOf: + # - nullable: true + - $ref: '#/components/schemas/MatchBeginAt' + MatchPlayerOpponentsObject: + additionalProperties: false + deprecated: false + properties: + opponent_type: + $ref: '#/components/schemas/OpponentTypePlayer' + OpponentType: + enum: + - Player + - Team + title: OpponentType + type: string + OpponentTypePlayer: + enum: + - Player + title: OpponentTypePlayer + type: string + OpponentTypeTeam: + enum: + - Team + title: OpponentTypeTeam + type: string + MatchBeginAt: + format: date-time + minLength: 1 + title: MatchBeginAt + type: string +paths: + /matches: + get: + description: List matches + operationId: get_matches + parameters: + - explode: true + in: query + name: filter + required: false + schema: + $ref: '#/components/schemas/MatchPlayerOpponentsObject' + style: deepObject + responses: + "200": + $ref: '#/components/responses/Matches' + summary: List matches + tags: + - Matches + /matches/opponents: + get: + description: List opponents + operationId: get_opponents + parameters: + - explode: true + in: query + name: opponent_type + required: false + schema: + $ref: '#/components/schemas/OpponentType' + style: deepObject + responses: + "200": + $ref: '#/components/responses/Matches' + summary: List matches + tags: + - Matches + diff --git a/pkg/codegen/merge_schemas.go b/pkg/codegen/merge_schemas.go index 04e7b2fa2b..4e96fab80c 100644 --- a/pkg/codegen/merge_schemas.go +++ b/pkg/codegen/merge_schemas.go @@ -82,8 +82,17 @@ func mergeAllOf(allOf []*openapi3.SchemaRef) (openapi3.Schema, error) { return schema, nil } +// isNullableOnlySchema returns true if the schema is nullable only. +func isNullableOnlySchema(s openapi3.Schema) bool { + if s.Nullable { + return false + } + s.Nullable = false + return s.IsEmpty() +} + // mergeOpenapiSchemas merges two openAPI schemas and returns the schema -// all of whose fields are composed. +// all of whose fields are composed. Case for allOf is barely supported. func mergeOpenapiSchemas(s1, s2 openapi3.Schema, allOf bool) (openapi3.Schema, error) { var result openapi3.Schema @@ -126,7 +135,12 @@ func mergeOpenapiSchemas(s1, s2 openapi3.Schema, allOf bool) (openapi3.Schema, e result.Type = s1.Type if s1.Format != s2.Format { - return openapi3.Schema{}, errors.New("can not merge incompatible formats") + if isNullableOnlySchema(s1) && !isNullableOnlySchema(s2) { + s1, s2 = s2, s1 + } else { + return openapi3.Schema{}, errors.New("can not merge incompatible formats") + } + s1.Nullable = true } result.Format = s1.Format @@ -148,6 +162,7 @@ func mergeOpenapiSchemas(s1, s2 openapi3.Schema, allOf bool) (openapi3.Schema, e // We skip Example // We skip ExternalDocs + // TODO: respect allOf / anyOf flag handling. // If two schemas disagree on any of these flags, we error out. if s1.UniqueItems != s2.UniqueItems { return openapi3.Schema{}, errors.New("merging two schemas with different UniqueItems") From 3404604bc999d4fbb616eba5e3c03cd7b25a99ab Mon Sep 17 00:00:00 2001 From: Max Collier Date: Fri, 17 Oct 2025 20:56:13 +1100 Subject: [PATCH 2/2] minor update --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index dce2708f39..19ab11cf22 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ ## Max's version -Traditional merging by `oapi-codegen` is fairly strict and fails on legal declarations of types following the OpenAPI 3.0 spec. +Traditional `oapi-codegen` is fairly strict and fails on legal declarations of types following the OpenAPI 3.0 spec. Consider: @@ -25,6 +25,7 @@ For example, the type _begin_at_ with `allOf`of nullable and ref should, in Go, * Global namespacing between, funcs, consts, types, enums not handled upstream. * More lenient merging of `openapi3.Schemas` to allow for merges across fields other than nullable. +* Irellevant types added with complex specs. ## DOCUMENTATION