Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<Op>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 `<Op>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
Expand Down
2 changes: 2 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,8 @@ Generate `<Op>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
Expand Down
4 changes: 2 additions & 2 deletions docs/streaming.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions examples/client/streaming/api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
114 changes: 110 additions & 4 deletions examples/client/streaming/gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

41 changes: 41 additions & 0 deletions examples/client/streaming/gen_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
82 changes: 82 additions & 0 deletions examples/responses/multiple/client-with-response/api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Loading
Loading