Автогенерация функций выборки данных и всей сопутствующей типизации с помощью Orval

Требования к быстрому и качественному созданию интерфейсов растут с каждым днем. Поэтому разработчики плавно отходят от написания вручную кода, который может быть сгенерирован автоматически. Мы перешли к автоматизации с таким инструментом, как Orval. Расскажем, как это было, поделимся примером кода и библиотеками (следите за ссылками в тексте).

Почему мы отказались от ручной выборки данных?

Правило нашей команды: если рутинные процессы могут быть успешно автоматизированы, мы обязательно так и сделаем. И потратим свободное время на куда более приоритетные вещи, чем написание повторяющегося кода из проекта в проект. Например, на дополнительную оптимизацию приложений.

Большинство наших проектов состоит из множества CRUD-ов, а количество запросов может превышать сотню. Ранее мы описывали запросы выборки данных и всю относящуюся к ним типизацию вручную. Выглядеть это могло так:

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;

Ранее мы написали оптимизированное API для отправки запросов на сервер на базе axios. Весь код с примерами сервисов на базе данного API вы сможете найти в другой нашей статье. К слову, метод get, используемый на скриншоте выше относится к данному API.

Главный минус, помимо времени: высокая вероятность допустить ошибки при создании подобных запросов. Например, при настройке опциональности внутри типов или неправильной передаче тела запроса. А в случае с автогенерацией, ошибка может быть ТОЛЬКО со стороны сервера — код опирается на yaml-файл, созданный бэкенд-разработчиком, поэтому ответственность лежит исключительно на одной стороне.

Создание тривиальных запросов на фронте буквально занимает 0 секунд.А единственный нюанс, с которым мы столкнулись за все время использования автогенерации, — модифицикация существующих запросов. А именно — создание прослойки в виде адаптора. Но она требуется не всегда.

Так использование Orval для генерации сервисов помогает сэкономить время и исключить вероятность возникновения ошибок на стороне фронтенда.

Почему Orval?

Далее мы рассмотрим самые важные настройки Orval и узнаем, как интегрировать автогенерацию в наше приложение.

Orval — это инструмент для генерации клиентского кода для RESTful API на основе OpenAPI-спецификаций. С его официальной документацией можно ознакомиться по ссылке.

Для базовой настройки поведения Orval достаточно просто создать конфигурационный файл в корне проекта. Выглядит он так — orval.config.js

Один из ключевых параметров конфигурации — это input. В orval.config.js он указывает на источник спецификации OpenAPI и включает различные опции для его настройки.

Давайте рассмотрим его подробнее.

Input

Данная часть конфигурации отвечает за импортирование и преобразование используемого OpenAPI-файла.

target — обязательный параметр, который содержит путь до openapi файла, из которого будут сгенерированы сервисы.

validation — параметр, отвечающий за использование линтера openapi-validator для openapi, разработанного IBM. По-умолчанию имеет значение false. Включает в себя стандартный набор правил, по желанию их можно расширить в .validaterc файле.

override.transformer — путь до файла, импортирующего функцию-трансформер, либо же сама функция-трансформер. Функция принимает первым параметром OpenAPIObject и должна возвращать объект с такой же структурой.

filters — принимает в себя объект с ключом tags, в который необходимо передать массив со строками, либо регулярным выражением. Будет произведена фильтрация по тегам, если они есть в openapi схеме. В случае если теги не найдены — генерация вернет пустой файл с заголовком и версией.

Output

Данная часть конфигурации отвечает за настройку генерируемого кода.

workspace — общий путь, который будет использоваться в последующих заданных путях внутри output.

target — путь к файлу, который будет включать сгенерированный код.

client — название клиента выборки данных, либо ваша собственная функция с реализацией.(angular, axios, axios-functions, react-query, svelte-query, vue-query, swr, zod, fetch.)

schemas — путь, по которому будут сгенерированы типы TS. (по-умолчанию типы генерируются в файле, указанном в target)

mode — способ генерации конечных файлов.

  • single — один общий файл, который включает в себя весь сгенерированный код.

  • split — разные файлы для запросов и типизации

  • tags — генерация собственного файла для каждого тега из openapi.

  • tags-split — генерация директории для каждого тега в целевой папке и разделение ее на несколько файлов.

Теперь рассмотрим полный флоу интеграции и пример сгенерированного кода.

Устанавливаем orval в проект.

Создаём конфигурационный файл orval.config.js в корне проекта.

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',
       },
     },
   },
 },
})

Добавляем в проект мутатор, если он вам необходим. Вы можете ограничиться стандартными клиентом выборки данных из числа предлагаемых самим Orval: Angular, Axios, Axios-functions, React-query, Svelte-query, Vue-query, Swr, Zod, Fetch.

Мы же написали свой собственный, который подходит для использования в последних версиях Next.js. Вот его код:

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
 }
}

Сгенерированные сервисы выглядят так:

/**
* @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
 )
}

Обратите внимание на функцию customInstance — это мутатор, в который Orval передаёт все необходимые данные. Вы можете реализовать эту функцию, как вам нужно. Главное правильно принять входные параметры.

Сгенерированная типизация выглядит так:

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
}

OpenAPI спецификация для данных сервисов выглядит так:

/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

После настройки автогенерации всё, что нам необходимо для удобного использования, — документация, где мы смотрим наименование необходимого сервиса, а затем используем автоимпорт.

66caba63e126f6cf213ecb614370f69c.png

Сперва мы внедрили данную конфигурацию в один из наших проектов и вносили корректировки, опираясь на полученные проблемы. Когда мы убедились, что все работает, как и планировалось, то начали использовать Orval для автоматической генерации функций выборки данных на всех новых проектах. Познакомиться с ними можно здесь.

Почему вам нужна автогенерация?

Один раз настроив Orval под реалии своего проекта, вы сэкономите кучу времени, которое лучше потратить на оптимизацию или рефакторинг.

Обязательно используйте его для:

  • Крупных проектов с большим количеством эндпоинтов — ваши фронтендеры избавятся от необходимости вручную писать повторяющийся код и станут не только свободнее для более приоритетных задач, но и счастливее;

  • Команд, в которых работает сразу несколько разработчиков — Orval генерирует стандартизированный код, что помогает поддерживать единообразие и упрощает работу с кодовой базой;

  • Кастомизации под другие проекты — инструмент можно адаптировать под конкретные нужды проекта, включая трансформацию данных, фильтрацию эндпоинтов и другие настройки.

Обновлять API также станет проще и быстрее: когда спецификация меняется, Orval позволяет быстро сгенерировать обновленные функции и типы, сокращая риск появления устаревшего или некорректного кода в проекте.

© Habrahabr.ru