OpenAPI to
C#.
Turn an OpenAPI 3.0 or 3.1 spec into a modern C# client. Records with required and init, nullable reference types on every optional field, [JsonPolymorphic] + [JsonDerivedType] for each oneOf, and an HttpClient-based service. Pure System.Text.Json — no Newtonsoft, no extra runtime package. No signup, no install.
openapi: 3.1.0
info:
title: Payments API
version: 1.2.0
paths:
/payments/{id}:
get:
operationId: getPayment
tags: [payments]
parameters:
- name: id
in: path
required: true
schema: { type: string }
- name: expand
in: query
required: false
schema: { type: string }
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/Payment'
components:
schemas:
Payment:
type: object
required: [id, amount, currency, status, method]
properties:
id: { type: string }
amount: { type: number, format: double }
currency: { $ref: '#/components/schemas/Currency' }
status: { $ref: '#/components/schemas/PaymentStatus' }
description: { type: string }
method: { $ref: '#/components/schemas/PaymentMethod' }
PaymentMethod:
oneOf:
- $ref: '#/components/schemas/CardPayment'
- $ref: '#/components/schemas/BankPayment'
discriminator:
propertyName: kind
mapping:
card: '#/components/schemas/CardPayment'
bank: '#/components/schemas/BankPayment'// Models/Payment.cs
using System.Text.Json.Serialization;
using System;
namespace Example.Payments.Models;
public record Payment
{
[JsonPropertyName("id")]
public required string Id { get; init; }
[JsonPropertyName("amount")]
public required double Amount { get; init; }
[JsonPropertyName("currency")]
public required Currency Currency { get; init; }
[JsonPropertyName("status")]
public required PaymentStatus Status { get; init; }
[JsonPropertyName("description")]
public string? Description { get; init; }
[JsonPropertyName("createdAt")]
public required DateTime CreatedAt { get; init; }
[JsonPropertyName("method")]
public required PaymentMethodUnion Method { get; init; }
}Records, a polymorphic union, an HttpClient.
Below: types emitted from a small payments spec with a oneOf + discriminator and an enum. Every schema becomes a record with init properties; required covers every required field; the oneOf union is an interface wired through [JsonPolymorphic] so System.Text.Json round-trips it without a custom converter. The discriminator mapping values land in [JsonDerivedType] — not the $ref'd schema names.
// Models/Payment.cs
using System.Text.Json.Serialization;
using System;
namespace Example.Payments.Models;
public record Payment
{
[JsonPropertyName("id")]
public required string Id { get; init; }
[JsonPropertyName("amount")]
public required double Amount { get; init; }
[JsonPropertyName("currency")]
public required Currency Currency { get; init; }
[JsonPropertyName("description")]
public string? Description { get; init; }
[JsonPropertyName("createdAt")]
public required DateTime CreatedAt { get; init; }
[JsonPropertyName("method")]
public required PaymentMethodUnion Method { get; init; }
}
// Models/PaymentMethodUnion.cs
using System.Text.Json.Serialization;
namespace Example.Payments.Models;
[JsonPolymorphic(TypeDiscriminatorPropertyName = "kind")]
[JsonDerivedType(typeof(CardPayment), "card")]
[JsonDerivedType(typeof(BankPayment), "bank")]
public interface PaymentMethodUnion
{
}
// Models/CardPayment.cs (BankPayment is the symmetric sibling)
public record CardPayment : PaymentMethodUnion
{
[JsonPropertyName("kind")]
public required string Kind { get; init; }
[JsonPropertyName("last4")]
public required string Last4 { get; init; }
}
// Exhaustive pattern match at the call site — no cast,
// no "which property is set?" guesswork:
string Label(Payment p) => p.Method switch
{
CardPayment c => $"card {c.Last4}",
BankPayment b => $"bank {b.Iban}",
_ => throw new InvalidOperationException(),
};Grounded in how the spec reads.
Each claim is something you can verify by running the converter on your own spec. Click a card to see the spec fragment that triggered it and the C# that came out.
components:
schemas:
Payment:
type: object
required: [id, amount, status]
properties:
id: { type: string }
amount: { type: number, format: double }
status: { $ref: '#/components/schemas/PaymentStatus' }
description: { type: string }public record Payment
{
[JsonPropertyName("id")]
public required string Id { get; init; }
[JsonPropertyName("amount")]
public required double Amount { get; init; }
[JsonPropertyName("status")]
public required PaymentStatus Status { get; init; }
[JsonPropertyName("description")]
public string? Description { get; init; }
}
// - record, not partial class
// - init accessors, not setters
// - required on every schema-required field — the
// compiler rejects an object initializer that
// forgets one
// - [JsonPropertyName] pins the wire name, so the
// C# property can be PascalCase while the JSON
// stays the spec's camelCaseA typed service, not a stub.
Operations group by OpenAPI tag — one <code>{Tag}Client</code> class per tag, all extending a shared BaseHttpClient that owns the HttpClient, the base URL, and the JsonSerializerOptions. Each method is async, takes a CancellationToken, returns the parsed body directly. Query parameters with required=false default to null and drop out of the URL when not passed. Path parameters interpolate inline. System.Text.Json serializes both sides.
// Client/PaymentsClient.cs
using System.Net.Http;
using System.Text.Json;
using System.Threading;
using Example.Payments.Models;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace Example.Payments.Client;
public class PaymentsClient : BaseHttpClient
{
public PaymentsClient(
HttpClient httpClient,
string baseUrl,
JsonSerializerOptions? jsonOptions)
: base(httpClient, baseUrl, jsonOptions) { }
public async Task<Payment> GetPaymentAsync(
string id,
string? expand = null,
CancellationToken cancellationToken = default)
{
var _queryParts = new List<string>();
if (expand != null) _queryParts.Add($"{Uri.EscapeDataString("expand")}={Uri.EscapeDataString((expand?.ToString()) ?? "")}");
var _qs = _queryParts.Count > 0 ? "?" + string.Join("&", _queryParts) : "";
return await ExecuteAsync<Payment>(
HttpMethod.Get,
$"/payments/{id}{_qs}",
cancellationToken);
}
public async Task<Payment> CreatePaymentAsync(
CreatePaymentRequest requestBody,
CancellationToken cancellationToken = default)
{
return await ExecuteAsync<CreatePaymentRequest, Payment>(
HttpMethod.Post,
$"/payments",
requestBody,
cancellationToken);
}
}
// Client/BaseHttpClient.cs (shared, condensed)
public abstract class BaseHttpClient
{
protected readonly HttpClient _httpClient;
protected readonly string _baseUrl;
protected readonly JsonSerializerOptions? _jsonOptions;
public BaseHttpClient(
HttpClient httpClient,
string baseUrl,
JsonSerializerOptions? jsonOptions = null)
{
_httpClient = httpClient;
_baseUrl = baseUrl.TrimEnd('/');
_jsonOptions = jsonOptions;
}
protected async Task<TResponse> ExecuteAsync<TResponse>(
HttpMethod method, string path, CancellationToken cancellationToken)
{
var requestUrl = $"{_baseUrl}{path}";
using var request = new HttpRequestMessage(method, requestUrl);
using var response = await _httpClient
.SendAsync(request, cancellationToken)
.ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var content = await response.Content
.ReadAsStringAsync()
.ConfigureAwait(false);
return JsonSerializer.Deserialize<TResponse>(content, _jsonOptions)!;
}
// Sibling overloads handle request body / no response /
// request body + no response. All use the same SendAsync +
// EnsureSuccessStatusCode + Deserialize pipeline.
}Or ask Claude to do it.
The same generator ships as a Model Context Protocol server. It calls the same web API this converter uses, so the output is identical. Point Claude Desktop, Cursor, or any MCP-aware agent at a spec and it produces the C# client as a diff — free, no token, no leaving the chat.
// claude-desktop config · .mcp.json
{
"mcpServers": {
"metaengine": {
"command": "npx",
"args": ["-y", "@metaengine/mcp-server"]
}
}
}
// Then in Claude:
// "Load specs/payments.yaml and generate a C# HttpClient
// in namespace Acme.Payments."Things people ask.
The questions that come up most when generating C# clients from OpenAPI. If yours is not here, run the converter — the answer is usually in the output.
nullable: true on 3.0, path / query / header parameters, JSON request/response bodies, oneOf with discriminator, allOf inheritance), 3.1 documents get the full parser treatment: type: ["string","null"] nullability, numeric exclusiveMinimum/exclusiveMaximum values, const reified to single-value enums, contentEncoding/contentMediaType normalized to format: binary, top-level webhooks, RFC 6901 JSON Pointer escapes, and discriminator mappings preserved across multi-file specs.Try it on your own spec.
The converter runs in the browser. Your spec never leaves the page.