Кодогенерация хуков RTK Query на основе OpenAPI схемы

Гайд по автоматической генерации хуков для RTK Query (OpenAPI v3)

OpenAPI — это стандарт для описания API, который позволяет разработчикам создавать документацию, тестировать и даже генерировать код на основе схемы (единого источника истины для бэкенда, аналитиков и фронтенда). С его помощью можно сильно упростить разработку, например, автоматически создавать хуки для RTK Query. В этом гайде я покажу, как настроить этот процесс и избавиться от рутинного ручного кодинга.

Шаг 1. Устанавливаем Redux Toolkit и настраиваем стор

Используйте мой предыдущий туториал (тык сюда), чтобы настроить Redux Toolkit в проекте. Структура папок, импорты и нейминги, которые я буду использовать далее, будут соответствовать этому туториалу.

Шаг 2. Создаем обертку для API (apiBase.ts), в которую будет внедряться наш сгенерированный код

Не рекомендую называть файлы абстрактно и сохранять в общую папку (например, src/services/api.ts), лучше сохранить их в деректорию, указывающую на конкретное назначение API (например, src/services/petStoreApi/apiBase.ts). Так мы облегчим масштабируемость приложения, ведь нам затем может понадобиться использовать одновременно несколько API с похожими ручками.

📄 src/services/petStoreApi/apiBase.ts

import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'

export const petStoreApiBase = createApi({
  reducerPath: 'petStoreApi',
  baseQuery: fetchBaseQuery({ baseUrl: 'https://petstore3.swagger.io/api/v3' }), // Рекомендую затем вынести URL в .env или конфигурацию
  endpoints: () => ({}), // Пустой объект, в который мы внедрим наш сгенерированный код
})

Для туториала я использую официальный пример OpenAPI от Swagger - API Магазина Питомцев


Шаг 3. Регистрируем сервис API в сторе

Добавляем reducer из слайса нашего api, а также добавляем middleware, который активирует кэширование, инвалидацию кэша, поллинг и другие фичи RTK-Query:

📄 src/store/index.ts

import { configureStore } from '@reduxjs/toolkit'
import { counterSlice } from '../components/counter/counterSlice'
import { petStoreApiBase } from '../services/petStoreApi/apiBase'

export const store = configureStore({
  reducer: {
    counter: counterSlice.reducer,
    [petStoreApiBase.reducerPath]: petStoreApiBase.reducer,
  },
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware().concat(petStoreApiBase.middleware),
})

// Infer the `RootState` and `AppDispatch` types from the store itself
export type RootState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatch

Шаг 4. Пишем файл конфигурации для кодогенератора (openapi-codegen-config.cjs)

Обратите внимание на расширение файла - .cjs. Это наиболее универсальный вариант для конфигурации, который позволяет не использовать сторонние зависимости, такие как ts-node. Я честно пытался запустить конфигурацию в формате .ts, но удалось мне это только после 15 минут фиксов ошибок запуска, поэтому я решил, что проще сразу использовать .cjs. Да, так мы лишимся подсказок и автодополнения в этом файле, но, справедливости ради, не так уж много в нем параметров, а возможные ошибки мы сможем отследить при запуске.

📄 src/services/petStoreApi/openapi-codegen-config.cjs

module.exports = {
    schemaFile: "https://petstore3.swagger.io/api/v3/openapi.json", // URL или локальный путь к openapi.json схеме
    apiFile: './apiBase.ts', // путь к файлу обертке, в который будут внедрены сгенерированные эндпоинты
    apiImport: 'petStoreApiBase', // укажите здесь экспортируемую из apiFile переменную
    outputFile: './api.generated.ts', // путь к файлу, который будет сгенерирован
    exportName: 'petStoreApiGenerated', // выберите имя, с которым будет экспортировано api из сгенерированного файла
    hooks: { queries: true, lazyQueries: true, mutations: true }, // типы хуков, которые должны сгенерироваться
    // ДАЛЕЕ ВЫ МОЖЕТЕ РАСКОММЕНТИРОВАТЬ ТРЕБУЕМЫЕ ВАМ ПАРАМЕТРЫ И УДАЛИТЬ ЛИШНИЕ
    // tag: true,
    // filterEndpoints: ['loginUser', /Order/], // позволяет добавить не сразу все, а только нужные вам эндпоинты. Просто укажите отдельные эндпоинты или RegEx паттерн в массиве.
    // argSuffix: 'Args', // Добавляет суффикс к аргументам, которые передаются в запросы. Это поле используется, чтобы избежать конфликтов с именами переменных. Если OpenAPI-спецификация имеет запрос getUser, то аргументы будут называться getUserArgs.
    // operationNameSuffix: 'Endpoint' // Добавляет суффикс к имени операции (генерируемого эндпоинта). Если OpenAPI-операция называется getUser, то сгенерированный эндпоинт будет называться getUserEndpoint.
    // responseSuffix: 'Response' // Добавляет суффикс к сгенерированным типам ответа для эндпоинтов. Если запрос называется getUser, то тип данных ответа будет называться getUserResponse.
    // endpointOverrides: [
    //  {
    //     pattern: '/users/{id}',
    //     override: {
    //       method: 'POST',
    //       responseType: 'CustomResponseType'
    //     }
    //   }
    // ] // endpointOverrides позволяет переопределить или настроить определенные эндпоинты из OpenAPI-спецификации
    // flattenArg: true // Если указано true, то аргументы запроса будут "разворачиваться" на верхний уровень вместо передачи в виде объекта. Вместо api.endpoints.getUser.initiate({ id: 1 }); будет api.endpoints.getUser.initiate(1);
    // useEnumType: true // Если указано true, то для генерации типов будут использоваться перечисления (enum), если они определены в OpenAPI-спецификации.
    // httpResolverOptions: {
    //    timeout: 5000,
    //    headers: {
    //          Accept: 'application/json',
    //          Authorization: 'Basic cmVkdXgtdG9vbGtpdDppcy1ncmVhdA==',
    //        },
    //  } httpResolverOptions позволяет настроить параметры HTTP-запросов, которые используются для загрузки удаленной OpenAPI-схемы. Это полезно, если нужно авторизоваться для загрузки OpenAPI схемы.
}

Если в вашей OpenAPI спецификации используются тэги, вы можете добавить в конфигурации tag: true. Тогда во всех сгенерированных эндпоинтах появятся определения providesTags для query, и invalidatesTags для mutation соответственно. Но заметьте, что сгенерируются только простые тэги без сложного поведения и id, что может привести к ненужным инвалидациям кэша и лишним запросам к серверу в мутациях.

Мне кажется, лучше вручную указывать параметры providesTags/invalidatesTags с помощью enhanceEndpoints поверх сгенерированного api, чтобы случайно не заспамить бэкенд постоянными запросами, так как если на одной странице будет много компонентов зависимых от общего тэга, вызов одной мутации будет отправлять заново все связанные с ее тэгом запросы, даже те которые мы не ожидаем. Как использовать enhanceEndpoints я покажу далее в гайде.

Также вы можете сгенерировать несколько выходных файлов используя параметры filterEndpoints и outputFiles. Полезно, когда ваш исходный API имеет широкую специфику, и вы хотите явно разделить сущности. Например:

outputFiles: {
  './userApi.generated.ts': {
    filterEndpoints: [/user/i],
  },
  './orderApi.generated.ts': {
    filterEndpoints: [/order/i],
  },
  './petApi.generated.ts': {
    filterEndpoints: [/pet/i],
  },
},

Шаг 5. Генерируем хуки из OpenAPI

Аналогом npx в pnpm является команда pnmp dlx - используем ее и плагин @rtk-query/codegen-openapi для генерации кода:

pnpm dlx @rtk-query/codegen-openapi ./src/services/petStoreApi/openapi-codegen-config.cjs

(Опционально) Можете добавить эту команду как скрипт в package.json, чтобы быстрее ее вызывать и использовать в CI/CD пайплайне

Таким образом мы получим файл api.generated.ts с хуками, которые мы сможем использовать в наших компонентах. ВАЖНО: Ни в коем случае не пытайтесь вручную изменять сгенерированный файл! Ведь если api еще в разработке, при каждой повторной кодогенерации, все изменения будут удаляться.

Шаг 6. Используем хуки в наших компонентах

Создадим компонент, который будет получать информацию о питомце по id. Импортируем нужный нам сгенерированный хук, благодаря React Query он способен самостоятельно следить за статусом запроса, кэширует данные и обновляет компонент при их изменении:

📄 src/components/petInfo/petInfo.tsx

import { useGetPetByIdQuery } from "../../services/petStoreApi/api.generated";
const PetInfo = () => {
    const { data, error, isLoading, isFetching } = useGetPetByIdQuery({ petId: 10 });

    if (isLoading || isFetching) { return <div>Loading...</div>;}
    if (error) { return <div>{ 'status' in error ? error.status : error.message}</div>}

    return (
        <div>
            {data && (
            <div>
                <p>Name: {data.name}</p>
                <p>Category: {data.category ? data.category.name : "Не известна"}</p>
                <p>Status: {data.status ? data.status : "Не известно"}</p>
            </div>
            )}
        </div>
    );
};

export default PetInfo;

Попробуйте дополнить этот компонент самостоятельно, например, добавьте в него поле input для ввода id питомца, и проследите за отправкой запросов через вкладку Network в devTools - вы увидите как RTK Query их кэширует.

Кастомизация и сложные кейсы

Важно помнить, что RTK Query спроектирован так, чтобы обеспечить нам максимальную декларативность при использовании в связке с React. Не нужно пытаться использовать createAsyncThunk или напрямую обращаться к API, ведь RTK Query самостоятельно следит за статусом запроса, кэширует данные и обновляет компонент при их изменении. Если вы хотите имплементировать что-то сложное, чтобы обеспечить общую консистентность, лучшим решением будет создать новый хук, который будет использовать внутри себя сгенерированные хуки.

Кейс 1: хочу отправить POST запрос и автоматически обновить связанные данные

Предположим, что мы хотим создать нового питомца и после этого обновить информацию получаемую по id. У нас уже есть компонент counter, который мы для примера используем как генератор id питомца. Обновим в соответствии с этим компонент PetInfo:

📄 src/components/petInfo/petInfo.tsx

import { useGetPetByIdQuery } from "../../services/petStoreApi/api.generated";
import { useAppSelector } from "../../store/hooks";

const PetInfo = () => {
    // Используем уже готовый сounter вместо id
    const id = useAppSelector((state) => state.counter.value);
    const { data, error, isLoading, isFetching } = useGetPetByIdQuery({ petId: id });

    if (isLoading || isFetching) { return <div>Loading...</div>;}
    if (error) { return <div>{ 'status' in error ? error.status : error.message}</div>}

    return (
        <div>
            {data && (
            <div>
                <p>Name: {data.name}</p>
                <p>Category: {data.category ? data.category.name : "Не известна"}</p>
                <p>Status: {data.status ? data.status : "Не известно"}</p>
            </div>
            )}
        </div>
    );
};

export default PetInfo;

Теперь создадим новый компонент, который будет отправлять запрос на создание питомца и обновлять информацию о нем:

📄 src/components/petInfo/addPet.tsx

import { useAddPetMutation } from '../../services/petStoreApi/api.generated';
import { useAppSelector } from '../../store/hooks';

const AddPet = () => {
    // Используем уже готовый Counter вместо поля ввода id
    const id = useAppSelector((state) => state.counter.value);
    // Используем готовый хук useAddPetMutation для добавления питомца
    const [triggerAddPet] = useAddPetMutation();

    function handleAddPet() {
        triggerAddPet({ pet: {
            id,
            name: 'New Pet with id ' + String(id),
            photoUrls: [],
            category: { id: 1, name: 'Dogs' },
            status: 'available'
        }});
    }

    return (
        <div><button onClick={handleAddPet}>Add Pet with id: {id}</button></div>
    );
};

export default AddPet;
                                

Но мы увидим, что при добавлении питомца, информация о нем по id не обновляется автоматически, а только после обновления страницы. Для того чтобы исправить это поведение, как мы обсуждали ранее, нужно добавить к эндпоинтам определения providesTags и invalidatesTags. Это можно сделать двумя способами.

Способ 1: Если в OpenAPI схеме прописаны тэги, то можно просто добавить свойство tags: true в openapi-codegen-config.cjs и повторно сгенерировать файл api.

Способ 2: вручную добавить определения providesTags и invalidatesTags с помощью enhanceEndpoints. Для этого создадим новый файл apiEnhanced:

📄 src/services/petStoreApi/apiEnhanced.ts

import { petStoreApiGenerated } from "./api.generated";
export const petStoreApiEnhanced = petStoreApiGenerated.enhanceEndpoints({
    addTagTypes: ['Pet'],
    endpoints: {
        getPetById: {
            providesTags: ['Pet'],
        },
        addPet: {
            invalidatesTags: ['Pet'],
        },
    },
});

export const {
    useAddPetMutation,
    useGetPetByIdQuery,
  } = petStoreApiEnhanced;

Теперь вместо импортирования из сгенерированного api, импортируем хуки из apiEnhanced в наших компонентах:

import { useGetPetByIdQuery } from "../../services/petStoreApi/apiEnhanced"; 
import { useAddPetMutation } from '../../services/petStoreApi/apiEnhanced'; 

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

Кейс 2: хочу отправить запрос с предустановленными аргументами

Если у вас есть сгенерированный эндпоинт, который принимает несколько аргументов, вы можете создать кастомный хук, где часть данных будет уже предустановлена.

Создадим кастомный хук useFindAvailablePetsQuery, который будет возвращать список питомцев со статусом "available". За основу возьмем хук useFindPetsByStatusQuery, в котором нужно явно указать статус, чтобы сделать запрос.

📄 src/services/petStoreApi/apiEnhanced.ts

import { useFindPetsByStatusQuery } from "./api.generated";

export const useFindAvailablePetsQuery = (args: { page: number; limit: number }) => {
    return useFindPetsByStatusQuery({ ...args, status: 'available' });
};

Теперь вместо импортирования из сгенерированного api, импортируем хук из apiEnhanced в наших компонентах:

📄 src/components/petInfo/availablePets.tsx

import { useFindAvailablePetsQuery } from '../../services/petStoreApi/apiEnhanced';
const AvailablePets = () => {
    const { data, error, isLoading, isFetching } = useFindAvailablePetsQuery({page: 1, limit: 20});
    if (isLoading || isFetching) { return <div>Loading...</div>;}
    if (error) { return <div>{ 'status' in error ? error.status : error.message}</div>}

    return (
        <div>
            {data && (
            <div>
                <h2>Available pets:</h2>
                <ul>
                    {data.map((pet) => (
                        <li key={pet.id}>{pet.name}</li>
                    ))}
                </ul>
            </div>
            )}
        </div>
    );
};

export default AvailablePets;

Кейс 3: хочу модифицировать результат запроса прямо в хуке

Если вам нужно модифицировать результат запроса, например, добавить новое поле или отфильтровать данные, вы можете сделать это прямо в хуке. Для примера создадим хук useFindAvailableSlavicPetsQuery, который будет возвращать список питомцев с именами на кириллице и добавит новое поле в получаемые объекты.

📄 src/services/petStoreApi/apiEnhanced.ts

import { Pet } from "./api.generated";
import { useFindPetsByStatusQuery } from "./api.generated";

type ModifiedPet = Pet & {
    isNameCyrillic: boolean; // Добавляем новое поле
};

export const useFindAvailableSlavicPetsQuery = () => {
    const { data, error, isLoading, isFetching } = useFindPetsByStatusQuery({ status: 'available' });

    // Модифицируем results - оставляем только питомцев с именами, содержащими кириллицу, и добавляем новое поле
    const modifiedData: ModifiedPet[] | undefined = data?.map((pet) => ({
        ...pet, // Сохраняем остальные данные
        isNameCyrillic: /[а-яА-ЯЁё]/.test(pet.name), // Проверяем имя на кириллицу и добавляем новое поле
    })).filter((pet) => pet.isNameCyrillic) // Оставляем только питомцев с именами на кириллице

    return {
        data: modifiedData,
        error,
        isLoading,
        isFetching,
    };
};

Кейс 4: хочу отправить несколько запросов параллельно и, если хоть один будет отклонен, отобразить ошибку для всех

Если вам нужно отправить несколько запросов параллельно и обработать их примерно также как это делает Promise.All(), то достаточно просто обработать ошибки прямо в компоненте.

📄 src/components/petInfo/parallelRequests.tsx

import { useFindAvailableSlavicPetsQuery, useGetPetByIdQuery } from '../../services/petStoreApi/apiEnhanced';

const ParallelRequests = () => {
    const { data: slavicPetsList, error: slavicPetsListError, isLoading: slavicPetsListLoading } = useFindAvailableSlavicPetsQuery();
    const { data: firstPet, error: firstPetError, isLoading: firstPetLoading } = useGetPetByIdQuery({petId: 0});

    // Обрабатываем статусы загрузки
    if (slavicPetsListLoading || firstPetLoading) return <div>Loading...</div>;
    if (slavicPetsListError || firstPetError) return <div>Error occurred</div>;

    // Если оба запроса завершились успешно
    return (
        <div>
            {slavicPetsList && firstPet && (
                <div>
                    <h1>Slavic Pets:</h1>
                    <ul>
                        {slavicPetsList.map((pet) => (
                            <li key={pet.id}>{pet.name}</li>
                        ))}
                    </ul>
                    <h1>First Pet</h1>
                    <ul>
                        {firstPet.name}
                    </ul>
                </div>
            )}
        </div>
    );
};

export default ParallelRequests;

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

Кейс 5: хочу чтобы запрос отправлялся заново каждые 3 секунды

Ну и напоследок самый легкий кейс - чтобы отправлять запрос циклично, просто добавьте свойство pollingInterval в используемый хук, указав интервал в миллисекундах:

📄 src/components/petInfo/petInfo.tsx

const { data, error, isLoading, isFetching } = useGetPetByIdQuery({ petId: id }, {
  pollingInterval: 3000
});

Заключение

Таким образом, мы рассмотрели базовые кейсы использования RTK Query с OpenAPI и сгенерированными хуками. Надеюсь, что этот гайд поможет вам быстрее начать работу с RTK Query и упростит взаимодействие с API в ваших проектах.