Skip to content

Open Api Advanced Client

recipe JSON Schema C# open api advanced client

Demonstrates advanced OpenAPI 3.2 client features using a realistic extended Petstore API. This recipe builds on 029-OpenApiClient by adding features that real-world APIs use: streaming, file transfers, forms, cookies, and complex parameter serialization.

What This Demonstrates

Feature API Method OpenAPI Mechanism
Deep-object filters GET /pets style: deepObject, explode: true query
Array query params GET /pets style: form, explode: true array query
Path array params GET /pets/batch/{ids} style: simple path array
Cookie authentication POST /pets in: cookie parameter
Header correlation IDs GET /pets in: header, required: true
SSE streaming POST /pets/{petId}/chat text/event-stream with itemSchema
NDJSON streaming GET /pets/{petId}/activity application/x-ndjson with itemSchema
Binary download GET /photos/{photoId} application/octet-stream response
Multipart upload POST /pets/{petId}/photos multipart/form-data with binary part
Form-encoded bodies POST /adoption/apply application/x-www-form-urlencoded

Prerequisites

dotnet tool install --global Corvus.Json.Cli

Generating the Client

corvusjson openapi-client petstore-extended.json \
    --rootNamespace Petstore.Extended \
    --outputPath Generated \
    --force

This produces:

  • 4 client classes (ApiPetsClient, ApiPhotosClient, ApiChatClient, ApiAdoptionClient) — one per tag
  • Request/response structs for each operation with typed MatchResult() handlers
  • 106 model types including deep-object filters, form bodies, streaming chunks, and enum types

How the Generated Code Helps

Parameter Serialization (You Don't Have To)

The generated client handles all the serialization styles defined in OpenAPI 3.2:

// You write typed builders...
await petsClient.ListPetsAsync(
    xRequestId: "req-abc-123"u8,
    filter: new GetPetsFilter.Source((ref GetPetsFilter.Builder b) =>
    {
        b.Create(status: "available"u8, breed: "labrador"u8, minAge: 1);
    }),
    tags: new GetPetsTags.Source((ref GetPetsTags.Builder b) =>
    {
        b.AddItem("dog"u8);
        b.AddItem("friendly"u8);
    }));

// ...the generated code produces:
// GET /pets?filter[status]=available&filter[breed]=labrador&filter[minAge]=1&tags=dog&tags=friendly
// Header: x-request-id: req-abc-123

Streaming Responses

For SSE and NDJSON streams, the generated response exposes IAsyncEnumerable<ParsedJsonDocument<T>>. Each item arrives as a strongly-typed, pooled document. SSE responses also expose EnumerateOkSseItems() when you need event metadata:

await foreach (ParsedJsonDocument<ChatChunk> chunk in chatResponse.EnumerateOkItems())
{
    using (chunk)
    {
        Console.Write(chunk.RootElement.Delta);
    }
}

Binary Transfers

File uploads use BinaryPartData — a lightweight record struct that streams content without buffering. The WriteContentAsync callback supports both synchronous and fully async sources:

// In-memory data (synchronous write, zero-allocation ValueTask)
file: new BinaryPartData(
    WriteContentAsync: (stream, ct) => { stream.Write(photoBytes); return default; },
    ContentType: "image/png",
    FileName: "bella-park.png")

// Async source (e.g. Azure Blob Storage)
file: new BinaryPartData(
    WriteContentAsync: (stream, ct) => blob.DownloadToAsync(stream, ct),
    ContentType: "application/octet-stream",
    FileName: "report.pdf")

File downloads expose the raw Stream via MatchResult<ValueTask> for async handling:

await downloadResponse.MatchResult<ValueTask>(
    matchOkStream: async stream => { await stream.CopyToAsync(fileStream); },
    matchNotFound: error => { Console.WriteLine($"Not found: {error.Message}"); return default; },
    matchDefault: statusCode => { Console.WriteLine($"Unexpected: {statusCode}"); return default; });

Form Bodies

URL-encoded and multipart bodies use the same typed builder pattern as JSON — the generated code handles encoding:

// The builder mirrors the schema — no manual encoding needed
body: new PostAdoptionApplyBody.Source((ref PostAdoptionApplyBody.Builder b) =>
{
    b.Create(
        applicantName: "Jane Smith"u8,
        email: "jane@example.com"u8,
        housingType: "house"u8,
        petId: "pet-42"u8);
})
// Generated output: applicantName=Jane+Smith&email=jane%40example.com&housingType=house&petId=pet-42

Running

dotnet build
dotnet run

The runnable recipe uses an in-memory IApiTransport so it produces deterministic console output for filtering, uploads, downloads, SSE, NDJSON, and form submission without needing the fictional petstore.example.com host to exist. In production code, replace the demo transport with HttpClientTransport configured with your API server's base address.

Project Structure

031-OpenApiAdvancedClient/
├── petstore-extended.json     # Extended OpenAPI 3.2 spec
├── OpenApiAdvancedClient.csproj
├── Program.cs                 # 8 scenarios demonstrating all features
├── README.md
└── Generated/                 # 132 generated files (corvusjson output)
    ├── ApiPetsClient.cs       # Pets operations (list, create, batch)
    ├── ApiPhotosClient.cs     # Photo upload/download
    ├── ApiChatClient.cs       # Vet chat (SSE) + activity feed (NDJSON)
    ├── ApiAdoptionClient.cs   # Adoption form submission
    └── Models/                # 106 typed models

Key Patterns

Cookie parameters appear as regular method parameters — the generated request struct sets the Cookie header:

await petsClient.CreatePetAsync(
    session_token: "sess_k7j2m9x4"u8,  // Becomes: Cookie: session_token=sess_k7j2m9x4
    body: ...);

Exhaustive Response Matching

Every response type provides MatchResult() with one handler per declared status code plus a default for unmatched codes:

createResponse.MatchResult(
    matchCreated: pet => { /* 201 */ return Ok(); },
    matchUnauthorized: error => { /* 401 */ return Unauthorized(); },
    matchDefault: statusCode => { /* anything else */ return StatusCode(statusCode); });

Pooled Streaming Documents

Each document from EnumerateOkItems() is backed by pooled memory. Use using to return memory promptly — this prevents heap pressure even for long-lived streams:

await foreach (ParsedJsonDocument<ActivityEvent> doc in response.EnumerateOkItems())
{
    using (doc)
    {
        // Process doc.RootElement here — fast, zero-copy access
    }
    // Memory returned to pool after using block
}