How to generate an OpenAPI document with ElysiaJS
This guide walks you through generating an OpenAPI document for an ElysiaJS (opens in a new tab) API and using Speakeasy to create an SDK based on the generated document.
Here’s what we’ll do:
- Add a Swagger endpoint, which uses Scalar UI, to an Elysia Bun app using the Elysia Swagger plugin.
- Improve the OpenAPI document to prepare it for code generation.
- Convert the JSON OpenAPI document to YAML.
- Use the Speakeasy CLI to generate an SDK based on the OpenAPI document.
- Add a Speakeasy OpenAPI extension to improve the generated SDK.
We’ll also take a look at how you can use the generated SDK.
Your Elysia project might not be as simple as our example app, but the steps below should translate well to any Elysia project.
The OpenAPI generation pipeline
The Elysia Swagger plugin (opens in a new tab) generates a Swagger API documentation endpoint. By default, Elysia uses the OpenAPI Specification and Scalar UI (opens in a new tab), an open-source interactive document UI for OpenAPI.
We’ll first add the Swagger plugin to an existing Elysia app.
Then, we’ll improve the plugin-generated OpenAPI document according to Speakeasy best practices (opens in a new tab). The quality of an OpenAPI document determines the quality of the SDKs and documentation it’s used to create.
Next, we’ll use Speakeasy to generate an SDK based on the OpenAPI document.
Finally, we’ll use a simplified example to demonstrate how to use the generated SDK and how to add SDK creation to a CI/CD pipeline so that Speakeasy automatically generates fresh SDKs whenever your Elysia API changes in the future.
Requirements
This guide assumes that you have an existing Elysia app and basic familiarity with Elysia.
Example repository
If you don’t have an Elysia app or if you want to follow the guide step by step, you can clone the Speakeasy ElysiaJS example repo (opens in a new tab) to access the example code used in this tutorial. The initial-app
branch contains the initial state of the app that we’ll use to start this tutorial.
The following should be installed on your machine:
- Bun (opens in a new tab): The Node.js alternative that Elysia is built on.
- Speakeasy CLI (opens in a new tab): The tool you’ll use to generate an SDK from the OpenAPI document.
Adding the Swagger plugin to an Elysia project
The Elysia Swagger plugin automatically generates an API documentation page for your server.
First, install the Swagger plugin:
bun add @elysiajs/swagger
Import the plugin, then register it by passing in an instance of swagger
to the use()
method and chaining the use()
method to the Elysia
instance:
import { Elysia } from 'elysia'import { users } from './controllers/users';import { swagger } from '@elysiajs/swagger'const app = new Elysia().onError(({ error, code }) => {console.log({code})if (code === 'NOT_FOUND') return 'Not Found :('if (code === 'VALIDATION') return 'Invalid user'console.error(error);}).use(users).use(swagger()).listen(3000)
In Elysia, a plugin (opens in a new tab) is a reusable component. In fact, everything in Elysia is a component, including Elysia instances, plugins, routers, stores, and more. Components split apps into small pieces, making it easier to add, remove, or modify app features. It’s important that we use method chaining (opens in a new tab) for type inference in our Elysia code. In the above code block, we use the onError
lifecycle method to catch any error that’s thrown on the server.
Run the Bun development server with bun run dev
and open http://localhost:3000/swagger
to see the Scalar UI with five API endpoints:
The API routes are listed in the navigation pane on the left. Click /users/
GET to navigate to the section for the /users/
GET request API endpoint:
Each section shows information about an API endpoint, such as its path parameters, body, and responses.
The code block on the right shows an example curl request. Click the Shell cURL dropdown menu to change the language or library used in the example request:
Click the Test Request button to open an API client that lets you test your API endpoints. Then, click Send to test the request:
In the response, you should get an array containing one user.
The user data in the Elysia server is stored temporarily in a singleton Users
class :
class Users {constructor(public data: UserInfo[] = [{id: "1",name: "Alice",age: 20}]) {}
This class is added to the Elysia
instance using the
decorate
(opens in a new tab) method:
export const users = new Elysia({ prefix: '/users' }).decorate('users', new Users())
This adds the Users
class to the context (opens in a new tab) that contains information for each request. You can access the context in route handlers.
Viewing the OpenAPI document and modifying its root object
Open http://localhost:3000/swagger/json
to view the OpenAPI document in JSON format:
{"openapi": "3.0.3","info": {"title": "Elysia Documentation","description": "Development documentation","version": "0.0.0"},"paths": {"/users/": {"get": {"operationId": "getUsers","responses": {"200": {}}},"post": {// ...
Add the following TypeScript configuration option to your tsconfig.json
file to enable JSON file imports:
"resolveJsonModule": true,
Add the following configuration object to the swagger
plugin:
import { Elysia } from 'elysia';import { users } from './controllers/users';import { swagger } from '@elysiajs/swagger';import packageJson from '../package.json';const app = new Elysia().onError(({ error, code }) => {if (code === 'NOT_FOUND') return 'Not Found :(';if (code === 'VALIDATION') return 'Invalid user';console.error(error);}).use(users).use(swagger({documentation: {info: {title: 'Users app documentation',version: packageJson.version,},externalDocs: {description: 'Find out more about the Users API',url: 'www.example.com',},}})).listen(3000)
This configures the root document object (opens in a new tab) of the OpenAPI document.
The info
object is a required property used to add metadata about the API. The externalDocs
object lets you extend your documentation by referencing an external resource.
Note that the API operation for each path has an operationId
value that’s named by combining the HTTP request type and the name of the path. The value can be modified using the detail
(opens in a new tab) field in a route; however, we won’t modify it in this guide, as Elysia produces consistently named, human-readable operationId
values.
The operationId
is the identifier for an operation. It is case sensitive and must be unique within the document. The Speakeasy SDK, which we’ll use later in this guide, uses it during code generation to name the method it generates for the operation.
OpenAPI Specification versions supported by Elysia and Speakeasy
Speakeasy currently supports the OpenAPI Specification versions 3.0.x and 3.1.x and recommends you use version 3.1, as it’s fully compatible with JSON Schema (opens in a new tab), which gives you access to a large ecosystem of tools and libraries.
However, we use OpenAPI Specification version 3.0.3 in this guide, as it is the version Elysia supports.
To check which version you are using, open http://localhost:3000/swagger/json
and see the OpenAPI Specification version in the root document object.
Adding example data to a data model and the POST request body
The API routes in the Scalar UI don’t have example values for the path parameters, requests, or responses. The user
model doesn’t have example values either. It’s important to add examples to make your API more user-friendly.
Let’s start by adding example values to the userInfo
model:
const userInfo = t.Object({id: t.String({example: '1'}),name: t.String({example: 'Alice'}),age: t.Number({example: 20})}, {title: 'User',description: 'User object',example:{id: "1",name: "Alice",age: 20}});
The Elysia schema builder, t
, gives compile-time and runtime type safety. It also registers the model as a reusable OpenAPI Components Object (opens in a new tab) schema, which you can see at the bottom of your OpenAPI document:
"components": {"schemas": {"user": {"example": {"id": "1","name": "Alice","age": 20},"type": "object","properties": {"id": {"example": "1","type": "string"},"name": {"example": "Alice","type": "string"},"age": {"example": 20,"type": "number"}},"required": ["id","name","age"]}}}
You’ll also see the example values in the user
model in the Scalar UI:
The userInfo
schema is used in the post()
and patch()
routes. Elysia HTTP request methods accept three arguments: the path, the function used to respond to the client, and the hook used to define extra metadata. Add an example to the body
in the hook object of the post()
route:
body:t.Omit(userInfo, ['id'],{example: {name: "Alice",age: 20}}),
If you look at your OpenAPI document now, you’ll see that the content
of the POST request body has three possible types: application/json
, multipart/form-data
, or text/plain
. To limit it to application/json
, set the type
in the hook object of the post()
route:
.post('/',({ users, body: user }) =>users.add(user),{body:t.Omit(userInfo, ['id'],{example: {name: "Alice",age: 20}}),type: 'json',
Adding extra information to a route using the detail field
The detail
(opens in a new tab) field is used to define a route for the OpenAPI document. It extends the OpenAPI Operation Object (opens in a new tab), which describes an API operation within a path.
Add the following detail
field to the hook object of the post()
route:
type: 'json',detail: {summary: 'Create user',description: 'Add user to the database',},
Add the following responses
property to the detail
object:
responses: {200: {description: 'The created users assigned id',content: {'application/json': {schema: {$ref: '#/components/schemas/id',},examples: {"Created user": {value: {id: "1",name: "Alice",age: 20}}}}},},},
The Responses Object (opens in a new tab) is used to list the possible responses returned from the POST request. There is one possible response listed - a successful response. This response has a schema
(opens in a new tab) that defines the content of the response. The id
schema is referenced using $ref
(opens in a new tab), the reference identifier that specifies the URI location of the value being referenced. Let’s define this id
model.
Add the following idObject
model to the users.ts
file, below the userInfo
model:
const idObject = t.Object({id: t.String({example: '1'})}, {title: 'ID object',description: 'ID object',example:{id: "1"}});
Create a reference model (opens in a new tab) for the model:
export const users = new Elysia({ prefix: '/users' }).decorate('users', new Users()).model({user: userInfo,id: idObject,})
A reference model lets us reuse a model by referencing its name.
It’s also good practice to add possible error responses. Add the following 500
response to the responses
property:
500: {description: 'Server error',content: {'application/json': {schema: {$ref: '#/components/schemas/errorResponse'},examples: {"Server error": {value: {message: 'There was an error',status: 500}}}}}}
Add the definition for the errorResponse
model below the idObject
model:
const errorResponse = t.Object({status: t.Number({example: 404}),message: t.String({example: 'User not found :('})}, {title: 'Error response',description: 'Error response object',example:{status: 404,message: 'User not found :('}});
Create a reference model for the errorResponse
model:
export const users = new Elysia({ prefix: '/users' }).decorate('users', new Users()).model({user: userInfo,id: idObject,errorResponse: errorResponse,})
You’ll now see example responses for the Create user POST route in Scalar:
Adding OpenAPI tags to routes
We recommend adding tags to all your Elysia routes. This allows you to group the routes according to tag in the generated SDK code and documentation.
Adding OpenAPI tags to routes in Elysia
To add OpenAPI tags to a route, use the tags
property to pass in an array of tags in the hook object of the post()
route:
tags: ['Users']
Adding tags to the root OpenAPI document object and adding metadata to tags
Add the following tags
array to the configuration object of the swagger
plugin:
.use(swagger({documentation: {info: {title: 'Users app documentation',version: packageJson.version,},externalDocs: {description: 'Find out more about the Users API',url: 'www.example.com',},tags: [{name: 'Users',description: 'Users operations',externalDocs: {description: 'Find more info here',url: 'https://example.com',},}],}}))
This adds a tags
array to the root OpenAPI document object. In the above code, we add metadata to the tag by passing in a Tag Object (opens in a new tab) (instead of a string) to the tag array item.
After adding tags to your routes, you’ll see that they are organized by tags in Scalar:
Adding example data, extra information, and tags to the other API routes
Let’s improve the other API route operations like we improved the Create user route.
Replace the Get all users route with the following lines of code:
.get('/', ({ users }) => users.data,{detail: {summary: 'Get all users',description: 'Get all users from the database',responses: {200: {description: 'The array of users',content: {'application/json': {schema: {type: 'array',items: {$ref: '#/components/schemas/user'},},examples: {basic: {value: [{id: "1",name: "Alice",age: 20},{id: "2",name: "Bob",age: 25}]}}}},},500: {description: 'Server error',content: {'application/json': {schema: {$ref: '#/components/schemas/errorResponse'},examples: {"Server error": {value: {message: 'There was an error',status: 500}}}}}}},tags: ['Users']},})
Replace the Get user route with the following lines of code:
.get('/:id',({ users, params: { id }, error }) => {return users.data.find(user => user.id === id) ?? error(404, 'User not found :(')},{params: 'id',detail: {summary: 'Get user',description: 'Get user by id from the database',responses: {200: {description: 'The user object',content: {'application/json': {schema: {$ref: '#/components/schemas/user',},examples: {basic: {value:{id: "1",name: "Alice",age: 20}}}}},},404: {description: 'User not found',content: {'application/json': {schema: {$ref: '#/components/schemas/errorResponse'},examples: {"User not found": {value: {message: 'User not found :(',status: 404}}}}}}},tags: ['Users']},},)
Replace the Delete user route with the following lines of code:
.delete('/:id', ({ users, params: { id }, error }) => {return users.remove(id) ?? error(422, 'Invalid user')},{params: 'id',detail: {summary: 'Delete user',description: 'Delete user by id from the database',responses: {200: {description: 'Deleting user was successful',content: {'application/json': {schema: {$ref: '#/components/schemas/successResponse'},examples: {success: {value: {success: true}}}}},},422: {description: 'Invalid user',content: {'application/json': {schema: {$ref: '#/components/schemas/errorResponse'},examples: {"Invalid user": {value: {message: 'Invalid user',status: 422}}}}}}},tags: ['Users']},})
Add the definition for the successResponse
model below the errorResponse
model:
const successResponse = t.Object({success: t.Boolean({example: true})}, {title: 'Success response',description: 'Success response object',example:{success: true}});
Create a reference model for the successResponse
model:
export const users = new Elysia({ prefix: '/users' }).decorate('users', new Users()).model({user: userInfo,id: idObject,errorResponse: errorResponse,successResponse: successResponse})
Replace the Update user route with the following lines of code:
.patch('/:id',({ users, params: { id }, body: user, error }) => {return users.update(id, user) ?? error(422, 'Invalid user')},{params: 'id',body:t.Partial(t.Omit(userInfo, ['id'],{example: {age: 21}}),),type: 'json',detail: {summary: 'Update user',description: 'Update user by id from the database',responses: {200: {description: 'Update was successful',content: {'application/json': {schema: {$ref: '#/components/schemas/successResponse'},examples: {Success: {value: {success: true}}}}},},422: {description: 'Invalid user',content: {'application/json': {schema: {$ref: '#/components/schemas/errorResponse'},examples: {"Invalid user": {value: {message: 'Invalid user',status: 422}}}}}}},tags: ['Users']},})
Adding a list of servers to the Elysia OpenAPI document
When validating an OpenAPI document, Speakeasy expects a list of servers (opens in a new tab) at the root of the document.
Add a server by adding a servers
property to the configuration object of the swagger
plugin:
.use(swagger({documentation: {info: {title: 'Users app documentation',version: packageJson.version,},externalDocs: {description: 'Find out more about the Users API',url: 'www.example.com',},servers: [{url: 'http://localhost:3000/',description: 'Development server',},],
You can add multiple servers
to define different environments or versions. This is useful for separating production and testing environments.
Adding retries to your SDK with x-speakeasy-retries
OpenAPI document extensions (opens in a new tab) allow us to add vendor-specific functionality to an OpenAPI document.
- Extension fields must be prefixed with
x-
. - Speakeasy uses extensions that start with
x-speakeasy-
.
Speakeasy gives you fine-tuned control over the Speakeasy SDK via its range of Speakeasy extensions (opens in a new tab), which you can use to modify retries, pagination, error handling, and other advanced SDK features.
Let’s add a Speakeasy extension that adds retries to requests from Speakeasy SDKs by adding a top-level x-speakeasy-retries
schema to the OpenAPI document. We can also override the retry strategy per operation.
Adding global retries
Apply the Speakeasy retries extension globally by adding the following 'x-speakeasy-retries'
property to the configuration object of the swagger
plugin:
servers: [{url: 'http://localhost:3000/',description: 'Development server',},],'x-speakeasy-retries': {strategy: 'backoff',backoff: {initialInterval: 500,maxInterval: 60000,maxElapsedTime: 3600000,exponent: 1.5,},statusCodes: ['5XX'],retryConnectionErrors: true,},
Adding retries per method
You can create a unique retry strategy for a single route by adding a 'x-speakeasy-retries'
property to the route’s hook object:
'x-speakeasy-retries': {strategy: 'backoff',backoff: {initialInterval: 300,maxInterval: 40000,maxElapsedTime: 3000000,exponent: 1.2,},statusCodes: ['5XX'],retryConnectionErrors: true,},tags: ['Users']},}).delete('/:id', ({ users, params: { id }, error }) => {
Creating an SDK based on your OpenAPI document
Before creating an SDK, we need to save the Elysia Swagger plugin-generated OpenAPI document to a file. OpenAPI files are written as JSON or YAML; we’ll save it as a YAML file, as it’s easier to read.
Saving the OpenAPI document to a YAML file using a Bun script
Let’s create a script that uses the JS-YAML (opens in a new tab) library to convert the JSON OpenAPI document to a YAML string.
Install the library and its types:
bun add js-yaml @types/js-yaml
Create a script called generateOpenAPIYamlFile.ts
in the src
folder and add the following lines of code to it:
import * as yaml from 'js-yaml';async function generateOpenAPIYaml() {try {const response = await fetch('http://localhost:3000/swagger/json');const openAPIObject = await response.json();// Convert to YAMLconst yamlString = yaml.dump(openAPIObject);// Save the YAML string to a fileawait Bun.write('openapi.yaml', yamlString);console.log('OpenAPI document saved to openapi.yaml');} catch (error) {console.error('Error generating OpenAPI document:', error);}}generateOpenAPIYaml();
This script fetches the JSON OpenAPI document from the Scalar OpenAPI document endpoint, converts it to a YAML string, and then saves it as a file.
Add the following script to your package.json
file:
"generate:openapi": "bun run src/generateOpenAPIYamlFile.ts"
Run the development server and then run the generate:openapi
script using the following command:
bun run generate:openapi
This generates an openapi.yaml
file in your root folder.
Linting the OpenAPI document with Speakeasy
The Speakeasy CLI has an OpenAPI linting (opens in a new tab) command that checks the OpenAPI document for errors and style issues.
Run the linting command:
speakeasy lint openapi --schema ./openapi.yaml
A lint report will be displayed in the terminal, showing errors, warnings, and hints:
The Speakeasy linter uses the speakeasy-recommended
(opens in a new tab) ruleset by default, but you can configure (opens in a new tab) a custom ruleset.
Speakeasy also has a VS Code extension (opens in a new tab), which you can use to help you validate your OpenAPI documents for the creation of production-grade SDKs.
Creating an SDK from the Speakeasy CLI
We’ll use the quickstart
(opens in a new tab) command for a guided SDK setup.
Run the command using the Speakeasy CLI:
speakeasy quickstart
Following the prompts, provide the OpenAPI document location, name the SDK SDK
, and select TypeScript
as the SDK language.
In the terminal, you’ll see the steps taken by Speakeasy to generate the SDK.
│ Workflow - success│ └─Target: sdk - success│ └─Source: SDK -OAS - success│ └─Validating Document - success│ └─Diagnosing OpenAPI - success│ └─Tracking OpenAPI Changes - success│ └─Snapshotting OpenAPI Revision - success│ └─Storing OpenAPI Revision - success│ └─Validating gen.yaml - success│ └─Generating Typescript SDK - success│ └─Setup Environment - success│ └─Load and Validate Document - success│ └─Generate SDK - success│ └─Compile SDK - success
Speakeasy validates (opens in a new tab) the OpenAPI document to check that it’s ready for code generation. Validation issues will be printed in the terminal. The generated SDK is saved as a folder in your project.
If you get ESLint styling errors, run the speakeasy quickstart
command from outside your project.
Adding SDK generation to your GitHub Actions
The Speakeasy sdk-generation-action
(opens in a new tab) repository provides workflows that integrate the Speakeasy CLI into CI/CD pipelines and automatically regenerate client SDKs when the reference OpenAPI document changes.
You can configure Speakeasy to push a new branch to your SDK repositories automatically when the OpenAPI document changes, allowing your engineers to review and merge the SDK changes.
The Speakeasy workflow matrix (opens in a new tab) provides an overview of how to set up automatic SDK generation.
Using your SDK
Once you’ve generated your SDK, you can publish (opens in a new tab) it for use. TypeScript SDKs are published as npm packages.
A quick, non-production-ready way to see your SDK in action is to copy your SDK folder to a frontend TypeScript project and use it there.
For example, you can create a Vite project that uses TypeScript:
npm create vite@latest
Then, copy the SDK folder from your Elysia app to the src
directory of your TypeScript Vite project and delete the SDK folder in your Elysia project.
In the SDK README.md
file, you’ll find the documentation for your Speakeasy SDK.
Note that the SDK is not ready for production use. To get it production-ready, follow the steps outlined in your Speakeasy workspace.
The SDK has Zod as a peer dependency, as can be seen in the sdk-typescript/package.json
file.
Install the required Zod version:
npm i zod
Replace the code in the src/main.ts
file with the following example code taken from the sdk-typescript/docs/sdk/users/README.md
file:
import { SDK } from './sdk-typescript/src/'; // Adjust the path as necessary e.g. if your generated SDK has a different nameconst sdk = new SDK();async function run() {const result = await sdk.users.getUsers();// Handle the resultconsole.log({ result });}run();
Make sure the Elysia server is running, then run the Vite dev server:
npm run dev
Now, you need to enable CORS in your Elysia dev server.
First, install the CORS plugin:
bun add @elysiajs/cors
Import the CORS plugin, then register it by passing the plugin into the use()
method and chaining the use()
method to the Elysia
instance:
import { cors } from '@elysiajs/cors';const app = new Elysia().onError(({ error, code }) => {if (code === 'NOT_FOUND') return 'Not Found :('console.error(error);}).use(cors({origin: 'http://localhost:5173'}))
Open http://localhost:5173
in your browser, then open your browser dev tools. You should see the following logged in the dev tools console:
{"result": [{"id": "1","name": "Alice","age": 20}]}
The SDK functions are type safe and include TypeScript autocompletion for arguments and outputs.
Consider the following example scenario:
const userOne = result[0].email;
When you try to access a property that doesn’t exist, as in the code block above, you get a TypeScript error:
Property 'email' does not exist on type 'User'.
Further reading
This guide covered the basics of generating an OpenAPI document using Elysia. Here are some resources to help you learn more about OpenAPI, the Elysia Swagger plugin, and Speakeasy:
- Elysia Swagger plugin (opens in a new tab): Learn more about using Elysia to generate OpenAPI documents. Elysia has first-class support for OpenAPI and follows the OpenAPI Specification by default.
- Speakeasy documentation (opens in a new tab): Speakeasy has extensive documentation covering how to generate SDKs from OpenAPI documents, customize SDKs, and more.
- Speakeasy OpenAPI reference (opens in a new tab): Review a detailed reference on the OpenAPI Specification.