From 87fdf4a444dd61b02e6ae1b8c326983962a4eb76 Mon Sep 17 00:00:00 2001 From: "Chris (ChrisJr404)" <11917633+ChrisJr404@users.noreply.github.com> Date: Tue, 25 Aug 2026 05:37:20 -0400 Subject: [PATCH] feat: add client-response-error-on-unexpected-response output option When enabled, the generated ParseResponse functions get a default case that returns a new ErrUnexpectedResponse sentinel when the response status code and content-type match none of the responses declared in the spec. Off by default, so existing output is unchanged. --- configuration-schema.json | 4 + docs/configuration.md | 1 + .../unexpected_response/enabled/config.yaml | 9 + .../unexpected_response/enabled/doc.go | 10 + .../enabled/unexpected_response.gen.go | 290 ++++++++++++++++++ .../enabled/unexpected_response_test.go | 41 +++ .../unexpected_response/skipped/config.yaml | 7 + .../unexpected_response/skipped/doc.go | 9 + .../skipped/unexpected_response.gen.go | 282 +++++++++++++++++ .../skipped/unexpected_response_test.go | 34 ++ .../options/unexpected_response/spec.yaml | 52 ++++ pkg/codegen/configuration.go | 9 + pkg/codegen/template_helpers.go | 3 + .../templates/client-with-responses.tmpl | 7 + 14 files changed, 758 insertions(+) create mode 100644 internal/test/options/unexpected_response/enabled/config.yaml create mode 100644 internal/test/options/unexpected_response/enabled/doc.go create mode 100644 internal/test/options/unexpected_response/enabled/unexpected_response.gen.go create mode 100644 internal/test/options/unexpected_response/enabled/unexpected_response_test.go create mode 100644 internal/test/options/unexpected_response/skipped/config.yaml create mode 100644 internal/test/options/unexpected_response/skipped/doc.go create mode 100644 internal/test/options/unexpected_response/skipped/unexpected_response.gen.go create mode 100644 internal/test/options/unexpected_response/skipped/unexpected_response_test.go create mode 100644 internal/test/options/unexpected_response/spec.yaml diff --git a/configuration-schema.json b/configuration-schema.json index 217694d62..67ae749cb 100644 --- a/configuration-schema.json +++ b/configuration-schema.json @@ -310,6 +310,10 @@ "type": "boolean", "description": "Disable the generation of a `ContentType()` method on response objects for `ClientWithResponses`, which is otherwise generated by default." }, + "client-response-error-on-unexpected-response": { + "type": "boolean", + "description": "Add a `default` case to the response switch in the generated `ParseResponse` functions that returns the sentinel `ErrUnexpectedResponse` error when the response status code and content-type match none of the responses declared in the OpenAPI specification." + }, "skip-response-body-getters": { "type": "boolean", "description": "Disable the generation of `GetBody()` and `Get()` getter methods on response objects for `ClientWithResponses`, which are otherwise generated by default." diff --git a/docs/configuration.md b/docs/configuration.md index 3ca7953e5..777396f8a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -145,6 +145,7 @@ output-options: # template: '{{.FieldName}}' client-response-bytes-function: false skip-client-response-content-type: false + client-response-error-on-unexpected-response: false skip-response-body-getters: false streaming-content-types: [] # Short names for media types, used in generated type names. Keys are the diff --git a/internal/test/options/unexpected_response/enabled/config.yaml b/internal/test/options/unexpected_response/enabled/config.yaml new file mode 100644 index 000000000..e90c2eade --- /dev/null +++ b/internal/test/options/unexpected_response/enabled/config.yaml @@ -0,0 +1,9 @@ +# yaml-language-server: $schema=../../../../../configuration-schema.json +package: optionsunexpectedresponseenabled +output: unexpected_response.gen.go +generate: + client: true + models: true +output-options: + # outputoptions/unexpected-response/enabled: error on responses the spec does not declare. + client-response-error-on-unexpected-response: true diff --git a/internal/test/options/unexpected_response/enabled/doc.go b/internal/test/options/unexpected_response/enabled/doc.go new file mode 100644 index 000000000..57cfb3c0d --- /dev/null +++ b/internal/test/options/unexpected_response/enabled/doc.go @@ -0,0 +1,10 @@ +// Package optionsunexpectedresponseenabled checks that, with +// client-response-error-on-unexpected-response set to true, the generated +// ParseResponse functions gain a default case that returns the +// sentinel ErrUnexpectedResponse when the response matches none of the +// declared responses. +// +// outputoptions/unexpected-response/enabled +package optionsunexpectedresponseenabled + +//go:generate go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen --config=config.yaml ../spec.yaml diff --git a/internal/test/options/unexpected_response/enabled/unexpected_response.gen.go b/internal/test/options/unexpected_response/enabled/unexpected_response.gen.go new file mode 100644 index 000000000..dc5886190 --- /dev/null +++ b/internal/test/options/unexpected_response/enabled/unexpected_response.gen.go @@ -0,0 +1,290 @@ +// Package optionsunexpectedresponseenabled 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 optionsunexpectedresponseenabled + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/url" + "strings" + + "github.com/oapi-codegen/runtime" +) + +// Thing defines model for Thing. +type Thing struct { + Id string `json:"id"` + Name string `json:"name"` +} + +// 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 { + + // GetThing Get a thing by id + // + // Corresponds with GET /things/{id} (the `GetThing` operationId). + GetThing(ctx context.Context, id string, reqEditors ...RequestEditorFn) (*http.Response, error) +} + +// GetThing Get a thing by id +// +// Corresponds with GET /things/{id} (the `GetThing` operationId). +func (c *Client) GetThing(ctx context.Context, id string, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetThingRequest(c.Server, id) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +// NewGetThingRequest constructs an http.Request for the GetThing method +func NewGetThingRequest(server string, id string) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "id", id, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := "/things/" + pathParam0 + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +func (c *Client) applyEditors(ctx context.Context, req *http.Request, additionalEditors []RequestEditorFn) error { + for _, r := range c.RequestEditors { + if err := r(ctx, req); err != nil { + return err + } + } + for _, r := range additionalEditors { + if err := r(ctx, req); err != nil { + return err + } + } + return nil +} + +// ClientWithResponses builds on ClientInterface to offer response payloads +type ClientWithResponses struct { + ClientInterface +} + +// NewClientWithResponses creates a new ClientWithResponses, which wraps +// Client with return type handling +func NewClientWithResponses(server string, opts ...ClientOption) (*ClientWithResponses, error) { + client, err := NewClient(server, opts...) + if err != nil { + return nil, err + } + return &ClientWithResponses{client}, nil +} + +// ErrUnexpectedResponse is returned by the ParseResponse functions +// when the server responds with a status code and content-type that match none +// of the responses declared in the OpenAPI specification. +var ErrUnexpectedResponse = errors.New("unexpected response") + +// 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 { + + // GetThingWithResponse Get a thing by id + // + // Returns a wrapper object for the known response body format(s). + // + // Corresponds with GET /things/{id} (the `GetThing` operationId). + GetThingWithResponse(ctx context.Context, id string, reqEditors ...RequestEditorFn) (*GetThingResponse, error) +} + +type GetThingResponse struct { + Body []byte + HTTPResponse *http.Response + // JSON200 the response for an HTTP 200 `application/json` response + JSON200 *Thing +} + +// GetJSON200 returns the response for an HTTP 200 `application/json` response +func (r GetThingResponse) GetJSON200() *Thing { + return r.JSON200 +} + +// GetBody returns the raw response body bytes +func (r GetThingResponse) GetBody() []byte { + return r.Body +} + +// Status returns HTTPResponse.Status +func (r GetThingResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetThingResponse) 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 GetThingResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +// GetThingWithResponse Get a thing by id +// +// Returns a wrapper object for the known response body format(s). +// +// Corresponds with GET /things/{id} (the `GetThing` operationId). +func (c *ClientWithResponses) GetThingWithResponse(ctx context.Context, id string, reqEditors ...RequestEditorFn) (*GetThingResponse, error) { + rsp, err := c.GetThing(ctx, id, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetThingResponse(rsp) +} + +// ParseGetThingResponse parses an HTTP response from a GetThingWithResponse call +func ParseGetThingResponse(rsp *http.Response) (*GetThingResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetThingResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest Thing + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + default: + return nil, ErrUnexpectedResponse + } + + return response, nil +} diff --git a/internal/test/options/unexpected_response/enabled/unexpected_response_test.go b/internal/test/options/unexpected_response/enabled/unexpected_response_test.go new file mode 100644 index 000000000..9cdafea50 --- /dev/null +++ b/internal/test/options/unexpected_response/enabled/unexpected_response_test.go @@ -0,0 +1,41 @@ +package optionsunexpectedresponseenabled + +import ( + "bytes" + "errors" + "io" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newResponse(status int, contentType, body string) *http.Response { + h := http.Header{} + if contentType != "" { + h.Set("Content-Type", contentType) + } + return &http.Response{ + StatusCode: status, + Header: h, + Body: io.NopCloser(bytes.NewReader([]byte(body))), + } +} + +// outputoptions/unexpected-response/enabled: a response the spec does not declare +// (here a 500) hits the generated default case and returns ErrUnexpectedResponse. +func TestUnexpectedResponseReturnsSentinel(t *testing.T) { + res, err := ParseGetThingResponse(newResponse(http.StatusInternalServerError, "application/json", `{"oops":true}`)) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrUnexpectedResponse), "expected ErrUnexpectedResponse, got %v", err) + assert.Nil(t, res) +} + +// A declared response (200) is still parsed normally, with no error. +func TestExpectedResponseParsesWithoutError(t *testing.T) { + res, err := ParseGetThingResponse(newResponse(http.StatusOK, "application/json", `{"id":"1","name":"rock"}`)) + require.NoError(t, err) + require.NotNil(t, res.JSON200) + assert.Equal(t, "rock", res.JSON200.Name) +} diff --git a/internal/test/options/unexpected_response/skipped/config.yaml b/internal/test/options/unexpected_response/skipped/config.yaml new file mode 100644 index 000000000..c2d8e5fbc --- /dev/null +++ b/internal/test/options/unexpected_response/skipped/config.yaml @@ -0,0 +1,7 @@ +# yaml-language-server: $schema=../../../../../configuration-schema.json +package: optionsunexpectedresponseskipped +output: unexpected_response.gen.go +# client-response-error-on-unexpected-response is left UNSET — no default case is generated. +generate: + client: true + models: true diff --git a/internal/test/options/unexpected_response/skipped/doc.go b/internal/test/options/unexpected_response/skipped/doc.go new file mode 100644 index 000000000..c9c8eeb8a --- /dev/null +++ b/internal/test/options/unexpected_response/skipped/doc.go @@ -0,0 +1,9 @@ +// Package optionsunexpectedresponseskipped checks that, with +// client-response-error-on-unexpected-response unset (the default), the +// generated ParseResponse functions have no default case and an +// unexpected response yields a response object with a nil error. +// +// outputoptions/unexpected-response/skipped +package optionsunexpectedresponseskipped + +//go:generate go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen --config=config.yaml ../spec.yaml diff --git a/internal/test/options/unexpected_response/skipped/unexpected_response.gen.go b/internal/test/options/unexpected_response/skipped/unexpected_response.gen.go new file mode 100644 index 000000000..dedbb884e --- /dev/null +++ b/internal/test/options/unexpected_response/skipped/unexpected_response.gen.go @@ -0,0 +1,282 @@ +// Package optionsunexpectedresponseskipped 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 optionsunexpectedresponseskipped + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/url" + "strings" + + "github.com/oapi-codegen/runtime" +) + +// Thing defines model for Thing. +type Thing struct { + Id string `json:"id"` + Name string `json:"name"` +} + +// 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 { + + // GetThing Get a thing by id + // + // Corresponds with GET /things/{id} (the `GetThing` operationId). + GetThing(ctx context.Context, id string, reqEditors ...RequestEditorFn) (*http.Response, error) +} + +// GetThing Get a thing by id +// +// Corresponds with GET /things/{id} (the `GetThing` operationId). +func (c *Client) GetThing(ctx context.Context, id string, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetThingRequest(c.Server, id) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +// NewGetThingRequest constructs an http.Request for the GetThing method +func NewGetThingRequest(server string, id string) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "id", id, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := "/things/" + pathParam0 + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +func (c *Client) applyEditors(ctx context.Context, req *http.Request, additionalEditors []RequestEditorFn) error { + for _, r := range c.RequestEditors { + if err := r(ctx, req); err != nil { + return err + } + } + for _, r := range additionalEditors { + if err := r(ctx, req); err != nil { + return err + } + } + return nil +} + +// ClientWithResponses builds on ClientInterface to offer response payloads +type ClientWithResponses struct { + ClientInterface +} + +// NewClientWithResponses creates a new ClientWithResponses, which wraps +// Client with return type handling +func NewClientWithResponses(server string, opts ...ClientOption) (*ClientWithResponses, error) { + client, err := NewClient(server, opts...) + if err != nil { + return nil, err + } + return &ClientWithResponses{client}, nil +} + +// WithBaseURL overrides the baseURL. +func WithBaseURL(baseURL string) ClientOption { + return func(c *Client) error { + newBaseURL, err := url.Parse(baseURL) + if err != nil { + return err + } + c.Server = newBaseURL.String() + return nil + } +} + +// ClientWithResponsesInterface is the interface specification for the client with responses above. +type ClientWithResponsesInterface interface { + + // GetThingWithResponse Get a thing by id + // + // Returns a wrapper object for the known response body format(s). + // + // Corresponds with GET /things/{id} (the `GetThing` operationId). + GetThingWithResponse(ctx context.Context, id string, reqEditors ...RequestEditorFn) (*GetThingResponse, error) +} + +type GetThingResponse struct { + Body []byte + HTTPResponse *http.Response + // JSON200 the response for an HTTP 200 `application/json` response + JSON200 *Thing +} + +// GetJSON200 returns the response for an HTTP 200 `application/json` response +func (r GetThingResponse) GetJSON200() *Thing { + return r.JSON200 +} + +// GetBody returns the raw response body bytes +func (r GetThingResponse) GetBody() []byte { + return r.Body +} + +// Status returns HTTPResponse.Status +func (r GetThingResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetThingResponse) 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 GetThingResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +// GetThingWithResponse Get a thing by id +// +// Returns a wrapper object for the known response body format(s). +// +// Corresponds with GET /things/{id} (the `GetThing` operationId). +func (c *ClientWithResponses) GetThingWithResponse(ctx context.Context, id string, reqEditors ...RequestEditorFn) (*GetThingResponse, error) { + rsp, err := c.GetThing(ctx, id, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetThingResponse(rsp) +} + +// ParseGetThingResponse parses an HTTP response from a GetThingWithResponse call +func ParseGetThingResponse(rsp *http.Response) (*GetThingResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetThingResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest Thing + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + } + + return response, nil +} diff --git a/internal/test/options/unexpected_response/skipped/unexpected_response_test.go b/internal/test/options/unexpected_response/skipped/unexpected_response_test.go new file mode 100644 index 000000000..ebee84f2f --- /dev/null +++ b/internal/test/options/unexpected_response/skipped/unexpected_response_test.go @@ -0,0 +1,34 @@ +package optionsunexpectedresponseskipped + +import ( + "bytes" + "io" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newResponse(status int, contentType, body string) *http.Response { + h := http.Header{} + if contentType != "" { + h.Set("Content-Type", contentType) + } + return &http.Response{ + StatusCode: status, + Header: h, + Body: io.NopCloser(bytes.NewReader([]byte(body))), + } +} + +// outputoptions/unexpected-response/skipped: with the option unset (the default), +// an undeclared response yields a response object and a nil error, so existing +// behaviour is preserved. +func TestUnexpectedResponseHasNoError(t *testing.T) { + res, err := ParseGetThingResponse(newResponse(http.StatusInternalServerError, "application/json", `{"oops":true}`)) + require.NoError(t, err) + require.NotNil(t, res) + assert.Nil(t, res.JSON200) + assert.Equal(t, http.StatusInternalServerError, res.StatusCode()) +} diff --git a/internal/test/options/unexpected_response/spec.yaml b/internal/test/options/unexpected_response/spec.yaml new file mode 100644 index 000000000..d4a6dc619 --- /dev/null +++ b/internal/test/options/unexpected_response/spec.yaml @@ -0,0 +1,52 @@ +# ========================================================================== +# Category: options/unexpected_response (shared spec for the enabled/ + skipped/ sub-packages) +# Tests: the client-response-error-on-unexpected-response output option. +# +# Both sub-packages generate client+models from THIS spec; only the +# client-response-error-on-unexpected-response flag differs. They live in +# separate Go packages because each emits the same client infrastructure +# (Client, GetThingResponse, ParseGetThingResponse, ...) which would collide +# in one package. +# +# The spec deliberately declares only a 200 response, so any other status code +# (or an unexpected content-type on 200) is "unexpected" and exercises the +# generated default case. +# ========================================================================== +openapi: "3.0.0" +info: + version: "1.0.0" + title: unexpected_response + description: | + Example for the client-response-error-on-unexpected-response output option: + GET /things/{id} declares only a 200 (Thing) response, so ParseGetThingResponse + has a single case in its response switch. +paths: + /things/{id}: + get: + summary: Get a thing by id + operationId: getThing + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + '200': + description: a thing + content: + application/json: + schema: + $ref: '#/components/schemas/Thing' +components: + schemas: + Thing: + type: object + required: + - id + - name + properties: + id: + type: string + name: + type: string diff --git a/pkg/codegen/configuration.go b/pkg/codegen/configuration.go index 4fd79e410..5aa05dc84 100644 --- a/pkg/codegen/configuration.go +++ b/pkg/codegen/configuration.go @@ -483,6 +483,15 @@ type OutputOptions struct { // SkipClientResponseContentType disables the generation of a `ContentType()` method on response objects for `ClientWithResponses`, which is otherwise generated by default. SkipClientResponseContentType bool `yaml:"skip-client-response-content-type,omitempty"` + // ClientResponseErrorOnUnexpectedResponse makes the generated + // `ParseResponse` functions add a `default` case to their + // response switch that returns the sentinel `ErrUnexpectedResponse` error + // when the server's status code and content-type match none of the + // responses declared in the OpenAPI specification. By default no such case + // is generated and an unexpected response yields a response object with + // only its `Body`/`HTTPResponse` populated and a nil error. + ClientResponseErrorOnUnexpectedResponse bool `yaml:"client-response-error-on-unexpected-response,omitempty"` + // PreferSkipOptionalPointer allows defining at a global level whether to omit the pointer for a type to indicate that the field/type is optional. // This is the same as adding `x-go-type-skip-optional-pointer` to each field (manually, or using an OpenAPI Overlay) PreferSkipOptionalPointer bool `yaml:"prefer-skip-optional-pointer,omitempty"` diff --git a/pkg/codegen/template_helpers.go b/pkg/codegen/template_helpers.go index 852cf7e91..e842497b5 100644 --- a/pkg/codegen/template_helpers.go +++ b/pkg/codegen/template_helpers.go @@ -278,6 +278,9 @@ func genResponseUnmarshal(op *OperationDefinition) string { fmt.Fprintf(buffer, "%s\n", unhandledCaseClauses[caseClauseKey]) } + if globalState.options.OutputOptions.ClientResponseErrorOnUnexpectedResponse { + fmt.Fprintf(buffer, "default:\nreturn nil, ErrUnexpectedResponse\n") + } fmt.Fprintf(buffer, "}\n") return buffer.String() diff --git a/pkg/codegen/templates/client-with-responses.tmpl b/pkg/codegen/templates/client-with-responses.tmpl index 13854253b..46b522a28 100644 --- a/pkg/codegen/templates/client-with-responses.tmpl +++ b/pkg/codegen/templates/client-with-responses.tmpl @@ -15,6 +15,13 @@ func NewClientWithResponses(server string, opts ...ClientOption) (*ClientWithRes {{$clientTypeName := opts.OutputOptions.ClientTypeName -}} +{{if opts.OutputOptions.ClientResponseErrorOnUnexpectedResponse}} +// ErrUnexpectedResponse is returned by the ParseResponse functions +// when the server responds with a status code and content-type that match none +// of the responses declared in the OpenAPI specification. +var ErrUnexpectedResponse = errors.New("unexpected response") +{{end}} + // WithBaseURL overrides the baseURL. func WithBaseURL(baseURL string) ClientOption { return func(c *{{ $clientTypeName }}) error {