From 5a671124e5aace0f1b50935aecb08fe8405da1cd Mon Sep 17 00:00:00 2001 From: Marcin Romaszewicz Date: Mon, 13 Jul 2026 07:27:28 -0700 Subject: [PATCH] Parse declared response headers into typed fields on client response wrappers Closes: #2011 Response headers declared in the spec were dropped by the generated client: the response wrapper exposed only the body fields, leaving declared headers reachable solely through the raw http.Response. The strict server already generates typed header structs and writes them in its Visit* methods; the client had no equivalent on the read side. Each response that declares headers now generates a client-local struct named after the response wrapper (e.g. GetFooResponse200Headers), and the wrapper gains a matching Headers pointer field (Headers200, HeadersDefault, ...) populated by ParseResponse using runtime.BindStyledParameterWithOptions with ParamLocationHeader ("simple" style, per the OpenAPI spec for response headers). Deriving the type name from the wrapper (via genResponseTypeName) keeps it collision-resolver-aware, and the types are emitted and consumed entirely within the client output, independent of the strict server's ResponseHeaders types. Binding is lenient: an absent header - even a required one - leaves the field at its zero value rather than failing the parse, mirroring the body unmarshal's tolerance of spec-violating servers; a present header that fails to bind to its declared type is an error. Case clauses are ordered most-specific-first (exact codes, then range wildcards, then default) by the same lexicographic scheme the body unmarshal uses. The change is purely additive: specs without response headers generate byte-identical output, and no strict-server or model output changes. Co-Authored-By: Claude Fable 5 --- .../test/clients/responseheaders/config.yaml | 10 + internal/test/clients/responseheaders/doc.go | 11 + .../clients/responseheaders/headers.gen.go | 355 ++++++++++++++++++ .../clients/responseheaders/headers_test.go | 99 +++++ .../test/clients/responseheaders/spec.yaml | 54 +++ .../test/naming/conflicts/conflicts.gen.go | 20 + .../multipackage/pruned_deps/api.gen.go | 29 ++ .../test/schemas/deprecated/deprecated.gen.go | 50 +++ .../test/servers/strict/client/client.gen.go | 128 +++++++ pkg/codegen/operations.go | 20 + pkg/codegen/template_helpers.go | 20 + .../templates/client-with-responses.tmpl | 44 +++ 12 files changed, 840 insertions(+) create mode 100644 internal/test/clients/responseheaders/config.yaml create mode 100644 internal/test/clients/responseheaders/doc.go create mode 100644 internal/test/clients/responseheaders/headers.gen.go create mode 100644 internal/test/clients/responseheaders/headers_test.go create mode 100644 internal/test/clients/responseheaders/spec.yaml diff --git a/internal/test/clients/responseheaders/config.yaml b/internal/test/clients/responseheaders/config.yaml new file mode 100644 index 000000000..a91444800 --- /dev/null +++ b/internal/test/clients/responseheaders/config.yaml @@ -0,0 +1,10 @@ +# yaml-language-server: $schema=../../../../configuration-schema.json +# From issue-2011 +package: responseheaders +output: headers.gen.go +generate: + models: true + client: true +output-options: + # Exercises the nullable.Nullable[T] header-binding path (IsNullable). + nullable-type: true diff --git a/internal/test/clients/responseheaders/doc.go b/internal/test/clients/responseheaders/doc.go new file mode 100644 index 000000000..c15695574 --- /dev/null +++ b/internal/test/clients/responseheaders/doc.go @@ -0,0 +1,11 @@ +// Package responseheaders verifies that response headers declared in the +// spec are parsed into typed Headers fields on the generated +// client response wrappers. The header structs are client-local types named +// after the response wrapper (e.g. GetFooResponse200Headers), emitted and +// consumed entirely within the client output; specs without response +// headers generate no additional code. +// +// From issue-2011. +package responseheaders + +//go:generate go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen --config=config.yaml spec.yaml diff --git a/internal/test/clients/responseheaders/headers.gen.go b/internal/test/clients/responseheaders/headers.gen.go new file mode 100644 index 000000000..74c6a80b9 --- /dev/null +++ b/internal/test/clients/responseheaders/headers.gen.go @@ -0,0 +1,355 @@ +// Package responseheaders 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 responseheaders + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "github.com/oapi-codegen/nullable" + "github.com/oapi-codegen/runtime" +) + +// Data defines model for data. +type Data struct { + Foo *string `json:"foo,omitempty"` +} + +// NotFound defines model for NotFound. +type NotFound = Data + +// 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 { + + // GetFoo performs a GET /foo (the `GetFoo` operationId) request. + GetFoo(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) +} + +// GetFoo performs a GET /foo (the `GetFoo` operationId) request. +func (c *Client) GetFoo(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetFooRequest(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) +} + +// NewGetFooRequest constructs an http.Request for the GetFoo method +func NewGetFooRequest(server string) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/foo") + 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 { + + // GetFooWithResponse performs a GET /foo (the `GetFoo` operationId) request. + // + // Returns a wrapper object for the known response body format(s). + GetFooWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetFooResponse, error) +} + +// GetFooResponse200Headers the declared response headers of an HTTP 200 response for GetFoo +type GetFooResponse200Headers struct { + Bar *string + XNextCursor nullable.Nullable[string] + XRequestId int +} + +// GetFooResponse404Headers the declared response headers of an HTTP 404 response for GetFoo +type GetFooResponse404Headers struct { + TraceId string +} + +// GetFooResponseDefaultHeaders the declared response headers of an HTTP default response for GetFoo +type GetFooResponseDefaultHeaders struct { + RetryAfter *int +} + +type GetFooResponse struct { + Body []byte + HTTPResponse *http.Response + // JSON200 the response for an HTTP 200 `application/json` response + JSON200 *Data + // JSON404 the response for an HTTP 404 `application/json` response + JSON404 *NotFound + // Headers200 the parsed response headers for an HTTP 200 response + Headers200 *GetFooResponse200Headers + // Headers404 the parsed response headers for an HTTP 404 response + Headers404 *GetFooResponse404Headers + // HeadersDefault the parsed response headers for an HTTP default response + HeadersDefault *GetFooResponseDefaultHeaders +} + +// GetJSON200 returns the response for an HTTP 200 `application/json` response +func (r GetFooResponse) GetJSON200() *Data { + return r.JSON200 +} + +// GetJSON404 returns the response for an HTTP 404 `application/json` response +func (r GetFooResponse) GetJSON404() *NotFound { + return r.JSON404 +} + +// GetBody returns the raw response body bytes +func (r GetFooResponse) GetBody() []byte { + return r.Body +} + +// Status returns HTTPResponse.Status +func (r GetFooResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetFooResponse) 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 GetFooResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +// GetFooWithResponse performs a GET /foo (the `GetFoo` operationId) request. +// +// Returns a wrapper object for the known response body format(s). +func (c *ClientWithResponses) GetFooWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetFooResponse, error) { + rsp, err := c.GetFoo(ctx, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetFooResponse(rsp) +} + +// ParseGetFooResponse parses an HTTP response from a GetFooWithResponse call +func ParseGetFooResponse(rsp *http.Response) (*GetFooResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetFooResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest Data + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest NotFound + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON404 = &dest + + } + + switch { + case rsp.StatusCode == 200: + var headers GetFooResponse200Headers + if values := rsp.Header.Values("bar"); len(values) > 0 { + var value string + if err := runtime.BindStyledParameterWithOptions("simple", "bar", values[0], &value, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationHeader, Explode: false, Required: false, Type: "string", Format: ""}); err != nil { + return nil, err + } + headers.Bar = &value + } + if values := rsp.Header.Values("x-next-cursor"); len(values) > 0 { + var value string + if err := runtime.BindStyledParameterWithOptions("simple", "x-next-cursor", values[0], &value, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationHeader, Explode: false, Required: false, Type: "string", Format: ""}); err != nil { + return nil, err + } + headers.XNextCursor.Set(value) + } + if values := rsp.Header.Values("x-request-id"); len(values) > 0 { + var value int + if err := runtime.BindStyledParameterWithOptions("simple", "x-request-id", values[0], &value, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationHeader, Explode: false, Required: true, Type: "integer", Format: ""}); err != nil { + return nil, err + } + headers.XRequestId = value + } + response.Headers200 = &headers + case rsp.StatusCode == 404: + var headers GetFooResponse404Headers + if values := rsp.Header.Values("trace-id"); len(values) > 0 { + var value string + if err := runtime.BindStyledParameterWithOptions("simple", "trace-id", values[0], &value, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationHeader, Explode: false, Required: true, Type: "string", Format: ""}); err != nil { + return nil, err + } + headers.TraceId = value + } + response.Headers404 = &headers + case true: + var headers GetFooResponseDefaultHeaders + if values := rsp.Header.Values("retry-after"); len(values) > 0 { + var value int + if err := runtime.BindStyledParameterWithOptions("simple", "retry-after", values[0], &value, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationHeader, Explode: false, Required: false, Type: "integer", Format: ""}); err != nil { + return nil, err + } + headers.RetryAfter = &value + } + response.HeadersDefault = &headers + } + + return response, nil +} diff --git a/internal/test/clients/responseheaders/headers_test.go b/internal/test/clients/responseheaders/headers_test.go new file mode 100644 index 000000000..e5318e78e --- /dev/null +++ b/internal/test/clients/responseheaders/headers_test.go @@ -0,0 +1,99 @@ +package responseheaders + +import ( + "bytes" + "io" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func makeResponse(status int, headers map[string]string, body string) *http.Response { + h := http.Header{} + h.Set("Content-Type", "application/json") + for k, v := range headers { + h.Set(k, v) + } + return &http.Response{ + StatusCode: status, + Header: h, + Body: io.NopCloser(bytes.NewBufferString(body)), + } +} + +func TestParsesDeclaredResponseHeaders(t *testing.T) { + rsp, err := ParseGetFooResponse(makeResponse(200, map[string]string{ + "bar": "bar-value", + "x-request-id": "42", + }, `{"foo":"hello"}`)) + require.NoError(t, err) + + require.NotNil(t, rsp.Headers200) + require.NotNil(t, rsp.Headers200.Bar) + assert.Equal(t, "bar-value", *rsp.Headers200.Bar) + assert.Equal(t, 42, rsp.Headers200.XRequestId) + assert.Nil(t, rsp.Headers404) + assert.Nil(t, rsp.HeadersDefault) +} + +func TestParsesNullableResponseHeader(t *testing.T) { + rsp, err := ParseGetFooResponse(makeResponse(200, map[string]string{ + "x-request-id": "42", + "x-next-cursor": "cursor-value", + }, `{"foo":"hello"}`)) + require.NoError(t, err) + + require.NotNil(t, rsp.Headers200) + require.True(t, rsp.Headers200.XNextCursor.IsSpecified()) + assert.Equal(t, "cursor-value", rsp.Headers200.XNextCursor.MustGet()) +} + +func TestAbsentNullableResponseHeaderIsUnspecified(t *testing.T) { + rsp, err := ParseGetFooResponse(makeResponse(200, nil, `{"foo":"hello"}`)) + require.NoError(t, err) + + require.NotNil(t, rsp.Headers200) + assert.False(t, rsp.Headers200.XNextCursor.IsSpecified()) +} + +func TestParsesComponentResponseHeaders(t *testing.T) { + rsp, err := ParseGetFooResponse(makeResponse(404, map[string]string{ + "trace-id": "abc", + }, `{}`)) + require.NoError(t, err) + + require.NotNil(t, rsp.Headers404) + assert.Equal(t, "abc", rsp.Headers404.TraceId) + assert.Nil(t, rsp.Headers200) +} + +func TestAbsentOptionalHeaderLeavesFieldNil(t *testing.T) { + rsp, err := ParseGetFooResponse(makeResponse(200, nil, `{"foo":"hello"}`)) + require.NoError(t, err) + + require.NotNil(t, rsp.Headers200) + assert.Nil(t, rsp.Headers200.Bar) + // Absent required headers are tolerated (spec-violating server); the + // field is left at its zero value rather than failing the parse. + assert.Equal(t, 0, rsp.Headers200.XRequestId) +} + +func TestParsesDefaultResponseHeaders(t *testing.T) { + rsp, err := ParseGetFooResponse(makeResponse(500, map[string]string{ + "retry-after": "3", + }, ``)) + require.NoError(t, err) + + require.NotNil(t, rsp.HeadersDefault) + require.NotNil(t, rsp.HeadersDefault.RetryAfter) + assert.Equal(t, 3, *rsp.HeadersDefault.RetryAfter) +} + +func TestMalformedHeaderValueErrors(t *testing.T) { + _, err := ParseGetFooResponse(makeResponse(200, map[string]string{ + "x-request-id": "not-a-number", + }, `{"foo":"hello"}`)) + assert.Error(t, err) +} diff --git a/internal/test/clients/responseheaders/spec.yaml b/internal/test/clients/responseheaders/spec.yaml new file mode 100644 index 000000000..777c3fca2 --- /dev/null +++ b/internal/test/clients/responseheaders/spec.yaml @@ -0,0 +1,54 @@ +openapi: 3.0.2 +info: + version: "0.0.1" + title: response-headers +paths: + /foo: + get: + operationId: getFoo + responses: + 200: + description: "endpoint" + headers: + "bar": + schema: + type: string + "x-next-cursor": + schema: + type: string + nullable: true + "x-request-id": + required: true + schema: + type: integer + content: + application/json: + schema: + $ref: "#/components/schemas/data" + 404: + $ref: "#/components/responses/NotFound" + default: + description: "error" + headers: + "retry-after": + schema: + type: integer +components: + responses: + NotFound: + description: "not found" + headers: + "trace-id": + required: true + schema: + type: string + content: + application/json: + schema: + $ref: "#/components/schemas/data" + schemas: + data: + type: object + properties: + foo: + type: string diff --git a/internal/test/naming/conflicts/conflicts.gen.go b/internal/test/naming/conflicts/conflicts.gen.go index 736cc88bb..fcf5b2fd3 100644 --- a/internal/test/naming/conflicts/conflicts.gen.go +++ b/internal/test/naming/conflicts/conflicts.gen.go @@ -2106,11 +2106,18 @@ func (r ListEntitiesResponse) ContentType() string { return "" } +// PostFooResponse200Headers the declared response headers of an HTTP 200 response for PostFoo +type PostFooResponse200Headers struct { + XBar *bool +} + type PostFooResponse struct { Body []byte HTTPResponse *http.Response // JSON200 the response for an HTTP 200 `application/json` response JSON200 *BarResponse + // Headers200 the parsed response headers for an HTTP 200 response + Headers200 *PostFooResponse200Headers } // GetJSON200 returns the response for an HTTP 200 `application/json` response @@ -3207,6 +3214,19 @@ func ParsePostFooResponse(rsp *http.Response) (*PostFooResponse, error) { } + switch { + case rsp.StatusCode == 200: + var headers PostFooResponse200Headers + if values := rsp.Header.Values("X-Bar"); len(values) > 0 { + var value bool + if err := runtime.BindStyledParameterWithOptions("simple", "X-Bar", values[0], &value, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationHeader, Explode: false, Required: false, Type: "boolean", Format: ""}); err != nil { + return nil, err + } + headers.XBar = &value + } + response.Headers200 = &headers + } + return response, nil } diff --git a/internal/test/references/multipackage/pruned_deps/api.gen.go b/internal/test/references/multipackage/pruned_deps/api.gen.go index fd716c421..96a0e17c8 100644 --- a/internal/test/references/multipackage/pruned_deps/api.gen.go +++ b/internal/test/references/multipackage/pruned_deps/api.gen.go @@ -14,6 +14,7 @@ import ( "github.com/go-chi/chi/v5" externalRef0 "github.com/oapi-codegen/oapi-codegen/v2/internal/test/references/multipackage/pruned_deps/deps" + "github.com/oapi-codegen/runtime" ) // Thing defines model for Thing. @@ -213,6 +214,12 @@ type ClientWithResponsesInterface interface { GetThingsWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetThingsResponse, error) } +// GetThingsResponse304Headers the declared response headers of an HTTP 304 response for GetThings +type GetThingsResponse304Headers struct { + CacheControl *string + ETag *string +} + type GetThingsResponse struct { Body []byte HTTPResponse *http.Response @@ -226,6 +233,8 @@ type GetThingsResponse struct { JSON404 *N404 // JSON500 the response for an HTTP 500 `application/json` response JSON500 *externalRef0.DefaultError + // Headers304 the parsed response headers for an HTTP 304 response + Headers304 *GetThingsResponse304Headers } // GetJSON200 returns the response for an HTTP 200 `application/json` response @@ -357,6 +366,26 @@ func ParseGetThingsResponse(rsp *http.Response) (*GetThingsResponse, error) { } + switch { + case rsp.StatusCode == 304: + var headers GetThingsResponse304Headers + if values := rsp.Header.Values("Cache-Control"); len(values) > 0 { + var value string + if err := runtime.BindStyledParameterWithOptions("simple", "Cache-Control", values[0], &value, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationHeader, Explode: false, Required: false, Type: "string", Format: ""}); err != nil { + return nil, err + } + headers.CacheControl = &value + } + if values := rsp.Header.Values("ETag"); len(values) > 0 { + var value string + if err := runtime.BindStyledParameterWithOptions("simple", "ETag", values[0], &value, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationHeader, Explode: false, Required: false, Type: "string", Format: ""}); err != nil { + return nil, err + } + headers.ETag = &value + } + response.Headers304 = &headers + } + return response, nil } diff --git a/internal/test/schemas/deprecated/deprecated.gen.go b/internal/test/schemas/deprecated/deprecated.gen.go index 151316235..c3d6ec0da 100644 --- a/internal/test/schemas/deprecated/deprecated.gen.go +++ b/internal/test/schemas/deprecated/deprecated.gen.go @@ -948,6 +948,12 @@ func (r GetDeprecatedFieldResponse) ContentType() string { return "" } +// GetDeprecatedParamsOldIdResponse200Headers the declared response headers of an HTTP 200 response for GetDeprecatedParamsOldId +type GetDeprecatedParamsOldIdResponse200Headers struct { + // Deprecated: Use the Authorization header instead. + XOldToken *string +} + type GetDeprecatedParamsOldIdResponse struct { Body []byte HTTPResponse *http.Response @@ -955,6 +961,8 @@ type GetDeprecatedParamsOldIdResponse struct { JSON200 *struct { Result *string `json:"result,omitempty"` } + // Headers200 the parsed response headers for an HTTP 200 response + Headers200 *GetDeprecatedParamsOldIdResponse200Headers } // GetJSON200 returns the response for an HTTP 200 `application/json` response @@ -1034,11 +1042,20 @@ func (r PostDeprecatedRequestBodyResponse) ContentType() string { return "" } +// GetLegacyResponse200Headers the declared response headers of an HTTP 200 response for GetLegacy +type GetLegacyResponse200Headers struct { + XCorrelationId *string + // Deprecated: Use X-Trace-Id instead. + XOldTrace *string +} + type GetLegacyResponse struct { Body []byte HTTPResponse *http.Response // JSON200 the response for an HTTP 200 `application/json` response JSON200 *LegacyResponse + // Headers200 the parsed response headers for an HTTP 200 response + Headers200 *GetLegacyResponse200Headers } // GetJSON200 returns the response for an HTTP 200 `application/json` response @@ -1325,6 +1342,19 @@ func ParseGetDeprecatedParamsOldIdResponse(rsp *http.Response) (*GetDeprecatedPa } + switch { + case rsp.StatusCode == 200: + var headers GetDeprecatedParamsOldIdResponse200Headers + if values := rsp.Header.Values("X-Old-Token"); len(values) > 0 { + var value string + if err := runtime.BindStyledParameterWithOptions("simple", "X-Old-Token", values[0], &value, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationHeader, Explode: false, Required: false, Type: "string", Format: ""}); err != nil { + return nil, err + } + headers.XOldToken = &value + } + response.Headers200 = &headers + } + return response, nil } @@ -1377,6 +1407,26 @@ func ParseGetLegacyResponse(rsp *http.Response) (*GetLegacyResponse, error) { } + switch { + case rsp.StatusCode == 200: + var headers GetLegacyResponse200Headers + if values := rsp.Header.Values("X-Correlation-Id"); len(values) > 0 { + var value string + if err := runtime.BindStyledParameterWithOptions("simple", "X-Correlation-Id", values[0], &value, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationHeader, Explode: false, Required: false, Type: "string", Format: ""}); err != nil { + return nil, err + } + headers.XCorrelationId = &value + } + if values := rsp.Header.Values("X-Old-Trace"); len(values) > 0 { + var value string + if err := runtime.BindStyledParameterWithOptions("simple", "X-Old-Trace", values[0], &value, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationHeader, Explode: false, Required: false, Type: "string", Format: ""}); err != nil { + return nil, err + } + headers.XOldTrace = &value + } + response.Headers200 = &headers + } + return response, nil } diff --git a/internal/test/servers/strict/client/client.gen.go b/internal/test/servers/strict/client/client.gen.go index c12648584..d8166877e 100644 --- a/internal/test/servers/strict/client/client.gen.go +++ b/internal/test/servers/strict/client/client.gen.go @@ -1833,9 +1833,17 @@ func (r MultipleRequestAndResponseTypesResponse) ContentType() string { return "" } +// NoContentHeadersResponse204Headers the declared response headers of an HTTP 204 response for NoContentHeaders +type NoContentHeadersResponse204Headers struct { + NullableHeader *string + OptionalHeader *string +} + type NoContentHeadersResponse struct { Body []byte HTTPResponse *http.Response + // Headers204 the parsed response headers for an HTTP 204 response + Headers204 *NoContentHeadersResponse204Headers } // GetBody returns the raw response body bytes @@ -1976,11 +1984,19 @@ func (r ReservedGoKeywordParametersResponse) ContentType() string { return "" } +// ReusableResponsesResponse200Headers the declared response headers of an HTTP 200 response for ReusableResponses +type ReusableResponsesResponse200Headers struct { + Header1 string + Header2 int +} + type ReusableResponsesResponse struct { Body []byte HTTPResponse *http.Response // JSON200 the response for an HTTP 200 `application/json` response JSON200 *Reusableresponse + // Headers200 the parsed response headers for an HTTP 200 response + Headers200 *ReusableResponsesResponse200Headers } // GetJSON200 returns the response for an HTTP 200 `application/json` response @@ -2194,11 +2210,21 @@ func (r URLEncodedExampleResponse) ContentType() string { return "" } +// HeadersExampleResponse200Headers the declared response headers of an HTTP 200 response for HeadersExample +type HeadersExampleResponse200Headers struct { + Header1 string + Header2 int + NullableHeader *string + OptionalHeader *string +} + type HeadersExampleResponse struct { Body []byte HTTPResponse *http.Response // JSON200 the response for an HTTP 200 `application/json` response JSON200 *Example + // Headers200 the parsed response headers for an HTTP 200 response + Headers200 *HeadersExampleResponse200Headers } // GetJSON200 returns the response for an HTTP 200 `application/json` response @@ -2235,6 +2261,12 @@ func (r HeadersExampleResponse) ContentType() string { return "" } +// UnionExampleResponse200Headers the declared response headers of an HTTP 200 response for UnionExample +type UnionExampleResponse200Headers struct { + Header1 string + Header2 int +} + type UnionExampleResponse struct { Body []byte HTTPResponse *http.Response @@ -2242,6 +2274,8 @@ type UnionExampleResponse struct { ApplicationalternativeJSON200 *Example // JSON200 the response for an HTTP 200 `application/json` response JSON200 *UnionExample200JSONResponseBody + // Headers200 the parsed response headers for an HTTP 200 response + Headers200 *UnionExampleResponse200Headers } // GetApplicationalternativeJSON200 returns the response for an HTTP 200 `application/alternative+json` response @@ -2743,6 +2777,26 @@ func ParseNoContentHeadersResponse(rsp *http.Response) (*NoContentHeadersRespons HTTPResponse: rsp, } + switch { + case rsp.StatusCode == 204: + var headers NoContentHeadersResponse204Headers + if values := rsp.Header.Values("nullable-header"); len(values) > 0 { + var value string + if err := runtime.BindStyledParameterWithOptions("simple", "nullable-header", values[0], &value, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationHeader, Explode: false, Required: false, Type: "string", Format: ""}); err != nil { + return nil, err + } + headers.NullableHeader = &value + } + if values := rsp.Header.Values("optional-header"); len(values) > 0 { + var value string + if err := runtime.BindStyledParameterWithOptions("simple", "optional-header", values[0], &value, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationHeader, Explode: false, Required: false, Type: "string", Format: ""}); err != nil { + return nil, err + } + headers.OptionalHeader = &value + } + response.Headers204 = &headers + } + return response, nil } @@ -2833,6 +2887,26 @@ func ParseReusableResponsesResponse(rsp *http.Response) (*ReusableResponsesRespo } + switch { + case rsp.StatusCode == 200: + var headers ReusableResponsesResponse200Headers + if values := rsp.Header.Values("header1"); len(values) > 0 { + var value string + if err := runtime.BindStyledParameterWithOptions("simple", "header1", values[0], &value, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationHeader, Explode: false, Required: true, Type: "string", Format: ""}); err != nil { + return nil, err + } + headers.Header1 = value + } + if values := rsp.Header.Values("header2"); len(values) > 0 { + var value int + if err := runtime.BindStyledParameterWithOptions("simple", "header2", values[0], &value, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationHeader, Explode: false, Required: true, Type: "integer", Format: ""}); err != nil { + return nil, err + } + headers.Header2 = value + } + response.Headers200 = &headers + } + return response, nil } @@ -2952,6 +3026,40 @@ func ParseHeadersExampleResponse(rsp *http.Response) (*HeadersExampleResponse, e } + switch { + case rsp.StatusCode == 200: + var headers HeadersExampleResponse200Headers + if values := rsp.Header.Values("header1"); len(values) > 0 { + var value string + if err := runtime.BindStyledParameterWithOptions("simple", "header1", values[0], &value, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationHeader, Explode: false, Required: true, Type: "string", Format: ""}); err != nil { + return nil, err + } + headers.Header1 = value + } + if values := rsp.Header.Values("header2"); len(values) > 0 { + var value int + if err := runtime.BindStyledParameterWithOptions("simple", "header2", values[0], &value, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationHeader, Explode: false, Required: true, Type: "integer", Format: ""}); err != nil { + return nil, err + } + headers.Header2 = value + } + if values := rsp.Header.Values("nullable-header"); len(values) > 0 { + var value string + if err := runtime.BindStyledParameterWithOptions("simple", "nullable-header", values[0], &value, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationHeader, Explode: false, Required: false, Type: "string", Format: ""}); err != nil { + return nil, err + } + headers.NullableHeader = &value + } + if values := rsp.Header.Values("optional-header"); len(values) > 0 { + var value string + if err := runtime.BindStyledParameterWithOptions("simple", "optional-header", values[0], &value, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationHeader, Explode: false, Required: false, Type: "string", Format: ""}); err != nil { + return nil, err + } + headers.OptionalHeader = &value + } + response.Headers200 = &headers + } + return response, nil } @@ -2988,5 +3096,25 @@ func ParseUnionExampleResponse(rsp *http.Response) (*UnionExampleResponse, error } + switch { + case rsp.StatusCode == 200: + var headers UnionExampleResponse200Headers + if values := rsp.Header.Values("header1"); len(values) > 0 { + var value string + if err := runtime.BindStyledParameterWithOptions("simple", "header1", values[0], &value, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationHeader, Explode: false, Required: true, Type: "string", Format: ""}); err != nil { + return nil, err + } + headers.Header1 = value + } + if values := rsp.Header.Values("header2"); len(values) > 0 { + var value int + if err := runtime.BindStyledParameterWithOptions("simple", "header2", values[0], &value, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationHeader, Explode: false, Required: true, Type: "integer", Format: ""}); err != nil { + return nil, err + } + headers.Header2 = value + } + response.Headers200 = &headers + } + return response, nil } diff --git a/pkg/codegen/operations.go b/pkg/codegen/operations.go index 2235c5ceb..90dca5f90 100644 --- a/pkg/codegen/operations.go +++ b/pkg/codegen/operations.go @@ -1028,6 +1028,26 @@ func (h ResponseHeaderDefinition) IsNullable() bool { return globalState.options.OutputOptions.NullableType && h.Nullable } +// SchemaType returns the first OpenAPI type string for this header's schema +// (e.g. "string", "integer"), or empty string if unavailable. +func (h ResponseHeaderDefinition) SchemaType() string { + if h.Schema.OAPISchema != nil && h.Schema.OAPISchema.Type != nil { + if s := h.Schema.OAPISchema.Type.Slice(); len(s) > 0 { + return s[0] + } + } + return "" +} + +// SchemaFormat returns the OpenAPI format string for this header's schema +// (e.g. "date-time", "duration"), or empty string if unavailable. +func (h ResponseHeaderDefinition) SchemaFormat() string { + if h.Schema.OAPISchema != nil { + return h.Schema.OAPISchema.Format + } + return "" +} + // FilterParameterDefinitionByType returns the subset of the specified parameters which are of the // specified type. func FilterParameterDefinitionByType(params []ParameterDefinition, in string) []ParameterDefinition { diff --git a/pkg/codegen/template_helpers.go b/pkg/codegen/template_helpers.go index e080aac62..3a91cfdeb 100644 --- a/pkg/codegen/template_helpers.go +++ b/pkg/codegen/template_helpers.go @@ -338,6 +338,24 @@ func getConditionOfResponseName(statusCodeVar, responseName string) string { } } +// responsesWithHeaders returns the subset of responses that declare headers, +// ordered most-specific-first for emission as switch case clauses: exact +// status codes sort numerically, range wildcards after the exact codes they +// cover ("204" < "2XX" since 'X' > '9'), and "default" (which compiles to +// `case true:`) after everything ('d' > 'X'). +func responsesWithHeaders(responses []ResponseDefinition) []ResponseDefinition { + var out []ResponseDefinition + for _, response := range responses { + if len(response.Headers) > 0 { + out = append(out, response) + } + } + slices.SortFunc(out, func(a, b ResponseDefinition) int { + return strings.Compare(a.StatusCode, b.StatusCode) + }) + return out +} + // This outputs a string array func toStringArray(sarr []string) string { s := strings.Join(sarr, `","`) @@ -427,6 +445,8 @@ var TemplateFunctions = template.FuncMap{ "genResponsePayload": genResponsePayload, "genResponseTypeName": genResponseTypeName, "genResponseUnmarshal": genResponseUnmarshal, + "getConditionOfResponseName": getConditionOfResponseName, + "responsesWithHeaders": responsesWithHeaders, "getResponseTypeDefinitions": getResponseTypeDefinitions, "toStringArray": toStringArray, "lower": strings.ToLower, diff --git a/pkg/codegen/templates/client-with-responses.tmpl b/pkg/codegen/templates/client-with-responses.tmpl index 7e1b77ba4..37fdd83b6 100644 --- a/pkg/codegen/templates/client-with-responses.tmpl +++ b/pkg/codegen/templates/client-with-responses.tmpl @@ -60,6 +60,16 @@ type ClientWithResponsesInterface interface { {{range .}}{{$opid := .OperationId}}{{$op := .}} {{$responseTypeDefinitions := getResponseTypeDefinitions .}} +{{$headerResponses := responsesWithHeaders .Responses}} +{{- range $headerResponses}} +// {{genResponseTypeName $opid | ucFirst}}{{.StatusCode | ucFirst}}Headers the declared response headers of an HTTP {{.StatusCode}} response for {{$opid}} +type {{genResponseTypeName $opid | ucFirst}}{{.StatusCode | ucFirst}}Headers struct { + {{range .Headers -}} + {{with .DeprecationComment}}{{.}} + {{end -}}{{.GoName}} {{.GoTypeDef}} + {{end -}} +} +{{end}} type {{genResponseTypeName $opid | ucFirst}} struct { Body []byte HTTPResponse *http.Response @@ -67,6 +77,10 @@ type {{genResponseTypeName $opid | ucFirst}} struct { // {{.TypeName}} the response for an HTTP {{ .ResponseName }} `{{ .ContentTypeName }}` response {{.TypeName}} *{{.Schema.TypeDecl}} {{- end}} + {{- range $headerResponses}} + // Headers{{.StatusCode | ucFirst}} the parsed response headers for an HTTP {{.StatusCode}} response + Headers{{.StatusCode | ucFirst}} *{{genResponseTypeName $opid | ucFirst}}{{.StatusCode | ucFirst}}Headers + {{- end}} } {{ if not opts.OutputOptions.SkipResponseBodyGetters }} @@ -177,6 +191,36 @@ func Parse{{genResponseTypeName $opid | ucFirst}}(rsp *http.Response) (*{{genRes response := {{genResponsePayload $opid}} {{genResponseUnmarshal .}} + {{- /* Bind declared response headers into the typed Headers + fields. Binding is lenient: an absent header (even a required one) leaves + the field at its zero value rather than failing the parse, mirroring the + body unmarshal's tolerance of spec-violating servers. A present header + that fails to bind to its declared type is an error. */ -}} + {{$headerResponses := responsesWithHeaders .Responses}} + {{- if $headerResponses}} + switch { + {{- range $headerResponses}} + case {{getConditionOfResponseName "rsp.StatusCode" .StatusCode}}: + var headers {{genResponseTypeName $opid | ucFirst}}{{.StatusCode | ucFirst}}Headers + {{- range .Headers}} + if values := rsp.Header.Values({{.Name | toGoString}}); len(values) > 0 { + var value {{.Schema.TypeDecl}} + if err := runtime.BindStyledParameterWithOptions("simple", {{.Name | toGoString}}, values[0], &value, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationHeader, Explode: false, Required: {{.Required}}, Type: "{{.SchemaType}}", Format: "{{.SchemaFormat}}"}); err != nil { + return nil, err + } + {{- if .IsNullable}} + headers.{{.GoName}}.Set(value) + {{- else if .IsOptional}} + headers.{{.GoName}} = &value + {{- else}} + headers.{{.GoName}} = value + {{- end}} + } + {{- end}} + response.Headers{{.StatusCode | ucFirst}} = &headers + {{- end}} + } + {{- end}} return response, nil }