Skip to content

Generate a client from OpenAPI

If your API has an OpenAPI document, TypedRest can write the client for you. This tutorial takes a spec for the example address book API, generates a client from it, fixes an endpoint the generator could not have known about, and adds hand-written code on top.

The Code generation section describes each tool in detail. This page shows how they fit together.

1. The spec

Save this as addressbook.yml. Besides the paths from the example API it has one more: /contacts/{id}/events, which pushes a contact's changes as Server-Sent Events.

openapi: 3.0.1
info:
  title: Address Book
  version: '1.0'
paths:
  /contacts:
    get:
      responses:
        '200':
          description: All contacts
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Contact'
    post:
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/Contact'
      responses:
        '201':
          description: Created
  '/contacts/{id}':
    get:
      responses:
        '200':
          description: A contact
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Contact'
    put:
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/Contact'
      responses:
        '204':
          description: Updated
    delete:
      responses:
        '204':
          description: Deleted
  '/contacts/{id}/note':
    get:
      responses:
        '200':
          description: The note attached to a contact
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Note'
    put:
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/Note'
      responses:
        '204':
          description: Updated
  '/contacts/{id}/poke':
    post:
      responses:
        '204':
          description: Poked
  '/contacts/{id}/events':
    get:
      responses:
        '200':
          description: Changes to a contact as Server-Sent Events
          content:
            text/event-stream:
              schema:
                type: string
components:
  schemas:
    Contact:
      type: object
      required: [name]
      properties:
        id:
          type: string
        name:
          type: string
        email:
          type: string
    Note:
      type: object
      properties:
        content:
          type: string
    ContactEvent:
      type: object
      properties:
        type:
          type: string
        contact:
          $ref: '#/components/schemas/Contact'

2. Install the tool

dotnet tool install -g typedrest-codegen

The command-line tool needs the .NET SDK to run, but it can generate clients in every language. C# projects can use the source generator instead, which is introduced in step 5. Install the CLI anyway, since step 3 uses it to inspect the inference.

3. Check what the generator infers

The generator does not translate paths one by one. It matches each path against patterns such as "a GET returning an array, plus a child path returning one element of it is a collection". Before generating code, look at the result of that step:

typedrest-codegen pattern -f addressbook.yml

This writes the inferred structure back into addressbook.yml as an x-typedrest extension:

x-typedrest:
  contacts:
    kind: collection
    uri: ./contacts
    description: All contacts
    schema:
      $ref: '#/components/schemas/Contact'
    element:
      kind: element
      description: A contact
      children:
        note:
          kind: element
          uri: ./note
          description: The note attached to a contact
          schema:
            $ref: '#/components/schemas/Note'
        poke:
          kind: action
          uri: ./poke
          description: Poked
        events:
          kind: blob
          uri: ./events
          description: Changes to a contact as Server-Sent Events

The descriptions are taken from the spec and end up as documentation comments in the generated code.

Run pattern only once

pattern always runs the inference again and replaces any x-typedrest already in the document. Running it after the edit in the next step would undo that edit. It also rewrites the whole file in a normalized form, so commit the spec before running it if you want to review the changes.

/contacts became a collection whose elements have a note element and a poke action, as intended. But events became a blob endpoint: its response is not JSON, and an OpenAPI document cannot say that a response is a stream of entities rather than a file to download.

4. Fix the inference

Edit the events entry by hand to make it an SSE endpoint that delivers ContactEvent entities:

        events:
          kind: sse
          uri: ./events
          description: Changes to a contact as Server-Sent Events
          schema:
            $ref: '#/components/schemas/ContactEvent'

When a document contains x-typedrest, the code generator (the generate command and the source generator) uses it as-is instead of running the inference again, so your edit survives every regeneration of the client. It also means that paths you add to the spec later have to be added to the extension too. Custom code explains this and lists every kind and its properties.

5. Generate the client

Add the source generator and the libraries the generated code depends on. The generated events endpoint needs TypedRest.Reactive:

dotnet add package TypedRest.SourceGenerator
dotnet add package TypedRest
dotnet add package TypedRest.Reactive

Reference the spec in your project file:

<ItemGroup>
  <TypedRestOpenApi Include="addressbook.yml" ServiceName="AddressBook" Namespace="MyCompany.AddressBook" />
</ItemGroup>

The client is generated on every build, together with interfaces and DTO classes. Set <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles> if you want to read the generated files in obj/.

If you would rather check the generated code in, use the CLI instead:

typedrest-codegen generate -f addressbook.yml -o AddressBook/ -s AddressBook -n MyCompany.AddressBook --generate-interfaces --generate-dtos
typedrest-codegen generate -l java -f addressbook.yml -o src/main/java/ -s AddressBook -n com.mycompany.addressbook --generate-dtos

Add the net.typedrest:typedrest, net.typedrest:typedrest-serializers-jackson and net.typedrest:typedrest-reactive dependencies, plus org.jspecify:jspecify for the nullability annotations on the DTOs.

typedrest-codegen generate -l kotlin -f addressbook.yml -o src/main/kotlin/ -s AddressBook -n com.mycompany.addressbook --generate-dtos

Add the net.typedrest:typedrest and net.typedrest:typedrest-reactive dependencies. The DTOs are annotated for kotlinx.serialization, so you also need the kotlin("plugin.serialization") Gradle plugin and an explicit org.jetbrains.kotlinx:kotlinx-serialization-json dependency.

typedrest-codegen generate -l typescript -f addressbook.yml -o src/addressbook/ -s AddressBook --generate-dtos

Run npm install typedrest. Reactive endpoints are part of the main package.

The command-line tool page lists all options, including how to pick a different serializer for the DTOs.

6. Use the generated client

The service name AddressBook produces an entry endpoint called AddressBookClient. The collection becomes a Contacts property, its elements are ContactElementEndpoints, and the children are named after their path segments:

using MyCompany.AddressBook;

var client = new AddressBookClient(new Uri("https://example.com/api/"));

var smith = (await client.Contacts.CreateAsync(new Contact { Name = "Smith" }))!;
await smith.Note.SetAsync(new Note { Content = "Call back on Monday" });
await smith.Poke.InvokeAsync();

// Print every change to the contact as it happens
smith.Events.GetObservable().Subscribe(x => Console.WriteLine($"{x.Type}: {x.Contact?.Name}"));
AddressBookClient client = new AddressBookClient(URI.create("https://example.com/api/"));

Contact contact = new Contact();
contact.name = "Smith";
ContactElementEndpoint smith = client.getContacts().create(contact);
smith.getNote().set(new Note("Call back on Monday"));
smith.getPoke().invoke();

// Print every change to the contact as it happens
smith.getEvents().getObservable().subscribe(
    x -> System.out.println(x.type + ": " + (x.contact == null ? null : x.contact.name)),
    Throwable::printStackTrace);

Generated Java DTOs have public fields; see Command-line tool for what else they look like.

val client = AddressBookClient(URI.create("https://example.com/api/"))

val smith = client.contacts.create(Contact(name = "Smith"))!!
smith.note.set(Note(content = "Call back on Monday"))
smith.poke.invoke()

// Print every change to the contact as it happens
smith.events.getObservable().subscribe { println("${it.type}: ${it.contact?.name}") }
import { AddressBookClient } from "./addressbook";

const client = new AddressBookClient("https://example.com/api/");

const smith = (await client.contacts.create({ name: "Smith" }))!;
await smith.note.set({ content: "Call back on Monday" });
await smith.poke.invoke();

// Print every change to the contact as it happens
for await (const x of smith.events.stream()) {
    console.log(`${x.type}: ${x.contact?.name}`);
}

Compare this with the hand-written client: the structure is the same, only the class names differ.

7. Add your own code

Generated code is regenerated, so do not edit it. Add to it instead, as described in Extend the generated code:

Add to the partial class and its partial interface in a file of your own. The collection hands out elements as IContactElementEndpoint, so a method that is only on the class would not be reachable from client.Contacts["1337"]:

namespace MyCompany.AddressBook;

public partial interface IContactElementEndpoint
{
    /// <summary>
    /// Changes the name of the contact, retrying if someone else modified it in the meantime.
    /// </summary>
    Task<Contact?> RenameAsync(string name, CancellationToken cancellationToken = default);
}

public partial class ContactElementEndpoint
{
    public Task<Contact?> RenameAsync(string name, CancellationToken cancellationToken = default)
        => UpdateAsync(contact => contact.Name = name, cancellationToken: cancellationToken);
}
await client.Contacts["1337"].RenameAsync("Smith");

Derive from the generated class:

public class MyAddressBookClient extends AddressBookClient {
    public MyAddressBookClient(URI uri) {
        super(uri);
    }

    public List<Contact> findByName(String name) {
        return getContacts().readAll().stream()
            .filter(x -> x.name.equals(name))
            .toList();
    }
}

Derive from the generated class, or use an extension function:

fun AddressBookClient.findByName(name: String) = contacts.readAll().filter { it.name == name }

Derive from the generated class:

export class MyAddressBookClient extends AddressBookClient {
    async findByName(name: string) {
        return (await this.contacts.readAll()).filter(x => x.name === name);
    }
}

RenameAsync uses a read-modify-write cycle with ETags; see Optimistic concurrency.

Keeping client and spec in sync

The source generator picks up a changed spec on the next build, while the CLI has to be rerun (details). Either way, paths added to the spec must also be added to x-typedrest, as described in step 4.

Next steps

  • Custom code: every x-typedrest kind, and how to plug your own patterns into the generator.
  • Live updates: more about the SSE endpoint used for events.