В нашей компании в процессе разработки frontend‑приложений мы иногда сталкиваемся с одной из следующих ситуаций (или с обеими сразу):
Когда мы имеем достаточно объемную и часто меняющуюся спецификацию API. Тут нам поможет генерация кода на основе этой спецификации.
Когда нам нужно работать с функционалом, отвечающим за обработку обращений к различным эндпойнтам, но сами запросы не работают по каким‑либо причинам. Эту проблему можно решить подстановкой в соответствующих местах mock‑объектов.
В данной статье мы поговорим и о генерации клиентского кода на основе спецификации OpenAPI, и о мокировании запросов.
Что конкретно мы будем делать:
научимся генерировать клиент API для Axios и TypeScript на основе спецификации;
взглянем на типизацию на основе спецификации;
настроим Mock Service Worker (MSW) для перехвата запросов;
поработаем с библиотеками Faker и Source для генерации данных.
Когда это нужно
Для начала рассмотрим некоторые случаи, когда подход и инструменты, о которых пойдет речь ниже, могут нам пригодиться.
Перехват запросов воркером поможет если сервер по каким‑либо причинам не доступен. Это могут быть сбои в работе сети, отключение непосредственно сервера или простой во время профилактики.
Бывают кейсы, когда контракт уже есть, но новый функционал сервера еще не развернут на стенде.
Нам нередко нужно проверить интерфейс на прочность, а данных для этого не хватает. Можно добавить данные вручную, но это может вылиться в нерациональную трату времени.
При написании тестов часто проще опираться на заранее подготовленные данные, а также на заглушки запросов.
Мокирование данных может расширить возможности различных инструментов, таких как, например, Storybook.
И это не все сценарии. В конце концов, мы всегда можем решить провести какой‑нибудь эксперимент для проверки нашей теории, а мокирование API избавляет от необходимости готовить для этого backend. Достаточно дописать несколько строк в спецификацию.
Если сервер для разработки разворачивается на нашей машине или, например, запускается в контейнере, некоторые приемы из данной статьи могут быть неактуальны, так что все ситуативно.
Проект и контракт для примера
В качестве тестового проекта будем использовать базовый проект Vue 3, из которого мы удалим все компоненты кроме App.vue
. Все фрагменты кода будут приведены в слегка упрощенном виде для наглядности. Расположение файлов не будет иметь никакого принципиального значения. В реальных же проектах всегда следует отталкиваться от принятой файловой структуры и руководствоваться архитектурными решениями, и если вы будете пробовать что‑то у себя на проекте, не забудьте проверить пути к файлам.
Описанные в статье инструменты являются достаточно универсальными. Я, например, применяю их на проектах Vue и React, а клиент нужен не только под Axios, но иногда и под нативный
fetch
. Принципы интеграции не меняются.
Разбираться с генерацией и мокированием будем на примере небольшой спецификации, которая описывает операции CRUD для сервиса управления списком пользователей. Спецификация приведена в формате JSON, потому что для одного из инструментов мокирования, который мы будем рассматривать, требуется именно такой (а для генерации можно брать файл и в формате YAML). Файл spec.json мы положим в папку openapi в корне проекта.
Код спецификации
{
"openapi": "3.0.4",
"info": {
"title": "User Management Service",
"version": "v1"
},
"servers": [
{
"url": "https://127.0.0.1:20000/api"
}
],
"paths": {
"/v1/users": {
"get": {
"tags": ["Users"],
"operationId": "getAllUsers",
"summary": "Получение списка всех пользователей",
"responses": {
"200": {
"description": "Список пользователей",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/User"
}
}
}
}
},
"401": {
"$ref": "#/components/responses/401"
},
"403": {
"$ref": "#/components/responses/403"
},
"404": {
"$ref": "#/components/responses/404"
}
}
},
"post": {
"tags": ["Users"],
"operationId": "createUser",
"summary": "Создание пользователя",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UserRequest"
}
}
}
},
"responses": {
"201": {
"$ref": "#/components/responses/201"
},
"401": {
"$ref": "#/components/responses/401"
},
"403": {
"$ref": "#/components/responses/403"
},
"404": {
"$ref": "#/components/responses/404"
}
}
}
},
"/v1/users/{id}": {
"get": {
"tags": ["Users"],
"operationId": "getUser",
"summary": "Получение пользователя",
"parameters": [
{
"$ref": "#/components/parameters/Id"
}
],
"responses": {
"200": {
"$ref": "#/components/responses/200"
},
"401": {
"$ref": "#/components/responses/401"
},
"403": {
"$ref": "#/components/responses/403"
},
"404": {
"$ref": "#/components/responses/404"
}
}
},
"put": {
"tags": ["Users"],
"operationId": "updateUser",
"summary": "Изменение пользователя",
"parameters": [
{
"$ref": "#/components/parameters/Id"
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UserRequest"
}
}
}
},
"responses": {
"200": {
"$ref": "#/components/responses/200"
},
"401": {
"$ref": "#/components/responses/401"
},
"403": {
"$ref": "#/components/responses/403"
},
"404": {
"$ref": "#/components/responses/404"
}
}
},
"delete": {
"tags": ["Users"],
"operationId": "deleteUser",
"summary": "Удаление пользователя",
"parameters": [
{
"$ref": "#/components/parameters/Id"
}
],
"responses": {
"204": {
"description": "Пользователь удален"
},
"401": {
"$ref": "#/components/responses/401"
},
"403": {
"$ref": "#/components/responses/403"
},
"404": {
"$ref": "#/components/responses/404"
}
}
}
}
},
"tags": [
{
"name": "Users",
"description": "Управление списком пользователей"
}
],
"components": {
"parameters": {
"Id": {
"name": "id",
"in": "path",
"required": true,
"schema": {
"$ref": "#/components/schemas/Id"
}
}
},
"schemas": {
"Id": {
"type": "string",
"description": "Идентификатор"
},
"User": {
"type": "object",
"required": ["userId", "login", "status"],
"properties": {
"userId": {
"type": "string",
"description": "Идентификатор"
},
"login": {
"type": "string",
"description": "Логин"
},
"firstName": {
"type": "string",
"description": "Имя"
},
"lastName": {
"type": "string",
"description": "Фамилия"
},
"age": {
"type": "integer",
"format": "int32",
"minimum": 0,
"description": "Возраст"
},
"status": {
"type": "string",
"enum": ["active", "blocked"]
}
}
},
"UserRequest": {
"type": "object",
"required": ["login"],
"properties": {
"login": {
"type": "string",
"description": "Логин"
},
"firstName": {
"type": "string",
"description": "Имя"
},
"lastName": {
"type": "string",
"description": "Фамилия"
},
"age": {
"type": "integer",
"minimum": 0,
"description": "Возраст"
}
}
}
},
"responses": {
"200": {
"description": "Пользователь",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/User"
}
}
}
},
"201": {
"$ref": "#/components/responses/200"
},
"401": {
"description": "Unauthorized"
},
"403": {
"description": "Forbidden"
},
"404": {
"description": "Not Found"
}
}
}
}
В конце статьи приведены ссылки на документацию и другие полезные ресурсы.
Генерация клиента
Установка OpenAPI Generator
Для начала нам нужно установить OpenAPI Generator CLI. У нас есть несколько вариантов установки. Если генератор используется постоянно и в разных проектах, можно установить его глобально. Мы же пойдем по самому простому пути и добавим пакет в проект как dev зависимость:
npm i @openapitools/openapi-generator-cli -D
Если есть необходимость генерировать новый клиент периодически, можно добавить скрипт в раздел scripts
в package.json, например:
"generate-api": "openapi-generator-cli generate -i openapi/spec.json -g typescript-axios -o src/api/api"
Генерация клиентского кода
Для получения готового к работе клиента из спецификации нужно выполнить команду:
npx openapi-generator-cli generate -i openapi/spec.json -g typescript-axios -o src/api/api
Мы воспользовались тремя основными опциями команды generate
:
-i
(или--input-spec
) — источник, то есть наш файл со спецификацией;-g
(или--generator-name
) — генератор, который мы хотим использовать, в нашем примере это будетtypescript-axios
(чуть ниже расскажу почему);-o
(или--output
) — указывает папку, куда нужно положить готовый клиент.
На выходе получим файл конфигурации openapitools.json в корне проекта и папку с файлами клиента по указанному пути. Содержимое папки крайне не рекомендуется править вручную. Последующая генерация перетрет эти правки.
В Windows 10 в некоторых случаях выполнение команды может завершиться ошибкой, связанной с Java Runtime, а папка с клиентом не появится. В таких случаях можно в файле конфигурации понизить версию генератора до 6.6.0 и выполнить команду еще раз. Правда в этом случае в версиях OpenAPI выше 3.0.4 может некорректно сгенерироваться типизация... Я потратил несколько вечеров, разбираясь с этой проблемой, но так ни к чему определенному и не пришел. Если у вас есть решение, поделитесь, пожалуйста, в комментариях.
При необходимости можно использовать содержимое папки как шаблон для подготовки клиента к публикации в виде пакета npm во внутренний реестр, например, в Nexus или Verdaccio.
Например, мы добавили на одном из наших проектов несколько этапов в GitLab CI, которые отрабатывают после обновления спецификации (примерно как на изображении ниже):
генерация клиента на основе обновленной спецификации;
добавление нужных полей в package.json;
публикация в Nexus новой версии клиента;
отправка уведомления в чат разработчикам о появлении в реестре нового клиента.
Так мы упростили коммуникацию между разработчиками и сократили сроки добавления нового функционала на стороне frontend.

Интеграция клиента
Настройка Axios
Добавим Axios в проект:
npm i axios
Теперь у нас есть все, чтобы связать клиент с проектом. Для этого создадим в проекте файл src/api/index.ts:
// src/api/index.ts
import axios, { AxiosError, isAxiosError } from 'axios'
import { UsersApi } from './api'
const axiosInstance = axios.create()
axiosInstance.interceptors.request.use((config) => {
const token = 'SOME_BEARER_TOKEN'
if (token) {
config.headers['Authorization'] = `Bearer ${token}`
}
return config
})
axiosInstance.interceptors.response.use(undefined, (error: AxiosError) => {
if (!isAxiosError(error)) {
return
}
if (error.response?.status === 401) {
console.warn('401 Unauthorized')
}
return Promise.reject(error)
})
export const usersApi = new UsersApi(undefined, 'https://127.0.0.1:20000/api', axiosInstance)
Давайте разберемся, что тут происходит, и при чем тут Axios.
Помните, мы выбрали typescript-axios
в качестве генератора? В сгенерированном клиенте для каждого тега из спецификации есть конструктор, который принимает три параметра:
конфигурацию, отвечающую в основном за авторизацию;
базовый путь для запросов;
инстанс Axios, который мы можем настроить под наши нужды.
В примере выше мы настроили интерсепторы, один из которых добавит заголовок Authorization
во все запросы, а другой будет реагировать на ошибку 401. Добавляя более сложную логику в интерсепторы мы можем очень гибко управлять реакцией, например, на различные коды ошибок, без необходимости делать это точечно при каждом вызове запроса.
В нашем примере всего один тег Users
и, соответственно, один конструктор UsersApi
. В экспортируемом объекте usersApi
есть пять методов, соответствующих каждому из описанных в контракте эндпойнтов: четыре — действия CRUD с учетными записями, и пятый — получение списка всех пользователей в виде массива. Параметры эндпойнта передаются в метод отдельно, тело запроса — в виде объекта. Есть еще последний, необязательный, параметр. О его назначении мы поговорим позже, когда дойдем до библиотеки Source.
TypeScript
Мы разобрались, почему нам нужен был Axios. Теперь поговорим о TypeScript. В контракте в разделе components
описаны схемы, которые через $ref
связываются с различными разделами спецификации. Это позволяет избежать дублирования громоздких описаний объектов. Каждая схема, используемая в теле запроса или в ответе, — это отдельный интерфейс в сгенерированном клиенте, который мы можем использовать в проекте. Это очень удобно, потому что мы всегда знаем, что нам вернет тот или иной запрос, или какой объект сервер ожидает от нас.
Давайте заглянем в сгенерированный код и посмотрим, что получилось. В файле api.ts можем найти такой фрагмент:
/**
*
* @export
* @interface User
*/
export interface User {
/**
* Идентификатор
* @type {string}
* @memberof User
*/
userId: string
/**
* Логин
* @type {string}
* @memberof User
*/
login: string
/**
* Имя
* @type {string}
* @memberof User
*/
firstName?: string
/**
* Фамилия
* @type {string}
* @memberof User
*/
lastName?: string
/**
* Возраст
* @type {number}
* @memberof User
*/
age?: number
/**
*
* @type {string}
* @memberof User
*/
status: UserStatusEnum
}
export const UserStatusEnum = {
Active: 'active',
Blocked: 'blocked',
} as const
export type UserStatusEnum = (typeof UserStatusEnum)[keyof typeof UserStatusEnum]
Как видим, у нас есть интерфейс User
, в котором типизированы все поля, и даже поле status
, описанное в контракте как enum
, имеет соответствующий тип UserStatusEnum
, при этом реализованный как тип union. Все интерфейсы и типы экспортируются из клиента, так что мы можем использовать их в любом месте проекта, не описывая их заново. Более того, при изменении структуры данных после генерации обновленного клиента мы увидим ошибки типизации, если столкнемся с критическими изменениями.
Замечу, что спецификации, сгенерированные на основе серверного кода, не всегда могут отличаться изящным неймингом интерфейсов и типов. Так получается из‑за принятых стандартов именования в серверных языках, что влияет на получившуюся спецификацию. В таких случаях можно делать реэкспорт, переименовывая или даже дополняя интерфейсы какими‑нибудь служебными полями, но это тема для отдельной статьи.
Еще один нюанс. Я специально добавил в спецификацию в схемы User и UserRequest нижнюю границу для значения поля age. К сожалению, TypeScript на примитивном уровне не умеет учитывать подобные ограничения, а генератор не включает их в аннотации JSDoc. Но, например, Swagger все это прекрасно показывает, поэтому бездумно доверять сгенерированным типам не стоит, лучше глазами пробежаться по спецификации.
После всех вышеописанных действий мы имеем в проекте слой API, который хорошо типизирован, не привязан к используемым фреймворкам, и даже может быть вынесен в отдельный пакет. Этого достаточно, чтобы реализовать всю требуемую логику работы с запросами в проекте.
Мокирование
Установка и настройка Mock Service Worker (MSW)
Теперь, когда у нас в проекте есть сгенерированный клиент, мы можем перейти к созданию заглушек для запросов. Для начала добавим в проект MSW и запустим генерацию кода воркера:
npm i msw -D
npx msw init public
В папке public появится файл mockServiceWorker.js. Этот файл содержит код самого воркера.
Теперь напишем обработчики для интересующих нас эндпойнтов (пока будем пользоваться консолью для вывода промежуточных результатов). Создадим файл src/mocks/handlers.ts:
// src/mocks/handlers.ts
import type { User } from '@/api/api'
import { http, HttpResponse } from 'msw'
const user: User = {
userId: 'uuid1',
login: 'admin',
firstName: 'Сергей',
age: 33,
status: 'active',
}
const secondUser: User = {
userId: 'uuid2',
login: 'user',
firstName: 'Алексей',
age: 18,
status: 'blocked',
}
export const handlers = [
http.get<never, undefined, User[]>('https://127.0.0.1:20000/api/v1/users', () => {
return HttpResponse.json([user, secondUser])
}),
http.put<{ id: string }, UserRequest, User>(
'https://127.0.0.1:20000/api/v1/users/:id',
async ({ params, request }) => {
const { id } = params
console.info('Update handler params', id)
const requestBody = await request.clone().json()
console.info('Update handler request', requestBody)
return HttpResponse.json(user)
},
),
http.delete<{ id: string }>('https://127.0.0.1:20000/api/v1/users/:id', ({ params }) => {
const { id } = params
console.info('Delete handler params', id)
return HttpResponse.error()
}),
]
Тут нужно обратить внимание на два момента.
Во‑первых, мы использовали интерфейс из клиента, чтобы создать образцы объектов, возвращаемых по запросам. И в процессе работы с кодом IDE хорошо подхватывает типы полей, в том числе варианты значения поля status
, о котором было сказано выше. Также для примера мы воспользовались сгенерированными интерфейсами в дженерике метода put
.
Во‑вторых, в разных методах мы из параметров коллбека можем достать параметры или тело запроса. Это может пригодиться, если мы хотим имитировать какое‑либо поведение сервера в зависимости от передаваемых данных.
Далее нам нужно добавить наши обработчики в воркер, в файл src/mocks/browser.js:
// src/mocks/browser.ts
import { setupWorker } from 'msw/browser'
import { handlers } from './handlers'
export const worker = setupWorker(...handlers)
И теперь осталось внести правки в основной файл проекта (в нашем случае это src/main.ts), чтобы запустить воркер:
// src/main.ts
import './assets/base.css'
import { createApp } from 'vue'
import App from './App.vue'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
const enableMocking = async () => {
const { worker } = await import('./mocks/browser')
return worker.start()
}
enableMocking().then(() => {
createApp(App).use(ElementPlus).mount('#app')
})
Для упрощения не будем делать проверку на режим запуска, примем, что проект запускается только в режиме development
. Разумеется, в реальных проектах такая проверка будет необходима, ведь MSW — это инструмент для конкретных задач при разработке.
После всех вышеописанных действий в консоли браузера должно появиться сообщение [MSW] Mocking enabled.
.
Теперь заменим содержимое компонента src/App.vue на несколько примеров запросов:
// src/App.vue
<script setup lang="ts">
import { usersApi } from './api'
const fetchUsers = async () => {
const users = (await usersApi.getAllUsers()).data
console.table(users)
}
const deleteUser = async () => {
await usersApi.deleteUser('uuid3')
}
const updateUser = async () => {
const user = (await usersApi.updateUser('uuid1', { login: 'admin2' })).data
console.info(user)
}
await fetchUsers()
try {
await deleteUser()
} catch (e) {
console.error(e)
}
await updateUser()
</script>
В консоли браузера видим, что в компонент попадают наши мокированные данные.

Использование Faker
Посмотрим еще раз на файл src/mocks/handlers.ts. В первом обработчике мы возвращали JSON, содержащий массив из двух объектов. Оба эти объекта мы ранее создавали вручную. Но иногда нам нужно замокать ответ из нескольких десятков записей. Например, для проверки работы таблицы. Для этого мы будем использовать компонент таблицы из библиотеки Element Plus, а мокированные данные будем генерировать с помощью библиотеки Faker, что позволит нам получать разные данные для тестирования, но попадающие в установленные нами же ограничения.
Добавляем Faker в проект:
npm i @faker-js/faker -D
Теперь, используя его, создадим массив объектов User
. Код в файле src/mocks/handlers.ts приобретет следующий вид:
// src/mocks/handlers.ts
import type { User } from '@/api/api'
import { http, HttpResponse } from 'msw'
import { faker } from '@faker-js/faker'
const statuses: User['status'][] = ['active', 'blocked']
const users: User[] = [...Array(faker.number.int({ min: 15, max: 50 })).keys()].map(() => ({
userId: faker.string.uuid(),
login: faker.internet.username(),
firstName: faker.person.firstName(),
lastName: faker.person.lastName(),
age: faker.number.int({ min: 18, max: 27 }),
status: statuses[faker.number.int(1)],
}))
export const handlers = [
http.get('https://127.0.0.1:20000/api/v1/users', () => {
return HttpResponse.json(users)
}),
http.put<{ id: string }>(
'https://127.0.0.1:20000/api/v1/users/:id',
async ({ params, request }) => {
const { id } = params
console.info('Update handler params', id)
const requestBody = await request.clone().json()
console.info('Update handler request', requestBody)
return HttpResponse.json(users[0])
},
),
http.delete<{ id: string }>('https://127.0.0.1:20000/api/v1/users/:id', ({ params }) => {
const { id } = params
console.info('Delete handler params', id)
return HttpResponse.error()
}),
]
Осталось только вывести результат в таблицу (будем использовать библиотеку Element Plus для вывода). Изменяем код в компоненте src/App.vue:
// src/App.vue
<script setup lang="ts">
import { usersApi } from './api'
import type { User } from './api/api'
import { onMounted, reactive } from 'vue'
const data: { users: User[] } = reactive({ users: [] })
const fetchUsers = async () => {
const users = (await usersApi.getAllUsers()).data
console.table(users)
data.users = users
}
onMounted(fetchUsers)
</script>
<template>
<ElTable :data="data.users" stripe style="width: 100%">
<ElTableColumn prop="userId" label="ID"></ElTableColumn>
<ElTableColumn prop="login" label="Login"></ElTableColumn>
<ElTableColumn prop="firstName" label="Имя"></ElTableColumn>
<ElTableColumn prop="lastName" label="Фамилия"></ElTableColumn>
<ElTableColumn prop="age" label="Возраст"></ElTableColumn>
</ElTable>
</template>
Source
Напоследок рассмотрим еще один инструмент.
Вместо того, чтобы описывать обработчики вручную и самостоятельно генерировать данные, можем воспользоваться библиотекой Source. Она позволяет упростить процесс мокирования и сократить код до нескольких строк.
Добавляем Source:
npm i @mswjs/source -D
Важный момент! Если до сих пор мы могли работать с файлом спецификации как в формате YAML, так и в формате JSON, то теперь формат JSON является обязательным требованием.
Модифицируем код в файле src/mocks/handlers.ts:
// src/mocks/handlers.ts
import { fromOpenApi } from '@mswjs/source/open-api'
import api from '../../openapi/spec.json'
export const handlers = await fromOpenApi(api)
Теперь воркер будет перехватывать все запросы, описанные в спецификации.
Мы можем эмулировать ошибки с интересующим нас кодом. Для этого в запрос нужно добавить GET‑параметр response
с кодом ошибки.
Выше я писал, что любой метод API принимает параметры, описанные в схемах спецификации, а также еще один необязательный параметр. Это объект типа AxiosRequestConfig
, позволяющий модифицировать конкретный запрос. Если мы передадим значение, например, { params: { response: 401 } }
, мы как раз получим желаемый результат. Теперь начнет отрабатывать интерсептор, который мы ранее описали в файле src/api/index.ts.
Строка из файла src/App.vue будет выглядеть так:
const users = (await usersApi.getAllUsers({ params: { response: 401 } })).data
Минусом при использовании Source является то, что мы теряем, во‑первых, гибкость настройки mock‑данных, которую нам давал Faker, и во‑вторых, в целом контроль за мокированием. Впрочем, можно сочетать оба подхода, а выбор инструментов всегда зависит от конкретной задачи.
Заключение
Одних генераторов для OpenAPI Generator для JS и TS существует несколько вариантов, есть из чего выбрать. Одна из целей написания статьи — продемонстрировать общий подход, а также принципы работы и возможности некоторых инструментов, не перегружая материал излишними мелкими подробностями. Все нужные детали можно найти в документации.
Другая цель — показать, какую цепочку можно выстроить во frontend‑приложении от получения файла со спецификацией до полной автоматизации мокирования. Результат, полученный на каждом из описанных этапов, может найти применение в разработке приложений, причем в большинстве случаев это не зависит от архитектуры проекта в целом или стека в частности.
olivera507224
Пробовали генерировать через докер-образ, а не из-под npm?
ekniazev Автор
Да, это рабочий вариант. Ещё WSL есть. Если нужно, можно быстро собрать подходящую инфраструктуру. А я то просто хотел именно вариант под Windows победить)