Autogeneration of data fetching functions and typing with Orval

Contents
The need for fast, high quality interface creation is increasing every day. As a result, developers are moving away from manually writing code that can be generated automatically. We have moved towards automation with a tool like Orval. We'll tell you how we did it, and share sample code and libraries (follow the links in the text).

Why we have abandoned manual data fetching

Our team philosophy is that if routine processes can be successfully automated, we will do it. And we will spend our free time on much higher priority things than writing repetitive code from project to project. For example, additional application optimisation.
Most of our projects consist of multiple CRUDs, and the number of queries can exceed a hundred. In the past, we used to manually describe data fetching queries and all the typing involved. 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;
Earlier we wrote an optimized API for sending requests to axios-based servers. You can find all the code with examples of services based on this API in our other article. By the way, the get method used in the screenshot above is part of this API.
Basic REST API for sending requests to the server
using Axios
The main disadvantage, apart from wasting time, is the high probability of making mistakes when creating such queries. For example, when setting optionality within types, or incorrectly passing the request body. And in the case of autogeneration, the error can ONLY be on the server side – the code relies on the yaml file created by the backend developer, so the responsibility is on one side.
Creating trivial queries on the front-end takes literally 0 seconds. And the only nuance we encountered during the whole time we were using autogeneration was the modification of existing queries. Namely, the creation of a layer in the form of an adapter. But this is not always necessary.
Using Orval for service generation therefore helps to save time and eliminate the possibility of errors on the front end.

Why is Orval

Next, let's look at the most important Orval settings and learn how to integrate autogeneration into our application.
Orval is a tool for generating client code for RESTful APIs based on OpenAPI specifications. Its official documentation can be found here.
For the basic configuration of Orval's behavior, it is enough to create a configuration file in the root of the project. It looks like this – orval.config.js
One of the most important configuration parameters is input. In orval.config.js it points to the source of the OpenAPI specification and contains various configuration options.
Let's have a look at it.

Input

This part of the configuration is responsible for importing and converting the OpenAPI file used.
target - required parameter that specifies the path to the OpenAPI file from which services are generated;
validation - parameter responsible for using the openapi-validator lint developed by IBM for OpenAPI. The default value is false. Includes a default set of rules which can be optionally extended in the .validaterc file;
override.transformer - OpenAPIObject as its first parameter and must return an object with the same structure;
filters - accepts an object with the tags key, to which an array of strings or a regular expression should be passed. If there are tags in the OpenAPI schema, it will filter by tags. If no tags are found, generation will return an empty file with title and version.

Output

This part of the configuration is responsible for setting up the generated code.
workspace - common path that will be used in subsequent specified paths within the output;
target - path to file containing 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 - path where TS types are generated (by default, types are generated in the file specified in target);
mode - a method of generating final files that includes the following options
  • single - a common file containing all generated code;
  • split -different files for querying and typing;
  • tags - the generation of a separate file for each tag from the OpenAPI;
  • tags-split - the generation of a directory for each tag in the destination folder and splitting of it into several files.
Now let's look at the whole integration path and an example of the generated code.
1
Install Orval in the project:
  • yarn add -D orval or npm i –save-dev orval – depending on the package manager you use
2
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',
       },
     },
   },
 },
})
3
Add a mutator to the project if you need to use one. You can use the standard data sampling client among 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 its 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
 )
}
Note the customInstance function – this is a mutator to which Orval passes all the necessary data. You can implement this function however you need. The main thing is to accept the input parameters correctly.
The generated typing will look something 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
Once autogeneration is set up, all we need for usability is documentation where we look up the name of the service we need and then use auto-import.
We first implemented this configuration in one of our projects and made adjustments based on the problems we encountered, then when we were sure that everything was working as planned, we started to extend this configuration to our other projects. We now use Orval on all new projects to automatically generate data sampling functions. Examples provided here.

Why do you need autogeneration

Once you have adapted Orval to the realities of your project, you will save a lot of time that could be better spent on optimisation or refactoring.
It's necessary for use in:
  • Large projects with lots of endpoints – your front-end users will no longer have to manually write repetitive code, leaving them not only freer to work on higher priority tasks, but also happier;
  • Multi-developer teams – Orval generates standardized code, which helps maintain consistency and makes it easier to work with the code base;
  • Customisation for other projects – the tool can be customized to meet specific project requirements, including data transformation, endpoint filtering and other customisations.
Updating the API is also easier and faster: when the specification changes, Orval allows you to quickly generate updated functions and types, reducing the risk of outdated or incorrect code in your project.