diff --git a/internal/test/issues/issue-2422/config.base.yaml b/internal/test/issues/issue-2422/config.base.yaml new file mode 100644 index 000000000..3220d1985 --- /dev/null +++ b/internal/test/issues/issue-2422/config.base.yaml @@ -0,0 +1,11 @@ +--- +# yaml-language-server: $schema=../../../../configuration-schema.json +package: spec_base +generate: + client: true + models: true +import-mapping: + spec-ext.yaml: "github.com/oapi-codegen/oapi-codegen/v2/internal/test/issues/issue-2422/gen/spec_ext" +output: gen/spec_base/issue.gen.go +output-options: + skip-prune: true diff --git a/internal/test/issues/issue-2422/config.ext.yaml b/internal/test/issues/issue-2422/config.ext.yaml new file mode 100644 index 000000000..6f6b3015f --- /dev/null +++ b/internal/test/issues/issue-2422/config.ext.yaml @@ -0,0 +1,9 @@ +--- +# yaml-language-server: $schema=../../../../configuration-schema.json +package: spec_ext +generate: + client: true + models: true +output: gen/spec_ext/issue.gen.go +output-options: + skip-prune: true diff --git a/internal/test/issues/issue-2422/doc.go b/internal/test/issues/issue-2422/doc.go new file mode 100644 index 000000000..0ebbadc90 --- /dev/null +++ b/internal/test/issues/issue-2422/doc.go @@ -0,0 +1,4 @@ +package issue2422 + +//go:generate go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen --config=config.ext.yaml spec-ext.yaml +//go:generate go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen --config=config.base.yaml spec-base.yaml diff --git a/internal/test/issues/issue-2422/gen/spec_base/.gitempty b/internal/test/issues/issue-2422/gen/spec_base/.gitempty new file mode 100644 index 000000000..e69de29bb diff --git a/internal/test/issues/issue-2422/gen/spec_base/issue.gen.go b/internal/test/issues/issue-2422/gen/spec_base/issue.gen.go new file mode 100644 index 000000000..3d7cab774 --- /dev/null +++ b/internal/test/issues/issue-2422/gen/spec_base/issue.gen.go @@ -0,0 +1,262 @@ +// Package spec_base 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 spec_base + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + externalRef0 "github.com/oapi-codegen/oapi-codegen/v2/internal/test/issues/issue-2422/gen/spec_ext" +) + +// 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 { + + // GetOutcome performs a GET /outcome (the `GetOutcome` operationId) request. + GetOutcome(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) +} + +// GetOutcome performs a GET /outcome (the `GetOutcome` operationId) request. +func (c *Client) GetOutcome(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetOutcomeRequest(c.Server) + 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) +} + +// NewGetOutcomeRequest constructs an http.Request for the GetOutcome method +func NewGetOutcomeRequest(server string) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/outcome") + 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 { + + // GetOutcomeWithResponse performs a GET /outcome (the `GetOutcome` operationId) request. + // + // Returns a wrapper object for the known response body format(s). + GetOutcomeWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetOutcomeResponse, error) +} + +type GetOutcomeResponse struct { + Body []byte + HTTPResponse *http.Response + // JSON200 the response for an HTTP 200 `application/json` response + JSON200 *externalRef0.OutcomeResult +} + +// GetJSON200 returns the response for an HTTP 200 `application/json` response +func (r GetOutcomeResponse) GetJSON200() *externalRef0.OutcomeResult { + return r.JSON200 +} + +// GetBody returns the raw response body bytes +func (r GetOutcomeResponse) GetBody() []byte { + return r.Body +} + +// Status returns HTTPResponse.Status +func (r GetOutcomeResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetOutcomeResponse) 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 GetOutcomeResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +// GetOutcomeWithResponse performs a GET /outcome (the `GetOutcome` operationId) request. +// +// Returns a wrapper object for the known response body format(s). +func (c *ClientWithResponses) GetOutcomeWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetOutcomeResponse, error) { + rsp, err := c.GetOutcome(ctx, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetOutcomeResponse(rsp) +} + +// ParseGetOutcomeResponse parses an HTTP response from a GetOutcomeWithResponse call +func ParseGetOutcomeResponse(rsp *http.Response) (*GetOutcomeResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetOutcomeResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest externalRef0.OutcomeResult + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + } + + return response, nil +} diff --git a/internal/test/issues/issue-2422/gen/spec_ext/.gitempty b/internal/test/issues/issue-2422/gen/spec_ext/.gitempty new file mode 100644 index 000000000..e69de29bb diff --git a/internal/test/issues/issue-2422/gen/spec_ext/issue.gen.go b/internal/test/issues/issue-2422/gen/spec_ext/issue.gen.go new file mode 100644 index 000000000..e5a05f2f2 --- /dev/null +++ b/internal/test/issues/issue-2422/gen/spec_ext/issue.gen.go @@ -0,0 +1,134 @@ +// Package spec_ext 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 spec_ext + +import ( + "context" + "net/http" + "net/url" + "strings" +) + +// OutcomeResult defines model for Outcome. +type OutcomeResult = string + +// 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 { +} + +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 { +} diff --git a/internal/test/issues/issue-2422/issue_test.go b/internal/test/issues/issue-2422/issue_test.go new file mode 100644 index 000000000..85834d315 --- /dev/null +++ b/internal/test/issues/issue-2422/issue_test.go @@ -0,0 +1,26 @@ +package issue2422 + +import ( + "testing" + + spec_base "github.com/oapi-codegen/oapi-codegen/v2/internal/test/issues/issue-2422/gen/spec_base" + spec_ext "github.com/oapi-codegen/oapi-codegen/v2/internal/test/issues/issue-2422/gen/spec_ext" +) + +// Regression test for https://github.com/oapi-codegen/oapi-codegen/issues/2422. +// +// The base spec references an external response directly. That response is +// renamed via x-go-name in the external package (Outcome -> OutcomeResult), so +// the generated client wrapper field must be typed as the imported model name. +// +// Assigning an external-typed value into the wrapper field only compiles when +// the field is exactly *spec_ext.OutcomeResult: two distinct named types are not +// assignable to each other, so this pins the field's type to the external +// package's model. +func TestDirectExternalResponseHonoursXGoName(t *testing.T) { + outcome := spec_ext.OutcomeResult("ok") + resp := spec_base.GetOutcomeResponse{ + JSON200: &outcome, + } + _ = resp +} diff --git a/internal/test/issues/issue-2422/spec-base.yaml b/internal/test/issues/issue-2422/spec-base.yaml new file mode 100644 index 000000000..c4472c809 --- /dev/null +++ b/internal/test/issues/issue-2422/spec-base.yaml @@ -0,0 +1,13 @@ +openapi: "3.1.0" +info: + version: "0.0.1" + title: base +paths: + # A direct external response $ref (no path item). The referenced response is + # renamed via x-go-name in spec-ext.yaml, so the generated client wrapper must + # reference externalRef0.OutcomeResult (issue #2422). + /outcome: + get: + responses: + '200': + $ref: 'spec-ext.yaml#/components/responses/Outcome' diff --git a/internal/test/issues/issue-2422/spec-ext.yaml b/internal/test/issues/issue-2422/spec-ext.yaml new file mode 100644 index 000000000..e63d2fb02 --- /dev/null +++ b/internal/test/issues/issue-2422/spec-ext.yaml @@ -0,0 +1,18 @@ +openapi: "3.1.0" +info: + version: "0.0.1" + title: ext +paths: {} +components: + responses: + # x-go-name renames the response model in *this* package to OutcomeResult. + # A base spec that references this response directly (not via a path item) + # must point its client wrapper at externalRef0.OutcomeResult, the imported + # model name -- not the raw component name "Outcome" (issue #2422). + Outcome: + x-go-name: OutcomeResult + description: the outcome + content: + application/json: + schema: + type: string diff --git a/pkg/codegen/operations.go b/pkg/codegen/operations.go index be44ba2a1..55e512a66 100644 --- a/pkg/codegen/operations.go +++ b/pkg/codegen/operations.go @@ -691,17 +691,26 @@ func (o *OperationDefinition) GetResponseTypeDefinitions() ([]ResponseTypeDefini // nor silently decodes into the JSON type when the JSON and // non-JSON schemas differ. if IsGoTypeReference(responseRef.Ref) && util.IsMediaTypeJson(contentTypeName) { - if externalPkg := externalPackageFor(o.PathItemRef); externalPkg != "" && strings.HasPrefix(responseRef.Ref, "#") { - // The operation came from an externally-ref'd path - // item and this response ref is relative to that - // external file, so the response type lives in the - // imported package (issue #2308). Resolve the name in - // the external document's context rather than via + // Determine the imported package for an external response. + // Either the operation came from an externally-ref'd path + // item and the response ref is relative to that file + // (issue #2308), or the response ref targets an external + // file directly (issue #2422). + externalPkg := "" + if pkg := externalPackageFor(o.PathItemRef); pkg != "" && strings.HasPrefix(responseRef.Ref, "#") { + externalPkg = pkg + } else if pkg := externalPackageFor(responseRef.Ref); pkg != "" { + externalPkg = pkg + } + + if externalPkg != "" { + // The client wrapper points at the imported package's + // response *model* type. Resolve its name in the + // external document's context rather than via // RefPathToGoType / resolvedNameForRefPath, which key - // off the root spec. The client wrapper points at the - // imported package's response *model* type, whose name - // honours x-go-name on the response component the same - // way the external package generated it. + // off the root spec; in particular honour x-go-name on + // the response component the same way the external + // package generated it. refParts := strings.Split(responseRef.Ref, "/") refType := SchemaNameToTypeName(refParts[len(refParts)-1]) if ext, ok := responseRef.Value.Extensions[extGoName]; ok {