Auto-generation of data sampling functions and all associated typing using Orval
Requirements for fast and high-quality creation of interfaces are growing every day. Therefore, developers are gradually moving away from manually writing code that can be generated automatically. We moved towards automation with a tool like Orval. We'll tell you how it happened, share example code and libraries (follow the links in the text).
Why did we abandon manual data sampling?
Our team’s rule: if routine processes can be successfully automated, we will definitely do so. And let's spend our free time on much higher priority things than writing repeating code from project to project. For example, for additional application optimization.
Most of our projects consist of many CRUDs, and the number of requests can exceed a hundred. Previously, we described data selection queries and all the manual typing associated with them. It could look like this:
const getVacanciesData = async ({
locale,
}: ServiceDefaultParams): Promise> => {
try {
const response: JsonResponse = await get({
url: VACANCIES_ENDPOINT,
headers: { ...getXLangHeader(locale) },
});
return { ok: true, data: response?.data || [] };
} catch (e) {
handleError(e);
return { ok: false, data: undefined };
}
};
export default getVacanciesData;
Previously, we wrote an optimized API for sending requests to an axios-based server. You can find all the code with examples of services based on this API in another our article. By the way, the get method used in the screenshot above belongs to this API.
The main disadvantage, besides time, is the high probability of making mistakes when creating such requests. For example, when setting optionality within types or incorrectly passing the request body. And in the case of auto-generation, the error can ONLY occur on the server side – the code relies on a yaml file created by the backend developer, so responsibility lies solely on one side.
Creating trivial queries on the front literally takes 0 seconds. And the only nuance that we have encountered in all the time we have been using autogeneration is the modification of existing queries. Namely, the creation of a layer in the form of an adapter. But it is not always required.
Thus, using Orval to generate services helps save time and eliminate the possibility of errors on the front-end side.
Why Orval?
Next, we'll look at the most important Orval settings and learn how to integrate auto-generation into our application.
Orval is a tool for generating client code for RESTful APIs based on the OpenAPI specifications. Its official documentation can be found follow the link.
To set up basic Orval behavior, simply create a configuration file in the root of your project. It looks like this – orval.config.js
One of the key configuration parameters is input. In orval.config.js it points to the source of the OpenAPI specification and includes various options for configuring it.
Let's take a closer look at it.
Input
This part of the configuration is responsible for importing and converting the OpenAPI file used.
target – a required parameter that contains the path to the openapi file from which the services will be generated.
validation – parameter responsible for using the linter openapi-validator for openapi, developed by IBM. The default value is false. Includes a standard set of rules, which can be expanded if desired in the .validaterc file.
override.transformer – path to the file importing the transformer function, or the transformer function itself. The function takes as its first parameter OpenAPIObject and should return an object with the same structure.
filters – accepts an object with the tags key, into which you need to pass an array with strings or a regular expression. Filtering will be done by tags if they are in the openapi scheme. If the tags are not found, the generation will return an empty file with a title and version.
Output
This part of the configuration is responsible for setting up the generated code.
workspace – the general path that will be used in subsequent specified paths within output.
target – path to the file that will include the generated code.
client – the name of the data fetch client, or your own function with implementation. (angular, axios, axios-functions, react-query, svelte-query, vue-query, swr, zod, fetch.)
schemas – the path along which the TS types will be generated. (by default, types are generated in the file specified in target)
mode – a way to generate final files.
single – one common file that includes all the generated code.
split – different files for queries and typing
tags – generating your own file for each tag from openapi.
tags-split – generating a directory for each tag in the target folder and dividing it into several files.
Now let's look at the full integration flow and an example of the generated code.
Install orval into the project.
Create a configuration file orval.config.js in the root of the project.
import { defineConfig } from 'orval'
export default defineConfig({
base: {
input: {
target: 'https://your-domen/api.openapi',
validation: true,
},
output: {
target: './path-to-generated-file/schema.ts',
headers: true,
prettier: true,
mode: 'split',
override: {
mutator: {
path: './path-to-your-mutator/fetch.ts',
name: 'customInstance',
},
},
},
},
})
Add a mutator to the project if you need it. You can limit yourself to standard data sampling clients from those offered by Orval itself: Angular, Axios, Axios-functions, React-query, Svelte-query, Vue-query, Swr, Zod, Fetch.
We wrote our own, which is suitable for use in the latest versions of Next.js. Here is his code:
import { getCookie } from 'cookies-next'
import qs from 'qs'
import { AUTH_TOKEN } from '../constants'
import { deleteEmptyKeys } from '../helpers'
import type { BaseRequestParams, ExternalRequestParams } from './typescript'
const API_URL = process.env.NEXT_PUBLIC_API_URL
const validateStatus = (status: number) => status >= 200 && status <= 399
const validateRequest = async (response: Response) => {
try {
const data = await response.json()
if (validateStatus(response.status)) {
return data
} else {
throw { ...data, status: response.status }
}
} catch (error) {
throw error
}
}
export async function customInstance(
{ url, method, data: body, headers, params = {} }: BaseRequestParams,
externalParams?: ExternalRequestParams
): Promise {
const baseUrl = `${API_URL}${url}`
const queryString = qs.stringify(deleteEmptyKeys(params))
const fullUrl = queryString ? `${baseUrl}?${queryString}` : baseUrl
const requestBody = body instanceof FormData ? body : JSON.stringify(body)
const authToken = typeof window !== 'undefined' ? getCookie(AUTH_TOKEN) : null
const requestConfig: RequestInit = {
method,
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
...(authToken && { Authorization: `Bearer ${authToken}` }),
...headers,
...externalParams?.headers,
},
next: {
revalidate: externalParams?.revalidate,
tags: externalParams?.tag ? [externalParams?.tag] : undefined,
},
body: ['POST', 'PUT', 'PATCH'].includes(method) ? requestBody : undefined,
}
try {
const response = await fetch(fullUrl, requestConfig)
return await validateRequest(response)
} catch (error) {
console.error(`Request failed with ${error.status}: ${error.message}`)
throw error
}
}
The generated services look like this:
/**
* @summary Get config for payout
*/
export const getConfigForPayout = (options?: SecondParameter) => {
return customInstance({ url: `/api/payout/config`, method: 'GET' }, options)
}
/**
* Method blocks specified user's balance for payout
* @summary Request payout action
*/
export const requestPayoutAction = (
requestPayoutActionBody: RequestPayoutActionBody,
options?: SecondParameter
) => {
return customInstance(
{
url: `/api/payout/request`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: requestPayoutActionBody,
},
options
)
}
Pay attention to the function customInstance is a mutator to which Orval passes all the necessary data. You can implement this feature as you need. The main thing is to accept the input parameters correctly.
The generated typing looks like this:
export type GetConfigForPayoutResult = NonNullable>>
export type GetConfigForPayout200DataRestrictions = {
max_amount: number
min_amount: number
}
export type GetConfigForPayout200DataAccount = {
created_at: string
id: number
type: string
}
export type GetConfigForPayout200Data = {
account?: GetConfigForPayout200DataAccount
balance: number
restrictions: GetConfigForPayout200DataRestrictions
}
export type GetConfigForPayout200 = {
data?: GetConfigForPayout200Data
}
The OpenAPI specification for these services looks like this:
/api/payout/config:
get:
summary: 'Get config for payout'
operationId: getConfigForPayout
description: ''
parameters: []
responses:
200:
description: ''
content:
application/json:
schema:
type: object
example:
data:
balance: 180068.71618
restrictions:
max_amount: 63012600.110975
min_amount: 22.2679516
account:
id: 20
type: eum
created_at: '1970-01-02T03:46:40.000000Z'
properties:
data:
type: object
properties:
balance:
type: number
example: 180068.71618
restrictions:
type: object
properties:
max_amount:
type: number
example: 63012600.110975
min_amount:
type: number
example: 22.2679516
required:
- max_amount
- min_amount
account:
type: object
properties:
id:
type: integer
example: 20
type:
type: string
example: eum
created_at:
type: string
example: '1970-01-02T03:46:40.000000Z'
required:
- id
- type
- created_at
required:
- balance
- restrictions
tags:
- Payout
/api/payout/request:
post:
summary: 'Request payout action'
operationId: requestPayoutAction
description: "Method blocks specified user's balance for payout"
parameters: []
responses:
200:
description: ''
content:
application/json:
schema:
type: object
example:
data: null
properties:
data:
type: string
example: null
tags:
- Payout
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
type:
type: string
description: ''
example: withdrawal
enum:
- withdrawal
method_id:
type: integer
description: 'Must be at least 1.'
example: 12
amount:
type: number
description: 'Must be at least 0.01. Must not be greater than 99999999.99.'
example: 17
required:
- type
- method_id
- amount
After setting up auto-generation, all we need for convenient use is documentation, where we look at the name of the required service, and then use auto-import.
First, we implemented this configuration in one of our projects and made adjustments based on the problems encountered. Once we were confident that everything was working as planned, we started using Orval to automatically generate data fetch functions on all new projects. You can get to know them Here.
Why do you need auto generation?
Once you configure Orval to suit the realities of your project, you will save a lot of time, which would be better spent on optimization or refactoring.
Be sure to use it for:
Large projects with a large number of endpoints – your front-end developers will get rid of the need to manually write repetitive code and will become not only freer for higher-priority tasks, but also happier;
Teams with multiple developers – Orval generates standardized code, which helps maintain consistency and makes working with the code base easier;
Customization for other projects – the tool can be adapted to the specific needs of the project, including data transformation, endpoint filtering and other settings.
It will also be easier and faster to update the API: when the specification changes, Orval allows you to quickly generate updated functions and types, reducing the risk of outdated or incorrect code appearing in the project.