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:
- Given the dynamic nature of my project, those definitions are changing very frequently
- The provided specification version may not be compatible with the generator
- The provided specification is slight off for its intended use
In order to solve those issues:
-
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)
-
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)
-
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)
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"})
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")
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"})
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
}}
})
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
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"})
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()
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()
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.
Top comments (1)
What were the generators and client alternatives that you've considered by ultimately dismissed?
Btw, I don't know if that is new or not, but
openapi-typescript
has an API: openapi-ts.dev/node