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.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *