Using Swagger or OpenAPI generated clients on Deno

gtmiyake

Toshio

Posted on April 21, 2023

Using Swagger or OpenAPI generated clients on Deno

Summary

I will share my experience on how to generate a client from a Swagger/OpenAPI definition, and how to better use it in a project.

FYI:What is the difference between Swagger and OpenAPI

Why?

TL;DR: I found no docs explaining how to do this properly

The OpenAPI Specification is a specification language for HTTP APIs that provides a standardized means to define your API to others.

Its may not be the best or perfect solution, but it is a popular solution. It's supported by many languages and frameworks, and AFAIK mainly used to provide automatically generated documentation.

A very interesting part of it feature set is the ability to generate properly typed client code from its definition, with the FOSS project OpenAPI Generator being the main reference for such utilities.

However, there is little to no documentation on how to actually generate and use those clients on projects, and to make matters worse the available/generated documentation is wrong at times.

Getting the definition

Given the definition often automated nature, I ran into three main issues:

  1. Given the dynamic nature of my project, those definitions are changing very frequently
  2. The provided specification version may not be compatible with the generator
  3. The provided specification is slight off for its intended use

In order to solve those issues:

  1. If you are in the same scenario, I strongly recommend writing an script to get the definition and fix all following issues. You can start getting the definition and writing it to disk with:

    const schemaUrl = "http://localhost:8000/openapi.json"
    const schemaResponse = await fetch(schemaUrl)
    const textSchema = await definitionResponse.text()
    Deno.writeTextFileSync('schema.json', textSchema)
    
  2. There are many conversion tools, however I found the Swagger Editor to be a good tool to both inspect and convert your schema (On the web UI the conversion options are under Edit). We can automate the conversion step using a script as well.

    const schemaUrl = "http://localhost:8000/openapi.json"
    const schemaResponse = await fetch(schemaUrl)
    const textSchema = await definitionResponse.text()
    const convertedResponse = await fetch('https://converter.swagger.io/api/convert', {
        headers: {
            accept: 'application/json',
            'Content-Type': 'application/yaml',
        },
        method: "POST",
        body: textSchema
    }
    ) // despite its content type being yaml, it will accept json definition as well
    const convertedText = await convertedResponse.text()
    Deno.writeTextFileSync('schema.json', convertedText)
    
  3. This might be not as obvious to everyone, but you can easily edit the schema by code before saving the final version.

    const schemaUrl = "http://localhost:8000/openapi.json"
    const schemaResponse = await fetch(schemaUrl)
    const textSchema = await definitionResponse.text()
    const convertedResponse = await fetch('https://converter.swagger.io/api/convert', {
        headers: {
            accept: 'application/json',
            contentType: 'application/yaml',
        },
        method: "POST",
        body: textSchema
    }
    ) // despite its content type being yaml, it will accept json definition as well
    const convertedSchema = await convertedResponse.json()
    delete convertedSchema['paths']['/undesired_route'] // delete a route
    delete convertedSchema['paths']['/some_route']['get'] // delete a specific method from a route
    Deno.writeTextFileSync('schema.json', JSON.stringify(convertedSchema))
    

Generating the client

Next, we use the NPX to run the OpenAPI Generator CLI to generate a client definition from the saved schema.

npx @openapitools/openapi-generator-cli generate -i ./schema.json -g typescript --additional-properties=platform=deno -o ./Client

If you inspect the contents of the created Client, you will find a lot of boilerplate code, definitions and even some Markdown documentation.

How to use the generated client

Basic Usage

Under its generated documentation you should be use to able the client in the following way:

import {SomeRouteApi, createConfiguration} from "./Client/index.ts"

const someRouteApi = new SomeRouteApi(createConfiguration())
const body = {
    someStringParameter: "someValue",
    someObjectParameter: { foo: "this", bar: "that"}
}
const response = someRouteApi.someRoutePost(body)
Enter fullscreen mode Exit fullscreen mode

This however seems to be wrong, and your IDE would point out that the method someRoutePost defines its first argument as someStringParameter and its expects a string. You could still call it passing positional arguments such as in:

import {SomeRouteApi, createConfiguration} from "./Client/index.ts"

const someRouteApi = new SomeRouteApi(createConfiguration())
const body = {
    someStringParameter: "someValue",
    someObjectParameter: { foo: "this", bar: "that"}
}
const response = someRouteApi.someRoutePost("someValue",{ foo: "this", bar: "that"})
Enter fullscreen mode Exit fullscreen mode

The positional argument might be acceptable to some use cases, but if you have many optional arguments you might end with a call such as:

const _unusedBodyObject:{
    paramZero?: string,
    paramOne?: string,
    paramTwo?: string,
    paramThree?: string,
    paramFour?: string,
    paramFive?: string
} = {
   paramFive: "someValue"
}
const response = someRouteApi.someRoutePost(undefined, undefined, undefined, undefined, undefined, "someValue")
Enter fullscreen mode Exit fullscreen mode

In order to "fix" that, there are specific API classes in the Client/types/ObjectParamAPI.ts file that will allow calls using objects. For the example above, we could use the following code:

import {createConfiguration} from "./Client/index.ts"
import {ObjectSomeRouteApi} from "Client/types/ObjectParamAPI.ts"

const someRouteApi = new ObjectSomeRouteApi(createConfiguration())
const response = someRouteApi.someRoutePost({paramFive: "someValue"})
Enter fullscreen mode Exit fullscreen mode

Changing default host and headers

As you might expect, those settings belong to the Configurations object created with the createConfiguration function.

However, the function don't expose anyway to update its values after creation neither does it expose direct APIs to set those values at creation.

In order to properly change those values, you could use the following example to generate custom configuration for your clients:

import {createConfiguration, RequestContext} from "/Client/index.ts"
const myConfiguration = (host: string, token?: string, ) => createConfiguration({
    baseServer: {makeRequestContext: (endpoint, httpMethod)=> {
        const context: RequestContext = new RequestContext(host+endpoint,httpMethod)
        context.setHeaderParam("Some-Custom-Header", "someValue")
        if (token) context.setHeaderParam('Authorization', `Bearer ${token}`)
        return context
    }}
})
Enter fullscreen mode Exit fullscreen mode

Going further, we can call every method passing specific configurations as the second argument on the ObjectParamApi

const localService = "http://localhost:3000"
const someRouteApi = new ObjectSomeRouteApi(myConfiguration(localService))
const resp = await someRouteApi.someRouteGet() // Call without an Authorization Header
const resp = await someRouteApi.someRouteGet({}, myConfiguration(localService, "userToken")) // Call with an Authorization Header
Enter fullscreen mode Exit fullscreen mode

Better names for multiple instances or reuse

So far, you might have noticed that the methods are verbose, which is fine should you to use like:

import {createConfiguration} from "./Client/index.ts"
import {ObjectSomeRouteApi} from "Client/types/ObjectParamAPI.ts"

const api = new ObjectSomeRouteApi(createConfiguration())
const response = api.someRoutePost({paramFive: "someValue"})
Enter fullscreen mode Exit fullscreen mode

However, if you are using and/or exporting multiple route APIs at the same time, this gets very verbose like in this example:

\\ apiClients.ts
import {createConfiguration} from "./Client/index.ts"
import {ObjectSomeRouteApi, ObjectOtherRouteApi, ObjectYetAnotherRouteApi } from "Client/types/ObjectParamAPI.ts"

export const someRoute = new ObjectSomeRouteApi(createConfiguration())
export const otherRoute = new ObjectOtherRouteApi(createConfiguration())
export const yetAnotherRoute = new ObjectYetAnotherRouteApi(createConfiguration())
\\ apiCaller.ts
import {someRoute, otherRoute, yetAnotherRoute} from "./apiClients.ts"
const some = await someRoute.someGet()
const other = await otherRoute.otherGet()
const yetAnother = await (await yetAnotherRoute.yetAnotherGet()
Enter fullscreen mode Exit fullscreen mode

In this scenario, I strong recommend remapping the route client methods to an object with better usability, such as:

\\ apiClients.ts
import {createConfiguration} from "./Client/index.ts"
import {ObjectSomeRouteApi, ObjectOtherRouteApi, ObjectYetAnotherRouteApi } from "Client/types/ObjectParamAPI.ts"

const someRoute = new ObjectSomeRouteApi(createConfiguration())
const otherRoute = new ObjectOtherRouteApi(createConfiguration())
const yetAnotherRoute = new ObjectYetAnotherRouteApi(createConfiguration())

export default const apiClient = {
    some: {
        get: someRoute.someGet.bind(someRoute)
    },
    other: {
        get: otherRoute.otherGet.bind(otherRoute)
    },
    yetAnother: {
        get: yetAnotherRoute.yetAnotherGet.bind(yetAnotherRoute)
    },
}
\\ apiCaller.ts
import apiClient from "./apiClients.ts"
const some = await apiClient.some.get()
const other = await apiClient.other.get()
const yetAnother = await apiClient.yetAnother.get()
Enter fullscreen mode Exit fullscreen mode

Final Considerations

This is a somewhat basic tutorial, it might not be the "best way" to do it, but its a way to get started.

I really wish that someone had written this tutorial before me, it would have saved me a few hours and a lot of frustration.

I had to dig through multiple sources (code and otherwise) and test a few generators and client alternatives before reaching a working solution.

💖 💪 🙅 🚩
gtmiyake
Toshio

Posted on April 21, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related