diff --git a/AGENTS.md b/AGENTS.md index fa40a2b0..bbe66bf1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -156,6 +156,16 @@ Central registry for managing type names and references: - `warnUnconsumableStreams` logs affected operations when the flag is off, so the flag is discoverable. It scans media type names only, independent of the detection pass, because the warning has to work precisely when detection is switched off - The client marks the request via `RequestOptionsParameters.Stream`, and `runtime.Client.ExecuteRequest` skips buffering only for a marked request whose 2xx response has a sequential Content-Type +### Status ranges and `default` (envelope client) +- `All` / `Success` / `Error` put a range or `default` at a stand-in `StatusCode` (`2XX` 200, `4XX` and `5XX` both 400, `default` 200 or 500). Handler generation depends on those codes, so they stay as they are. The envelope client never matches on them +- The envelope reads `Successes` / `Errors` (exact codes only), `StatusRanges` and `Default`, built by `setEnvelopeResponses` from every documented response. `ResponseContentDefinition.StatusRange` / `IsDefault` say what a response covers, and `StatusName()` gives the `JSON4XX` / `JSONDefault` / `Headers5XX` suffix. Templates get header struct names from `OperationDefinition.HeaderTypeName(rcd)`; `HeaderTypeNames` stays keyed by status code so custom templates indexing it by `StatusCode` keep working +- Fields earlier versions generated at a stand-in code stay as deprecated aliases that point at the same value: `LegacyBodyField` (e.g. `JSON400` for an inline `4XX`, `JSON200` for `2XX`) and `HasLegacyHeaders` (e.g. `Headers500` plus a `Resp500Headers` type alias for `default`). Only the response `All` holds at that code gets them, never one referencing a component response (it had no field), and an exact code documented there owns the name instead. `decodeBody` sets the alias through its optional `alias` input +- A type name that needs a status suffix to stay unique takes the range (`...JSON4XX`), so `...JSON400` always means an explicit 400 +- Exact codes stay `case N:` values, so operations without ranges or `default` generate byte-identical code. Ranges (`resp.StatusCode/100 == N`) and then `default` are matched inside the `default:` branch, giving OpenAPI precedence +- A documented error without a body only joins the envelope when a range or `default` would otherwise claim its status, so it keeps precedence without changing other operations' output +- `default` next to explicit error codes is only built with `generate.client-with-response` (`ParseOptions.ClientWithResponse`), since it adds a type nothing else uses +- `client-stream.tmpl`'s `StreamWithResponse` matches only error responses past the streaming status, so it skips success ranges and a `default` standing in for the success + ### Union types (oneOf/anyOf) - Union types are generated as structs with pointer fields for each variant - Use `ContainsUnions()` method on schemas to check if they contain union elements diff --git a/docs/configuration.md b/docs/configuration.md index 0616021b..7f25c2f8 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -158,6 +158,8 @@ Generate `WithResponse` sibling functions that return a typed envelope: one 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`. +Status ranges and `default` get fields of their own: `JSON4XX` for `4XX`, `JSONDefault` for `default`. Responses are matched as OpenAPI specifies, so an exact status wins over the range covering it, and both win over `default`: with `404`, `4XX` and `default` documented, a 404 fills `JSON404`, a 409 `JSON4XX` and a 503 `JSONDefault`. The returned error is nil only for a documented success. When `default` is the only success documented, it counts as one for 2xx statuses only. Earlier versions exposed a range under a single stand-in code, such as `JSON400` for `4XX` or `JSON200` for `2XX`; those fields remain as deprecated aliases of the new ones, unless an explicit code now owns the name. + 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 diff --git a/docs/streaming.md b/docs/streaming.md index f05fdf80..e8dff3e9 100644 --- a/docs/streaming.md +++ b/docs/streaming.md @@ -54,14 +54,14 @@ frames - `Chunk` here, generated from the media type's schema like any other response body: ```go ---8<-- "client/streaming/gen.go:778:782" +--8<-- "client/streaming/gen.go:884:888" ``` With `generate.client-with-response` on, one envelope type carries either shape, populated by whichever method was called: ```go ---8<-- "client/streaming/gen.go:725:731" +--8<-- "client/streaming/gen.go:827:833" ``` ## Consuming a Stream diff --git a/examples/client/streaming/api.yaml b/examples/client/streaming/api.yaml index 0addea9b..568ee948 100644 --- a/examples/client/streaming/api.yaml +++ b/examples/client/streaming/api.yaml @@ -75,6 +75,18 @@ paths: type: string host: type: string + '4XX': + description: The tail was refused + content: + application/json: + schema: + $ref: '#/components/schemas/ServiceError' + default: + description: Any other failure + content: + application/json: + schema: + $ref: '#/components/schemas/ServiceError' components: schemas: Completion: diff --git a/examples/client/streaming/gen.go b/examples/client/streaming/gen.go index f50e34f1..58b37bd0 100644 --- a/examples/client/streaming/gen.go +++ b/examples/client/streaming/gen.go @@ -164,7 +164,25 @@ func (c *Client) StreamLogs(ctx context.Context, reqEditors ...runtime.RequestEd responseParser := func(ctx context.Context, resp *runtime.Response) (*StreamLogsResponse, error) { bodyBytes := resp.Content if resp.StatusCode != 200 { - return nil, runtime.NewClientAPIError(fmt.Errorf("unexpected status code: %d", resp.StatusCode), + target := new(StreamLogsErrorResponse) + // 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: "StreamLogsErrorResponse", + 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)) } result := StreamLogsResponse(bodyBytes) @@ -456,7 +474,23 @@ func (c *Client) StreamLogsStream(ctx context.Context, reqEditors ...runtime.Req if resp.Raw != nil && resp.Raw.Body != nil { _ = resp.Raw.Body.Close() } - return nil, runtime.NewClientAPIError(fmt.Errorf("unexpected status code: %d", resp.StatusCode), + target := new(StreamLogsErrorResponse) + if len(resp.Content) > 0 { + if err = json.Unmarshal(resp.Content, target); err != nil { + return nil, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(resp.Content), + TargetType: "StreamLogsErrorResponse", + Body: resp.Content, + Err: err, + } + } + } + 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)) } @@ -511,7 +545,39 @@ func (c *Client) StreamLogsStreamWithResponse(ctx context.Context, reqEditors .. if resp.Raw != nil && resp.Raw.Body != nil { _ = resp.Raw.Body.Close() } - return out, runtime.NewClientAPIError(fmt.Errorf("unexpected status code: %d", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + if resp.StatusCode/100 == 4 { + out.JSON4XX = new(StreamLogsErrorResponse) + out.JSON400 = out.JSON4XX + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON4XX); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "StreamLogsErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + } + out.JSONDefault = new(StreamLogsErrorResponseJSON) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSONDefault); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "StreamLogsErrorResponseJSON", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) } } @@ -639,7 +705,39 @@ func (c *Client) StreamLogsWithResponse(ctx context.Context, reqEditors ...runti case 200: return out, nil default: - return out, runtime.NewClientAPIError(fmt.Errorf("unexpected status code: %d", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + if resp.StatusCode/100 == 4 { + out.JSON4XX = new(StreamLogsErrorResponse) + out.JSON400 = out.JSON4XX + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON4XX); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "StreamLogsErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + } + out.JSONDefault = new(StreamLogsErrorResponseJSON) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSONDefault); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "StreamLogsErrorResponseJSON", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) } } @@ -715,6 +813,10 @@ type GetEventsErrorResponse = ServiceError type StreamLogsResponse = []byte +type StreamLogsErrorResponse = ServiceError + +type StreamLogsErrorResponseJSON = ServiceError + type StreamLogsResponseItem struct { Level StreamLogsResponseLevel `json:"level" validate:"required"` Message string `json:"message" validate:"required"` @@ -743,6 +845,10 @@ type StreamLogsResp struct { Body []byte StatusCode int Stream200 *runtime.Stream[StreamLogsResponseItem] + JSON4XX *StreamLogsErrorResponse + // Deprecated: Use JSON4XX, which this always equals. + JSON400 *StreamLogsErrorResponse + JSONDefault *StreamLogsErrorResponseJSON } type Completion struct { diff --git a/examples/client/streaming/gen_test.go b/examples/client/streaming/gen_test.go index ee38b49b..43a2da37 100644 --- a/examples/client/streaming/gen_test.go +++ b/examples/client/streaming/gen_test.go @@ -295,6 +295,47 @@ func TestStreamLogs_DecodesLineDelimitedJSON(t *testing.T) { assert.Equal(t, []StreamLogsResponseLevel{Info, Warn}, levels) } +// A status range and `default` reach the streaming envelope too, matched after +// the exact codes like in the buffered method. +func TestStreamLogsStreamWithResponse_DecodesRangeAndDefault(t *testing.T) { + errorServer := func(t *testing.T, status int) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, _ = fmt.Fprintf(w, `{"message":"status %d"}`, status) + })) + } + + t.Run("4XX", func(t *testing.T) { + srv := errorServer(t, http.StatusTooManyRequests) + defer srv.Close() + + resp, err := newClient(t, srv).StreamLogsStreamWithResponse(context.Background()) + var apiErr *runtime.ClientAPIError + require.ErrorAs(t, err, &apiErr) + assert.Equal(t, http.StatusTooManyRequests, apiErr.StatusCode()) + require.NotNil(t, resp.JSON4XX) + assert.Equal(t, "status 429", resp.JSON4XX.Message) + assert.Same(t, resp.JSON4XX, resp.JSON400, "the deprecated name earlier versions used") + assert.Nil(t, resp.JSONDefault) + assert.Nil(t, resp.Stream200) + }) + + t.Run("default", func(t *testing.T) { + srv := errorServer(t, http.StatusServiceUnavailable) + defer srv.Close() + + resp, err := newClient(t, srv).StreamLogsStreamWithResponse(context.Background()) + var apiErr *runtime.ClientAPIError + require.ErrorAs(t, err, &apiErr) + assert.Equal(t, http.StatusServiceUnavailable, apiErr.StatusCode()) + require.NotNil(t, resp.JSONDefault) + assert.Equal(t, "status 503", resp.JSONDefault.Message) + assert.Nil(t, resp.JSON4XX) + }) +} + func TestGetEventsWithResponse_ExposesStreamOnEnvelope(t *testing.T) { srv := eventServer(t, 2, "") defer srv.Close() diff --git a/examples/responses/multiple/client-with-response/api.yaml b/examples/responses/multiple/client-with-response/api.yaml index 7e83a68e..44c3ce7a 100644 --- a/examples/responses/multiple/client-with-response/api.yaml +++ b/examples/responses/multiple/client-with-response/api.yaml @@ -54,6 +54,70 @@ paths: text/plain: schema: type: string + /documents/{id}: + parameters: + - name: id + in: path + required: true + schema: + type: string + get: + operationId: getDocument + summary: Fetch a document + responses: + '200': + description: Document found + content: + application/json: + schema: + $ref: '#/components/schemas/DocumentStored' + '404': + description: No document with this ID. Takes precedence over 4XX. + content: + application/json: + schema: + $ref: '#/components/schemas/NotFoundError' + '410': + description: Document was deleted. No body, and still not a 4XX. + '5XX': + description: Any server error + headers: + Retry-After: + schema: + type: integer + content: + application/json: + schema: + $ref: '#/components/schemas/ServiceError' + '4XX': + description: Any other client error + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + delete: + operationId: deleteDocument + summary: Delete a document + responses: + '204': + description: Document deleted + default: + description: Any status not documented above + content: + application/json: + schema: + $ref: '#/components/schemas/ServiceError' + /health: + get: + operationId: getHealth + summary: Report service health, whatever the status + responses: + default: + description: Health report. Only a 2xx means healthy. + content: + application/json: + schema: + $ref: '#/components/schemas/Health' components: schemas: @@ -93,3 +157,21 @@ components: type: string message: type: string + NotFoundError: + type: object + required: [id] + properties: + id: + type: string + ServiceError: + type: object + required: [message] + properties: + message: + type: string + Health: + type: object + required: [status] + properties: + status: + type: string diff --git a/examples/responses/multiple/client-with-response/gen/client_options.go b/examples/responses/multiple/client-with-response/gen/client_options.go index c50e4a36..cfcbb0c5 100644 --- a/examples/responses/multiple/client-with-response/gen/client_options.go +++ b/examples/responses/multiple/client-with-response/gen/client_options.go @@ -49,3 +49,91 @@ func (o *UploadDocumentRequestOptions) GetBody() any { func (o *UploadDocumentRequestOptions) GetHeader() (map[string]string, error) { return nil, nil } + +// GetDocumentRequestOptions is the options needed to make a request to GetDocument. +type GetDocumentRequestOptions struct { + PathParams *GetDocumentPath +} + +// Validate validates all the fields in the options. +// Use it if fields validation was not run. +func (o *GetDocumentRequestOptions) Validate() error { + var errors runtime.ValidationErrors + + if o.PathParams != nil { + if v, ok := any(o.PathParams).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("PathParams", err) + } + } + } + if len(errors) == 0 { + return nil + } + + return errors +} + +// GetPathParams returns the path params as a map. +func (o *GetDocumentRequestOptions) GetPathParams() (map[string]any, error) { + return runtime.AsMap[any](o.PathParams) +} + +// GetQuery returns the query params as a map. +func (o *GetDocumentRequestOptions) 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 *GetDocumentRequestOptions) GetBody() any { + return nil +} + +// GetHeader returns the headers as a map. +func (o *GetDocumentRequestOptions) GetHeader() (map[string]string, error) { + return nil, nil +} + +// DeleteDocumentRequestOptions is the options needed to make a request to DeleteDocument. +type DeleteDocumentRequestOptions struct { + PathParams *DeleteDocumentPath +} + +// Validate validates all the fields in the options. +// Use it if fields validation was not run. +func (o *DeleteDocumentRequestOptions) Validate() error { + var errors runtime.ValidationErrors + + if o.PathParams != nil { + if v, ok := any(o.PathParams).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("PathParams", err) + } + } + } + if len(errors) == 0 { + return nil + } + + return errors +} + +// GetPathParams returns the path params as a map. +func (o *DeleteDocumentRequestOptions) GetPathParams() (map[string]any, error) { + return runtime.AsMap[any](o.PathParams) +} + +// GetQuery returns the query params as a map. +func (o *DeleteDocumentRequestOptions) 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 *DeleteDocumentRequestOptions) GetBody() any { + return nil +} + +// GetHeader returns the headers as a map. +func (o *DeleteDocumentRequestOptions) 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 index 3ca94a9b..efaa7f61 100644 --- a/examples/responses/multiple/client-with-response/gen/client_with_response.go +++ b/examples/responses/multiple/client-with-response/gen/client_with_response.go @@ -33,6 +33,9 @@ func NewDefaultClient(baseURL string, opts ...runtime.APIClientOption) (*Client, // envelope-only generation it lists the WithResponse methods. type ClientInterface interface { UploadDocumentWithResponse(ctx context.Context, options *UploadDocumentRequestOptions, reqEditors ...runtime.RequestEditorFn) (*UploadDocumentResp, error) + GetDocumentWithResponse(ctx context.Context, options *GetDocumentRequestOptions, reqEditors ...runtime.RequestEditorFn) (*GetDocumentResp, error) + DeleteDocumentWithResponse(ctx context.Context, options *DeleteDocumentRequestOptions, reqEditors ...runtime.RequestEditorFn) (*DeleteDocumentResp, error) + GetHealthWithResponse(ctx context.Context, reqEditors ...runtime.RequestEditorFn) (*GetHealthResp, error) } func (c *Client) UploadDocumentWithResponse(ctx context.Context, options *UploadDocumentRequestOptions, reqEditors ...runtime.RequestEditorFn) (*UploadDocumentResp, error) { @@ -126,4 +129,198 @@ func (c *Client) UploadDocumentWithResponse(ctx context.Context, options *Upload } } +func (c *Client) GetDocumentWithResponse(ctx context.Context, options *GetDocumentRequestOptions, reqEditors ...runtime.RequestEditorFn) (*GetDocumentResp, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/documents/{id}", + Method: "GET", + Options: options, + } + + 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/{id}") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + + out := &GetDocumentResp{ + HTTPResponse: resp.Raw, + Body: resp.Content, + StatusCode: resp.StatusCode, + } + + switch resp.StatusCode { + case 200: + out.JSON200 = new(GetDocumentResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON200); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetDocumentResponse", + Body: bodyBytes, + Err: err, + } + } + } + return out, nil + case 404: + out.JSON404 = new(GetDocumentErrorResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON404); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetDocumentErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 410: + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + default: + if resp.StatusCode/100 == 4 { + out.JSON4XX = new(GetDocumentErrorResponseJSON4XX) + out.JSON400 = out.JSON4XX + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON4XX); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetDocumentErrorResponseJSON4XX", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + } + if resp.StatusCode/100 == 5 { + out.JSON5XX = new(GetDocumentErrorResponseJSON) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON5XX); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetDocumentErrorResponseJSON", + Body: bodyBytes, + Err: err, + } + } + } + out.Headers5XX = &GetDocumentResp5XXHeaders{ + RetryAfter: resp.Headers.Get("Retry-After"), + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + } + return out, runtime.NewClientAPIError(fmt.Errorf("unexpected status code: %d", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + } +} + +func (c *Client) DeleteDocumentWithResponse(ctx context.Context, options *DeleteDocumentRequestOptions, reqEditors ...runtime.RequestEditorFn) (*DeleteDocumentResp, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/documents/{id}", + Method: "DELETE", + Options: options, + } + + 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/{id}") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + + out := &DeleteDocumentResp{ + HTTPResponse: resp.Raw, + Body: resp.Content, + StatusCode: resp.StatusCode, + } + + switch resp.StatusCode { + case 204: + return out, nil + default: + out.JSONDefault = new(DeleteDocumentErrorResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSONDefault); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "DeleteDocumentErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + } +} + +func (c *Client) GetHealthWithResponse(ctx context.Context, reqEditors ...runtime.RequestEditorFn) (*GetHealthResp, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/health", + Method: "GET", + } + + 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, "/health") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + + out := &GetHealthResp{ + HTTPResponse: resp.Raw, + Body: resp.Content, + StatusCode: resp.StatusCode, + } + + switch resp.StatusCode { + default: + out.JSONDefault = new(GetHealthResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSONDefault); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetHealthResponse", + Body: bodyBytes, + Err: err, + } + } + } + if resp.StatusCode/100 == 2 { + return out, nil + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + } +} + var _ ClientInterface = (*Client)(nil) diff --git a/examples/responses/multiple/client-with-response/gen/gen_test.go b/examples/responses/multiple/client-with-response/gen/gen_test.go index 0f655dd1..d7ab711e 100644 --- a/examples/responses/multiple/client-with-response/gen/gen_test.go +++ b/examples/responses/multiple/client-with-response/gen/gen_test.go @@ -2,6 +2,7 @@ package gen import ( "encoding/json" + "fmt" "net/http" "net/http/httptest" "testing" @@ -166,3 +167,137 @@ func TestUploadDocumentWithResponse(t *testing.T) { assert.Nil(t, resp.JSON422) }) } + +// requireAPIError asserts err is the client's API error for the given status. +func requireAPIError(t *testing.T, err error, status int) { + t.Helper() + var apiErr *runtime.ClientAPIError + require.ErrorAs(t, err, &apiErr) + assert.Equal(t, status, apiErr.StatusCode()) +} + +func getDocumentOpts() *GetDocumentRequestOptions { + return &GetDocumentRequestOptions{PathParams: &GetDocumentPath{ID: "doc-1"}} +} + +func TestGetDocumentWithResponse(t *testing.T) { + t.Run("an exact code takes precedence over the range covering it", func(t *testing.T) { + srv := newServer(t, http.StatusNotFound, NotFoundError{ID: "doc-1"}, nil) + defer srv.Close() + + resp, err := newClient(t, srv).GetDocumentWithResponse(t.Context(), getDocumentOpts()) + requireAPIError(t, err, http.StatusNotFound) + require.NotNil(t, resp.JSON404) + assert.Equal(t, "doc-1", resp.JSON404.ID) + assert.Nil(t, resp.JSON4XX) + }) + + t.Run("an exact code without a body is not decoded as its range", func(t *testing.T) { + srv := newServer(t, http.StatusGone, ValidationError{Code: "gone", Message: "deleted"}, nil) + defer srv.Close() + + resp, err := newClient(t, srv).GetDocumentWithResponse(t.Context(), getDocumentOpts()) + requireAPIError(t, err, http.StatusGone) + assert.Nil(t, resp.JSON4XX) + }) + + t.Run("4XX decodes any other client error", func(t *testing.T) { + srv := newServer(t, http.StatusConflict, ValidationError{Code: "conflict", Message: "busy"}, nil) + defer srv.Close() + + resp, err := newClient(t, srv).GetDocumentWithResponse(t.Context(), getDocumentOpts()) + requireAPIError(t, err, http.StatusConflict) + require.NotNil(t, resp.JSON4XX) + assert.Equal(t, "conflict", resp.JSON4XX.Code) + assert.Nil(t, resp.JSON404) + assert.Nil(t, resp.JSON5XX) + }) + + // Earlier versions exposed 4XX as JSON400. The field stays, deprecated, so + // code written against it keeps compiling and now sees every 4xx. + t.Run("the field earlier versions used for 4XX stays as an alias", func(t *testing.T) { + for _, status := range []int{http.StatusBadRequest, http.StatusConflict} { + srv := newServer(t, status, ValidationError{Code: "invalid", Message: "nope"}, nil) + + resp, err := newClient(t, srv).GetDocumentWithResponse(t.Context(), getDocumentOpts()) + srv.Close() + requireAPIError(t, err, status) + require.NotNil(t, resp.JSON4XX) + assert.Same(t, resp.JSON4XX, resp.JSON400) + } + }) + + t.Run("5XX decodes into its own type and headers", func(t *testing.T) { + srv := newServer(t, http.StatusServiceUnavailable, ServiceError{Message: "overloaded"}, map[string]string{ + "Retry-After": "30", + }) + defer srv.Close() + + resp, err := newClient(t, srv).GetDocumentWithResponse(t.Context(), getDocumentOpts()) + requireAPIError(t, err, http.StatusServiceUnavailable) + require.NotNil(t, resp.JSON5XX) + assert.Equal(t, "overloaded", resp.JSON5XX.Message) + require.NotNil(t, resp.Headers5XX) + assert.Equal(t, "30", resp.Headers5XX.RetryAfter) + assert.Nil(t, resp.JSON4XX) + }) + + t.Run("a status no response covers is unexpected", func(t *testing.T) { + srv := newServer(t, http.StatusNotModified, nil, nil) + defer srv.Close() + + resp, err := newClient(t, srv).GetDocumentWithResponse(t.Context(), getDocumentOpts()) + requireAPIError(t, err, http.StatusNotModified) + assert.Nil(t, resp.JSON200) + assert.Nil(t, resp.JSON4XX) + assert.Nil(t, resp.JSON5XX) + }) +} + +func TestDeleteDocumentWithResponse(t *testing.T) { + opts := &DeleteDocumentRequestOptions{PathParams: &DeleteDocumentPath{ID: "doc-1"}} + + t.Run("204 is a success", func(t *testing.T) { + srv := newServer(t, http.StatusNoContent, nil, nil) + defer srv.Close() + + resp, err := newClient(t, srv).DeleteDocumentWithResponse(t.Context(), opts) + require.NoError(t, err) + assert.Equal(t, http.StatusNoContent, resp.StatusCode) + assert.Nil(t, resp.JSONDefault) + }) + + for _, status := range []int{http.StatusNotFound, http.StatusInternalServerError} { + t.Run(fmt.Sprintf("default decodes %d", status), func(t *testing.T) { + srv := newServer(t, status, ServiceError{Message: "cannot delete"}, nil) + defer srv.Close() + + resp, err := newClient(t, srv).DeleteDocumentWithResponse(t.Context(), opts) + requireAPIError(t, err, status) + require.NotNil(t, resp.JSONDefault) + assert.Equal(t, "cannot delete", resp.JSONDefault.Message) + }) + } +} + +func TestGetHealthWithResponse(t *testing.T) { + t.Run("default standing in for the success reports success for a 2xx", func(t *testing.T) { + srv := newServer(t, http.StatusOK, Health{Status: "ok"}, nil) + defer srv.Close() + + resp, err := newClient(t, srv).GetHealthWithResponse(t.Context()) + require.NoError(t, err) + require.NotNil(t, resp.JSONDefault) + assert.Equal(t, "ok", resp.JSONDefault.Status) + }) + + t.Run("and an error for any other status", func(t *testing.T) { + srv := newServer(t, http.StatusServiceUnavailable, Health{Status: "degraded"}, nil) + defer srv.Close() + + resp, err := newClient(t, srv).GetHealthWithResponse(t.Context()) + requireAPIError(t, err, http.StatusServiceUnavailable) + require.NotNil(t, resp.JSONDefault) + assert.Equal(t, "degraded", resp.JSONDefault.Status) + }) +} diff --git a/examples/responses/multiple/client-with-response/gen/paths.go b/examples/responses/multiple/client-with-response/gen/paths.go new file mode 100644 index 00000000..42e82f45 --- /dev/null +++ b/examples/responses/multiple/client-with-response/gen/paths.go @@ -0,0 +1,23 @@ +// Code generated by oapi-codegen. DO NOT EDIT. + +package gen + +import ( + "github.com/doordash-oss/oapi-codegen-dd/v3/pkg/runtime" +) + +type GetDocumentPath struct { + ID string `json:"id" validate:"required"` +} + +func (g GetDocumentPath) Validate() error { + return runtime.ConvertValidatorError(typesValidator.Struct(g)) +} + +type DeleteDocumentPath struct { + ID string `json:"id" validate:"required"` +} + +func (d DeleteDocumentPath) Validate() error { + return runtime.ConvertValidatorError(typesValidator.Struct(d)) +} diff --git a/examples/responses/multiple/client-with-response/gen/responses.go b/examples/responses/multiple/client-with-response/gen/responses.go index 9684fc97..567bc277 100644 --- a/examples/responses/multiple/client-with-response/gen/responses.go +++ b/examples/responses/multiple/client-with-response/gen/responses.go @@ -12,6 +12,18 @@ type UploadDocumentErrorResponse = ValidationError type UploadDocumentErrorResponseText string +type GetDocumentResponse = DocumentStored + +type GetDocumentErrorResponse = NotFoundError + +type GetDocumentErrorResponseJSON = ServiceError + +type GetDocumentErrorResponseJSON4XX = ValidationError + +type DeleteDocumentErrorResponse = ServiceError + +type GetHealthResponse = Health + type UploadDocumentResp201Headers struct { Location string `header:"Location"` XRequestID string `header:"X-Request-Id"` @@ -33,3 +45,34 @@ type UploadDocumentResp struct { JSON422 *UploadDocumentErrorResponse Text503 *UploadDocumentErrorResponseText } + +type GetDocumentResp5XXHeaders struct { + RetryAfter string `header:"Retry-After"` +} + +type GetDocumentResp struct { + HTTPResponse *http.Response + Body []byte + StatusCode int + JSON200 *GetDocumentResponse + JSON404 *GetDocumentErrorResponse + JSON4XX *GetDocumentErrorResponseJSON4XX + // Deprecated: Use JSON4XX, which this always equals. + JSON400 *GetDocumentErrorResponseJSON4XX + JSON5XX *GetDocumentErrorResponseJSON + Headers5XX *GetDocumentResp5XXHeaders +} + +type DeleteDocumentResp struct { + HTTPResponse *http.Response + Body []byte + StatusCode int + JSONDefault *DeleteDocumentErrorResponse +} + +type GetHealthResp struct { + HTTPResponse *http.Response + Body []byte + StatusCode int + JSONDefault *GetHealthResponse +} diff --git a/examples/responses/multiple/client-with-response/gen/types.go b/examples/responses/multiple/client-with-response/gen/types.go index e71d9a91..9ed590ae 100644 --- a/examples/responses/multiple/client-with-response/gen/types.go +++ b/examples/responses/multiple/client-with-response/gen/types.go @@ -67,3 +67,35 @@ func (v ValidationError) Validate() error { func (s ValidationError) Error() string { return "unmapped client error" } + +type NotFoundError struct { + ID string `json:"id" validate:"required"` +} + +func (n NotFoundError) Validate() error { + return runtime.ConvertValidatorError(typesValidator.Struct(n)) +} + +func (s NotFoundError) Error() string { + return "unmapped client error" +} + +type ServiceError struct { + Message string `json:"message" validate:"required"` +} + +func (s ServiceError) Validate() error { + return runtime.ConvertValidatorError(typesValidator.Struct(s)) +} + +func (s ServiceError) Error() string { + return "unmapped client error" +} + +type Health struct { + Status string `json:"status" validate:"required"` +} + +func (h Health) Validate() error { + return runtime.ConvertValidatorError(typesValidator.Struct(h)) +} diff --git a/pkg/codegen/codegen.go b/pkg/codegen/codegen.go index 7070c8f3..d7477265 100644 --- a/pkg/codegen/codegen.go +++ b/pkg/codegen/codegen.go @@ -105,6 +105,7 @@ func CreateParseContextFromModel(model *v3high.Document, cfg Configuration) (*Pa AdditionalTags: cfg.Generate.AdditionalTags, SkipValidation: cfg.Generate.Validation.Skip, ClientStreaming: cfg.Generate.ClientStreaming, + ClientWithResponse: cfg.Generate.ClientWithResponse, ErrorMapping: cfg.ErrorMapping, typeTracker: newTypeTracker(), visited: map[string]bool{}, @@ -383,22 +384,29 @@ func assignWithResponseTypeNames(operations []OperationDefinition, tracker *Type 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) + for _, rcd := range op.Response.EnvelopeResponses() { + if len(rcd.Headers) == 0 { + continue + } + if op.HeaderTypeNames == nil { + op.HeaderTypeNames = make(map[int]string) + op.statusHeaderTypeNames = make(map[string]string) + } + header := tracker.generateUniqueName(op.WithResponseTypeName + rcd.StatusName() + "Headers") + tracker.registerName(header) + if rcd.isExactStatus() { op.HeaderTypeNames[rcd.StatusCode] = header + continue + } + op.statusHeaderTypeNames[rcd.StatusName()] = header + + // The name it had at its stand-in code, for the deprecated alias. + if op.Response.HasLegacyHeaders(rcd) { + legacy := tracker.generateUniqueName(fmt.Sprintf("%s%dHeaders", op.WithResponseTypeName, rcd.StatusCode)) + tracker.registerName(legacy) + op.HeaderTypeNames[rcd.StatusCode] = legacy } } - statusesWithHeaders(op.Response.Successes) - statusesWithHeaders(op.Response.Errors) } } diff --git a/pkg/codegen/codegen_test.go b/pkg/codegen/codegen_test.go index c7d716ac..c77cc635 100644 --- a/pkg/codegen/codegen_test.go +++ b/pkg/codegen/codegen_test.go @@ -423,6 +423,38 @@ func TestAssignWithResponseTypeNames(t *testing.T) { assert.Equal(t, "UploadDocumentResp422Headers", ops[0].HeaderTypeNames[422]) }) + t.Run("ranges and default get header type names apart from the code they stand in at", func(t *testing.T) { + tracker := newTypeTracker() + retryAfter := map[string]GoSchema{"Retry-After": {GoType: "string"}} + badRequest := &ResponseContentDefinition{StatusCode: 400, Headers: retryAfter} + // 4XX stands in at 400, like the exact code next to it. + clientErr := &ResponseContentDefinition{StatusCode: 400, StatusRange: 4, Headers: retryAfter} + fallback := &ResponseContentDefinition{StatusCode: 500, IsDefault: true, Headers: retryAfter} + ops := []OperationDefinition{{ + ID: "uploadDocument", + Response: ResponseDefinition{ + All: map[int]*ResponseContentDefinition{400: clientErr, 500: fallback}, + Errors: []*ResponseContentDefinition{badRequest}, + StatusRanges: []*ResponseContentDefinition{clientErr}, + Default: fallback, + }, + }} + + assignWithResponseTypeNames(ops, tracker) + + op := ops[0] + assert.Equal(t, "UploadDocumentResp400Headers", op.HeaderTypeName(badRequest)) + assert.Equal(t, "UploadDocumentResp4XXHeaders", op.HeaderTypeName(clientErr)) + assert.Equal(t, "UploadDocumentRespDefaultHeaders", op.HeaderTypeName(fallback)) + + // The exact 400 keeps its name; default keeps the name it had at 500 + // for the deprecated alias. + assert.Equal(t, map[int]string{ + 400: "UploadDocumentResp400Headers", + 500: "UploadDocumentResp500Headers", + }, op.HeaderTypeNames) + }) + 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 diff --git a/pkg/codegen/operations.go b/pkg/codegen/operations.go index 701fd2fd..9fb1176f 100644 --- a/pkg/codegen/operations.go +++ b/pkg/codegen/operations.go @@ -60,8 +60,23 @@ type OperationDefinition struct { // 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. + // that declare at least one header in the spec. A range or `default` the + // envelope used to expose at its stand-in code keeps its old name here, + // as a deprecated alias; HeaderTypeName returns any response's current name. HeaderTypeNames map[int]string + + // statusHeaderTypeNames maps a range or `default` response, by its + // StatusName, to the name of its typed header struct. + statusHeaderTypeNames map[string]string +} + +// HeaderTypeName returns the name of the typed header struct the envelope +// declares for rcd, a response of this operation that documents headers. +func (o OperationDefinition) HeaderTypeName(rcd *ResponseContentDefinition) string { + if rcd.isExactStatus() { + return o.HeaderTypeNames[rcd.StatusCode] + } + return o.statusHeaderTypeNames[rcd.StatusName()] } // 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 98c57c31..f932ed7e 100644 --- a/pkg/codegen/parser.go +++ b/pkg/codegen/parser.go @@ -75,6 +75,11 @@ type ParseOptions struct { // is bit-for-bit what it was before streaming support existed. ClientStreaming bool + // ClientWithResponse resolves the `default` response next to explicit + // error codes, which only the envelope client consumes. Off means no type + // generated for it there, so output is unchanged without the envelope. + ClientWithResponse bool + // ErrorMapping maps response type names to the field that should be used // for the Error() method. When a response type has error mapping configured, // it cannot be an alias (aliases don't support methods). diff --git a/pkg/codegen/templates/client-stream.tmpl b/pkg/codegen/templates/client-stream.tmpl index a8af85df..b2074438 100644 --- a/pkg/codegen/templates/client-stream.tmpl +++ b/pkg/codegen/templates/client-stream.tmpl @@ -105,7 +105,21 @@ func (c *{{$clientName}}) {{$op.StreamMethodName}}WithResponse(ctx context.Conte {{- end }} default: {{ template "streamReleaseBody" }} + {{- /* Like the explicit cases above, only error responses: a success + other than the streaming status is not what was asked for. */ -}} + {{- range $rcd := $op.Response.StatusRanges }}{{ if not $rcd.IsSuccess }} + if resp.StatusCode/100 == {{ $rcd.StatusRange }} { + {{- template "decodeBody" (dict "rcd" $rcd "alias" ($op.Response.LegacyBodyField $rcd)) }} + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + } + {{- end }}{{ end }} + {{- $default := $op.Response.Default }} + {{- if and $default (not $default.IsSuccess) }} + {{- template "decodeBody" (dict "rcd" $default) }} + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + {{- else }} return out, runtime.NewClientAPIError(fmt.Errorf("unexpected status code: %d", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + {{- end }} } } {{ end }} diff --git a/pkg/codegen/templates/client-with-response-decode.tmpl b/pkg/codegen/templates/client-with-response-decode.tmpl index 126a74f7..b1c331ab 100644 --- a/pkg/codegen/templates/client-with-response-decode.tmpl +++ b/pkg/codegen/templates/client-with-response-decode.tmpl @@ -17,12 +17,15 @@ 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 +Text / etc. fields on the envelope, where is the rcd's +StatusName: "404", "4XX" or "Default". 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. +Inputs (from caller's dict): rcd = a *ResponseContentDefinition, and +optionally alias = the name of a deprecated field kept pointing at the same +value (see ResponseDefinition.LegacyBodyField). Expected names in the surrounding scope: - resp *runtime.Response (Content, StatusCode, Headers) - out *Resp (the envelope value being populated) @@ -30,13 +33,20 @@ Expected names in the surrounding scope: {{- define "decodeBody" -}} {{- $rcd := .rcd -}} -{{- $field := printf "%s%d" $rcd.NameTag $rcd.StatusCode -}} +{{- $alias := .alias -}} +{{- $field := printf "%s%s" $rcd.NameTag $rcd.StatusName -}} {{- 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 + {{- with $alias }} + out.{{.}} = out.{{$field}} + {{- end }} {{- else }} out.{{$field}} = new({{$rcd.ResponseName}}) + {{- with $alias }} + out.{{.}} = out.{{$field}} + {{- end }} bodyBytes := resp.Content if len(bodyBytes) > 0 { {{- if eq $rcd.NameTag "Formdata" }} diff --git a/pkg/codegen/templates/client-with-response-types.tmpl b/pkg/codegen/templates/client-with-response-types.tmpl index 186c9663..6b57e976 100644 --- a/pkg/codegen/templates/client-with-response-types.tmpl +++ b/pkg/codegen/templates/client-with-response-types.tmpl @@ -27,22 +27,17 @@ imports / package decl - those come from the surrounding file. {{- $config := $args.config -}} {{- range $operations }}{{ $op := . }} -{{- range $rcd := $op.Response.Successes }} +{{- range $rcd := $op.Response.EnvelopeResponses }} {{- if gt (len $rcd.Headers) 0 }} -type {{ index $op.HeaderTypeNames $rcd.StatusCode }} struct { +type {{ $op.HeaderTypeName $rcd }} 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 }} -} +{{- if $op.Response.HasLegacyHeaders $rcd }} +// Deprecated: Use {{ $op.HeaderTypeName $rcd }}. +type {{ index $op.HeaderTypeNames $rcd.StatusCode }} = {{ $op.HeaderTypeName $rcd }} {{ end -}} {{- end }} @@ -50,26 +45,28 @@ type {{$op.WithResponseTypeName}} struct { HTTPResponse *http.Response Body []byte StatusCode int - {{- range $rcd := $op.Response.Successes }} + {{- range $rcd := $op.Response.EnvelopeResponses }} {{- if and (ne $rcd.ResponseName "struct{}") (not $rcd.IsRaw) (ne $rcd.NameTag "") }} - {{$rcd.NameTag}}{{$rcd.StatusCode}} *{{$rcd.ResponseName}} + {{$rcd.NameTag}}{{$rcd.StatusName}} *{{$rcd.ResponseName}} + {{- end }} + {{- /* Earlier versions named a range's field after its stand-in + code, such as JSON400 for 4XX. Kept so their callers compile. */ -}} + {{- with $op.Response.LegacyBodyField $rcd }} + // Deprecated: Use {{$rcd.NameTag}}{{$rcd.StatusName}}, which this always equals. + {{.}} *{{$rcd.ResponseName}} {{- end }} {{- /* Populated by the streaming sibling, never by the same call as the buffered body field. */ -}} {{- if $config.Generate.ClientStreaming }} - {{- with $op.Response.StreamAt $rcd.StatusCode }} + {{- with $op.Response.StreamFor $rcd }} Stream{{.StatusCode}} *runtime.Stream[{{.ItemName}}] {{- end }} {{- end }} {{- if gt (len $rcd.Headers) 0 }} - Headers{{$rcd.StatusCode}} *{{ index $op.HeaderTypeNames $rcd.StatusCode }} + Headers{{$rcd.StatusName}} *{{ $op.HeaderTypeName $rcd }} {{- 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 }} + {{- if $op.Response.HasLegacyHeaders $rcd }} + // Deprecated: Use Headers{{$rcd.StatusName}}, which this always equals. Headers{{$rcd.StatusCode}} *{{ index $op.HeaderTypeNames $rcd.StatusCode }} {{- end }} {{- end }} diff --git a/pkg/codegen/templates/client-with-response.tmpl b/pkg/codegen/templates/client-with-response.tmpl index b3707909..6d7e603a 100644 --- a/pkg/codegen/templates/client-with-response.tmpl +++ b/pkg/codegen/templates/client-with-response.tmpl @@ -95,30 +95,25 @@ func (c *{{$clientName}}) {{$op.ID}}WithResponse(ctx context.Context{{ if $op.Ha 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 + {{- template "envelopeResponse" (dict "op" $op "rcd" $rcd) }} {{- 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)) + {{- template "envelopeResponse" (dict "op" $op "rcd" $rcd) }} {{- end }} default: + {{- /* Only reached when no exact code matched, so the exact codes + take precedence over ranges, and ranges over `default`. */ -}} + {{- range $rcd := $op.Response.StatusRanges }} + if resp.StatusCode/100 == {{ $rcd.StatusRange }} { + {{- template "envelopeResponse" (dict "op" $op "rcd" $rcd) }} + } + {{- end }} + {{- with $op.Response.Default }} + {{- template "envelopeResponse" (dict "op" $op "rcd" .) }} + {{- else }} return out, runtime.NewClientAPIError(fmt.Errorf("unexpected status code: %d", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + {{- end }} } } @@ -133,3 +128,32 @@ var _ {{$clientName}}Interface = (*{{$clientName}})(nil) {{ end -}} {{ template "client-with-response" dict "config" .Config "operations" .Operations }} + +{{- /* Populates the envelope from one documented response and returns it. + A `default` standing in for the success also covers error statuses, + so it only reports success for a 2xx. */ -}} +{{- define "envelopeResponse" }} +{{- $op := .op }} +{{- $rcd := .rcd }} + {{- template "decodeBody" (dict "rcd" $rcd "alias" ($op.Response.LegacyBodyField $rcd)) }} + {{- if gt (len $rcd.Headers) 0 }} + out.Headers{{$rcd.StatusName}} = &{{ $op.HeaderTypeName $rcd }}{ + {{- range $hName, $hSchema := $rcd.Headers }} + {{ genTypeName $hName }}: resp.Headers.Get("{{ escapeGoString $hName }}"), + {{- end }} + } + {{- if $op.Response.HasLegacyHeaders $rcd }} + out.Headers{{$rcd.StatusCode}} = out.Headers{{$rcd.StatusName}} + {{- end }} + {{- end }} + {{- if and $rcd.IsSuccess $rcd.IsDefault }} + if resp.StatusCode/100 == 2 { + return out, nil + } + {{- end }} + {{- if and $rcd.IsSuccess (not $rcd.IsDefault) }} + return out, nil + {{- else }} + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + {{- end }} +{{- end }} diff --git a/pkg/codegen/typedef_responses.go b/pkg/codegen/typedef_responses.go index df3f8870..670758b9 100644 --- a/pkg/codegen/typedef_responses.go +++ b/pkg/codegen/typedef_responses.go @@ -11,9 +11,10 @@ package codegen import ( + "cmp" "fmt" "iter" - "sort" + "slices" "strconv" "strings" @@ -29,16 +30,27 @@ type ResponseDefinition struct { 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 lists every 2xx response documented under an exact status + // code, in ascending 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 lists every non-2xx response documented under an exact status + // code, in ascending order. Same motivation as Successes - the envelope + // populates `JSON404`/`JSON500` fields per documented status. Errors []*ResponseContentDefinition + // StatusRanges lists the responses documented under a range such as + // `4XX`, in ascending order. The envelope client matches them only after + // every exact code, so an explicit `404` takes precedence over `4XX`. + StatusRanges []*ResponseContentDefinition + + // Default is the `default` response, which the envelope client matches + // last: it covers every status nothing more specific documents. Next to + // explicit error codes it is only resolved when ParseOptions.ClientWithResponse + // is set, because nothing else consumes it there. + Default *ResponseContentDefinition + // Streams lists every sequential media type documented on a success // response, in ascending status order. Populated independently of which // media type the primary method selected, so an operation offering both @@ -77,8 +89,21 @@ type ResponseContentDefinition struct { Description string Ref string IsSuccess bool - StatusCode int - Headers map[string]GoSchema + + // StatusCode is the documented status code. For a range or `default` it + // is the stand-in code handler generation uses: 200 for `2XX`, 400 for + // both `4XX` and `5XX`, 200 or 500 for `default`. The envelope client + // matches StatusRange and IsDefault instead. + StatusCode int + + // StatusRange is the class digit of a range such as `4XX` (4), or zero + // when the response documents an exact status code. + StatusRange int + + // IsDefault is true for the `default` response. + IsDefault bool + + Headers map[string]GoSchema // IsRaw is true for unsupported content types (XML, form-urlencoded, etc.) // that require the user to handle marshaling manually. IsRaw bool @@ -88,6 +113,11 @@ type ResponseContentDefinition struct { // callers that cannot stream (the MCP tool template) refuse instead of // blocking forever. IsStream bool + + // componentResponse is true when the response's type comes from a + // component response it references, which the envelope gave no typed + // field before ranges and `default` got their own. + componentResponse bool } // HasStream reports whether streaming siblings can be generated. @@ -114,6 +144,84 @@ func (r ResponseDefinition) StreamAt(status int) *StreamResponseDefinition { return nil } +// StreamFor returns the sequential response streamed in place of rcd's body, +// or nil. A range sharing its stand-in code with an exact status, such as +// `2XX` next to `200`, shares the stream too; only the response All holds at +// that code owns it, so the envelope declares the field once. +func (r ResponseDefinition) StreamFor(rcd *ResponseContentDefinition) *StreamResponseDefinition { + if r.All[rcd.StatusCode] != rcd { + return nil + } + return r.StreamAt(rcd.StatusCode) +} + +// EnvelopeResponses returns every response the envelope client matches, in +// the order it tries them: exact codes, then ranges, then `default`. +func (r ResponseDefinition) EnvelopeResponses() []*ResponseContentDefinition { + responses := slices.Concat(r.Successes, r.Errors, r.StatusRanges) + if r.Default != nil { + responses = append(responses, r.Default) + } + return responses +} + +// LegacyBodyField returns the name the envelope gave a range's body field +// before ranges were matched by class, such as JSON400 for an inline `4XX`, +// so it stays as a deprecated alias of the current field. It is "" when there +// was none - only the range All holds at its stand-in code had one - or when +// an exact code documented there owns that name now. +func (r ResponseDefinition) LegacyBodyField(rcd *ResponseContentDefinition) string { + // A response referencing a component response had no typed field. + if rcd.StatusRange == 0 || rcd.componentResponse || !rcd.hasBodyField() || r.All[rcd.StatusCode] != rcd { + return "" + } + name := rcd.NameTag + strconv.Itoa(rcd.StatusCode) + for _, exact := range slices.Concat(r.Successes, r.Errors) { + if exact.hasBodyField() && exact.NameTag+strconv.Itoa(exact.StatusCode) == name { + return "" + } + } + return name +} + +// HasLegacyHeaders reports whether a range or `default` keeps the headers +// field and type the envelope declared under its stand-in code, such as +// Headers500 for `default`, as deprecated aliases. As with LegacyBodyField, +// only the response All holds at that code had them, and an exact code there +// documenting headers owns the names now. +func (r ResponseDefinition) HasLegacyHeaders(rcd *ResponseContentDefinition) bool { + if rcd.isExactStatus() || len(rcd.Headers) == 0 || r.All[rcd.StatusCode] != rcd { + return false + } + for _, exact := range slices.Concat(r.Successes, r.Errors) { + if exact.StatusCode == rcd.StatusCode && len(exact.Headers) > 0 { + return false + } + } + return true +} + +// StatusName names the response in the envelope client's field and type +// names: the status code ("404"), the range ("4XX"), or "Default". +func (r ResponseContentDefinition) StatusName() string { + if r.IsDefault { + return "Default" + } + return statusName(r.StatusCode, r.StatusRange) +} + +// isExactStatus reports whether the response documents a single status code +// rather than a range or `default`. +func (r ResponseContentDefinition) isExactStatus() bool { + return r.StatusRange == 0 && !r.IsDefault +} + +// hasBodyField reports whether the envelope declares a typed body field for +// the response. +func (r ResponseContentDefinition) hasBodyField() bool { + return r.ResponseName != "struct{}" && !r.IsRaw && r.NameTag != "" +} + func getOperationResponses(operationID string, responses *v3high.Responses, options ParseOptions) (*ResponseDefinition, []TypeDefinition, error) { var ( successCode int @@ -126,6 +234,12 @@ func getOperationResponses(operationID string, responses *v3high.Responses, opti all := make(map[int]*ResponseContentDefinition) + // Every response the envelope client can match. Unlike `all`, which puts a + // range or `default` at a stand-in code, it keeps each response as + // documented, so `4XX` and `5XX` do not overwrite each other and an exact + // code can take precedence over the range covering it. + var documented []*ResponseContentDefinition + // Content of every documented success status, plus any schema the primary // pass already generated for a sequential media type. Resolved into // ResponseDefinition.Streams after the loop, so detection never depends on @@ -144,15 +258,14 @@ func getOperationResponses(operationID string, responses *v3high.Responses, opti } all[successCode] = successDefinition - successes, errs := partitionResponses(all) - return &ResponseDefinition{ + res := &ResponseDefinition{ SuccessStatusCode: successCode, Success: successDefinition, Error: nil, All: all, - Successes: successes, - Errors: errs, - }, nil, nil + } + res.setEnvelopeResponses([]*ResponseContentDefinition{successDefinition}) + return res, nil, nil } defaultResponse := responses.Default @@ -194,6 +307,7 @@ func getOperationResponses(operationID string, responses *v3high.Responses, opti return nil, nil, fmt.Errorf("error parsing status code %s: %w", statusCode, err) } } + statusRange := statusRangeOf(statusCode) if status >= 200 && status < 300 { isSuccess = true @@ -241,20 +355,25 @@ func getOperationResponses(operationID string, responses *v3high.Responses, opti bodySchema := responseBodySchema(content, contentType) if content == nil || bodySchema == nil { + bodyless := &ResponseContentDefinition{ + IsSuccess: isSuccess, + Description: response.Description, + ResponseName: "struct{}", + StatusCode: status, + StatusRange: statusRange, + Headers: headers, + // Preserve the declared media type even when the schema + // is empty (e.g. `text/html: {}`); strict response + // validators rely on it. + ContentType: contentType, + } + // An error without a body has nothing to decode, so only the + // envelope client sees it, to keep its precedence over a range + // or `default`. if isSuccess { - successDefinition := &ResponseContentDefinition{ - IsSuccess: isSuccess, - Description: response.Description, - ResponseName: "struct{}", - StatusCode: status, - Headers: headers, - // Preserve the declared media type even when the schema - // is empty (e.g. `text/html: {}`); strict response - // validators rely on it. - ContentType: contentType, - } - all[status] = successDefinition + all[status] = bodyless } + documented = append(documented, bodyless) continue } @@ -302,7 +421,7 @@ func getOperationResponses(operationID string, responses *v3high.Responses, opti } var responseName string - tag := "" + tag := responseNameTag(contentType) // If this is a component reference AND the type exists (was processed by getComponentResponses), // use the component name and don't create a duplicate TypeDefinition. @@ -405,22 +524,8 @@ func getOperationResponses(operationID string, responses *v3high.Responses, opti // rather than duplicates generated with the response path context. contentSchema = componentTd.Schema } else { - switch { - case contentType == "application/json": - tag = "JSON" - case isMediaTypeJson(contentType): - tag = mediaTypeToCamelCase(contentType) - case contentType == "application/x-www-form-urlencoded": - tag = "Formdata" - case strings.HasPrefix(contentType, "multipart/"): - tag = "Multipart" - case contentType == "text/plain": - tag = "Text" - case contentType == "text/html": - tag = "HTML" - } - - codeName := strconv.Itoa(status) + // A range's stand-in code would name a 5XX type after 400. + codeName := statusName(status, statusRange) baseName := operationID + typeSuffix nameSuffixes := []string{tag, tag + codeName} responseName = options.typeTracker.generateUniqueNameWithSuffixes(baseName, nameSuffixes) @@ -486,11 +591,15 @@ func getOperationResponses(operationID string, responses *v3high.Responses, opti ContentType: contentType, NameTag: tag, StatusCode: status, + StatusRange: statusRange, Headers: headers, IsRaw: isRaw, IsStream: isStream, + + componentResponse: componentTypeExists, } all[status] = rcd + documented = append(documented, rcd) } // When no explicit success is documented but `default` has content, treat @@ -500,13 +609,14 @@ func getOperationResponses(operationID string, responses *v3high.Responses, opti // would make mocks return content that doesn't satisfy the real schema. defaultAsSuccess := false if successCode == 0 && defaultResponse != nil && defaultResponse.Content != nil && defaultResponse.Content.First() != nil { - rcd, tds, err := buildDefaultResponseDefinition(operationID, defaultResponse, 200, true, options) + rcd, tds, err := buildDefaultResponseDefinition(operationID, defaultResponse, 200, true, false, options) if err != nil { return nil, nil, err } if rcd != nil { successCode = 200 all[200] = rcd + documented = append(documented, rcd) typeDefinitions = append(typeDefinitions, tds...) defaultAsSuccess = true // A `default` standing in as the success can be sequential too, so @@ -525,16 +635,25 @@ func getOperationResponses(operationID string, responses *v3high.Responses, opti } all[successCode] = successDefinition + documented = append(documented, successDefinition) } - if errorCode == 0 && defaultResponse != nil && !defaultAsSuccess { - rcd, tds, err := buildDefaultResponseDefinition(operationID, defaultResponse, 500, false, options) + // Handler generation answers errors with the first explicit error code, so + // it only needs `default` when none is documented. The envelope client + // matches `default` against every status nothing else documents, so it + // needs it next to explicit error codes too. + alongsideErrors := errorCode != 0 + if defaultResponse != nil && !defaultAsSuccess && (!alongsideErrors || options.ClientWithResponse) { + rcd, tds, err := buildDefaultResponseDefinition(operationID, defaultResponse, 500, false, alongsideErrors, options) if err != nil { return nil, nil, err } if rcd != nil { - fstErrorCode = 500 - all[500] = rcd + if !alongsideErrors { + fstErrorCode = 500 + all[500] = rcd + } + documented = append(documented, rcd) typeDefinitions = append(typeDefinitions, tds...) } } @@ -545,16 +664,14 @@ func getOperationResponses(operationID string, responses *v3high.Responses, opti } typeDefinitions = append(typeDefinitions, streamTypes...) - successes, errs := partitionResponses(all) res := &ResponseDefinition{ SuccessStatusCode: successCode, Success: all[successCode], Error: all[fstErrorCode], All: all, - Successes: successes, - Errors: errs, Streams: streams, } + res.setEnvelopeResponses(documented) return res, typeDefinitions, nil } @@ -563,12 +680,21 @@ func getOperationResponses(operationID string, responses *v3high.Responses, opti // ResponseContentDefinition installed at the given status. Used both when no // explicit success is documented (install as 200 success) and when no // explicit error is documented (install as 500 error). -func buildDefaultResponseDefinition(operationID string, defaultResponse *v3high.Response, status int, isSuccess bool, options ParseOptions) (*ResponseContentDefinition, []TypeDefinition, error) { +// +// With alongsideErrors it is built for the envelope client next to explicit +// error codes. The first of those already owns the plain type name and schema +// path, so `default` gets its own to keep nested types apart. +func buildDefaultResponseDefinition(operationID string, defaultResponse *v3high.Response, status int, isSuccess, alongsideErrors bool, options ParseOptions) (*ResponseContentDefinition, []TypeDefinition, error) { typeSuffix := "ErrorResponse" if isSuccess { typeSuffix = "Response" } + pathParts := []string{operationID, typeSuffix} + if alongsideErrors { + pathParts = append(pathParts, "default") + } + content := defaultResponse.Content.First() ref := "" contentType := "application/json" @@ -586,7 +712,7 @@ func buildDefaultResponseDefinition(operationID string, defaultResponse *v3high. if bodySchema != nil { ref = bodySchema.GetReference() - opts := options.WithReference(ref).WithPath([]string{operationID, typeSuffix}) + opts := options.WithReference(ref).WithPath(pathParts) contentSchema, err = GenerateGoSchema(bodySchema, opts) if err != nil { return nil, nil, fmt.Errorf("error generating request body definition: %w", err) @@ -594,7 +720,10 @@ func buildDefaultResponseDefinition(operationID string, defaultResponse *v3high. } } - if ref != "" { + // Only a component schema has a type of its own to point at. A JSON pointer + // into another operation's response, as bundled specs use, names nothing + // generated: GenerateGoSchema already produced the type inline. + if ref != "" && isStandardComponentReference(ref) { refType, err = refPathToGoType(ref) if err != nil { return nil, nil, fmt.Errorf("error turning reference (%s) into a Go type: %w", ref, err) @@ -625,7 +754,11 @@ func buildDefaultResponseDefinition(operationID string, defaultResponse *v3high. if refType != "" { contentSchema.RefType = refType } + tag := responseNameTag(contentType) responseName := operationID + typeSuffix + if alongsideErrors { + responseName = options.typeTracker.generateUniqueNameWithSuffixes(responseName, []string{tag, tag + "Default"}) + } if contentSchema.ArrayType != nil { contentSchema, _ = replaceInlineTypes(contentSchema, options) } @@ -667,7 +800,9 @@ func buildDefaultResponseDefinition(operationID string, defaultResponse *v3high. Schema: contentSchema, Ref: refType, ContentType: contentType, + NameTag: tag, StatusCode: status, + IsDefault: true, Headers: headers, IsRaw: isRaw, IsStream: isStream, @@ -675,27 +810,104 @@ func buildDefaultResponseDefinition(operationID string, defaultResponse *v3high. return rcd, 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) +// setEnvelopeResponses sorts every documented response into the lists the +// envelope client matches, each in ascending order: Successes and Errors for +// exact codes, then StatusRanges, then Default. +// +// An error without a body has nothing to decode, so it is left to the +// unexpected-status fallback - unless a range or `default` would claim its +// status instead. Then it keeps an entry of its own, since OpenAPI gives the +// more specific response precedence. +func (r *ResponseDefinition) setEnvelopeResponses(documented []*ResponseContentDefinition) { + var bodyless []*ResponseContentDefinition + for _, rcd := range documented { + switch { + case rcd.IsDefault: + r.Default = rcd + case !rcd.IsSuccess && rcd.ResponseName == "struct{}": + bodyless = append(bodyless, rcd) + case rcd.StatusRange != 0: + r.StatusRanges = append(r.StatusRanges, rcd) + case rcd.IsSuccess: + r.Successes = append(r.Successes, rcd) + default: + r.Errors = append(r.Errors, rcd) + } } - sort.Ints(statuses) - - for _, s := range statuses { - r := all[s] - if r.IsSuccess { - successes = append(successes, r) + for _, rcd := range bodyless { + if !r.coveredByFallback(rcd) { + continue + } + if rcd.StatusRange != 0 { + r.StatusRanges = append(r.StatusRanges, rcd) } else { - errors = append(errors, r) + r.Errors = append(r.Errors, rcd) } } - return successes, errors + + byStatus := func(a, b *ResponseContentDefinition) int { return cmp.Compare(a.StatusCode, b.StatusCode) } + slices.SortFunc(r.Successes, byStatus) + slices.SortFunc(r.Errors, byStatus) + slices.SortFunc(r.StatusRanges, func(a, b *ResponseContentDefinition) int { + return cmp.Compare(a.StatusRange, b.StatusRange) + }) +} + +// coveredByFallback reports whether a range or `default` would match the +// statuses rcd documents, were rcd not matched before them. +func (r *ResponseDefinition) coveredByFallback(rcd *ResponseContentDefinition) bool { + if r.Default != nil { + return true + } + if rcd.StatusRange != 0 { + return false + } + for _, rng := range r.StatusRanges { + if rng.StatusRange == rcd.StatusCode/100 { + return true + } + } + return false +} + +// statusName names an exact status code ("404"), or the range ("4XX") when +// statusRange is set - never the range's stand-in code. +func statusName(code, statusRange int) string { + if statusRange != 0 { + return strconv.Itoa(statusRange) + "XX" + } + return strconv.Itoa(code) +} + +// statusRangeOf returns the class digit of a response key documenting a +// status range, such as 4 for "4XX", or zero for any other key. +func statusRangeOf(key string) int { + if len(key) != 3 || !strings.EqualFold(key[1:], "XX") || key[0] < '1' || key[0] > '5' { + return 0 + } + return int(key[0] - '0') +} + +// responseNameTag returns the tag a response's media type contributes to its +// Go type and envelope field names, such as "JSON" in JSON404, or "" when +// the media type has none. +func responseNameTag(contentType string) string { + switch { + case contentType == "application/json": + return "JSON" + case isMediaTypeJson(contentType): + return mediaTypeToCamelCase(contentType) + case contentType == "application/x-www-form-urlencoded": + return "Formdata" + case strings.HasPrefix(contentType, "multipart/"): + return "Multipart" + case contentType == "text/plain": + return "Text" + case contentType == "text/html": + return "HTML" + } + return "" } func generateResponseHeadersSchema(headers iter.Seq2[string, *v3high.Header], operationID string, options ParseOptions) (map[string]GoSchema, error) { diff --git a/pkg/codegen/typedef_responses_test.go b/pkg/codegen/typedef_responses_test.go index 7dfc7549..84150285 100644 --- a/pkg/codegen/typedef_responses_test.go +++ b/pkg/codegen/typedef_responses_test.go @@ -232,6 +232,556 @@ paths: }) } +func TestGetOperationResponsesEnvelope(t *testing.T) { + const spec = ` +openapi: "3.1.0" +info: + version: 1.0.0 + title: Test +paths: + /ranges: + get: + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Thing' + '4XX': + description: client error + content: + application/json: + schema: + $ref: '#/components/schemas/ClientError' + '5XX': + description: server error + content: + application/json: + schema: + $ref: '#/components/schemas/ServerError' + /mixed: + get: + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Thing' + '404': + description: not found + content: + application/json: + schema: + $ref: '#/components/schemas/ClientError' + default: + description: anything else + content: + application/json: + schema: + $ref: '#/components/schemas/ServerError' + /bodyless: + get: + responses: + '200': + description: ok + '404': + description: not found, no body + '503': + description: unavailable, no body + '4XX': + description: client error + content: + application/json: + schema: + $ref: '#/components/schemas/ClientError' + '5XX': + description: server error, no body + /bodyless-range: + get: + responses: + '200': + description: ok + '5XX': + description: server error, no body + default: + description: anything else + content: + application/json: + schema: + $ref: '#/components/schemas/ServerError' + /component-ref: + get: + responses: + '200': + description: ok + '4XX': + $ref: '#/components/responses/ClientError' + /default-only: + get: + responses: + default: + description: anything + content: + application/json: + schema: + $ref: '#/components/schemas/Thing' + /exact-and-range: + get: + responses: + '200': + description: ok + '400': + description: bad request + content: + application/json: + schema: + $ref: '#/components/schemas/ClientError' + '404': + description: not found + content: + application/json: + schema: + $ref: '#/components/schemas/ClientError' + '4XX': + description: any other client error + content: + application/json: + schema: + $ref: '#/components/schemas/ServerError' + /success-range: + get: + responses: + '2XX': + description: any success + content: + application/json: + schema: + $ref: '#/components/schemas/Thing' + /default-headers: + get: + responses: + '200': + description: ok + default: + description: any error + headers: + X-Request-Id: + schema: + type: string + content: + application/json: + schema: + $ref: '#/components/schemas/ServerError' + /default-headers-alongside: + get: + responses: + '200': + description: ok + '404': + description: not found + content: + application/json: + schema: + $ref: '#/components/schemas/ClientError' + default: + description: any other error + headers: + X-Request-Id: + schema: + type: string + content: + application/json: + schema: + $ref: '#/components/schemas/ServerError' +components: + responses: + ClientError: + description: client error + content: + application/json: + schema: + $ref: '#/components/schemas/ClientError' + schemas: + Thing: + type: object + properties: + id: + type: string + ClientError: + type: object + properties: + message: + type: string + ServerError: + type: object + properties: + reason: + type: string +` + + withEnvelope := func(opts ParseOptions) ParseOptions { + opts.ClientWithResponse = true + return opts + } + + // The pipeline registers component types before operations, which is how + // a response referencing a component response finds its type. + withComponents := func(t *testing.T, opts ParseOptions) ParseOptions { + t.Helper() + _, err := collectComponentDefinitions(opts.model, opts) + require.NoError(t, err) + return opts + } + + t.Run("4XX and 5XX keep their own entries and types", func(t *testing.T) { + op, opts := loadOperation(t, []byte(spec), "/ranges", "get") + def, typeDefs, err := getOperationResponses("GetRanges", op.Responses, withEnvelope(opts)) + require.NoError(t, err) + + assert.Empty(t, def.Errors, "neither range is an exact code") + require.Len(t, def.StatusRanges, 2) + clientErr, serverErr := def.StatusRanges[0], def.StatusRanges[1] + + assert.Equal(t, 4, clientErr.StatusRange) + assert.Equal(t, "4XX", clientErr.StatusName()) + assert.Equal(t, "JSON", clientErr.NameTag) + assert.Equal(t, 5, serverErr.StatusRange) + assert.Equal(t, "5XX", serverErr.StatusName()) + + // Both ranges once landed on 400, where 5XX replaced 4XX. Each must + // decode into its own schema: ClientError has "message", ServerError + // has "reason". + fields := map[string]string{} + for _, td := range typeDefs { + if len(td.Schema.Properties) == 1 { + fields[td.Name] = td.Schema.Properties[0].JsonFieldName + } + } + assert.Equal(t, "message", fields[clientErr.ResponseName]) + assert.Equal(t, "reason", fields[serverErr.ResponseName]) + }) + + t.Run("default next to explicit errors is resolved for the envelope client", func(t *testing.T) { + op, opts := loadOperation(t, []byte(spec), "/mixed", "get") + def, typeDefs, err := getOperationResponses("GetMixed", op.Responses, withEnvelope(opts)) + require.NoError(t, err) + + require.NotNil(t, def.Default) + assert.True(t, def.Default.IsDefault) + assert.False(t, def.Default.IsSuccess) + assert.Equal(t, "JSON", def.Default.NameTag) + assert.Equal(t, "Default", def.Default.StatusName()) + assert.NotEqual(t, def.Errors[0].ResponseName, def.Default.ResponseName, + "default must not reuse the name the 404 response owns") + + var names []string + for _, td := range typeDefs { + names = append(names, td.Name) + } + assert.Contains(t, names, def.Default.ResponseName) + + // Handler generation still answers errors with the 404. + require.NotNil(t, def.Error) + assert.Equal(t, 404, def.Error.StatusCode) + _, has500 := def.All[500] + assert.False(t, has500) + }) + + t.Run("default next to explicit errors is skipped without the envelope client", func(t *testing.T) { + op, opts := loadOperation(t, []byte(spec), "/mixed", "get") + def, typeDefs, err := getOperationResponses("GetMixed", op.Responses, opts) + require.NoError(t, err) + assert.Nil(t, def.Default) + + op, opts = loadOperation(t, []byte(spec), "/mixed", "get") + _, envelopeTypeDefs, err := getOperationResponses("GetMixed", op.Responses, withEnvelope(opts)) + require.NoError(t, err) + assert.Len(t, envelopeTypeDefs, len(typeDefs)+1, "only the envelope client gets a type for default") + }) + + t.Run("an exact code without a body keeps precedence over the range covering it", func(t *testing.T) { + op, opts := loadOperation(t, []byte(spec), "/bodyless", "get") + def, _, err := getOperationResponses("GetBodyless", op.Responses, withEnvelope(opts)) + require.NoError(t, err) + + // 404 gets its own case so 4XX does not decode it. Nothing with a body + // covers 503 or the bodyless 5XX, so both stay with the + // unexpected-status fallback. + require.Len(t, def.Errors, 1) + assert.Equal(t, 404, def.Errors[0].StatusCode) + assert.Equal(t, "struct{}", def.Errors[0].ResponseName) + require.Len(t, def.StatusRanges, 1) + assert.Equal(t, 4, def.StatusRanges[0].StatusRange) + + // The handler-facing view never held bodyless errors. + _, has404 := def.All[404] + assert.False(t, has404) + }) + + t.Run("a range without a body keeps precedence over default", func(t *testing.T) { + op, opts := loadOperation(t, []byte(spec), "/bodyless-range", "get") + def, _, err := getOperationResponses("GetBodylessRange", op.Responses, withEnvelope(opts)) + require.NoError(t, err) + + require.Len(t, def.StatusRanges, 1) + assert.Equal(t, 5, def.StatusRanges[0].StatusRange) + assert.Equal(t, "struct{}", def.StatusRanges[0].ResponseName) + assert.NotNil(t, def.Default) + }) + + t.Run("a range referencing a component response gets a typed field", func(t *testing.T) { + op, opts := loadOperation(t, []byte(spec), "/component-ref", "get") + def, _, err := getOperationResponses("GetComponentRef", op.Responses, withEnvelope(withComponents(t, opts))) + require.NoError(t, err) + + require.Len(t, def.StatusRanges, 1) + assert.True(t, def.StatusRanges[0].componentResponse, "the component response's type must be used") + assert.Equal(t, "JSON", def.StatusRanges[0].NameTag) + }) + + t.Run("default standing in for the success is the envelope's Default", func(t *testing.T) { + op, opts := loadOperation(t, []byte(spec), "/default-only", "get") + def, _, err := getOperationResponses("GetDefaultOnly", op.Responses, withEnvelope(opts)) + require.NoError(t, err) + + require.NotNil(t, def.Default) + assert.Same(t, def.Success, def.Default) + assert.True(t, def.Default.IsSuccess) + assert.Empty(t, def.Successes, "default is matched last, not as an exact 200") + }) + + t.Run("an exact code and the range covering it sit alongside, names included", func(t *testing.T) { + op, opts := loadOperation(t, []byte(spec), "/exact-and-range", "get") + def, _, err := getOperationResponses("GetExactAndRange", op.Responses, withEnvelope(opts)) + require.NoError(t, err) + + require.Len(t, def.Errors, 2) + badRequest := def.Errors[0] + assert.Equal(t, 400, badRequest.StatusCode) + require.Len(t, def.StatusRanges, 1) + clientErr := def.StatusRanges[0] + + // A type name suffixed with a status takes the range, so 400 stays + // the explicit code's. + assert.Equal(t, "GetExactAndRangeErrorResponseJSON4XX", clientErr.ResponseName) + assert.NotContains(t, clientErr.ResponseName, "400") + + // 4XX overwrote 400 in All, but the explicit 400 owns JSON400, so the + // range gets no deprecated alias under that name. + assert.Same(t, clientErr, def.All[400]) + assert.Empty(t, def.LegacyBodyField(clientErr)) + }) + + t.Run("a range keeps the field it had at its stand-in code as an alias", func(t *testing.T) { + op, opts := loadOperation(t, []byte(spec), "/success-range", "get") + def, _, err := getOperationResponses("GetSuccessRange", op.Responses, withEnvelope(opts)) + require.NoError(t, err) + + require.Len(t, def.StatusRanges, 1) + assert.Equal(t, "JSON200", def.LegacyBodyField(def.StatusRanges[0])) + }) + + t.Run("of 4XX and 5XX, the one the old field held keeps it", func(t *testing.T) { + op, opts := loadOperation(t, []byte(spec), "/ranges", "get") + def, _, err := getOperationResponses("GetRanges", op.Responses, withEnvelope(opts)) + require.NoError(t, err) + + // 5XX came last, so the old JSON400 field had its type. + clientErr, serverErr := def.StatusRanges[0], def.StatusRanges[1] + assert.Empty(t, def.LegacyBodyField(clientErr)) + assert.Equal(t, "JSON400", def.LegacyBodyField(serverErr)) + }) + + t.Run("a range referencing a component response had no field to keep", func(t *testing.T) { + op, opts := loadOperation(t, []byte(spec), "/component-ref", "get") + def, _, err := getOperationResponses("GetComponentRef", op.Responses, withEnvelope(withComponents(t, opts))) + require.NoError(t, err) + + assert.Empty(t, def.LegacyBodyField(def.StatusRanges[0])) + }) + + t.Run("default keeps its headers under the code it stood in at", func(t *testing.T) { + op, opts := loadOperation(t, []byte(spec), "/default-headers", "get") + def, _, err := getOperationResponses("GetDefaultHeaders", op.Responses, withEnvelope(opts)) + require.NoError(t, err) + + require.NotNil(t, def.Default) + assert.True(t, def.HasLegacyHeaders(def.Default)) + assert.Empty(t, def.LegacyBodyField(def.Default), "default never had a body field") + }) + + t.Run("default next to explicit errors was never in the envelope", func(t *testing.T) { + op, opts := loadOperation(t, []byte(spec), "/default-headers-alongside", "get") + def, _, err := getOperationResponses("GetDefaultHeadersAlongside", op.Responses, withEnvelope(opts)) + require.NoError(t, err) + + require.NotNil(t, def.Default) + require.NotEmpty(t, def.Default.Headers) + assert.False(t, def.HasLegacyHeaders(def.Default), "the old envelope dropped it, so there is nothing to keep") + }) +} + +// Bundled specs point a response at another operation's inline schema with a +// JSON pointer. That names no generated type, so default gets its own. +func TestGetOperationResponsesDefaultPointer(t *testing.T) { + const spec = ` +openapi: "3.0.3" +info: + version: 1.0.0 + title: Test +paths: + /errors: + get: + responses: + '200': + description: ok + '401': + description: unauthorized + content: + application/json: + schema: + type: object + required: [id] + properties: + id: + type: string + default: + description: anything else + content: + application/json: + schema: + $ref: '#/paths/~1errors/get/responses/401/content/application~1json/schema' + /default-only: + get: + responses: + '200': + description: ok + default: + description: any error + content: + application/json: + schema: + $ref: '#/paths/~1errors/get/responses/401/content/application~1json/schema' +` + + requireInline := func(t *testing.T, rcd *ResponseContentDefinition, typeDefs []TypeDefinition) { + t.Helper() + require.NotNil(t, rcd) + assert.Empty(t, rcd.Ref) + for _, td := range typeDefs { + if td.Name != rcd.ResponseName { + continue + } + assert.Empty(t, td.Schema.RefType, "a JSON pointer must not become a reference to an ungenerated type") + require.Len(t, td.Schema.Properties, 1) + assert.Equal(t, "id", td.Schema.Properties[0].JsonFieldName) + return + } + t.Fatalf("no type definition for %s", rcd.ResponseName) + } + + t.Run("next to explicit errors", func(t *testing.T) { + op, opts := loadOperation(t, []byte(spec), "/errors", "get") + opts.ClientWithResponse = true + def, typeDefs, err := getOperationResponses("GetErrors", op.Responses, opts) + require.NoError(t, err) + requireInline(t, def.Default, typeDefs) + }) + + t.Run("as the only error", func(t *testing.T) { + op, opts := loadOperation(t, []byte(spec), "/default-only", "get") + def, typeDefs, err := getOperationResponses("GetDefaultOnly", op.Responses, opts) + require.NoError(t, err) + assert.Same(t, def.Error, def.Default) + requireInline(t, def.Default, typeDefs) + }) +} + +func TestStatusRangeOf(t *testing.T) { + tests := []struct { + key string + expected int + }{ + {key: "1XX", expected: 1}, + {key: "2XX", expected: 2}, + {key: "4XX", expected: 4}, + {key: "5xx", expected: 5}, + {key: "6XX"}, + {key: "0XX"}, + {key: "404"}, + {key: "40X"}, + {key: "4X"}, + {key: "default"}, + {key: ""}, + } + + for _, tt := range tests { + t.Run(tt.key, func(t *testing.T) { + assert.Equal(t, tt.expected, statusRangeOf(tt.key)) + }) + } +} + +func TestResponseNameTag(t *testing.T) { + tests := map[string]string{ + "application/json": "JSON", + "application/problem+json": "ApplicationProblemPlusJSON", + "application/x-www-form-urlencoded": "Formdata", + "multipart/form-data": "Multipart", + "text/plain": "Text", + "text/html": "HTML", + "application/octet-stream": "", + "application/xml": "", + } + + for contentType, expected := range tests { + t.Run(contentType, func(t *testing.T) { + assert.Equal(t, expected, responseNameTag(contentType)) + }) + } +} + +func TestResponseDefinitionEnvelopeAccessors(t *testing.T) { + ok := &ResponseContentDefinition{StatusCode: 200, IsSuccess: true} + notFound := &ResponseContentDefinition{StatusCode: 404} + clientErr := &ResponseContentDefinition{StatusCode: 400, StatusRange: 4} + fallback := &ResponseContentDefinition{StatusCode: 500, IsDefault: true} + + t.Run("StatusName", func(t *testing.T) { + assert.Equal(t, "200", ok.StatusName()) + assert.Equal(t, "4XX", clientErr.StatusName()) + assert.Equal(t, "Default", fallback.StatusName()) + }) + + t.Run("EnvelopeResponses lists responses in match order", func(t *testing.T) { + def := ResponseDefinition{ + Successes: []*ResponseContentDefinition{ok}, + Errors: []*ResponseContentDefinition{notFound}, + StatusRanges: []*ResponseContentDefinition{clientErr}, + Default: fallback, + } + assert.Equal(t, []*ResponseContentDefinition{ok, notFound, clientErr, fallback}, def.EnvelopeResponses()) + + def.Default = nil + assert.Equal(t, []*ResponseContentDefinition{ok, notFound, clientErr}, def.EnvelopeResponses()) + }) + + t.Run("StreamFor matches only the response All holds at the stream's code", func(t *testing.T) { + stream := &StreamResponseDefinition{StatusCode: 200, ContentType: "text/event-stream", Framing: streamFramingSSE, ItemName: "Event"} + successRange := &ResponseContentDefinition{StatusCode: 200, StatusRange: 2, IsSuccess: true} + def := ResponseDefinition{ + All: map[int]*ResponseContentDefinition{200: ok}, + Streams: []*StreamResponseDefinition{stream}, + } + + assert.Same(t, stream, def.StreamFor(ok)) + assert.Nil(t, def.StreamFor(successRange), "2XX shares 200 with the exact code, which owns the stream") + assert.Nil(t, def.StreamFor(notFound)) + }) +} + func TestResponseDefinitionStreamAccessors(t *testing.T) { sse := &StreamResponseDefinition{StatusCode: 200, ContentType: "text/event-stream", Framing: streamFramingSSE, ItemName: "Event"} lines := &StreamResponseDefinition{StatusCode: 206, ContentType: "application/jsonl", Framing: streamFramingLines, ItemName: "Row"}