OpenAPI·C#·Free

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.0 / 3.1·C# 11+·.NET 8+·HttpClient·System.Text.Json·MIT
payments.openapi.yamlInput
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'
compile
Models/Payment.cs tsc cleanOutput
// 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; }
}
What gets generated

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(),
};
Three things we get right

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.

01ProofRecords with required and init — not partial classes with setters.Spec · InCode · Out
spec.yaml fragment
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 }
Payment.cs
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 camelCase
C# · HttpClient

A 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 generated
// 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.
}
Also available via MCP

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."
See the MCP page
Questions

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.

Both OpenAPI 3.0 and 3.1. Beyond the shared subset (string enums, 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.

Open the converter