From 3171edf4c27ad39253b6483f42ed17f1803bfe27 Mon Sep 17 00:00:00 2001 From: Hittrich <126671023+cubahno@users.noreply.github.com> Date: Fri, 8 May 2026 14:22:30 +0200 Subject: [PATCH] Generate client with response --- README.md | 1 + configuration-schema.json | 6 +- docs/configuration.md | 30 ++- .../multiple/client-combined/api.yaml | 95 ++++++++++ .../multiple/client-combined/cfg.yaml | 8 + .../multiple/client-combined/gen/client.go | 101 ++++++++++ .../client-combined/gen/client_options.go | 51 +++++ .../gen/client_with_response.go | 102 ++++++++++ .../multiple/client-combined/gen/common.go | 15 ++ .../multiple/client-combined/gen/gen_test.go | 91 +++++++++ .../multiple/client-combined/gen/payloads.go | 5 + .../multiple/client-combined/gen/responses.go | 35 ++++ .../multiple/client-combined/gen/types.go | 69 +++++++ .../{ => client-combined}/generate.go | 2 +- .../multiple/client-with-response/api.yaml | 95 ++++++++++ .../multiple/client-with-response/cfg.yaml | 8 + .../gen/client_options.go | 51 +++++ .../gen/client_with_response.go | 129 +++++++++++++ .../client-with-response/gen/common.go | 15 ++ .../client-with-response/gen/gen_test.go | 177 ++++++++++++++++++ .../client-with-response/gen/payloads.go | 5 + .../client-with-response/gen/responses.go | 35 ++++ .../client-with-response/gen/types.go | 69 +++++++ .../multiple/client-with-response/generate.go | 3 + .../responses/multiple/{ => ex1}/api.yaml | 0 .../responses/multiple/{ => ex1}/cfg.yaml | 0 examples/responses/multiple/ex1/generate.go | 3 + .../multiple/{ => ex1}/multiple/client.go | 0 .../{ => ex1}/multiple/client_options.go | 0 .../multiple/{ => ex1}/multiple/common.go | 0 .../multiple/{ => ex1}/multiple/payloads.go | 0 .../multiple/{ => ex1}/multiple/responses.go | 0 .../multiple/{ => ex1}/multiple/types.go | 0 integration_test.go | 3 +- pkg/codegen/codegen.go | 30 +++ pkg/codegen/codegen_test.go | 118 ++++++++++++ pkg/codegen/configuration.go | 35 +++- pkg/codegen/operations.go | 11 ++ pkg/codegen/parser.go | 75 +++++++- pkg/codegen/schema.go | 2 +- .../client-with-response-decode.tmpl | 70 +++++++ .../templates/client-with-response-types.tmpl | 73 ++++++++ .../templates/client-with-response.tmpl | 132 +++++++++++++ pkg/codegen/templates/client.tmpl | 3 + pkg/codegen/typedef_responses.go | 40 ++++ 45 files changed, 1784 insertions(+), 9 deletions(-) create mode 100644 examples/responses/multiple/client-combined/api.yaml create mode 100644 examples/responses/multiple/client-combined/cfg.yaml create mode 100644 examples/responses/multiple/client-combined/gen/client.go create mode 100644 examples/responses/multiple/client-combined/gen/client_options.go create mode 100644 examples/responses/multiple/client-combined/gen/client_with_response.go create mode 100644 examples/responses/multiple/client-combined/gen/common.go create mode 100644 examples/responses/multiple/client-combined/gen/gen_test.go create mode 100644 examples/responses/multiple/client-combined/gen/payloads.go create mode 100644 examples/responses/multiple/client-combined/gen/responses.go create mode 100644 examples/responses/multiple/client-combined/gen/types.go rename examples/responses/multiple/{ => client-combined}/generate.go (86%) create mode 100644 examples/responses/multiple/client-with-response/api.yaml create mode 100644 examples/responses/multiple/client-with-response/cfg.yaml create mode 100644 examples/responses/multiple/client-with-response/gen/client_options.go create mode 100644 examples/responses/multiple/client-with-response/gen/client_with_response.go create mode 100644 examples/responses/multiple/client-with-response/gen/common.go create mode 100644 examples/responses/multiple/client-with-response/gen/gen_test.go create mode 100644 examples/responses/multiple/client-with-response/gen/payloads.go create mode 100644 examples/responses/multiple/client-with-response/gen/responses.go create mode 100644 examples/responses/multiple/client-with-response/gen/types.go create mode 100644 examples/responses/multiple/client-with-response/generate.go rename examples/responses/multiple/{ => ex1}/api.yaml (100%) rename examples/responses/multiple/{ => ex1}/cfg.yaml (100%) create mode 100644 examples/responses/multiple/ex1/generate.go rename examples/responses/multiple/{ => ex1}/multiple/client.go (100%) rename examples/responses/multiple/{ => ex1}/multiple/client_options.go (100%) rename examples/responses/multiple/{ => ex1}/multiple/common.go (100%) rename examples/responses/multiple/{ => ex1}/multiple/payloads.go (100%) rename examples/responses/multiple/{ => ex1}/multiple/responses.go (100%) rename examples/responses/multiple/{ => ex1}/multiple/types.go (100%) create mode 100644 pkg/codegen/templates/client-with-response-decode.tmpl create mode 100644 pkg/codegen/templates/client-with-response-types.tmpl create mode 100644 pkg/codegen/templates/client-with-response.tmpl diff --git a/README.md b/README.md index b4c464a9..7d32d045 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ on the real value-add for your organization. ### Client Generation - **HTTP client generation** - Generate type-safe HTTP clients with customizable timeout and request editors +- **Envelope `WithResponse` clients** - Opt-in `WithResponse` siblings that return a typed envelope with per-status bodies, typed headers, and the raw `*http.Response` - useful for operations that legitimately return multiple 2xx statuses or need typed access to response headers - **Custom client types** - Wrap generated clients with your own types for additional functionality - **Error mapping** - Map response types to implement the `error` interface automatically diff --git a/configuration-schema.json b/configuration-schema.json index db71f6ce..b23b5726 100644 --- a/configuration-schema.json +++ b/configuration-schema.json @@ -100,7 +100,11 @@ "properties": { "client": { "type": "boolean", - "description": "Client specifies whether to generate a client. Defaults to false." + "description": "Generate the classic client: one method per operation returning the picked 2xx body directly (e.g. UploadDocument(...) (*DocumentStored, error)). Use this when callers only need the response body and the operation has a single documented success status; headers and non-picked 2xx bodies are not exposed. Combine with client-with-response when an operation has multiple 2xx statuses or callers need typed headers - both flags can be true and the generated ClientInterface will list both styles. Defaults to false." + }, + "client-with-response": { + "type": "boolean", + "description": "Generate WithResponse sibling functions that return a typed envelope: one JSON field per documented response, one Headers typed struct per status that declares headers, plus HTTPResponse *http.Response for raw access. Use when an operation has multiple 2xx statuses with different bodies (e.g. 201 sync + 202 queued) or when callers need typed access to headers like Location or Retry-After. Additive to client: when both are true the combined ClientInterface lists both styles. Defaults to false." }, "models": { "type": "boolean", diff --git a/docs/configuration.md b/docs/configuration.md index e38f634c..27947d5e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -140,7 +140,9 @@ output: #### `generate.client` **Type:** `boolean` | **Default:** `false` -Generate HTTP client code for calling the API. +Generate the classic HTTP client: one method per operation that returns the picked 2xx body type directly (e.g. `func (c *Client) UploadDocument(...) (*DocumentStored, error)`). + +Use this when callers only need the response body and the operation has a single documented success status. Headers and non-picked 2xx bodies are not exposed by this style; for those, use [`generate.client-with-response`](#generateclient-with-response) instead, or both together. ```yaml generate: @@ -149,6 +151,32 @@ generate: See [examples/client/example1/cfg.yaml](https://github.com/doordash-oss/oapi-codegen-dd/blob/main/examples/client/example1/cfg.yaml){:target="_blank"} for a complete example. +#### `generate.client-with-response` +**Type:** `boolean` | **Default:** `false` + +Generate `WithResponse` sibling functions that return a typed envelope: one `JSON` (or `Text` / `HTML` / etc.) field per documented response, one `Headers` typed struct per status that declares headers, plus `HTTPResponse *http.Response` for raw access (undocumented headers, raw body bytes, status string, etc.). + +Use this when an operation has multiple 2xx statuses with different bodies (e.g. 201 sync + 202 queued), or when callers need typed access to response headers like `Location` or `Retry-After`. + +Additive to [`generate.client`](#generateclient). When both flags are true, the generated `ClientInterface` lists every classic method alongside its `WithResponse` sibling so a single mock or test double covers both shapes. + +```yaml +generate: + client: true + client-with-response: true +``` + +The four valid combinations: + +| `client` | `client-with-response` | Output | +|----------|------------------------|--------| +| `false` | `false` | No client (types/server only) | +| `true` | `false` | Classic client only | +| `false` | `true` | Envelope client only | +| `true` | `true` | Both styles, combined into one `ClientInterface` | + +See [examples/responses/multiple/client-with-response/cfg.yaml](https://github.com/doordash-oss/oapi-codegen-dd/blob/main/examples/responses/multiple/client-with-response/cfg.yaml){:target="_blank"} for envelope-only and [examples/responses/multiple/client-combined/cfg.yaml](https://github.com/doordash-oss/oapi-codegen-dd/blob/main/examples/responses/multiple/client-combined/cfg.yaml){:target="_blank"} for the combined case. + #### `generate.omit-description` **Type:** `boolean` | **Default:** `false` diff --git a/examples/responses/multiple/client-combined/api.yaml b/examples/responses/multiple/client-combined/api.yaml new file mode 100644 index 00000000..7e83a68e --- /dev/null +++ b/examples/responses/multiple/client-combined/api.yaml @@ -0,0 +1,95 @@ +openapi: 3.0.3 +info: + title: Document Upload API + version: 1.0.0 + +paths: + /documents: + post: + operationId: uploadDocument + summary: Upload a document + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UploadRequest' + responses: + '201': + description: Document stored synchronously + headers: + Location: + schema: + type: string + format: uri + X-Request-Id: + schema: + type: string + content: + application/json: + schema: + $ref: '#/components/schemas/DocumentStored' + '202': + description: Document queued for async processing + headers: + X-Request-Id: + schema: + type: string + Retry-After: + schema: + type: integer + content: + application/json: + schema: + $ref: '#/components/schemas/DocumentQueued' + '422': + description: Invalid document + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + '503': + description: Service unavailable. Plain-text human-readable reason. + content: + text/plain: + schema: + type: string + +components: + schemas: + UploadRequest: + type: object + required: [filename, content] + properties: + filename: + type: string + content: + type: string + format: byte + DocumentStored: + type: object + required: [id, url] + properties: + id: + type: string + format: uuid + url: + type: string + format: uri + DocumentQueued: + type: object + required: [job_id, estimated_completion_seconds] + properties: + job_id: + type: string + format: uuid + estimated_completion_seconds: + type: integer + ValidationError: + type: object + required: [code, message] + properties: + code: + type: string + message: + type: string diff --git a/examples/responses/multiple/client-combined/cfg.yaml b/examples/responses/multiple/client-combined/cfg.yaml new file mode 100644 index 00000000..a179ecf1 --- /dev/null +++ b/examples/responses/multiple/client-combined/cfg.yaml @@ -0,0 +1,8 @@ +# yaml-language-server: $schema=../../../configuration-schema.json +package: gen +output: + use-single-file: false +generate: + client: true + client-with-response: true + omit-description: true diff --git a/examples/responses/multiple/client-combined/gen/client.go b/examples/responses/multiple/client-combined/gen/client.go new file mode 100644 index 00000000..87838571 --- /dev/null +++ b/examples/responses/multiple/client-combined/gen/client.go @@ -0,0 +1,101 @@ +// Code generated by oapi-codegen. DO NOT EDIT. + +package gen + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/doordash-oss/oapi-codegen-dd/v3/pkg/runtime" +) + +// Client is the client for the API implementing the Client interface. +type Client struct { + apiClient runtime.APIClient +} + +// NewClient creates a new instance of the Client client. +func NewClient(apiClient runtime.APIClient) *Client { + return &Client{apiClient: apiClient} +} + +// NewDefaultClient creates a new instance of the Client client with default api client. +func NewDefaultClient(baseURL string, opts ...runtime.APIClientOption) (*Client, error) { + apiClient, err := runtime.NewAPIClient(baseURL, opts...) + if err != nil { + return nil, fmt.Errorf("error creating API client: %w", err) + } + return &Client{apiClient: apiClient}, nil +} + +// ClientInterface is the interface for the API client. +type ClientInterface interface { + UploadDocument(ctx context.Context, options *UploadDocumentRequestOptions, reqEditors ...runtime.RequestEditorFn) (*UploadDocumentResponseJSON, error) + UploadDocumentWithResponse(ctx context.Context, options *UploadDocumentRequestOptions, reqEditors ...runtime.RequestEditorFn) (*UploadDocumentResp, error) +} + +func (c *Client) UploadDocument(ctx context.Context, options *UploadDocumentRequestOptions, reqEditors ...runtime.RequestEditorFn) (*UploadDocumentResponseJSON, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/documents", + Method: "POST", + Options: options, + ContentType: "application/json", + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + responseParser := func(ctx context.Context, resp *runtime.Response) (*UploadDocumentResponseJSON, error) { + bodyBytes := resp.Content + if resp.StatusCode != 202 { + target := new(UploadDocumentErrorResponse) + // Handle empty error response body gracefully - skip unmarshal if no content + if len(bodyBytes) > 0 { + if err = json.Unmarshal(bodyBytes, target); err != nil { + return nil, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "UploadDocumentErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + // Return error with (possibly empty) target + if errTarget, ok := any(*target).(error); ok { + return nil, runtime.NewClientAPIError(errTarget, runtime.WithStatusCode(resp.StatusCode)) + } + return nil, runtime.NewClientAPIError(fmt.Errorf("API error (status %d): %v", resp.StatusCode, *target), + runtime.WithStatusCode(resp.StatusCode)) + } + target := new(UploadDocumentResponseJSON) + // Handle empty response body gracefully + if len(bodyBytes) == 0 { + return target, nil + } + if err = json.Unmarshal(bodyBytes, target); err != nil { + return nil, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "UploadDocumentResponseJSON", + Body: bodyBytes, + Err: err, + } + } + return target, nil + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/documents") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + return responseParser(ctx, resp) +} + +var _ ClientInterface = (*Client)(nil) diff --git a/examples/responses/multiple/client-combined/gen/client_options.go b/examples/responses/multiple/client-combined/gen/client_options.go new file mode 100644 index 00000000..c50e4a36 --- /dev/null +++ b/examples/responses/multiple/client-combined/gen/client_options.go @@ -0,0 +1,51 @@ +// Code generated by oapi-codegen. DO NOT EDIT. + +package gen + +import ( + "github.com/doordash-oss/oapi-codegen-dd/v3/pkg/runtime" +) + +// UploadDocumentRequestOptions is the options needed to make a request to UploadDocument. +type UploadDocumentRequestOptions struct { + Body *UploadDocumentBody +} + +// Validate validates all the fields in the options. +// Use it if fields validation was not run. +func (o *UploadDocumentRequestOptions) Validate() error { + var errors runtime.ValidationErrors + + if o.Body != nil { + if v, ok := any(o.Body).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Body", err) + } + } + } + if len(errors) == 0 { + return nil + } + + return errors +} + +// GetPathParams returns the path params as a map. +func (o *UploadDocumentRequestOptions) GetPathParams() (map[string]any, error) { + return nil, nil +} + +// GetQuery returns the query params as a map. +func (o *UploadDocumentRequestOptions) GetQuery() (map[string]any, error) { + return nil, nil +} + +// GetBody returns the payload in any type that can be marshalled to JSON by the client. +func (o *UploadDocumentRequestOptions) GetBody() any { + return o.Body +} + +// GetHeader returns the headers as a map. +func (o *UploadDocumentRequestOptions) GetHeader() (map[string]string, error) { + return nil, nil +} diff --git a/examples/responses/multiple/client-combined/gen/client_with_response.go b/examples/responses/multiple/client-combined/gen/client_with_response.go new file mode 100644 index 00000000..446e297f --- /dev/null +++ b/examples/responses/multiple/client-combined/gen/client_with_response.go @@ -0,0 +1,102 @@ +// Code generated by oapi-codegen. DO NOT EDIT. + +package gen + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/doordash-oss/oapi-codegen-dd/v3/pkg/runtime" +) + +func (c *Client) UploadDocumentWithResponse(ctx context.Context, options *UploadDocumentRequestOptions, reqEditors ...runtime.RequestEditorFn) (*UploadDocumentResp, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/documents", + Method: "POST", + Options: options, + ContentType: "application/json", + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/documents") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + + out := &UploadDocumentResp{ + HTTPResponse: resp.Raw, + Body: resp.Content, + StatusCode: resp.StatusCode, + } + + switch resp.StatusCode { + case 201: + out.JSON201 = new(UploadDocumentResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON201); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "UploadDocumentResponse", + Body: bodyBytes, + Err: err, + } + } + } + out.Headers201 = &UploadDocumentResp201Headers{ + Location: resp.Headers.Get("Location"), + XRequestID: resp.Headers.Get("X-Request-Id"), + } + return out, nil + case 202: + out.JSON202 = new(UploadDocumentResponseJSON) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON202); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "UploadDocumentResponseJSON", + Body: bodyBytes, + Err: err, + } + } + } + out.Headers202 = &UploadDocumentResp202Headers{ + RetryAfter: resp.Headers.Get("Retry-After"), + XRequestID: resp.Headers.Get("X-Request-Id"), + } + return out, nil + case 422: + out.JSON422 = new(UploadDocumentErrorResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON422); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "UploadDocumentErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 503: + v := UploadDocumentErrorResponseText(resp.Content) + out.Text503 = &v + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + default: + return out, runtime.NewClientAPIError(fmt.Errorf("unexpected status code: %d", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + } +} diff --git a/examples/responses/multiple/client-combined/gen/common.go b/examples/responses/multiple/client-combined/gen/common.go new file mode 100644 index 00000000..a8814d2b --- /dev/null +++ b/examples/responses/multiple/client-combined/gen/common.go @@ -0,0 +1,15 @@ +// Code generated by oapi-codegen. DO NOT EDIT. + +package gen + +import ( + "github.com/doordash-oss/oapi-codegen-dd/v3/pkg/runtime" + "github.com/go-playground/validator/v10" +) + +var typesValidator *validator.Validate + +func init() { + typesValidator = validator.New(validator.WithRequiredStructEnabled()) + runtime.RegisterCustomTypeFunc(typesValidator) +} diff --git a/examples/responses/multiple/client-combined/gen/gen_test.go b/examples/responses/multiple/client-combined/gen/gen_test.go new file mode 100644 index 00000000..bd3b0035 --- /dev/null +++ b/examples/responses/multiple/client-combined/gen/gen_test.go @@ -0,0 +1,91 @@ +package gen + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/doordash-oss/oapi-codegen-dd/v3/pkg/runtime" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type httpDoer struct{ c *http.Client } + +func (d httpDoer) Do(_ context.Context, req *http.Request) (*http.Response, error) { + return d.c.Do(req) +} + +func newClient(t *testing.T, srv *httptest.Server) *Client { + t.Helper() + c, err := NewDefaultClient(srv.URL, runtime.WithHTTPClient(httpDoer{srv.Client()})) + require.NoError(t, err) + return c +} + +func newServer(t *testing.T, status int, body any, headers map[string]string) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + for k, v := range headers { + w.Header().Set(k, v) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if body != nil { + _ = json.NewEncoder(w).Encode(body) + } + })) +} + +func uploadOpts() *UploadDocumentRequestOptions { + body := UploadDocumentBody{Filename: "doc.pdf", Content: []byte("hi")} + return &UploadDocumentRequestOptions{Body: &body} +} + +// TestClassicVsEnvelope demonstrates that *Client satisfies both +// ClientInterface (classic, body-only return) and ClientWithResponseInterface +// (envelope) when generate.client and generate.client-with-response are both +// true. Each call site picks the ergonomics that fit. +func TestClassicVsEnvelope(t *testing.T) { + t.Run("envelope returns 201 success", func(t *testing.T) { + docID := uuid.New() + srv := newServer(t, http.StatusCreated, DocumentStored{ + ID: docID, URL: "https://example.com/doc/abc", + }, map[string]string{"Location": "https://example.com/doc/abc", "X-Request-Id": "r1"}) + defer srv.Close() + client := newClient(t, srv) + + resp, err := client.UploadDocumentWithResponse(t.Context(), uploadOpts()) + require.NoError(t, err) + require.NotNil(t, resp.JSON201) + assert.Equal(t, docID, resp.JSON201.ID) + assert.Equal(t, "https://example.com/doc/abc", resp.Headers201.Location) + }) + + t.Run("classic still works for a single 2xx (202 case)", func(t *testing.T) { + jobID := uuid.New() + srv := newServer(t, http.StatusAccepted, DocumentQueued{ + JobID: jobID, EstimatedCompletionSeconds: 30, + }, nil) + defer srv.Close() + client := newClient(t, srv) + + // Classic picks 202 (last 2xx in spec order). For specs like ours, this + // means the classic call works for 202 responses but treats 201 as an + // error. Callers who need 201 (or headers) should use the envelope sibling. + resp, err := client.UploadDocument(t.Context(), uploadOpts()) + require.NoError(t, err) + require.NotNil(t, resp) + assert.Equal(t, jobID, resp.JobID) + }) + + t.Run("a single ClientInterface covers both classic and envelope methods", func(t *testing.T) { + // When both flags are on, the generated ClientInterface lists every + // classic method plus every WithResponse sibling, so a single mock + // satisfies both shapes. *Client implements it. + var _ ClientInterface = (*Client)(nil) + }) +} diff --git a/examples/responses/multiple/client-combined/gen/payloads.go b/examples/responses/multiple/client-combined/gen/payloads.go new file mode 100644 index 00000000..298126cb --- /dev/null +++ b/examples/responses/multiple/client-combined/gen/payloads.go @@ -0,0 +1,5 @@ +// Code generated by oapi-codegen. DO NOT EDIT. + +package gen + +type UploadDocumentBody = UploadRequest diff --git a/examples/responses/multiple/client-combined/gen/responses.go b/examples/responses/multiple/client-combined/gen/responses.go new file mode 100644 index 00000000..9684fc97 --- /dev/null +++ b/examples/responses/multiple/client-combined/gen/responses.go @@ -0,0 +1,35 @@ +// Code generated by oapi-codegen. DO NOT EDIT. + +package gen + +import "net/http" + +type UploadDocumentResponse = DocumentStored + +type UploadDocumentResponseJSON = DocumentQueued + +type UploadDocumentErrorResponse = ValidationError + +type UploadDocumentErrorResponseText string + +type UploadDocumentResp201Headers struct { + Location string `header:"Location"` + XRequestID string `header:"X-Request-Id"` +} + +type UploadDocumentResp202Headers struct { + RetryAfter string `header:"Retry-After"` + XRequestID string `header:"X-Request-Id"` +} + +type UploadDocumentResp struct { + HTTPResponse *http.Response + Body []byte + StatusCode int + JSON201 *UploadDocumentResponse + Headers201 *UploadDocumentResp201Headers + JSON202 *UploadDocumentResponseJSON + Headers202 *UploadDocumentResp202Headers + JSON422 *UploadDocumentErrorResponse + Text503 *UploadDocumentErrorResponseText +} diff --git a/examples/responses/multiple/client-combined/gen/types.go b/examples/responses/multiple/client-combined/gen/types.go new file mode 100644 index 00000000..e71d9a91 --- /dev/null +++ b/examples/responses/multiple/client-combined/gen/types.go @@ -0,0 +1,69 @@ +// Code generated by oapi-codegen. DO NOT EDIT. + +package gen + +import ( + "github.com/doordash-oss/oapi-codegen-dd/v3/pkg/runtime" + "github.com/google/uuid" +) + +type UploadRequest struct { + Filename string `json:"filename" validate:"required"` + Content []byte `json:"content" validate:"required"` +} + +func (u UploadRequest) Validate() error { + return runtime.ConvertValidatorError(typesValidator.Struct(u)) +} + +type DocumentStored struct { + ID uuid.UUID `json:"id" validate:"required"` + URL string `json:"url" validate:"required"` +} + +func (d DocumentStored) Validate() error { + var errors runtime.ValidationErrors + if v, ok := any(d.ID).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("ID", err) + } + } + if err := typesValidator.Var(d.URL, "required"); err != nil { + errors = errors.Append("URL", err) + } + if len(errors) == 0 { + return nil + } + return errors +} + +type DocumentQueued struct { + JobID uuid.UUID `json:"job_id" validate:"required"` + EstimatedCompletionSeconds int `json:"estimated_completion_seconds"` +} + +func (d DocumentQueued) Validate() error { + var errors runtime.ValidationErrors + if v, ok := any(d.JobID).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("JobID", err) + } + } + if len(errors) == 0 { + return nil + } + return errors +} + +type ValidationError struct { + Code string `json:"code" validate:"required"` + Message string `json:"message" validate:"required"` +} + +func (v ValidationError) Validate() error { + return runtime.ConvertValidatorError(typesValidator.Struct(v)) +} + +func (s ValidationError) Error() string { + return "unmapped client error" +} diff --git a/examples/responses/multiple/generate.go b/examples/responses/multiple/client-combined/generate.go similarity index 86% rename from examples/responses/multiple/generate.go rename to examples/responses/multiple/client-combined/generate.go index d27beaf5..1e5c805e 100644 --- a/examples/responses/multiple/generate.go +++ b/examples/responses/multiple/client-combined/generate.go @@ -1,3 +1,3 @@ -package multiple +package combined //go:generate go run github.com/doordash-oss/oapi-codegen-dd/v3/cmd/oapi-codegen -config cfg.yaml api.yaml diff --git a/examples/responses/multiple/client-with-response/api.yaml b/examples/responses/multiple/client-with-response/api.yaml new file mode 100644 index 00000000..7e83a68e --- /dev/null +++ b/examples/responses/multiple/client-with-response/api.yaml @@ -0,0 +1,95 @@ +openapi: 3.0.3 +info: + title: Document Upload API + version: 1.0.0 + +paths: + /documents: + post: + operationId: uploadDocument + summary: Upload a document + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UploadRequest' + responses: + '201': + description: Document stored synchronously + headers: + Location: + schema: + type: string + format: uri + X-Request-Id: + schema: + type: string + content: + application/json: + schema: + $ref: '#/components/schemas/DocumentStored' + '202': + description: Document queued for async processing + headers: + X-Request-Id: + schema: + type: string + Retry-After: + schema: + type: integer + content: + application/json: + schema: + $ref: '#/components/schemas/DocumentQueued' + '422': + description: Invalid document + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + '503': + description: Service unavailable. Plain-text human-readable reason. + content: + text/plain: + schema: + type: string + +components: + schemas: + UploadRequest: + type: object + required: [filename, content] + properties: + filename: + type: string + content: + type: string + format: byte + DocumentStored: + type: object + required: [id, url] + properties: + id: + type: string + format: uuid + url: + type: string + format: uri + DocumentQueued: + type: object + required: [job_id, estimated_completion_seconds] + properties: + job_id: + type: string + format: uuid + estimated_completion_seconds: + type: integer + ValidationError: + type: object + required: [code, message] + properties: + code: + type: string + message: + type: string diff --git a/examples/responses/multiple/client-with-response/cfg.yaml b/examples/responses/multiple/client-with-response/cfg.yaml new file mode 100644 index 00000000..2292984e --- /dev/null +++ b/examples/responses/multiple/client-with-response/cfg.yaml @@ -0,0 +1,8 @@ +# yaml-language-server: $schema=../../../configuration-schema.json +package: gen +output: + use-single-file: false +generate: + client: false + client-with-response: true + omit-description: true diff --git a/examples/responses/multiple/client-with-response/gen/client_options.go b/examples/responses/multiple/client-with-response/gen/client_options.go new file mode 100644 index 00000000..c50e4a36 --- /dev/null +++ b/examples/responses/multiple/client-with-response/gen/client_options.go @@ -0,0 +1,51 @@ +// Code generated by oapi-codegen. DO NOT EDIT. + +package gen + +import ( + "github.com/doordash-oss/oapi-codegen-dd/v3/pkg/runtime" +) + +// UploadDocumentRequestOptions is the options needed to make a request to UploadDocument. +type UploadDocumentRequestOptions struct { + Body *UploadDocumentBody +} + +// Validate validates all the fields in the options. +// Use it if fields validation was not run. +func (o *UploadDocumentRequestOptions) Validate() error { + var errors runtime.ValidationErrors + + if o.Body != nil { + if v, ok := any(o.Body).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Body", err) + } + } + } + if len(errors) == 0 { + return nil + } + + return errors +} + +// GetPathParams returns the path params as a map. +func (o *UploadDocumentRequestOptions) GetPathParams() (map[string]any, error) { + return nil, nil +} + +// GetQuery returns the query params as a map. +func (o *UploadDocumentRequestOptions) GetQuery() (map[string]any, error) { + return nil, nil +} + +// GetBody returns the payload in any type that can be marshalled to JSON by the client. +func (o *UploadDocumentRequestOptions) GetBody() any { + return o.Body +} + +// GetHeader returns the headers as a map. +func (o *UploadDocumentRequestOptions) GetHeader() (map[string]string, error) { + return nil, nil +} diff --git a/examples/responses/multiple/client-with-response/gen/client_with_response.go b/examples/responses/multiple/client-with-response/gen/client_with_response.go new file mode 100644 index 00000000..3ca94a9b --- /dev/null +++ b/examples/responses/multiple/client-with-response/gen/client_with_response.go @@ -0,0 +1,129 @@ +// Code generated by oapi-codegen. DO NOT EDIT. + +package gen + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/doordash-oss/oapi-codegen-dd/v3/pkg/runtime" +) + +// Client is the client for the API implementing the ClientInterface interface. +type Client struct { + apiClient runtime.APIClient +} + +// NewClient creates a new instance of the Client client. +func NewClient(apiClient runtime.APIClient) *Client { + return &Client{apiClient: apiClient} +} + +// NewDefaultClient creates a new instance of the Client client with default api client. +func NewDefaultClient(baseURL string, opts ...runtime.APIClientOption) (*Client, error) { + apiClient, err := runtime.NewAPIClient(baseURL, opts...) + if err != nil { + return nil, fmt.Errorf("error creating API client: %w", err) + } + return &Client{apiClient: apiClient}, nil +} + +// ClientInterface is the interface for the API client. With +// envelope-only generation it lists the WithResponse methods. +type ClientInterface interface { + UploadDocumentWithResponse(ctx context.Context, options *UploadDocumentRequestOptions, reqEditors ...runtime.RequestEditorFn) (*UploadDocumentResp, error) +} + +func (c *Client) UploadDocumentWithResponse(ctx context.Context, options *UploadDocumentRequestOptions, reqEditors ...runtime.RequestEditorFn) (*UploadDocumentResp, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/documents", + Method: "POST", + Options: options, + ContentType: "application/json", + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/documents") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + + out := &UploadDocumentResp{ + HTTPResponse: resp.Raw, + Body: resp.Content, + StatusCode: resp.StatusCode, + } + + switch resp.StatusCode { + case 201: + out.JSON201 = new(UploadDocumentResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON201); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "UploadDocumentResponse", + Body: bodyBytes, + Err: err, + } + } + } + out.Headers201 = &UploadDocumentResp201Headers{ + Location: resp.Headers.Get("Location"), + XRequestID: resp.Headers.Get("X-Request-Id"), + } + return out, nil + case 202: + out.JSON202 = new(UploadDocumentResponseJSON) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON202); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "UploadDocumentResponseJSON", + Body: bodyBytes, + Err: err, + } + } + } + out.Headers202 = &UploadDocumentResp202Headers{ + RetryAfter: resp.Headers.Get("Retry-After"), + XRequestID: resp.Headers.Get("X-Request-Id"), + } + return out, nil + case 422: + out.JSON422 = new(UploadDocumentErrorResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON422); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "UploadDocumentErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 503: + v := UploadDocumentErrorResponseText(resp.Content) + out.Text503 = &v + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + default: + return out, runtime.NewClientAPIError(fmt.Errorf("unexpected status code: %d", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + } +} + +var _ ClientInterface = (*Client)(nil) diff --git a/examples/responses/multiple/client-with-response/gen/common.go b/examples/responses/multiple/client-with-response/gen/common.go new file mode 100644 index 00000000..a8814d2b --- /dev/null +++ b/examples/responses/multiple/client-with-response/gen/common.go @@ -0,0 +1,15 @@ +// Code generated by oapi-codegen. DO NOT EDIT. + +package gen + +import ( + "github.com/doordash-oss/oapi-codegen-dd/v3/pkg/runtime" + "github.com/go-playground/validator/v10" +) + +var typesValidator *validator.Validate + +func init() { + typesValidator = validator.New(validator.WithRequiredStructEnabled()) + runtime.RegisterCustomTypeFunc(typesValidator) +} diff --git a/examples/responses/multiple/client-with-response/gen/gen_test.go b/examples/responses/multiple/client-with-response/gen/gen_test.go new file mode 100644 index 00000000..d257daad --- /dev/null +++ b/examples/responses/multiple/client-with-response/gen/gen_test.go @@ -0,0 +1,177 @@ +package gen + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/doordash-oss/oapi-codegen-dd/v3/pkg/runtime" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// httpDoer adapts *http.Client to runtime.HttpRequestDoer (which takes ctx as +// its first argument). +type httpDoer struct{ c *http.Client } + +func (d httpDoer) Do(_ context.Context, req *http.Request) (*http.Response, error) { + return d.c.Do(req) +} + +func newClient(t *testing.T, srv *httptest.Server) *Client { + t.Helper() + c, err := NewDefaultClient(srv.URL, runtime.WithHTTPClient(httpDoer{srv.Client()})) + require.NoError(t, err) + return c +} + +// newServer returns a test server that always replies with the given status, +// body and headers. +func newServer(t *testing.T, status int, body any, headers map[string]string) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + for k, v := range headers { + w.Header().Set(k, v) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if body != nil { + _ = json.NewEncoder(w).Encode(body) + } + })) +} + +func uploadOpts() *UploadDocumentRequestOptions { + body := UploadDocumentBody{Filename: "doc.pdf", Content: []byte("hi")} + return &UploadDocumentRequestOptions{Body: &body} +} + +func TestUploadDocumentWithResponse(t *testing.T) { + t.Run("201 sync upload populates JSON201 + Headers201", func(t *testing.T) { + docID := uuid.New() + srv := newServer(t, http.StatusCreated, DocumentStored{ + ID: docID, + URL: "https://example.com/doc/abc", + }, map[string]string{ + "Location": "https://example.com/doc/abc", + "X-Request-Id": "req-201", + "X-Custom": "ad-hoc-undocumented", + }) + defer srv.Close() + + client := newClient(t, srv) + + resp, err := client.UploadDocumentWithResponse(t.Context(), uploadOpts()) + require.NoError(t, err) + require.NotNil(t, resp) + assert.Equal(t, http.StatusCreated, resp.StatusCode) + + require.NotNil(t, resp.JSON201) + assert.Equal(t, docID, resp.JSON201.ID) + assert.Equal(t, "https://example.com/doc/abc", resp.JSON201.URL) + + require.NotNil(t, resp.Headers201) + assert.Equal(t, "https://example.com/doc/abc", resp.Headers201.Location) + assert.Equal(t, "req-201", resp.Headers201.XRequestID) + + // 202-shaped fields stay nil on a 201 response + assert.Nil(t, resp.JSON202) + assert.Nil(t, resp.Headers202) + assert.Nil(t, resp.JSON422) + + // Undocumented headers reachable via the raw response. + require.NotNil(t, resp.HTTPResponse) + assert.Equal(t, "ad-hoc-undocumented", resp.HTTPResponse.Header.Get("X-Custom")) + }) + + t.Run("202 async upload populates JSON202 + Headers202", func(t *testing.T) { + jobID := uuid.New() + srv := newServer(t, http.StatusAccepted, DocumentQueued{ + JobID: jobID, + EstimatedCompletionSeconds: 30, + }, map[string]string{ + "X-Request-Id": "req-202", + "Retry-After": "30", + }) + defer srv.Close() + + client := newClient(t, srv) + + resp, err := client.UploadDocumentWithResponse(t.Context(), uploadOpts()) + require.NoError(t, err) + require.NotNil(t, resp) + assert.Equal(t, http.StatusAccepted, resp.StatusCode) + + require.NotNil(t, resp.JSON202) + assert.Equal(t, jobID, resp.JSON202.JobID) + assert.Equal(t, 30, resp.JSON202.EstimatedCompletionSeconds) + + require.NotNil(t, resp.Headers202) + assert.Equal(t, "30", resp.Headers202.RetryAfter) + assert.Equal(t, "req-202", resp.Headers202.XRequestID) + + assert.Nil(t, resp.JSON201) + assert.Nil(t, resp.Headers201) + }) + + t.Run("422 returns both populated JSON422 and an error", func(t *testing.T) { + srv := newServer(t, http.StatusUnprocessableEntity, ValidationError{ + Code: "invalid_filename", + Message: "filename required", + }, nil) + defer srv.Close() + + client := newClient(t, srv) + + resp, err := client.UploadDocumentWithResponse(t.Context(), uploadOpts()) + require.Error(t, err) + require.NotNil(t, resp) + assert.Equal(t, http.StatusUnprocessableEntity, resp.StatusCode) + + require.NotNil(t, resp.JSON422) + assert.Equal(t, "invalid_filename", resp.JSON422.Code) + assert.Equal(t, "filename required", resp.JSON422.Message) + }) + + t.Run("503 text/plain decodes into Text503", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(http.StatusServiceUnavailable) + _, _ = w.Write([]byte("upstream offline, retry later")) + })) + defer srv.Close() + + client := newClient(t, srv) + + resp, err := client.UploadDocumentWithResponse(t.Context(), uploadOpts()) + require.Error(t, err) // documented errors return both envelope and error + require.NotNil(t, resp) + assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode) + + require.NotNil(t, resp.Text503) + assert.Equal(t, "upstream offline, retry later", string(*resp.Text503)) + + // JSON-typed fields stay nil for text responses + assert.Nil(t, resp.JSON201) + assert.Nil(t, resp.JSON422) + }) + + t.Run("undocumented status returns envelope and error", func(t *testing.T) { + srv := newServer(t, http.StatusTeapot, nil, nil) + defer srv.Close() + + client := newClient(t, srv) + + resp, err := client.UploadDocumentWithResponse(t.Context(), uploadOpts()) + require.Error(t, err) + require.NotNil(t, resp) + assert.Equal(t, http.StatusTeapot, resp.StatusCode) + // All typed bodies stay nil for unknown statuses. + assert.Nil(t, resp.JSON201) + assert.Nil(t, resp.JSON202) + assert.Nil(t, resp.JSON422) + }) +} diff --git a/examples/responses/multiple/client-with-response/gen/payloads.go b/examples/responses/multiple/client-with-response/gen/payloads.go new file mode 100644 index 00000000..298126cb --- /dev/null +++ b/examples/responses/multiple/client-with-response/gen/payloads.go @@ -0,0 +1,5 @@ +// Code generated by oapi-codegen. DO NOT EDIT. + +package gen + +type UploadDocumentBody = UploadRequest diff --git a/examples/responses/multiple/client-with-response/gen/responses.go b/examples/responses/multiple/client-with-response/gen/responses.go new file mode 100644 index 00000000..9684fc97 --- /dev/null +++ b/examples/responses/multiple/client-with-response/gen/responses.go @@ -0,0 +1,35 @@ +// Code generated by oapi-codegen. DO NOT EDIT. + +package gen + +import "net/http" + +type UploadDocumentResponse = DocumentStored + +type UploadDocumentResponseJSON = DocumentQueued + +type UploadDocumentErrorResponse = ValidationError + +type UploadDocumentErrorResponseText string + +type UploadDocumentResp201Headers struct { + Location string `header:"Location"` + XRequestID string `header:"X-Request-Id"` +} + +type UploadDocumentResp202Headers struct { + RetryAfter string `header:"Retry-After"` + XRequestID string `header:"X-Request-Id"` +} + +type UploadDocumentResp struct { + HTTPResponse *http.Response + Body []byte + StatusCode int + JSON201 *UploadDocumentResponse + Headers201 *UploadDocumentResp201Headers + JSON202 *UploadDocumentResponseJSON + Headers202 *UploadDocumentResp202Headers + JSON422 *UploadDocumentErrorResponse + Text503 *UploadDocumentErrorResponseText +} diff --git a/examples/responses/multiple/client-with-response/gen/types.go b/examples/responses/multiple/client-with-response/gen/types.go new file mode 100644 index 00000000..e71d9a91 --- /dev/null +++ b/examples/responses/multiple/client-with-response/gen/types.go @@ -0,0 +1,69 @@ +// Code generated by oapi-codegen. DO NOT EDIT. + +package gen + +import ( + "github.com/doordash-oss/oapi-codegen-dd/v3/pkg/runtime" + "github.com/google/uuid" +) + +type UploadRequest struct { + Filename string `json:"filename" validate:"required"` + Content []byte `json:"content" validate:"required"` +} + +func (u UploadRequest) Validate() error { + return runtime.ConvertValidatorError(typesValidator.Struct(u)) +} + +type DocumentStored struct { + ID uuid.UUID `json:"id" validate:"required"` + URL string `json:"url" validate:"required"` +} + +func (d DocumentStored) Validate() error { + var errors runtime.ValidationErrors + if v, ok := any(d.ID).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("ID", err) + } + } + if err := typesValidator.Var(d.URL, "required"); err != nil { + errors = errors.Append("URL", err) + } + if len(errors) == 0 { + return nil + } + return errors +} + +type DocumentQueued struct { + JobID uuid.UUID `json:"job_id" validate:"required"` + EstimatedCompletionSeconds int `json:"estimated_completion_seconds"` +} + +func (d DocumentQueued) Validate() error { + var errors runtime.ValidationErrors + if v, ok := any(d.JobID).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("JobID", err) + } + } + if len(errors) == 0 { + return nil + } + return errors +} + +type ValidationError struct { + Code string `json:"code" validate:"required"` + Message string `json:"message" validate:"required"` +} + +func (v ValidationError) Validate() error { + return runtime.ConvertValidatorError(typesValidator.Struct(v)) +} + +func (s ValidationError) Error() string { + return "unmapped client error" +} diff --git a/examples/responses/multiple/client-with-response/generate.go b/examples/responses/multiple/client-with-response/generate.go new file mode 100644 index 00000000..ede7b3d6 --- /dev/null +++ b/examples/responses/multiple/client-with-response/generate.go @@ -0,0 +1,3 @@ +package gen + +//go:generate go run github.com/doordash-oss/oapi-codegen-dd/v3/cmd/oapi-codegen -config cfg.yaml api.yaml diff --git a/examples/responses/multiple/api.yaml b/examples/responses/multiple/ex1/api.yaml similarity index 100% rename from examples/responses/multiple/api.yaml rename to examples/responses/multiple/ex1/api.yaml diff --git a/examples/responses/multiple/cfg.yaml b/examples/responses/multiple/ex1/cfg.yaml similarity index 100% rename from examples/responses/multiple/cfg.yaml rename to examples/responses/multiple/ex1/cfg.yaml diff --git a/examples/responses/multiple/ex1/generate.go b/examples/responses/multiple/ex1/generate.go new file mode 100644 index 00000000..3f1cf18c --- /dev/null +++ b/examples/responses/multiple/ex1/generate.go @@ -0,0 +1,3 @@ +package ex1 + +//go:generate go run github.com/doordash-oss/oapi-codegen-dd/v3/cmd/oapi-codegen -config cfg.yaml api.yaml diff --git a/examples/responses/multiple/multiple/client.go b/examples/responses/multiple/ex1/multiple/client.go similarity index 100% rename from examples/responses/multiple/multiple/client.go rename to examples/responses/multiple/ex1/multiple/client.go diff --git a/examples/responses/multiple/multiple/client_options.go b/examples/responses/multiple/ex1/multiple/client_options.go similarity index 100% rename from examples/responses/multiple/multiple/client_options.go rename to examples/responses/multiple/ex1/multiple/client_options.go diff --git a/examples/responses/multiple/multiple/common.go b/examples/responses/multiple/ex1/multiple/common.go similarity index 100% rename from examples/responses/multiple/multiple/common.go rename to examples/responses/multiple/ex1/multiple/common.go diff --git a/examples/responses/multiple/multiple/payloads.go b/examples/responses/multiple/ex1/multiple/payloads.go similarity index 100% rename from examples/responses/multiple/multiple/payloads.go rename to examples/responses/multiple/ex1/multiple/payloads.go diff --git a/examples/responses/multiple/multiple/responses.go b/examples/responses/multiple/ex1/multiple/responses.go similarity index 100% rename from examples/responses/multiple/multiple/responses.go rename to examples/responses/multiple/ex1/multiple/responses.go diff --git a/examples/responses/multiple/multiple/types.go b/examples/responses/multiple/ex1/multiple/types.go similarity index 100% rename from examples/responses/multiple/multiple/types.go rename to examples/responses/multiple/ex1/multiple/types.go diff --git a/integration_test.go b/integration_test.go index 95686a01..59190fb0 100644 --- a/integration_test.go +++ b/integration_test.go @@ -356,7 +356,8 @@ func TestIntegration(t *testing.T) { cfg := codegen.Configuration{ PackageName: "integration", Generate: &codegen.GenerateOptions{ - Client: true, + Client: true, + ClientWithResponse: true, Validation: codegen.ValidationOptions{ Response: true, }, diff --git a/pkg/codegen/codegen.go b/pkg/codegen/codegen.go index 76837b13..0923d6e0 100644 --- a/pkg/codegen/codegen.go +++ b/pkg/codegen/codegen.go @@ -369,6 +369,36 @@ func collectOperationDefinitions(model *v3high.Document, options ParseOptions) ( }, nil } +// assignWithResponseTypeNames pre-computes the envelope wrapper name and the +// per-status header struct names for each operation, registering them on the +// TypeTracker so collisions with user-declared schemas are resolved before the +// templates run. Mutates the operations slice in place. +func assignWithResponseTypeNames(operations []OperationDefinition, tracker *TypeTracker) { + for i := range operations { + op := &operations[i] + baseName := UppercaseFirstCharacter(op.ID) + "Resp" + op.WithResponseTypeName = tracker.generateUniqueName(baseName) + tracker.registerName(op.WithResponseTypeName) + + statusesWithHeaders := func(rcds []*ResponseContentDefinition) { + for _, rcd := range rcds { + if len(rcd.Headers) == 0 { + continue + } + if op.HeaderTypeNames == nil { + op.HeaderTypeNames = make(map[int]string) + } + header := op.WithResponseTypeName + fmt.Sprintf("%dHeaders", rcd.StatusCode) + header = tracker.generateUniqueName(header) + tracker.registerName(header) + op.HeaderTypeNames[rcd.StatusCode] = header + } + } + statusesWithHeaders(op.Response.Successes) + statusesWithHeaders(op.Response.Errors) + } +} + // resolveRequestOptionsCollisions checks if any operation's RequestOptions type name // would collide with existing component schemas, and renames the operation ID if needed. // It also checks for ServiceRequestOptions collisions (used by handler generation). diff --git a/pkg/codegen/codegen_test.go b/pkg/codegen/codegen_test.go index c2a7742b..287ec57e 100644 --- a/pkg/codegen/codegen_test.go +++ b/pkg/codegen/codegen_test.go @@ -366,6 +366,124 @@ func TestOperationResponseAliasReusesSameType(t *testing.T) { require.NoError(t, err, "Generated code should compile without syntax errors") } +func TestAssignWithResponseTypeNames(t *testing.T) { + t.Run("empty operations slice is a no-op", func(t *testing.T) { + tracker := newTypeTracker() + var ops []OperationDefinition + assignWithResponseTypeNames(ops, tracker) + assert.Empty(t, ops) + }) + + t.Run("operation without response headers gets WithResponseTypeName but no HeaderTypeNames", func(t *testing.T) { + tracker := newTypeTracker() + ops := []OperationDefinition{{ + ID: "uploadDocument", + Response: ResponseDefinition{ + Successes: []*ResponseContentDefinition{ + {StatusCode: 201, IsSuccess: true}, + }, + }, + }} + + assignWithResponseTypeNames(ops, tracker) + + assert.Equal(t, "UploadDocumentResp", ops[0].WithResponseTypeName) + assert.Nil(t, ops[0].HeaderTypeNames) + }) + + t.Run("headers on success and error both get header type names", func(t *testing.T) { + tracker := newTypeTracker() + ops := []OperationDefinition{{ + ID: "uploadDocument", + Response: ResponseDefinition{ + Successes: []*ResponseContentDefinition{ + {StatusCode: 201, IsSuccess: true, Headers: map[string]GoSchema{ + "Location": {GoType: "string"}, + }}, + // no headers on this one + {StatusCode: 202, IsSuccess: true}, + }, + Errors: []*ResponseContentDefinition{ + {StatusCode: 422, Headers: map[string]GoSchema{ + "Retry-After": {GoType: "string"}, + }}, + }, + }, + }} + + assignWithResponseTypeNames(ops, tracker) + + require.NotNil(t, ops[0].HeaderTypeNames) + assert.Equal(t, "UploadDocumentResp201Headers", ops[0].HeaderTypeNames[201]) + _, has202 := ops[0].HeaderTypeNames[202] + assert.False(t, has202, "202 has no spec headers, so no Headers202 type should be reserved") + assert.Equal(t, "UploadDocumentResp422Headers", ops[0].HeaderTypeNames[422]) + }) + + t.Run("colliding wrapper name is disambiguated against existing tracker entries", func(t *testing.T) { + tracker := newTypeTracker() + // Simulate a user-declared schema that collides with the natural wrapper + // name we'd otherwise generate. + tracker.registerName("UploadDocumentResp") + + ops := []OperationDefinition{{ + ID: "uploadDocument", + Response: ResponseDefinition{ + Successes: []*ResponseContentDefinition{{StatusCode: 201, IsSuccess: true}}, + }, + }} + + assignWithResponseTypeNames(ops, tracker) + + assert.NotEqual(t, "UploadDocumentResp", ops[0].WithResponseTypeName, + "wrapper name must avoid the existing schema; got the colliding name back") + assert.NotEmpty(t, ops[0].WithResponseTypeName) + }) + + t.Run("colliding header name is disambiguated", func(t *testing.T) { + tracker := newTypeTracker() + tracker.registerName("UploadDocumentResp201Headers") + + ops := []OperationDefinition{{ + ID: "uploadDocument", + Response: ResponseDefinition{ + Successes: []*ResponseContentDefinition{ + {StatusCode: 201, IsSuccess: true, Headers: map[string]GoSchema{ + "Location": {GoType: "string"}, + }}, + }, + }, + }} + + assignWithResponseTypeNames(ops, tracker) + + assert.NotEqual(t, "UploadDocumentResp201Headers", ops[0].HeaderTypeNames[201], + "header name must avoid the existing schema; got the colliding name back") + assert.NotEmpty(t, ops[0].HeaderTypeNames[201]) + }) + + t.Run("two operations get independent names registered with the tracker", func(t *testing.T) { + tracker := newTypeTracker() + ops := []OperationDefinition{ + {ID: "uploadDocument", Response: ResponseDefinition{ + Successes: []*ResponseContentDefinition{{StatusCode: 201, IsSuccess: true}}, + }}, + {ID: "deleteDocument", Response: ResponseDefinition{ + Successes: []*ResponseContentDefinition{{StatusCode: 204, IsSuccess: true}}, + }}, + } + + assignWithResponseTypeNames(ops, tracker) + + assert.Equal(t, "UploadDocumentResp", ops[0].WithResponseTypeName) + assert.Equal(t, "DeleteDocumentResp", ops[1].WithResponseTypeName) + // Both names must be present in the tracker so subsequent unique-name + // resolution doesn't reuse them. + assert.True(t, tracker.Exists("UploadDocumentResp")) + assert.True(t, tracker.Exists("DeleteDocumentResp")) + }) +} + func TestOverlayAppliesExtensions(t *testing.T) { cfg := Configuration{ PackageName: "api", diff --git a/pkg/codegen/configuration.go b/pkg/codegen/configuration.go index 9dc209e7..22ecde20 100644 --- a/pkg/codegen/configuration.go +++ b/pkg/codegen/configuration.go @@ -162,6 +162,9 @@ func (o Configuration) OverwriteWith(other Configuration) Configuration { if other.Generate.Client { o.Generate.Client = other.Generate.Client } + if other.Generate.ClientWithResponse { + o.Generate.ClientWithResponse = other.Generate.ClientWithResponse + } if other.Generate.OmitDescription { o.Generate.OmitDescription = other.Generate.OmitDescription } @@ -291,9 +294,39 @@ func (o FilterParamsConfig) IsEmpty() bool { } type GenerateOptions struct { - // Client specifies whether to generate a client. Defaults to false. + // Client specifies whether to generate the classic client - one method + // per operation that returns the picked 2xx body type directly (e.g. + // `func (c *Client) UploadDocument(...) (*DocumentStored, error)`). + // Use this when callers only need the response body and the operation + // has a single documented success status. Headers and non-picked 2xx + // bodies are not exposed by this style; for that, use + // ClientWithResponse instead, or both together. + // + // Combinations with ClientWithResponse: + // - client: true, client-with-response: false → classic only + // - client: false, client-with-response: true → envelope only + // - client: true, client-with-response: true → both styles, combined + // into a single ClientInterface + // + // Defaults to false. Client bool `yaml:"client"` + // ClientWithResponse specifies whether to generate `WithResponse` + // sibling functions that return a typed envelope: one `JSON` + // field per documented response, one `Headers` typed struct per + // status that declares headers, plus `HTTPResponse *http.Response` for + // raw access (undocumented headers, status string, etc.). Use this when + // an operation has multiple 2xx statuses with different bodies (e.g. + // 201 sync + 202 queued), or when callers need typed access to headers + // like Location or Retry-After. + // + // Additive to Client: when both flags are true, the generated + // ClientInterface lists both `` and `WithResponse` methods so a + // single mock or test double covers both shapes. + // + // Defaults to false. + ClientWithResponse bool `yaml:"client-with-response"` + // Models specifies whether to generate model types. Defaults to true. // Set to false when models are generated in a separate package. Models *bool `yaml:"models,omitempty"` diff --git a/pkg/codegen/operations.go b/pkg/codegen/operations.go index 00fe7953..0900ab12 100644 --- a/pkg/codegen/operations.go +++ b/pkg/codegen/operations.go @@ -45,6 +45,17 @@ type OperationDefinition struct { // MCP contains x-mcp extension configuration for MCP tool generation MCP *MCPExtension + + // WithResponseTypeName is the name of the envelope wrapper struct emitted + // when generate.client-with-response is true (e.g. "UploadDocumentResp"). + // Empty when the feature is disabled. + WithResponseTypeName string + + // HeaderTypeNames maps each documented status code to the name of the + // per-status typed header struct emitted alongside the envelope (e.g. + // 201 -> "UploadDocumentResp201Headers"). Only populated for statuses + // that declare at least one header in the spec. + HeaderTypeNames map[int]string } // RequiresParamObject indicates If we have parameters other than path parameters, they're bundled into an diff --git a/pkg/codegen/parser.go b/pkg/codegen/parser.go index dc1f3f85..f0ac02b5 100644 --- a/pkg/codegen/parser.go +++ b/pkg/codegen/parser.go @@ -203,17 +203,34 @@ func (p *Parser) Parse() (GeneratedCode, error) { } } - if p.cfg.Generate.Client { + // When envelope generation is on, pre-compute the wrapper and per-status + // header type names once so they're stable across every emission step + // (response types append, client-with-response template, etc.). + if p.cfg.Generate.ClientWithResponse { + assignWithResponseTypeNames(p.ctx.Operations, p.ctx.TypeTracker) + } + + if p.cfg.Generate.Client || p.cfg.Generate.ClientWithResponse { opsCtx := &TplOperationsContext{ Operations: p.ctx.Operations, Imports: p.ctx.Imports, Config: p.cfg, WithHeader: withHeader, } - for _, tmpl := range []string{"client", "client-options"} { + // client-options runs whenever any client style is enabled - the + // envelope function calls into RequestOptions just like the classic + // client does. + tmpls := []string{"client-options"} + if p.cfg.Generate.Client { + tmpls = append(tmpls, "client") + } + if p.cfg.Generate.ClientWithResponse { + tmpls = append(tmpls, "client-with-response") + } + for _, tmpl := range tmpls { out, err := p.ParseTemplates([]string{tmpl + ".tmpl"}, opsCtx) if err != nil { - return nil, fmt.Errorf("error generating code for client: %w", err) + return nil, fmt.Errorf("error generating code for %s: %w", tmpl, err) } formatted := out if !useSingleFile { @@ -497,6 +514,58 @@ func (p *Parser) Parse() (GeneratedCode, error) { typesOut[getSpecLocationOutName(sl)] = formatted } + // When envelope client generation is on, emit the wrapper struct and + // per-status header structs into responses.go so they sit alongside + // the body type aliases they reference. goimports adds net/http on + // format. If the spec produced no other SpecLocationResponse types + // (e.g. operations only reference component schemas), the responses + // file may not exist yet - seed it with a package-decl header in + // multi-file mode so my types-only append produces compilable Go. + if p.cfg.Generate.ClientWithResponse { + respKey := getSpecLocationOutName(SpecLocationResponse) + if _, ok := typesOut[respKey]; !ok { + if useSingleFile { + typesOut[respKey] = "" + } else { + seedCtx := &TplTypeContext{ + Types: nil, + TypeSchemaMap: typeSchemaMap, + SpecLocation: string(SpecLocationResponse), + Imports: p.ctx.Imports, + Config: p.cfg, + WithHeader: withHeader, + ResponseErrors: responseErrs, + TypeTracker: p.ctx.TypeTracker, + } + seed, err := p.ParseTemplates([]string{"types.tmpl"}, seedCtx) + if err != nil { + return nil, fmt.Errorf("error seeding responses.go for envelope: %w", err) + } + typesOut[respKey] = seed + } + } + + opsCtx := &TplOperationsContext{ + Operations: p.ctx.Operations, + Imports: p.ctx.Imports, + Config: p.cfg, + WithHeader: false, + } + extra, err := p.ParseTemplates([]string{"client-with-response-types.tmpl"}, opsCtx) + if err != nil { + return nil, fmt.Errorf("error generating envelope response types: %w", err) + } + + combined := typesOut[respKey] + "\n" + extra + if !useSingleFile { + combined, err = p.formatCode(combined) + if err != nil { + return nil, fmt.Errorf("error formatting responses.go after envelope append: %w", err) + } + } + typesOut[respKey] = combined + } + if len(p.ctx.UnionTypes) > 0 { out, err := p.ParseTemplates([]string{"types.tmpl", "union.tmpl"}, &TplTypeContext{ Types: p.ctx.UnionTypes, diff --git a/pkg/codegen/schema.go b/pkg/codegen/schema.go index 613611ca..363785ae 100644 --- a/pkg/codegen/schema.go +++ b/pkg/codegen/schema.go @@ -427,7 +427,7 @@ func GenerateGoSchema(schemaProxy *base.SchemaProxy, options ParseOptions) (GoSc // 1. RefType affects TypeDecl() which would change the type name used in struct fields // 2. Component references already have their own type definitions with Validate() methods // 3. The validation will be delegated via the type assertion in Property.needsCustomValidation() - // Include Constraints so that consumers (like connexions) can access min/max values + // Include Constraints so that consumers (like Mockzilla) can access min/max values // for data generation even when using component references. constraints := newConstraints(schema, ConstraintsContext{ hasNilType: slices.Contains(schema.Type, "null"), diff --git a/pkg/codegen/templates/client-with-response-decode.tmpl b/pkg/codegen/templates/client-with-response-decode.tmpl new file mode 100644 index 00000000..98a06e98 --- /dev/null +++ b/pkg/codegen/templates/client-with-response-decode.tmpl @@ -0,0 +1,70 @@ +{{/* +Copyright 2025 DoorDash, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/}} + +{{- /* +Defines the "decodeBody" template used by client-with-response.tmpl (and any +user-provided overrides of it) to populate per-status JSON / +Text / etc. fields on the envelope. Lives in its own file so that +overriding client-with-response.tmpl doesn't shadow this helper - the same +dispatch logic is reusable from custom client templates that wrap the return +in futures, channels, or other shapes. + +Inputs (from caller's dict): rcd = a *ResponseContentDefinition. +Expected names in the surrounding scope: + - resp *runtime.Response (Content, StatusCode, Headers) + - out *Resp (the envelope value being populated) +*/ -}} + +{{- define "decodeBody" -}} +{{- $rcd := .rcd -}} +{{- $field := printf "%s%d" $rcd.NameTag $rcd.StatusCode -}} +{{- if or (eq $rcd.ResponseName "struct{}") $rcd.IsRaw (eq $rcd.NameTag "") -}} +{{- else if and (or (eq $rcd.NameTag "Text") (eq $rcd.NameTag "HTML")) (eq $rcd.Schema.GoType "string") }} + v := {{$rcd.ResponseName}}(resp.Content) + out.{{$field}} = &v +{{- else }} + out.{{$field}} = new({{$rcd.ResponseName}}) + bodyBytes := resp.Content + {{- if eq $rcd.NameTag "Formdata" }} + if len(bodyBytes) > 0 { + converted, convErr := runtime.ConvertFormFields(bodyBytes) + if convErr != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "{{$rcd.ResponseName}}", + Body: bodyBytes, + Err: convErr, + } + } + bodyBytes = converted + } + {{- end }} + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.{{$field}}); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "{{$rcd.ResponseName}}", + Body: bodyBytes, + Err: err, + } + } + } +{{- end }} +{{- end }} diff --git a/pkg/codegen/templates/client-with-response-types.tmpl b/pkg/codegen/templates/client-with-response-types.tmpl new file mode 100644 index 00000000..8848c45d --- /dev/null +++ b/pkg/codegen/templates/client-with-response-types.tmpl @@ -0,0 +1,73 @@ +{{/* +Copyright 2026 DoorDash, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/}} + +{{- /* +This template emits only the envelope type declarations (the wrapper struct +and per-status typed header structs). Output is appended to responses.go so +the wrapper sits next to the body type aliases it references. No header / +imports / package decl - those come from the surrounding file. +*/ -}} + +{{ define "client-with-response-types" }} +{{- $args := . -}} +{{- $operations := $args.operations -}} + +{{- range $operations }}{{ $op := . }} +{{- range $rcd := $op.Response.Successes }} +{{- if gt (len $rcd.Headers) 0 }} +type {{ index $op.HeaderTypeNames $rcd.StatusCode }} struct { + {{- range $hName, $hSchema := $rcd.Headers }} + {{ genTypeName $hName }} string `header:"{{ escapeGoString $hName }}"` + {{- end }} +} +{{ end -}} +{{- end -}} +{{- range $rcd := $op.Response.Errors }} +{{- if gt (len $rcd.Headers) 0 }} +type {{ index $op.HeaderTypeNames $rcd.StatusCode }} struct { + {{- range $hName, $hSchema := $rcd.Headers }} + {{ genTypeName $hName }} string `header:"{{ escapeGoString $hName }}"` + {{- end }} +} +{{ end -}} +{{- end }} + +type {{$op.WithResponseTypeName}} struct { + HTTPResponse *http.Response + Body []byte + StatusCode int + {{- range $rcd := $op.Response.Successes }} + {{- if and (ne $rcd.ResponseName "struct{}") (not $rcd.IsRaw) (ne $rcd.NameTag "") }} + {{$rcd.NameTag}}{{$rcd.StatusCode}} *{{$rcd.ResponseName}} + {{- end }} + {{- if gt (len $rcd.Headers) 0 }} + Headers{{$rcd.StatusCode}} *{{ index $op.HeaderTypeNames $rcd.StatusCode }} + {{- end }} + {{- end }} + {{- range $rcd := $op.Response.Errors }} + {{- if and (ne $rcd.ResponseName "struct{}") (not $rcd.IsRaw) (ne $rcd.NameTag "") }} + {{$rcd.NameTag}}{{$rcd.StatusCode}} *{{$rcd.ResponseName}} + {{- end }} + {{- if gt (len $rcd.Headers) 0 }} + Headers{{$rcd.StatusCode}} *{{ index $op.HeaderTypeNames $rcd.StatusCode }} + {{- end }} + {{- end }} +} + +{{ end }}{{- /* end range operations */ -}} +{{ end -}} + +{{ template "client-with-response-types" dict "operations" .Operations }} diff --git a/pkg/codegen/templates/client-with-response.tmpl b/pkg/codegen/templates/client-with-response.tmpl new file mode 100644 index 00000000..b0ccbdff --- /dev/null +++ b/pkg/codegen/templates/client-with-response.tmpl @@ -0,0 +1,132 @@ +{{/* +Copyright 2026 DoorDash, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/}} + +{{- template "header" $ }} + +{{ define "client-with-response" }} +{{ $args := . }} +{{ $config := $args.config }} +{{ $operations := $args.operations }} + +{{ $clientName := $config.Client.Name }} + +{{- /* When generate.client is off, the Client struct, constructors, and the + single ClientInterface are not produced by client.tmpl. Emit them here + so the envelope methods have a receiver and a stable interface name. + When generate.client is also on, client.tmpl already emits all of these + (with envelope methods folded into the combined ClientInterface) - this + template just adds the function bodies. */ -}} +{{- if not $config.Generate.Client }} +// {{$clientName}} is the client for the API implementing the {{$clientName}}Interface interface. +type {{$clientName}} struct { + apiClient runtime.APIClient +} + +// New{{$clientName}} creates a new instance of the {{$clientName}} client. +func New{{$clientName}}(apiClient runtime.APIClient) *{{$clientName}} { + return &{{$clientName}}{apiClient: apiClient} +} + +// NewDefault{{$clientName}} creates a new instance of the {{$clientName}} client with default api client. +func NewDefault{{$clientName}}(baseURL string, opts ...runtime.APIClientOption) (*{{$clientName}}, error) { + apiClient, err := runtime.NewAPIClient(baseURL, opts...) + if err != nil { + return nil, fmt.Errorf("error creating API client: %w", err) + } + return &{{$clientName}}{apiClient: apiClient}, nil +} + +// {{$clientName}}Interface is the interface for the API client. With +// envelope-only generation it lists the WithResponse methods. +type {{$clientName}}Interface interface { + {{- range $operations }}{{ $op := . }} + {{$op.ID}}WithResponse(ctx context.Context{{- if $op.HasRequestOptions }}, options *{{$op.ID | ucFirst}}RequestOptions{{ end }}, reqEditors ...runtime.RequestEditorFn) (*{{$op.WithResponseTypeName}}, error) + {{- end }} +} +{{- end }} + +{{ range $operations }}{{ $op := . }} +{{ if not $config.Generate.OmitDescription }}{{ toGoComment $op.Summary $op.ID }}{{ end }} +func (c *{{$clientName}}) {{$op.ID}}WithResponse(ctx context.Context{{ if $op.HasRequestOptions }}, options *{{$op.ID | ucFirst}}RequestOptions{{ end }}, reqEditors ...runtime.RequestEditorFn) (*{{$op.WithResponseTypeName}}, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "{{escapeGoString $op.Path}}", + Method: "{{$op.Method}}", + {{- if $op.HasRequestOptions }} + Options: options, + {{- end }} + {{- if $op.Body }} + ContentType: "{{$op.Body.ContentType}}", + {{- end }} + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "{{ escapeGoString $op.Path }}") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + + out := &{{$op.WithResponseTypeName}}{ + HTTPResponse: resp.Raw, + Body: resp.Content, + StatusCode: resp.StatusCode, + } + + switch resp.StatusCode { + {{- range $rcd := $op.Response.Successes }} + case {{ $rcd.StatusCode }}: + {{- template "decodeBody" (dict "rcd" $rcd) }} + {{- if gt (len $rcd.Headers) 0 }} + out.Headers{{$rcd.StatusCode}} = &{{ index $op.HeaderTypeNames $rcd.StatusCode }}{ + {{- range $hName, $hSchema := $rcd.Headers }} + {{ genTypeName $hName }}: resp.Headers.Get("{{ escapeGoString $hName }}"), + {{- end }} + } + {{- end }} + return out, nil + {{- end }} + {{- range $rcd := $op.Response.Errors }} + case {{ $rcd.StatusCode }}: + {{- template "decodeBody" (dict "rcd" $rcd) }} + {{- if gt (len $rcd.Headers) 0 }} + out.Headers{{$rcd.StatusCode}} = &{{ index $op.HeaderTypeNames $rcd.StatusCode }}{ + {{- range $hName, $hSchema := $rcd.Headers }} + {{ genTypeName $hName }}: resp.Headers.Get("{{ escapeGoString $hName }}"), + {{- end }} + } + {{- end }} + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + {{- end }} + default: + return out, runtime.NewClientAPIError(fmt.Errorf("unexpected status code: %d", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + } +} + +{{ end }}{{- /* end range operations */ -}} + +{{- /* The interface assertion is emitted by client.tmpl when classic is on + (its ClientInterface already covers envelope methods); only emit it + here in envelope-only mode. */ -}} +{{- if not $config.Generate.Client }} +var _ {{$clientName}}Interface = (*{{$clientName}})(nil) +{{- end }} +{{ end -}} + +{{ template "client-with-response" dict "config" .Config "operations" .Operations }} diff --git a/pkg/codegen/templates/client.tmpl b/pkg/codegen/templates/client.tmpl index 025baa2b..b0c99974 100644 --- a/pkg/codegen/templates/client.tmpl +++ b/pkg/codegen/templates/client.tmpl @@ -47,6 +47,9 @@ type {{$clientName}}Interface interface { {{- range $operations }}{{$op := .}} {{if not $config.Generate.OmitDescription}}{{ toGoComment $op.Summary $op.ID}}{{end}} {{$op.ID}}(ctx context.Context{{- if $op.HasRequestOptions }}, options *{{$op.ID | ucFirst}}RequestOptions{{end}}, reqEditors ...runtime.RequestEditorFn) (*{{ $op.Response.Success.ResponseName }}, error) + {{- if $config.Generate.ClientWithResponse }} + {{$op.ID}}WithResponse(ctx context.Context{{- if $op.HasRequestOptions }}, options *{{$op.ID | ucFirst}}RequestOptions{{end}}, reqEditors ...runtime.RequestEditorFn) (*{{$op.WithResponseTypeName}}, error) + {{- end }} {{ end }} } diff --git a/pkg/codegen/typedef_responses.go b/pkg/codegen/typedef_responses.go index 6c774a98..714b404a 100644 --- a/pkg/codegen/typedef_responses.go +++ b/pkg/codegen/typedef_responses.go @@ -13,6 +13,7 @@ package codegen import ( "fmt" "iter" + "sort" "strconv" "strings" @@ -25,6 +26,16 @@ type ResponseDefinition struct { Success *ResponseContentDefinition Error *ResponseContentDefinition All map[int]*ResponseContentDefinition + + // Successes lists every 2xx response in ascending status order. Drives + // `client-with-response` envelope generation, which needs a deterministic + // per-status iteration. + Successes []*ResponseContentDefinition + + // Errors lists every non-2xx response in ascending status order. Same + // motivation as Successes - the envelope populates `JSON4xx`/`JSON5xx` + // fields per documented status. + Errors []*ResponseContentDefinition } // ResponseContentDefinition describes Operation response. @@ -74,11 +85,14 @@ func getOperationResponses(operationID string, responses *v3high.Responses, opti } all[successCode] = successDefinition + successes, errs := partitionResponses(all) return &ResponseDefinition{ SuccessStatusCode: successCode, Success: successDefinition, Error: nil, All: all, + Successes: successes, + Errors: errs, }, nil, nil } @@ -490,16 +504,42 @@ func getOperationResponses(operationID string, responses *v3high.Responses, opti } } + successes, errs := partitionResponses(all) res := &ResponseDefinition{ SuccessStatusCode: successCode, Success: all[successCode], Error: all[fstErrorCode], All: all, + Successes: successes, + Errors: errs, } return res, typeDefinitions, nil } +// partitionResponses splits an `all` map of every documented response into +// (successes, errors) slices, each ascending by status code. Used to populate +// ResponseDefinition.Successes / .Errors so templates can iterate +// deterministically. +func partitionResponses(all map[int]*ResponseContentDefinition) (successes, errors []*ResponseContentDefinition) { + statuses := make([]int, 0, len(all)) + for s := range all { + statuses = append(statuses, s) + } + + sort.Ints(statuses) + + for _, s := range statuses { + r := all[s] + if r.IsSuccess { + successes = append(successes, r) + } else { + errors = append(errors, r) + } + } + return successes, errors +} + func generateResponseHeadersSchema(headers iter.Seq2[string, *v3high.Header], operationID string, options ParseOptions) (map[string]GoSchema, error) { res := make(map[string]GoSchema) opts := options.WithReference("").WithPath([]string{operationID, "Header"})