OpenAPI Code Generation
Try the OpenAPI Playground — generate strongly-typed OpenAPI client code, inspect request/response types, and run samples in your browser.
Overview
Corvus.Text.Json includes a code generator that produces strongly-typed HTTP clients and ASP.NET Core server stubs from OpenAPI specifications (versions 3.0, 3.1, and 3.2).
Both sides are generated from the same spec. The generated code handles all the HTTP plumbing — parameter serialization, schema validation, request/response parsing, streaming, and error handling — so you focus purely on business logic.
The generator leverages the Corvus.JsonSchema V5 engine for model generation, producing zero-allocation, pooled-memory types with full JSON Schema validation built in.
Installation
# Install the CLI tool globally
dotnet tool install --global Corvus.Json.Cli
# Or as a local tool
dotnet tool install Corvus.Json.Cli
For client projects, add the transport package:
dotnet add package Corvus.Text.Json.OpenApi.HttpTransport
For server projects, add the OpenAPI runtime:
dotnet add package Corvus.Text.Json.OpenApi
Both also need the core library:
dotnet add package Corvus.Text.Json
Quick Start — Client
Generate a typed client from any OpenAPI spec:
corvusjson openapi-client petstore.json \
--rootNamespace Petstore.Client \
--outputPath ./Generated
Use it with just a few lines:
using Corvus.Text.Json.OpenApi;
using Corvus.Text.Json.OpenApi.HttpTransport;
using Petstore.Client;
using Petstore.Client.Models;
using HttpClient httpClient = new() { BaseAddress = new Uri("https://petstore.example.com/v1") };
await using HttpClientTransport transport = new(httpClient);
await using ApiPetsClient client = new(transport);
// List pets — the generated code validates limit <= 100 before sending
await using ListPetsResponse response = await client.ListPetsAsync(limit: 10);
response.MatchResult(
matchOk: pets =>
{
foreach (Pet pet in pets.EnumerateArray())
{
Console.WriteLine($"[{pet.Id}] {pet.Name}");
}
return 0;
},
matchDefault: error =>
{
Console.WriteLine($"Error {error.Code}: {error.Message}");
return 0;
});
Quick Start — Server
Generate handler interfaces and endpoint registration:
corvusjson openapi-server petstore.json \
--rootNamespace Petstore.Server \
--outputPath ./Generated
Wire up with ASP.NET Core minimal APIs:
using Corvus.Text.Json;
using Petstore.Server;
using Petstore.Server.Models;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
WebApplication app = builder.Build();
PetsHandler handler = new();
app.MapApiEndpoints(handler);
app.Run();
Implement your business logic:
internal sealed class PetsHandler : IApiPetsHandler
{
public ValueTask<ListPetsResult> HandleListPetsAsync(
ListPetsParams parameters,
JsonWorkspace workspace,
CancellationToken cancellationToken = default)
{
// Parameters are already validated — limit <= 100 is guaranteed
// Build response using typed Result factory + array builder
ListPetsResult result = ListPetsResult.Ok(
body: new Pets.Source((ref Pets.Builder b) =>
{
b.AddItem(new Pet.Source((ref Pet.Builder pb) =>
{
pb.Create(id: 1, name: "Luna"u8, tag: "cat"u8);
}));
}),
workspace: workspace);
return new(result);
}
}
What the Generator Produces
Client Generation (openapi-client)
| Generated artifact | What it handles |
|---|---|
Client class (e.g., ApiPetsClient) |
Orchestrates request lifecycle: builds parameters, validates against JSON Schema, sends via transport, parses response |
| Request structs | Serializes path/query/header/cookie parameters into correct wire format |
| Response structs | Parses response body into typed models; MatchResult for exhaustive status code handling |
Model types (in .Models sub-namespace) |
Strongly-typed JSON Schema models with validation, zero-allocation access, and builder patterns |
| Client interface | Contract for dependency injection and testing |
Server Generation (openapi-server)
| Generated artifact | What it handles |
|---|---|
Handler interface (e.g., IApiPetsHandler) |
One async method per operation — you implement business logic here |
Endpoint registration (ApiEndpointRegistration) |
Maps all routes with correct HTTP methods and path templates |
| Params structs | Strongly-typed, schema-validated request parameters and bodies |
| Result structs | Factory methods for each response status code with typed body builders |
Model types (in .Models sub-namespace) |
Same models as client generation |
Namespace Layout
The generator places types into two namespaces to avoid name collisions:
| Namespace | Contains | Example |
|---|---|---|
{rootNamespace} |
Client/server classes, request, response, params, result types | Petstore.Client.ApiPetsClient, Petstore.Client.ListPetsResponse |
{rootNamespace}.Models |
JSON Schema model types (from inline schemas and #/components/schemas) |
Petstore.Client.Models.Pet, Petstore.Client.Models.Error |
Model types are generated into a Models/ subdirectory of the output path and use the .Models sub-namespace. This prevents collisions when a schema name (e.g., CreateUserRequest) matches a generated request type name.
Generated Code Architecture
Client Flow
Your Code → Client.MethodAsync(params...)
→ Generated Request: serialize params (path/query/header/cookie)
→ Generated Validation: validate against JSON Schema
→ IApiTransport: send HTTP request
→ Generated Response: parse status + body + headers
→ MatchResult: dispatch to your handler
Server Flow
HTTP Request arrives
→ Generated Middleware: parse path/query/header/cookie params
→ Generated Validation: validate params and body against schemas
→ If invalid: return 400 Problem Details (your handler never runs)
→ Your Handler: receives typed params, returns typed Result
→ Generated Middleware: validate response body, serialize, write HTTP
Parameter Styles
The generator supports all OpenAPI 3.x parameter serialization styles:
| Style | Location | Example | Wire Format |
|---|---|---|---|
| Simple | path | {petId} |
/pets/123 |
| Simple array | path | {ids} |
/pets/batch/1,2,3 |
| Form | query | limit=10 |
?limit=10 |
| Form array (explode) | query | tags |
?tags=dog&tags=cat |
| Deep object | query | filter |
?filter[status]=available&filter[breed]=lab |
| Simple | header | x-request-id |
x-request-id: abc-123 |
| Form | cookie | session_token |
Cookie: session_token=tok123 |
Deep-Object Filters (Client)
await using ListPetsResponse response = await petsClient.ListPetsAsync(
xRequestId: "req-abc-123"u8,
filter: new GetPetsFilter.Source((ref GetPetsFilter.Builder b) =>
{
b.AddProperty("status"u8, "available"u8);
b.AddProperty("breed"u8, "labrador"u8);
}),
tags: new GetPetsTags.Source((ref GetPetsTags.Builder b) =>
{
b.AddItem("dog"u8);
b.AddItem("friendly"u8);
}));
// Produces: GET /pets?filter[status]=available&filter[breed]=labrador&tags=dog&tags=friendly
Deep-Object Filters (Server)
public ValueTask<ListPetsResult> HandleListPetsAsync(
ListPetsParams parameters, JsonWorkspace workspace, CancellationToken ct)
{
// The generated code already parsed ?filter[status]=available into a typed object
if (!parameters.Filter.IsUndefined())
{
string status = (string)parameters.Filter.GetProperty("status"u8);
// Use for filtering...
}
// Array params are typed too
foreach (JsonString tag in parameters.Tags.EnumerateArray())
{
// Filter by each tag...
}
}
Response Handling
MatchResult (Exhaustive Pattern Matching)
Every response type provides MatchResult() with one handler per declared status code:
string message = showResponse.MatchResult<string>(
matchOk: static pet => $"Found: {pet.Name}",
matchNotFound: static error => $"Not found: {error.Message}",
matchDefault: static error => $"Unexpected: {error.Message}");
This is the preferred approach — it ensures you handle every possible response the API can return.
Response Headers
Declared response headers are exposed as typed properties:
await using ListPetsResponse response = await client.ListPetsAsync(limit: 10);
JsonInteger totalCount = response.XTotalCountHeader;
JsonString nextPage = response.XNextHeader;
Request Bodies
JSON Bodies (Client)
Object bodies use the generated Builder pattern. Required properties are mandatory parameters; optional ones have defaults:
await using CreatePetResponse response = await client.CreatePetAsync(
session_token: "admin-token"u8,
body: new NewPet.Source((ref NewPet.Builder b) =>
{
b.Create(
name: "Fido"u8,
status: "available"u8,
tags: new NewPet.JsonStringArray.Source(
(ref NewPet.JsonStringArray.Builder ab) =>
{
ab.AddItem("friendly"u8);
ab.AddItem("vaccinated"u8);
}));
}));
JSON Bodies (Server)
On the server, the body arrives pre-parsed and validated in the params struct:
public ValueTask<CreatePetResult> HandleCreatePetAsync(
CreatePetParams parameters, JsonWorkspace workspace, CancellationToken ct)
{
// Body already deserialized and validated — read typed properties directly
string name = (string)parameters.Body.Name;
string status = (string)parameters.Body.Status;
// Build a typed response
return new(CreatePetResult.Created(
body: new Pet.Source((ref Pet.Builder pb) =>
{
pb.Create(id: nextId++, name: name.AsSpan(), status: status.AsSpan());
}),
workspace: workspace));
}
Form-Encoded Bodies
URL-encoded forms use the same builder pattern — the generated code handles encoding:
await using SubmitAdoptionApplicationResponse response =
await adoptionClient.SubmitAdoptionApplicationAsync(
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)));
// Wire format: applicantName=Jane+Smith&email=jane%40example.com&housingType=house&petId=pet-42
On the server, the generated middleware deserializes form data back into the same typed struct:
public ValueTask<SubmitAdoptionApplicationResult> HandleSubmitAdoptionApplicationAsync(
SubmitAdoptionApplicationParams parameters, JsonWorkspace workspace, CancellationToken ct)
{
// parameters.Body is typed — same properties regardless of encoding
string applicant = (string)parameters.Body.ApplicantName;
string email = (string)parameters.Body.Email;
return new(SubmitAdoptionApplicationResult.Accepted(
body: new PostAdoptionApplyAccepted.Source(
(ref PostAdoptionApplyAccepted.Builder rb) =>
rb.Create(applicationId: "app-42"u8, status: "received"u8)),
workspace: workspace));
}
Streaming
Server-Sent Events (SSE)
For server text/event-stream responses with an OpenAPI itemSchema, the generated result factory accepts a writer callback. Append typed items to the generated stream; the endpoint registration serializes each item as compact JSON and applies the SSE frame (data: {...}\n\n):
public ValueTask<StartVetChatResult> HandleStartVetChatAsync(
StartVetChatParams parameters, JsonWorkspace workspace, CancellationToken ct)
{
return new(StartVetChatResult.Ok(static async (stream, cancellationToken) =>
{
ChatChunk greeting = ChatChunk.ParseValue(
"""{"delta":"Hello! ","done":false}"""u8);
await stream.AppendChatChunk(greeting, cancellationToken);
ChatChunk answer = ChatChunk.ParseValue(
"""{"delta":"How can I help?","done":true}"""u8);
await stream.AppendChatChunk(answer, cancellationToken);
}));
}
There is no generated End* method. For SSE, stream completion is the HTTP response completing: when the callback returns, the endpoint flushes the response and closes the response body. If the client disconnects, the callback receives cancellation through the provided token.
On the client, streaming responses expose IAsyncEnumerable. Use EnumerateOkItems() when you only need JSON payloads, or EnumerateOkSseItems() when you also need SSE metadata such as id or event:
await foreach (ParsedJsonDocument<ChatChunk> chunk in chatResponse.EnumerateOkItems())
{
using (chunk)
{
Console.Write(chunk.RootElement.Delta);
}
}
NDJSON (Newline-Delimited JSON)
For server application/x-ndjson responses, the same generated writer callback is used. Each appended item is written as one JSON line ({...}\n):
public ValueTask<StreamPetActivityResult> HandleStreamPetActivityAsync(
StreamPetActivityParams parameters, JsonWorkspace workspace, CancellationToken ct)
{
return new(StreamPetActivityResult.Ok(static async (stream, cancellationToken) =>
{
ActivityEvent checkIn = ActivityEvent.ParseValue(
"""{"eventId":"evt-1","timestamp":"2026-05-30T18:00:00Z","type":"check-in","description":"Bella checked in"}"""u8);
await stream.AppendActivityEvent(checkIn, cancellationToken);
}));
}
The client reads each JSON line as a pooled typed document:
await foreach (ParsedJsonDocument<ActivityEvent> doc in activityResponse.EnumerateOkItems())
{
using (doc)
{
Console.WriteLine($"[{doc.RootElement.Timestamp}] {doc.RootElement.Type}: {doc.RootElement.Description}");
}
}
Binary Transfers
File Upload (Multipart)
await using UploadPetPhotoResponse uploadResponse = await photosClient.UploadPetPhotoAsync(
petId: "pet-1"u8,
metadata: new PhotoMetadata.Source((ref PhotoMetadata.Builder b) =>
b.Create(petId: "pet-1"u8, description: "At the park"u8)),
file: new BinaryPartData(
WriteContentAsync: (stream, ct) => { stream.Write(photoBytes); return default; },
ContentType: "image/png",
FileName: "bella-park.png"));
For async sources (e.g. downloading from cloud storage), the callback can be fully async:
file: new BinaryPartData(
WriteContentAsync: (stream, ct) => blob.DownloadToAsync(stream, ct),
ContentType: "application/octet-stream",
FileName: "report.pdf")
File Download
Use MatchResult<ValueTask> to handle stream responses asynchronously:
await downloadResponse.MatchResult<ValueTask>(
matchOkStream: async stream =>
{
// Read binary data asynchronously from the raw stream
await stream.CopyToAsync(fileStream);
},
matchNotFound: error =>
{
Console.WriteLine($"Photo not found: {error.Message}");
return default;
},
matchDefault: statusCode =>
{
Console.WriteLine($"Unexpected: {statusCode}");
return default;
});
Validation
Client-Side Validation
By default, the generated client validates all parameters and request bodies before sending:
try
{
// limit: 200 exceeds the schema's "maximum: 100" constraint
await using ListPetsResponse _ = await client.ListPetsAsync(
limit: 200,
validationMode: ValidationMode.Detailed);
}
catch (InvalidOperationException ex)
{
// Detailed mode includes full JSON Schema evaluation output
Console.WriteLine($"Validation failed: {ex.Message}");
}
| Mode | Behaviour |
|---|---|
ValidationMode.Basic |
Validates parameters and bodies; throws on failure with a brief message |
ValidationMode.Detailed |
Same, but includes full JSON Schema evaluation output in the exception |
ValidationMode.None |
Skips validation — use for trusted inputs in performance-critical paths |
Server-Side Validation
The generated middleware validates all inputs before your handler runs:
| Invalid input | Generated behaviour |
|---|---|
| Required parameter missing | 400 Problem Details — handler never called |
| Parameter fails schema validation | 400 Problem Details |
| Request body fails JSON parse | 400 Problem Details |
| Request body fails schema validation | 400 Problem Details |
| Response body fails validation | 500 Internal Server Error |
You never write validation code. Your handler only receives valid, typed data.
Customizing Generated Endpoints
By default MapApiEndpoints registers each operation as a bare minimal-API route. When you need per-endpoint conventions — authorization, route names, tags, Produces metadata, output caching, rate limiting — use the configureEndpoint overload. It is invoked once per generated endpoint, after the route is mapped (so your conventions win), with a descriptor identifying the operation and the route's IEndpointConventionBuilder:
app.MapApiEndpoints(petsHandler, static (in EndpointDescriptor endpoint, IEndpointConventionBuilder builder) =>
{
builder.WithName(endpoint.OperationId ?? endpoint.MethodName);
builder.WithTags([.. endpoint.Tags]);
// Webhook/callback operations (from openapi-callback-server) are flagged separately.
if (endpoint.IsCallback)
{
builder.WithMetadata(new EndpointGroupNameAttribute("webhooks"));
}
});
The bare MapApiEndpoints(petsHandler) overload is preserved unchanged; the callback variant is an additional overload, so adopting the hook is both source- and binary-compatible. No new package dependency is added — the callback receives IEndpointConventionBuilder from Microsoft.AspNetCore.Routing, which the generated server already references.
EndpointDescriptor
EndpointDescriptor, EndpointSecurityRequirementSet, EndpointSecurityRequirement, and the ConfigureEndpoint delegate are read-only types generated alongside ApiEndpointRegistration in your server's root namespace.
| Member | Type | Description |
|---|---|---|
OperationId |
string? |
OpenAPI operationId, or null if the operation declares none |
MethodName |
string |
Generated handler method name (the {MethodName} in Handle{MethodName}Async) |
HttpMethod |
string |
HTTP method (GET, POST, …) |
RouteTemplate |
string |
ASP.NET route template as registered |
Tags |
IReadOnlyList<string> |
OpenAPI tags for the operation |
IsCallback |
bool |
true for webhook/callback operations, false for main paths |
SecurityRequirements |
IReadOnlyList<EndpointSecurityRequirementSet> |
The operation's declared security as a list of alternatives (see below) |
SecurityRequirements is populated for all supported spec versions (OpenAPI 3.0, 3.1, and 3.2). Operation-level security takes precedence over the document-level default; operations that declare neither surface an empty list.
The OR/AND structure
OpenAPI security mirrors the spec's security array exactly: it is a list of alternatives, and the operation is satisfied if any one alternative is met (OR). Each alternative — an EndpointSecurityRequirementSet — is a group of scheme requirements that must all be met together (AND). For example [{bearerAuth: []}, {apiKeyAuth: []}] means bearerAuth OR apiKeyAuth, whereas [{bearerAuth: [], apiKeyAuth: []}] means bearerAuth AND apiKeyAuth.
EndpointSecurityRequirementSet (one alternative):
| Member | Type | Description |
|---|---|---|
Requirements |
IReadOnlyList<EndpointSecurityRequirement> |
The scheme requirements that must all be satisfied together (AND) |
IsOptional |
bool |
true when this alternative is the empty OpenAPI requirement ({}), which permits anonymous access |
PolicyName |
string |
The canonical policy name for the alternative: the single requirement's PolicyName, or the requirement policy names joined with && (empty for an anonymous alternative) |
EndpointSecurityRequirement (one scheme within an alternative):
| Member | Type | Description |
|---|---|---|
SchemeName |
string |
The security scheme name, as declared in components.securitySchemes |
Scopes |
IReadOnlyList<string> |
The scopes required by this requirement (empty for non-scoped schemes) |
SchemeType |
string? |
The scheme's OpenAPI type (oauth2, apiKey, http, openIdConnect), or null if the scheme is not declared in components.securitySchemes. Lets the callback branch on scheme type without cross-referencing the scheme table |
PolicyName |
string |
The canonical policy name: the scheme name alone when no scopes are required, otherwise {schemeName}:{scope+scope...}. Use the same value when registering policies so endpoint mapping and policy registration stay in sync |
Applying OpenAPI Security as Authorization
The generator surfaces components.securitySchemes and per-operation security on the descriptor, but it deliberately does not decide how a scheme maps to an authentication handler or how scopes are enforced — those are application concerns. It does, however, generate a small helper that applies the declared security using a sensible default convention, so the common case needs only one line.
The generated RequireDeclaredAuthorization helper
Alongside the registration types, the generator emits an EndpointSecurityConventions.RequireDeclaredAuthorization extension. Its behaviour follows the alternatives:
- No declared security, or any anonymous (
{}) alternative →AllowAnonymous. - A single alternative →
RequireAuthorizationfor each scheme in it (AND), using each requirement'sPolicyName. - Multiple alternatives (OR) → a single
RequireAuthorizationwith a combined name (the alternatives'PolicyNames joined by||), because ASP.NET endpoint conventions cannot OR policies. You register that one policy with your own OR logic.
You register the referenced policies and wire the helper into the hook:
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthentication(/* your scheme(s) */);
builder.Services.AddAuthorization(options =>
{
// Single-alternative operations require one policy per requirement PolicyName.
options.AddPolicy("oauth2:read:pets", p => p.RequireClaim("scope", "read:pets"));
// An OR operation ([{bearerAuth}, {apiKeyAuth}]) requires the combined policy; you supply the OR logic.
options.AddPolicy("bearerAuth || apiKeyAuth", p => p.RequireAssertion(ctx =>
ctx.User.HasClaim(c => c.Type == "bearer") || ctx.User.HasClaim(c => c.Type == "apikey")));
});
WebApplication app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
PetsHandler petsHandler = new();
app.MapApiEndpoints(petsHandler, static (in EndpointDescriptor endpoint, IEndpointConventionBuilder builder) =>
builder.RequireDeclaredAuthorization(endpoint));
app.Run();
Writing the mapping yourself
RequireDeclaredAuthorization is just a convenience over SecurityRequirements. When you need different policy names or to branch on SchemeType, walk the alternatives yourself:
app.MapApiEndpoints(petsHandler, static (in EndpointDescriptor endpoint, IEndpointConventionBuilder builder) =>
{
if (endpoint.SecurityRequirements.Count == 0)
{
builder.AllowAnonymous();
return;
}
foreach (EndpointSecurityRequirementSet alternative in endpoint.SecurityRequirements)
{
foreach (EndpointSecurityRequirement requirement in alternative.Requirements)
{
// requirement.SchemeType is "oauth2", "apiKey", "http", or "openIdConnect".
_ = requirement.PolicyName;
}
}
// ...translate the OR-of-ANDs however your app enforces it.
});
Because the callback just hands you an IEndpointConventionBuilder, every standard ASP.NET endpoint extension is available the same way — RequireRateLimiting, CacheOutput, RequireCors, DisableAntiforgery, and so on. See the 034-OpenApiCallbackServer recipe for the hook applied to a callback/webhook server.
Authentication
Corvus generates typed parameters for OpenAPI security schemes declared in the spec — but authentication itself is implemented using standard .NET HttpClient patterns. The generated transport (HttpClientTransport) wraps a regular HttpClient, so you configure auth the same way you would for any HTTP client.
Kiota provides built-in IAuthenticationProvider implementations (BaseBearerTokenAuthenticationProvider, ApiKeyAuthenticationProvider, AnonymousAuthenticationProvider, and an Azure Identity provider for Microsoft Entra). Corvus achieves the same result using IHttpClientFactory and standard DelegatingHandler middleware — the same patterns used across the .NET ecosystem.
Key advantage: Corvus extracts OAuth2 scopes from the specification and emits them as generated constants — per-operation (ListPetsOauth2Scopes) and unioned (AllOauth2Scopes). This eliminates hardcoded scope strings and ensures your token requests stay in sync with the API contract. Kiota does not surface per-operation scopes in generated code.
Microsoft Entra ID (Azure AD / MSAL)
Kiota provides a dedicated Azure Identity authentication provider that wraps MSAL. The Corvus equivalent uses Azure.Identity (which wraps MSAL internally) with a standard DelegatingHandler.
The code generator emits per-operation OAuth2 scope constants directly from the specification's security requirements. This means you never need to hardcode scopes — they come from the spec:
// Generated constants from the specification
IApiPetsClient.SecuritySchemes.Oauth2TokenUrl // "https://auth.example.com/token"
IApiPetsClient.SecuritySchemes.Oauth2AuthorizationUrl // "https://auth.example.com/authorize"
IApiPetsClient.SecuritySchemes.Oauth2AvailableScopes // ["read:pets", "write:pets"]
// Per-operation scopes
IApiPetsClient.SecurityRequirements.ListPetsOauth2Scopes // ["read:pets"]
IApiPetsClient.SecurityRequirements.CreatePetOauth2Scopes // ["read:pets", "write:pets"]
IApiPetsClient.SecurityRequirements.AllOauth2Scopes // ["read:pets", "write:pets"]
Use these with Azure.Identity and TokenRequestContext:
// Register Azure.Identity credential + handler in DI
services.AddSingleton<TokenCredential>(
new ClientSecretCredential(tenantId, clientId, clientSecret));
services.AddTransient<EntraTokenHandler>();
services.AddHttpClient("petstore")
.AddHttpMessageHandler<EntraTokenHandler>();
// Handler acquires tokens using generated scope constants
public class EntraTokenHandler(TokenCredential credential) : DelegatingHandler
{
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
AccessToken token = await credential.GetTokenAsync(
new TokenRequestContext(IApiPetsClient.SecurityRequirements.AllOauth2Scopes),
cancellationToken);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Token);
return await base.SendAsync(request, cancellationToken);
}
}
For per-operation scopes (e.g., requesting a minimal token for read-only operations), use HttpRequestMessage.Options to pass the required scopes to your handler:
// A scope-aware handler that reads per-request scopes from Options
public class ScopeAwareEntraHandler(TokenCredential credential) : DelegatingHandler
{
private static readonly HttpRequestOptionsKey<string[]> ScopesKey = new("OAuth2Scopes");
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
// Read scopes from the request options (set by calling code)
if (!request.Options.TryGetValue(ScopesKey, out string[]? scopes))
{
// Fall back to union of all scopes if none specified
scopes = IApiPetsClient.SecurityRequirements.AllOauth2Scopes;
}
AccessToken token = await credential.GetTokenAsync(
new TokenRequestContext(scopes), cancellationToken);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Token);
return await base.SendAsync(request, cancellationToken);
}
/// <summary>
/// Sets the OAuth2 scopes for a specific request.
/// </summary>
public static void SetScopes(HttpRequestMessage request, string[] scopes)
{
request.Options.Set(ScopesKey, scopes);
}
}
Then at the call site, set operation-specific scopes when you need fine-grained token permissions:
// Read-only operation — request minimal scopes
var listRequest = new HttpRequestMessage(HttpMethod.Get, "/pets");
ScopeAwareEntraHandler.SetScopes(
listRequest, IApiPetsClient.SecurityRequirements.ListPetsOauth2Scopes);
// Write operation — request elevated scopes
var createRequest = new HttpRequestMessage(HttpMethod.Post, "/pets");
ScopeAwareEntraHandler.SetScopes(
createRequest, IApiPetsClient.SecurityRequirements.CreatePetOauth2Scopes);
Interactive and Device-Code Flows
For user-facing applications, substitute the credential type:
// Interactive browser login (desktop/native apps)
services.AddSingleton<TokenCredential>(
new InteractiveBrowserCredential(new InteractiveBrowserCredentialOptions
{
ClientId = clientId,
TenantId = tenantId,
RedirectUri = new Uri("http://localhost:1234"),
}));
// Device code flow (CLI tools, headless terminals)
services.AddSingleton<TokenCredential>(
new DeviceCodeCredential(new DeviceCodeCredentialOptions
{
ClientId = clientId,
TenantId = tenantId,
DeviceCodeCallback = (info, cancel) =>
{
Console.WriteLine(info.Message); // "To sign in, use a web browser..."
return Task.CompletedTask;
},
}));
// Managed identity (Azure-hosted services — no secrets needed)
services.AddSingleton<TokenCredential>(new DefaultAzureCredential());
All credential types work with the same ScopeAwareEntraHandler — the handler acquires tokens using whichever TokenCredential is registered. The generated scope constants ensure the correct permissions are requested regardless of flow.
Bearer Token (Generic OAuth2 / JWT)
For non-Entra APIs that use OAuth2 bearer tokens, implement a token service:
services.AddTransient<BearerTokenHandler>();
services.AddHttpClient("petstore")
.AddHttpMessageHandler<BearerTokenHandler>();
public class BearerTokenHandler(ITokenService tokenService) : DelegatingHandler
{
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
string token = await tokenService.GetTokenAsync(cancellationToken);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
return await base.SendAsync(request, cancellationToken);
}
}
// Create the Corvus client from the named HttpClient
HttpClient httpClient = httpClientFactory.CreateClient("petstore");
await using HttpClientTransport transport = new(httpClient, baseUri);
PetstorePetsClient client = new(transport);
API Key (Header)
Kiota provides ApiKeyAuthenticationProvider with KeyLocation.Header. The Corvus equivalent uses default request headers:
services.AddHttpClient("petstore", client =>
{
client.DefaultRequestHeaders.Add("X-Api-Key", configuration["ApiKey"]);
});
API Key (Query Parameter)
Kiota provides ApiKeyAuthenticationProvider with KeyLocation.QueryParameter. The Corvus equivalent uses a DelegatingHandler:
public class ApiKeyQueryHandler(string paramName, string apiKey) : DelegatingHandler
{
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
var uriBuilder = new UriBuilder(request.RequestUri!);
string separator = string.IsNullOrEmpty(uriBuilder.Query) ? "" : "&";
uriBuilder.Query = uriBuilder.Query.TrimStart('?') + separator
+ Uri.EscapeDataString(paramName) + "=" + Uri.EscapeDataString(apiKey);
request.RequestUri = uriBuilder.Uri;
return base.SendAsync(request, cancellationToken);
}
}
services.AddTransient(sp => new ApiKeyQueryHandler("api_key", configuration["ApiKey"]!));
services.AddHttpClient("petstore")
.AddHttpMessageHandler<ApiKeyQueryHandler>();
Basic Authentication
services.AddHttpClient("petstore", client =>
{
byte[] credentials = Encoding.UTF8.GetBytes($"{username}:{password}");
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Basic", Convert.ToBase64String(credentials));
});
Anonymous (No Authentication)
Kiota provides AnonymousAuthenticationProvider. With Corvus, this is the default — an HttpClient with no auth handler:
services.AddHttpClient("petstore");
// No auth handler — requests go out without credentials
HttpClient httpClient = httpClientFactory.CreateClient("petstore");
await using HttpClientTransport transport = new(httpClient, baseUri);
Combining Authentication with Resilience
Use Microsoft.Extensions.Http.Resilience (Polly v8) to compose authentication with retry policies:
services.AddHttpClient("petstore")
.AddHttpMessageHandler<EntraTokenHandler>()
.AddStandardResilienceHandler(options =>
{
options.Retry.MaxRetryAttempts = 3;
options.Retry.BackoffType = DelayBackoffType.Exponential;
});
This gives you exponential backoff with jitter, circuit breaking, and request hedging — all composable with your auth handler via the standard IHttpClientFactory pipeline.
Per-Request Authentication Override
Because HttpClientTransport wraps a standard HttpClient, you can also set auth per-request using HttpRequestMessage.Options with a custom handler that reads per-request tokens from the options dictionary — the same pattern used for differentiated auth in multi-tenant scenarios.
Cookie Authentication
Cookie parameters appear as regular method parameters — the generated code sets the Cookie header:
// Client: cookie becomes a method parameter
await using CreatePetResponse response = await petsClient.CreatePetAsync(
session_token: "sess_k7j2m9x4"u8,
body: ...);
// Wire: Cookie: session_token=sess_k7j2m9x4
On the server, cookie presence is validated automatically:
// Server: cookie is extracted and validated before your handler runs
public ValueTask<CreatePetResult> HandleCreatePetAsync(
CreatePetParams parameters, JsonWorkspace workspace, CancellationToken ct)
{
// parameters.SessionToken is guaranteed non-undefined (required cookie)
string token = (string)parameters.SessionToken;
// Your auth logic here...
}
If the cookie is declared required: true and missing, the generated middleware returns 400 Problem Details automatically.
Integration with ASP.NET Core Cookie Authentication
In practice, cookie-based APIs typically use ASP.NET Core's cookie authentication middleware. The cookie is issued by a login endpoint and validated on subsequent requests by the framework — your handler receives an already-authenticated ClaimsPrincipal.
When the OpenAPI spec declares a security scheme with type: apiKey and in: cookie, the generator emits a SecuritySchemes.{Name}KeyName constant containing the cookie name from the spec. On the server side, this lives on the ApiEndpointRegistration class. Use this constant to configure the ASP.NET Core cookie name — keeping your server in sync with the API contract:
Server setup — configure cookie auth in the ASP.NET Core pipeline:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options =>
{
// Use the generated constant — derived from the OpenAPI securitySchemes definition
options.Cookie.Name = ApiEndpointRegistration.SecuritySchemes.SessionAuthKeyName;
options.Cookie.HttpOnly = true;
options.Cookie.SameSite = SameSiteMode.Strict;
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
options.ExpireTimeSpan = TimeSpan.FromHours(2);
options.SlidingExpiration = true;
options.Events.OnRedirectToLogin = context =>
{
// API servers return 401, not a redirect
context.Response.StatusCode = 401;
return Task.CompletedTask;
};
});
builder.Services.AddAuthorization();
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
// Register the generated endpoints (validation runs before auth in the pipeline)
app.MapPetstoreEndpoints(new PetstoreHandlers());
Login endpoint — issue the cookie via ASP.NET's SignInAsync:
app.MapPost("/login", async (LoginRequest login, HttpContext context) =>
{
// Validate credentials (e.g., against a database)
User? user = await userService.ValidateAsync(login.Username, login.Password);
if (user is null)
{
return Results.Unauthorized();
}
// Build claims and sign in — ASP.NET Core sets the cookie automatically
List<Claim> claims =
[
new(ClaimTypes.NameIdentifier, user.Id.ToString()),
new(ClaimTypes.Name, user.Username),
new(ClaimTypes.Role, user.Role),
];
ClaimsIdentity identity = new(claims, CookieAuthenticationDefaults.AuthenticationScheme);
await context.SignInAsync(
CookieAuthenticationDefaults.AuthenticationScheme,
new ClaimsPrincipal(identity),
new AuthenticationProperties { IsPersistent = true });
return Results.Ok();
});
Handler — in your generated handler, the cookie has already been validated by the middleware. Access the authenticated user via HttpContext:
public ValueTask<CreatePetResult> HandleCreatePetAsync(
CreatePetParams parameters, JsonWorkspace workspace, CancellationToken ct)
{
// The cookie was validated by ASP.NET Core's CookieAuthenticationHandler
// before this handler is reached. Access claims via HttpContext:
// var userId = httpContext.User.FindFirstValue(ClaimTypes.NameIdentifier);
// The generated cookie parameter still gives you the raw value if needed
string rawToken = (string)parameters.SessionToken;
// Your business logic...
}
How client and server cookie names stay in sync
Both the client and server generators emit the cookie name as a constant derived from the same OpenAPI spec. The spec is the single source of truth:
| Side | Constant | Value |
|---|---|---|
| Server | ApiEndpointRegistration.SecuritySchemes.SessionAuthKeyName |
"session_token" |
| Client | IApiPetsClient.SecuritySchemes.SessionAuthKeyName |
"session_token" |
In the normal login flow, you don't need to reference the client constant explicitly — the server's Set-Cookie: session_token=... response header tells CookieContainer the name, and it matches automatically. The constant is useful when you need to manually inject a cookie (see below).
Client — integration tests with WebApplicationFactory
ASP.NET Core integration tests use WebApplicationFactory<T> to create an in-memory test server. Its CreateClient() method returns an HttpClient with cookies enabled by default (HttpClientHandler.UseCookies = true). Call the login endpoint, and subsequent requests include the cookie automatically:
// Arrange: create the test server and a cookie-aware HttpClient
await using var factory = new WebApplicationFactory<Program>();
using var httpClient = factory.CreateClient();
// Act: log in — ASP.NET Core's SignInAsync sets the Set-Cookie header,
// and HttpClient's CookieContainer captures it automatically
using var loginResponse = await httpClient.PostAsJsonAsync("/login",
new { username = "admin", password = "secret" });
Assert.AreEqual(HttpStatusCode.OK, loginResponse.StatusCode);
// All subsequent requests from this HttpClient include the cookie automatically
var petsClient = new ApiPetsClient(new HttpClientTransport(httpClient));
await using var response = await petsClient.ListPetsAsync();
// The generated middleware validates the cookie and your handler runs normally
The CookieContainer on the underlying HttpClientHandler stores cookies from Set-Cookie response headers and attaches them to subsequent requests for the same domain — exactly the same way a browser behaves. No manual header extraction is needed.
Client — production service-to-service with IHttpClientFactory
For production, register a named client with IHttpClientFactory. The default SocketsHttpHandler has cookies enabled, so the login → capture → replay flow works the same way:
// In Startup / Program.cs — register the named client
builder.Services.AddHttpClient("PetstoreApi", client =>
{
client.BaseAddress = new Uri("https://api.example.com");
});
// At runtime — resolve and use the client
var httpClient = httpClientFactory.CreateClient("PetstoreApi");
// Login — the server's Set-Cookie is captured by the handler's CookieContainer
await httpClient.PostAsJsonAsync("/login", new { username = "svc", password = "secret" });
// Subsequent calls include the cookie automatically
var petsClient = new ApiPetsClient(new HttpClientTransport(httpClient));
await using var response = await petsClient.CreatePetAsync(body: ...);
Note:
IHttpClientFactorypools handlers (default lifetime: 2 minutes). Cookies persist within the handler's lifetime. For long-lived sessions, increase the handler lifetime viaSetHandlerLifetime()or manage cookies externally.
Client — manual cookie injection
If you already have a session token (e.g., from a shared auth service or a config store) and need to inject it without calling a login endpoint, use the generated client constant to ensure the cookie name matches the server's expectation:
var cookieContainer = new CookieContainer();
cookieContainer.Add(
new Uri("https://api.example.com"),
new Cookie(IApiPetsClient.SecuritySchemes.SessionAuthKeyName, existingToken));
var handler = new HttpClientHandler { CookieContainer = cookieContainer };
using var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://api.example.com") };
var petsClient = new ApiPetsClient(new HttpClientTransport(httpClient));
await using var response = await petsClient.ListPetsAsync();
Webhooks and Callbacks
OpenAPI specifications can define webhooks (top-level, spec-wide notifications) and callbacks (per-operation, triggered by runtime expressions). The Corvus code generator supports both with dedicated commands that produce the same output structure as regular client/server generation.
Understanding the Symmetry
Consider a spec where an API server sends webhook notifications to its clients:
┌────────────────┐ POST /subscribe ┌────────────────┐
│ Client App │ ──────────────────▶ │ API Server │
│ │ │ │
│ │ ◀────────────────── │ │
│ (webhook │ POST {callbackUrl} │ (sends │
│ receiver) │ "pet was adopted" │ webhooks) │
└────────────────┘ └────────────────┘
This creates two new generation scenarios:
| You are building… | You need… | Command |
|---|---|---|
| The client app | A server to receive the webhooks | openapi-callback-server |
| The API server | A client to send the webhooks | openapi-callback-client |
Callback Server — Receiving Webhooks
When your application subscribes to a service's events, you need a server endpoint to receive the callbacks. Generate the server stubs:
corvusjson openapi-callback-server petstore.json \
--rootNamespace MyApp.WebhookReceiver \
--outputPath ./Generated/WebhookReceiver
This produces:
- Handler interfaces — implement these to process incoming notifications
- Endpoint registration — wire up ASP.NET minimal API routes
- Params/Result types — schema-validated request bodies and typed responses
Wire it up in your client application:
using Corvus.Text.Json;
using MyApp.WebhookReceiver;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
WebApplication app = builder.Build();
WebhookHandler handler = new();
app.MapApiEndpoints(handler);
app.Run();
Callback Client — Sending Webhooks
When your server needs to notify subscribed clients at their registered callback URLs, generate a typed client:
corvusjson openapi-callback-client petstore.json \
--rootNamespace MyApp.WebhookSender \
--outputPath ./Generated/WebhookSender
This produces:
- Client class — type-safe methods for each webhook/callback operation
- Request/Response types — serialize the notification payload, parse the client's response
Use it in your server's business logic:
using Corvus.Text.Json.OpenApi.HttpTransport;
using MyApp.WebhookSender;
// When a pet is adopted, notify the subscriber
using HttpClient httpClient = new() { BaseAddress = new Uri(subscriberCallbackUrl) };
await using HttpClientTransport transport = new(httpClient);
await using ApiWebhooksClient client = new(transport);
await using PetAdoptedWebhookResponse response = await client.PetAdoptedWebhookAsync(
body: new PetAdoptedEvent.Source((ref PetAdoptedEvent.Builder b) =>
{
b.Create(petId: "pet-123"u8, adopterId: "user-456"u8);
}));
Runtime Expressions and Context
OpenAPI callback keys use runtime expressions like {$request.body#/callbackUrl} to specify where values come from. The code generator resolves these automatically — the generated client extracts values from the originating request/response context using typed property navigation.
| Expression | What it resolves to | Generated code |
|---|---|---|
$request.body#/callbackUrl |
A JSON Pointer into the request body | sourceRequest.CallbackUrl (typed accessor) |
$request.query.eventType |
A query parameter from the original request | sourceRequest.EventType |
$request.header.X-Correlation-Id |
A header from the original request | sourceRequest.XCorrelationId |
$response.body#/id |
A JSON Pointer into the response body | response.CreatedBody.Id |
$response.header.Location |
A response header | response.LocationHeader |
The generated Response struct captures all the context needed to follow links and invoke callbacks. The generated code automatically fills in parameters bound by runtime expressions — you don't write this code yourself; it is emitted by the generator:
// This is GENERATED code — the generator creates this method and its
// runtime expression bindings from your OpenAPI spec automatically.
// After creating a subscription, the response carries context for callbacks.
await using CreateSubscriptionResponse response = await client.CreateSubscriptionAsync(
body: new Subscription.Source((ref Subscription.Builder b) =>
{
b.Create(callbackUrl: "https://my-app.example.com/hooks"u8, events: ...);
}));
// response.Links gives typed access to linked/callback operations.
// Runtime expressions like $request.body#/callbackUrl are resolved automatically
// from the captured request/response context — no manual wiring needed.
This is the same runtime expression infrastructure used by OpenAPI Links — both callbacks and links share the context-capture mechanism.
Links
OpenAPI Links define follow-on operations that can be invoked from a response, with parameters automatically populated from runtime expressions. The code generator supports links on response objects.
When a response declares links, the generated Response struct provides typed navigation methods:
// Create a pet — the 201 response links to showPetById
await using CreatePetResponse response = await client.CreatePetAsync(
body: new NewPet.Source((ref NewPet.Builder b) =>
{
b.Create(name: "Luna"u8, tag: "cat"u8);
}));
// Follow the link — petId is automatically populated from $response.body#/id
response.MatchResult(
matchCreated: async (pet) =>
{
// The generated link method fills parameters from runtime expressions
await using ShowPetByIdResponse petResponse = await response.CreatedLinks.ShowPetByIdAsync();
// petId was automatically extracted from the create response body
},
matchDefault: (error) => { });
The generator captures request and response state in the response struct so that linked operations can resolve their parameter bindings without manual wiring. Parameters bound by $request.body#/..., $request.header.X-..., $response.body#/..., and $response.header.X-... expressions are all resolved automatically.
Example Spec with Webhooks and Callbacks
{
"openapi": "3.2.0",
"info": { "title": "Event API", "version": "1.0.0" },
"paths": {
"/subscriptions": {
"post": {
"operationId": "createSubscription",
"callbacks": {
"onEvent": {
"{$request.body#/callbackUrl}": {
"post": {
"operationId": "onEventCallback",
"requestBody": {
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/Event" }
}
}
},
"responses": { "200": { "description": "Received" } }
}
}
}
}
}
}
},
"webhooks": {
"statusChange": {
"post": {
"operationId": "statusChangeWebhook",
"requestBody": {
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/StatusChange" }
}
}
},
"responses": { "200": { "description": "Acknowledged" } }
}
}
}
}
CLI Reference
Client Generation
corvusjson openapi-client <spec-path> [options]
| Option | Description | Default |
|---|---|---|
--rootNamespace |
Root namespace for generated types | Derived from spec title |
--outputPath |
Output directory | ./ |
--clientName |
Prefix for generated client type names | Derived from spec title |
--force |
Regenerate even if lock file is current | false |
--spec-url |
Original URL of the spec (recorded in lock file for update-style re-fetch) | — |
--include-path |
Glob patterns for paths to include (repeatable or comma-separated) | All paths |
--exclude-path |
Glob patterns for paths to exclude (repeatable or comma-separated) | None |
--specVersion |
Override auto-detected OpenAPI version (3.0, 3.1, or 3.2) |
Auto-detected |
--ignoreEmptyFormUrlEncodedBody |
Treat form-urlencoded bodies with no schema properties as absent | false |
Server Generation
corvusjson openapi-server <spec-path> [options]
Same options as client generation. The output includes handler interfaces, endpoint registration, and shared model types.
Callback Server Generation
corvusjson openapi-callback-server <spec-path> [options]
Generates server stubs for webhooks and callbacks defined in the spec. Use this when you are building a client application that needs to receive webhook/callback notifications from a service.
Same options as openapi-server. Only webhook and per-operation callback path-items are processed — the main paths object is ignored.
Callback Client Generation
corvusjson openapi-callback-client <spec-path> [options]
Generates a typed HTTP client for invoking webhooks and callbacks defined in the spec. Use this when you are building the server side of an API and need to call back into client-provided endpoints.
Same options as openapi-client. Only webhook and per-operation callback path-items are processed.
Inspecting Operations
corvusjson openapi-show <spec-path> [options]
Displays the operation tree of an OpenAPI specification. Useful for previewing which operations a filter will select before running generation.
| Option | Description | Default |
|---|---|---|
--include-path |
Glob patterns for paths to include | All paths |
--exclude-path |
Glob patterns for paths to exclude | None |
--group-by |
Group operations by path or tag |
path |
--specVersion |
Override auto-detected OpenAPI version | Auto-detected |
Path Filtering
For large specs, use --include-path and --exclude-path to generate code for a subset of operations. Filters apply to both client and server generation and to openapi-show.
Pattern syntax:
| Pattern | Matches |
|---|---|
/pets |
Exactly /pets |
/pets/* |
One segment after /pets/ (e.g., /pets/{petId}) |
/pets/** |
Any depth under /pets/ (e.g., /pets/{petId}/toys/{toyId}) |
{param} |
Any path parameter segment (e.g., /pets/{petId} matches /pets/*) |
Patterns are case-insensitive. Include patterns are additive (union). Exclude patterns subtract from the include set. If no include patterns are specified, all paths are included.
Examples:
# Generate only the /pets endpoints (including nested paths like /pets/{petId})
corvusjson openapi-client petstore.json --include-path "/pets/**"
# Generate everything except admin endpoints
corvusjson openapi-client petstore.json --exclude-path "/admin/**"
# Combine: include /pets and /store, but exclude store admin
corvusjson openapi-client petstore.json \
--include-path "/pets/**" --include-path "/store/**" \
--exclude-path "/store/admin/**"
# Multiple patterns via comma-separated values
corvusjson openapi-client petstore.json --include-path "/pets/**,/users/**"
# Preview what will be generated before running
corvusjson openapi-show petstore.json --include-path "/pets/**"
Workflow: Use openapi-show with filters to preview, then apply the same filter to openapi-client or openapi-server:
# 1. See what's in the spec
corvusjson openapi-show petstore.json --group-by tag
# 2. Preview filtered subset
corvusjson openapi-show petstore.json --include-path "/pets/**"
# 3. Generate only that subset
corvusjson openapi-client petstore.json --include-path "/pets/**" \
--rootNamespace MyApp.Pets --outputPath ./Generated/Pets
Only operations matching the filter are generated. Model types referenced by filtered operations are still included — filtering is at the operation level, not the schema level.
Real-world example: Stripe Payments subset
Stripe publishes their OpenAPI spec at openapi/spec3.json — it contains 300+ endpoints. You can generate a focused client for just the payment processing operations:
# Download the Stripe spec (or use --spec-url for automatic re-fetch)
corvusjson openapi-client spec3.json \
--spec-url "https://raw.githubusercontent.com/stripe/openapi/master/openapi/spec3.json" \
--include-path "/v1/payment_intents/**,/v1/charges/**,/v1/refunds/**" \
--rootNamespace MyApp.Stripe.Payments \
--outputPath ./Generated/StripePayments \
--ignoreEmptyFormUrlEncodedBody
# Or preview what that includes first
corvusjson openapi-show spec3.json \
--include-path "/v1/payment_intents/**,/v1/charges/**,/v1/refunds/**"
The --ignoreEmptyFormUrlEncodedBody flag is recommended for Stripe because their spec declares application/x-www-form-urlencoded request bodies on every operation — even those with no body properties (e.g. GET /v1/charges). Without this flag, the generated client methods include a redundant body parameter.
You can split a large spec into multiple domain-specific clients:
# Payments domain
corvusjson openapi-client spec3.json \
--include-path "/v1/payment_intents/**,/v1/charges/**,/v1/refunds/**" \
--rootNamespace MyApp.Stripe.Payments --outputPath ./Generated/Payments
# Customer domain
corvusjson openapi-client spec3.json \
--include-path "/v1/customers/**,/v1/subscriptions/**" \
--rootNamespace MyApp.Stripe.Customers --outputPath ./Generated/Customers
# Billing domain (everything except one-off payments)
corvusjson openapi-client spec3.json \
--include-path "/v1/invoices/**,/v1/plans/**,/v1/prices/**" \
--rootNamespace MyApp.Stripe.Billing --outputPath ./Generated/Billing
Each produces an independent client with only the operations and models relevant to that domain.
Lock File and Regeneration
The generator writes a corvusjson-openapi.lock file alongside the output. On subsequent runs, if the spec hasn't changed, generation is skipped. Use --force to regenerate unconditionally.
Version Support
| OpenAPI Version | Detected From | Notes |
|---|---|---|
| 3.2 | "openapi": "3.2.0" |
Full support including itemSchema for streaming |
| 3.1 | "openapi": "3.1.x" |
Full support; JSON Schema 2020-12 for models |
| 3.0 | "openapi": "3.0.x" |
Full support; JSON Schema Draft 4 subset for models |
The generator auto-detects the version from the openapi field. All three versions produce the same client/server API surface — only the internal model schemas differ.
Best Practices
- Use
u8literals for scalar parameters —petId: "abc"u8,name: "Fido"u8. Implicit conversions handle encoding and avoid UTF-16→UTF-8 transcoding. - Use
Builder.Create()for request/response bodies — mirrors schema properties with typed parameters. - Always
await usingresponses and transports — they hold pooled memory that must be returned. - Prefer
MatchResultfor response handling — ensures exhaustive coverage of all status codes. - Leave
validationModeatBasicfor development; considerNonefor production hot paths. - Pass the
workspacethrough on the server — it enables efficient pooled-memory JSON building. - Let the middleware validate — don't re-validate inputs the generated code already checked.
Comparison with Kiota
Microsoft Kiota is the most widely-used OpenAPI client generator for .NET. This section compares the two generators across architecture, features, and performance.
Architecture
| Aspect | Corvus | Kiota |
|---|---|---|
| Generation model | CLI tool (corvusjson openapi-client) |
CLI tool (kiota generate) |
| Runtime dependency | Corvus.Text.Json.OpenApi (thin transport abstraction) |
Microsoft.Kiota.Bundle (abstractions + HTTP + serialization + auth) |
| Serialization | Zero-copy over pooled JSON document (no POCO hydration) | POCO model classes with IParsable self-deserialization |
| Memory model | Struct-based types backed by pooled byte[] — GC-free hot path |
Class-based models allocated per response |
| Transport abstraction | IApiTransport (4 overloads: no-body, typed, stream, writer) |
IRequestAdapter wrapping HttpClient with middleware pipeline |
| Schema validation | Built-in, configurable per-request (None/Basic/Detailed) |
None — no schema validation support |
Feature Support
| Feature | Corvus | Kiota |
|---|---|---|
| OpenAPI 3.0 | ✅ | ✅ |
| OpenAPI 3.1 | ✅ | ✅ |
| OpenAPI 3.2 | ✅ | Partial |
| JSON request/response bodies | ✅ | ✅ |
| Form-urlencoded bodies | ✅ | ❌ |
| Multipart/mixed bodies | ✅ | Limited |
| Binary upload (octet-stream) | ✅ | ✅ |
| Server stub generation | ✅ | ❌ |
| Webhooks (top-level, OA 3.1+) | ✅ | ❌ (confirmed by team) |
| Per-operation callbacks | ✅ | ❌ (no tracking issue) |
| OpenAPI Links (typed follow-on ops) | ✅ | ❌ (no tracking issue) |
| Runtime expression resolution | ✅ | ❌ (not applicable — no callbacks/links) |
| Typed response headers | ✅ | ❌ |
| Cookie parameters | ✅ | ❌ |
| Schema validation | ✅ (None/Basic/Detailed) | ❌ |
| Per-operation OAuth2 scopes | ✅ (generated constants) | ❌ (must hardcode) |
| Error response discrimination | ✅ (typed MatchResult) |
✅ (exception-based) |
| Middleware pipeline | Platform-native (IHttpClientFactory + Polly) |
Built-in DelegatingHandler chain |
| Multiple languages | C# only | C#, Java, Go, TypeScript, Python, PHP, Ruby, Swift, CLI |
| IDE autocompletion | ✅ (pre-generated files) | ✅ (pre-generated files) |
Response Handling
| Aspect | Corvus | Kiota |
|---|---|---|
| Return type | Value-type response struct with IAsyncDisposable |
Nullable POCO or Task<T?> |
| Status discrimination | MatchResult() with typed handlers per status code |
Exception-based (ApiException subclasses) |
| Memory lifetime | Response owns pooled document; disposed via await using |
GC-managed; no explicit lifetime |
| Response headers | Generated typed properties (lazy-parsed) | Not generated — must use NativeResponseHandler |
Middleware and Resilience
Kiota ships its own DelegatingHandler pipeline with default handlers for retry, redirect, user-agent injection, and header inspection.
Corvus uses platform-native .NET patterns instead:
| Concern | Corvus | Kiota |
|---|---|---|
| Retry | Microsoft.Extensions.Http.Resilience (Polly v8) |
Built-in RetryHandler (exponential backoff) |
| Redirect | SocketsHttpHandler.AllowAutoRedirect (platform) |
Built-in RedirectHandler |
| Observability | OpenTelemetry HttpClient instrumentation |
Custom Activity tracing in handlers |
| Composition | IHttpClientFactory.AddHttpMessageHandler() |
KiotaClientFactory.CreateDefaultHandlers() |
This means Corvus doesn't reimplement retry/redirect logic that the platform already provides, and users can mix any community middleware (Polly, OpenTelemetry, logging) without learning a framework-specific handler API.
The trade-off is that these middleware patterns are tied to HttpClient — if you swap HttpClientTransport for a non-HTTP transport (e.g., in-process or message queue), the DelegatingHandler pipeline no longer applies. Corvus has no transport-independent middleware abstraction; Kiota's built-in handler chain works regardless of the underlying transport adapter.
Performance
All benchmarks use mock transports (no real I/O) measuring the full client pipeline: construct request → serialize → transport → parse response → typed access. "Corvus + Validation" uses ValidationMode.Basic — boolean pass/fail schema validation of both request and response bodies (no error-location collection).
| Operation | Corvus | Corvus + Basic Validation | Kiota | Speed (×Kiota) | Alloc (×Kiota) |
|---|---|---|---|---|---|
| GET /pets (10-item array) | 1,342 ns | 3,059 ns | 6,088 ns | 4.5× / 2.0× | 17× |
| GET /pets/ | 409 ns | 610 ns | 2,290 ns | 5.6× / 3.8× | 14× |
| POST /pets (JSON body) | 434 ns | 701 ns | 2,910 ns | 6.7× / 4.2× | 15× |
| PUT /pets/ (form body) | 536 ns | 833 ns | 2,957 ns | 5.5× / 3.5× | 12× |
| Response header (x-next) | 472 ns | — | N/A | — | — |
Speed column: first number = Corvus (no validation) vs Kiota; second = Corvus + Basic validation vs Kiota. Alloc column: Kiota allocation ÷ Corvus allocation.
Corvus with full schema validation is 2–4× faster than Kiota without any validation, and allocates 12–17× less memory.
Validation mode overhead (GET /pets, 3-item array):
| Mode | Mean | vs None | Alloc |
|---|---|---|---|
| None | 616 ns | 1.0× | 1,044 B |
| Basic (boolean pass/fail) | 1,272 ns | 2.1× | 1,044 B |
| Detailed (error locations) | 2,039 ns | 3.3× | 1,044 B |
All validation modes produce zero additional allocation.
BenchmarkDotNet v0.15.8, .NET 10.0.8, 13th Gen Intel Core i7-13800H, Windows 11. OutlierMode=RemoveAll, RunStrategy=Throughput.
When to Choose Corvus
- Performance-critical services where latency and allocation matter
- APIs requiring request/response validation without external tooling
- .NET-only projects wanting zero-copy JSON and compile-time safety
- APIs using form-urlencoded, multipart/mixed, or cookie parameters
- Scenarios requiring typed response header access
- Projects needing both client and server from the same spec
- APIs with webhooks or callbacks that need both receiver stubs and sender clients
- OAuth2-secured APIs where you want spec-driven scope management (Corvus generates per-operation scope constants from
securityrequirements; Kiota requires manual hardcoding) - Specs using OpenAPI Links for typed operation chaining (Corvus generates follow-on methods with automatic runtime expression resolution)
When to Choose Kiota
- Multi-language projects needing consistent client generation across platforms
- Teams wanting a self-contained middleware pipeline without configuring
IHttpClientFactory - Projects where POCO-style models are preferred over struct-based types
- APIs where performance is not the primary concern
Example Recipes
| Recipe | What it shows |
|---|---|
| 029-OpenApiClient | Basic client: list, create, show with MatchResult |
| 030-OpenApiServer | Basic server: handler interface + endpoint registration |
| 031-OpenApiAdvancedClient | Advanced: streaming, binary, forms, cookies, deep-object queries |
| 032-OpenApiAdvancedServer | Advanced server: all parameter styles, streaming, uploads |
| 033-OpenApiEndToEnd | Full round-trip: generated client calls generated server over real HTTP |
| 034-OpenApiCallbackServer | Webhook receiver: callback server stubs for client-side webhook handling |
| 035-OpenApiCallbackClient | Webhook sender: callback client for server-side webhook delivery |