Кастомные baseQuery функции

В основе работы RTK Query лежит механизм baseQuery — функция, отвечающая за выполнение HTTP-запросов. По умолчанию чаще всего используется fetchBaseQuery, представляющая собой тонкую обёртку над стандартным fetch.

Базовая конфигурация обычно выглядит так:

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

export const api = createApi({
    reducerPath: 'api',
    baseQuery: fetchBaseQuery({
        baseUrl: 'https://api.example.com',
    }),
    endpoints: (builder) => ({
        getUsers: builder.query({
            query: () => '/users',
        }),
    }),
});

Однако стандартного fetchBaseQuery недостаточно в сложных приложениях, где требуется:

  • автоматическое обновление токенов;
  • централизованная обработка ошибок;
  • повторные запросы;
  • логирование;
  • интеграция с Axios;
  • поддержка GraphQL;
  • шифрование данных;
  • работа с нестандартными API;
  • запросы через WebSocket;
  • контроль таймаутов;
  • динамическое переключение серверов.

В подобных случаях создаются кастомные baseQuery функции.


Сигнатура baseQuery

Любая baseQuery функция получает три аргумента:

const customBaseQuery = async (
    args,
    api,
    extraOptions
) => {

};

Аргумент args

Содержит параметры запроса, переданные из query.

Пример:

getUsers: builder.query({
    query: () => ({
        url: '/users',
        method: 'GET',
    }),
}),

Внутри baseQuery:

args = {
    url: '/users',
    method: 'GET',
}

Аргумент api

Предоставляет доступ к внутренним возможностям RTK Query.

Основные свойства:

api.dispatch
api.getState
api.signal
api.endpoint
api.type

dispatch

Позволяет отправлять Redux actions.

api.dispatch(logout());

getState

Доступ к Redux store.

const state = api.getState();
const token = state.auth.token;

signal

Объект AbortSignal для отмены запросов.

signal.addEventListener('abort', () => {
    console.log('Request aborted');
});

endpoint

Имя endpoint.

console.log(api.endpoint);

type

Тип операции:

'query'
'mutation'

Аргумент extraOptions

Используется реже. Позволяет передавать дополнительные параметры в baseQuery.


Возвращаемое значение baseQuery

Функция обязана возвращать объект строго определённого формата.

Успешный запрос

return {
    data: result,
};

Ошибка

return {
    error: {
        status: 500,
        data: 'Server Error',
    },
};

Простейшая кастомная baseQuery

Реализация через fetch

const customBaseQuery = async (args) => {
    try {
        const response = await fetch(args.url);

        const data = await response.json();

        return {
            data,
        };
    } catch (error) {
        return {
            error: {
                status: 'FETCH_ERROR',
                data: error.message,
            },
        };
    }
};

Подключение:

export const api = createApi({
    reducerPath: 'api',
    baseQuery: customBaseQuery,
    endpoints: (builder) => ({
        getUsers: builder.query({
            query: () => ({
                url: '/users',
            }),
        }),
    }),
});

Кастомная baseQuery поверх fetchBaseQuery

Наиболее распространённый подход — не переписывать весь механизм запросов, а расширять стандартный fetchBaseQuery.

Создание базового запроса

const baseQuery = fetchBaseQuery({
    baseUrl: 'https://api.example.com',
});

Создание обёртки

const customBaseQuery = async (
    args,
    api,
    extraOptions
) => {
    const result = await baseQuery(
        args,
        api,
        extraOptions
    );

    return result;
};

Подобная архитектура позволяет внедрять дополнительную логику между отправкой запроса и возвратом результата.


Автоматическое добавление JWT токена

Одна из главных задач кастомного baseQuery — централизованная авторизация.

Получение токена из Redux store

const customBaseQuery = async (
    args,
    api,
    extraOptions
) => {
    const state = api.getState();

    const token = state.auth.token;

    const headers = {
        ...args.headers,
        Authorization: `Bearer ${token}`,
    };

    const result = await fetchBaseQuery({
        baseUrl: 'https://api.example.com',
    })(
        {
            ...args,
            headers,
        },
        api,
        extraOptions
    );

    return result;
};

Использование prepareHeaders

Во многих случаях токены удобнее внедрять через prepareHeaders.

const baseQuery = fetchBaseQuery({
    baseUrl: 'https://api.example.com',

    prepareHeaders: (headers, { getState }) => {
        const token = getState().auth.token;

        if (token) {
            headers.set(
                'Authorization',
                `Bearer ${token}`
            );
        }

        return headers;
    },
});

Но кастомный baseQuery остаётся необходимым, если логика становится сложнее.


Автоматическое обновление Access Token

Один из важнейших сценариев.

Базовая схема

  1. Запрос получает 401 Unauthorized
  2. Выполняется refresh-запрос
  3. Новый токен сохраняется
  4. Исходный запрос повторяется

Реализация refresh token механизма

Создание базового fetchBaseQuery

const baseQuery = fetchBaseQuery({
    baseUrl: 'https://api.example.com',
});

Кастомная обёртка

const baseQueryWithReauth = async (
    args,
    api,
    extraOptions
) => {
    let result = await baseQuery(
        args,
        api,
        extraOptions
    );

    if (result.error?.status === 401) {

        const refreshResult = await baseQuery(
            {
                url: '/auth/refresh',
                method: 'POST',
            },
            api,
            extraOptions
        );

        if (refreshResult.data) {

            api.dispatch(
                setCredentials(refreshResult.data)
            );

            result = await baseQuery(
                args,
                api,
                extraOptions
            );

        } else {

            api.dispatch(logout());

        }
    }

    return result;
};

Предотвращение множественных refresh-запросов

При параллельных запросах может возникнуть проблема:

  • 20 запросов одновременно получают 401;
  • все начинают refresh;
  • сервер перегружается;
  • появляются race conditions.

Mutex-подход

Часто используется библиотека:

async-mutex

Пример

import { Mutex } from 'async-mutex';

const mutex = new Mutex();

Полная реализация

const baseQueryWithReauth = async (
    args,
    api,
    extraOptions
) => {

    await mutex.waitForUnlock();

    let result = await baseQuery(
        args,
        api,
        extraOptions
    );

    if (result.error?.status === 401) {

        if (!mutex.isLocked()) {

            const release = await mutex.acquire();

            try {

                const refreshResult = await baseQuery(
                    {
                        url: '/auth/refresh',
                        method: 'POST',
                    },
                    api,
                    extraOptions
                );

                if (refreshResult.data) {

                    api.dispatch(
                        setCredentials(refreshResult.data)
                    );

                    result = await baseQuery(
                        args,
                        api,
                        extraOptions
                    );

                } else {

                    api.dispatch(logout());

                }

            } finally {

                release();

            }

        } else {

            await mutex.waitForUnlock();

            result = await baseQuery(
                args,
                api,
                extraOptions
            );

        }
    }

    return result;
};

Централизованная обработка ошибок

Кастомный baseQuery позволяет унифицировать обработку ошибок.

Пример

const customBaseQuery = async (
    args,
    api,
    extraOptions
) => {

    const result = await baseQuery(
        args,
        api,
        extraOptions
    );

    if (result.error) {

        switch (result.error.status) {

            case 403:
                console.error('Access denied');
                break;

            case 500:
                console.error('Server error');
                break;

            default:
                console.error('Unknown error');
        }
    }

    return result;
};

Глобальные уведомления

Часто ошибки показываются через toast-уведомления.

if (result.error) {

    showToast({
        type: 'error',
        message: 'Request failed',
    });

}

Логирование запросов

Логирование входящих запросов

console.log('Request:', args);

Логирование ответов

console.log('Response:', result);

Измерение времени запроса

const start = performance.now();

const result = await baseQuery(
    args,
    api,
    extraOptions
);

const end = performance.now();

console.log(`Request time: ${end - start}ms`);

Повторные запросы (retry)

Иногда запрос необходимо повторить автоматически.

Простейшая реализация

const retryBaseQuery = async (
    args,
    api,
    extraOptions
) => {

    let attempts = 3;

    while (attempts > 0) {

        const result = await baseQuery(
            args,
            api,
            extraOptions
        );

        if (!result.error) {
            return result;
        }

        attempts--;
    }

    return {
        error: {
            status: 'RETRY_FAILED',
        },
    };
};

Экспоненциальная задержка

Более корректный retry-механизм:

const sleep = (ms) =>
    new Promise((resolve) =>
        setTimeout(resolve, ms)
    );

Реализация

let delay = 1000;

while (attempts > 0) {

    const result = await baseQuery(
        args,
        api,
        extraOptions
    );

    if (!result.error) {
        return result;
    }

    await sleep(delay);

    delay *= 2;

    attempts--;
}

Таймаут запросов

Стандартный fetch не имеет встроенного timeout.


Реализация через AbortController

const customBaseQuery = async (args) => {

    const controller = new AbortController();

    const timeout = setTimeout(() => {
        controller.abort();
    }, 5000);

    try {

        const response = await fetch(
            args.url,
            {
                signal: controller.signal,
            }
        );

        clearTimeout(timeout);

        return {
            data: await response.json(),
        };

    } catch (error) {

        return {
            error: {
                status: 'TIMEOUT_ERROR',
                data: error.message,
            },
        };

    }
};

Использование Axios вместо fetch

RTK Query не привязан к fetch.


Axios baseQuery

Реализация

import axios from 'axios';

const axiosBaseQuery =
    (
        { baseUrl } = { baseUrl: '' }
    ) =>
    async ({
        url,
        method,
        data,
        params,
        headers,
    }) => {

        try {

            const result = await axios({
                url: baseUrl + url,
                method,
                data,
                params,
                headers,
            });

            return {
                data: result.data,
            };

        } catch (axiosError) {

            return {
                error: {
                    status: axiosError.response?.status,
                    data: axiosError.response?.data,
                },
            };

        }
    };

Подключение

export const api = createApi({
    reducerPath: 'api',

    baseQuery: axiosBaseQuery({
        baseUrl: 'https://api.example.com',
    }),

    endpoints: (builder) => ({
        getUsers: builder.query({
            query: () => ({
                url: '/users',
                method: 'GET',
            }),
        }),
    }),
});

Преимущества Axios baseQuery

Интерцепторы

axios.interceptors.request.use();
axios.interceptors.response.use();

Автоматический JSON parsing

Axios автоматически преобразует JSON.


Timeout из коробки

timeout: 5000

Отмена запросов

signal: api.signal

GraphQL baseQuery

RTK Query может использоваться с GraphQL API.


Установка GraphQL клиента

Часто используются:

  • graphql-request
  • Apollo Client
  • urql

Пример через graphql-request

import { GraphQLClient } from 'graphql-request';

const client = new GraphQLClient(
    'https://graphql.example.com'
);

GraphQL baseQuery

const graphqlBaseQuery =
    ({ baseUrl }) =>
    async ({ body }) => {

        try {

            const result =
                await client.request(body);

            return {
                data: result,
            };

        } catch (error) {

            return {
                error: {
                    status: error.response.status,
                    data: error,
                },
            };

        }
    };

Использование

getPosts: builder.query({
    query: () => ({
        body: `
            query {
                posts {
                    id
                    title
                }
            }
        `,
    }),
}),

Динамическое изменение baseUrl

Иногда сервер выбирается во время выполнения.

Пример

const dynamicBaseQuery = async (
    args,
    api,
    extraOptions
) => {

    const state = api.getState();

    const baseUrl =
        state.settings.apiUrl;

    const rawBaseQuery = fetchBaseQuery({
        baseUrl,
    });

    return rawBaseQuery(
        args,
        api,
        extraOptions
    );
};

Поддержка нескольких API

const baseUrl =
    args.service === 'users'
        ? 'https://users.api.com'
        : 'https://orders.api.com';

Преобразование запросов

Кастомный baseQuery позволяет изменять запросы до отправки.

Пример нормализации данных

args.body = {
    ...args.body,
    timestamp: Date.now(),
};

Преобразование ответов

if (result.data) {

    result.data = normalizeData(
        result.data
    );

}

Шифрование данных

Иногда API требует зашифрованные payload.

Пример

args.body = encrypt(args.body);

Дешифровка ответа

result.data = decrypt(result.data);

Работа с FormData

Автоматическая отправка файлов

uploadFile: builder.mutation({
    query: (file) => {

        const formData = new FormData();

        formData.append('file', file);

        return {
            url: '/upload',
            method: 'POST',
            body: formData,
        };
    },
}),

Важная особенность Content-Type

При использовании FormData нельзя вручную указывать:

Content-Type: multipart/form-data

Иначе boundary будет сформирован неправильно.


Обработка бинарных данных

Загрузка файлов

const response = await fetch(args.url);

const blob = await response.blob();

Кастомная сериализация параметров

Иногда API требует нестандартный query string.


Пример

const params = new URLSearchParams();

params.append('ids[]', 1);
params.append('ids[]', 2);
params.append('ids[]', 3);

Поддержка WebSocket

RTK Query может использовать кастомный baseQuery даже для WebSocket-коммуникаций.

Упрощённый пример

const websocketBaseQuery =
    () =>
    async ({ message }) => {

        return new Promise((resolve) => {

            socket.send(message);

            socket.onmess age = (event) => {

                resolve({
                    data: JSON.parse(event.data),
                });

            };

        });
    };

Комбинирование нескольких baseQuery

Можно создавать цепочки обработчиков.

Пример композиции

const withLogger =
    (baseQuery) =>
    async (args, api, extraOptions) => {

        console.log('Request started');

        const result = await baseQuery(
            args,
            api,
            extraOptions
        );

        console.log('Request ended');

        return result;
    };

Комбинирование

const enhancedBaseQuery =
    withLogger(
        withRetry(
            withAuth(baseQuery)
        )
    );

Типизация кастомного baseQuery

При использовании TypeScript желательно явно указывать типы.

Пример

import {
    BaseQueryFn,
    FetchArgs,
    FetchBaseQueryError,
} from '@reduxjs/toolkit/query';

const customBaseQuery: BaseQueryFn<
    string | FetchArgs,
    unknown,
    FetchBaseQueryError
> = async (
    args,
    api,
    extraOptions
) => {

    return fetchBaseQuery({
        baseUrl: '/api',
    })(
        args,
        api,
        extraOptions
    );
};

Типизация ошибок

type CustomError = {
    status: number;
    message: string;
};

Типизация данных ответа

BaseQueryFn<
    FetchArgs,
    UserResponse,
    CustomError
>

Особенности производительности

Нежелательное создание fetchBaseQuery

Плохо:

const customBaseQuery = async (
    args,
    api,
    extraOptions
) => {

    const rawBaseQuery =
        fetchBaseQuery({
            baseUrl: '/api',
        });

    return rawBaseQuery(
        args,
        api,
        extraOptions
    );
};

Проблема — новый экземпляр создаётся при каждом запросе.


Правильный вариант

const rawBaseQuery = fetchBaseQuery({
    baseUrl: '/api',
});

Создание должно происходить один раз вне функции.


Обработка network ошибок

if (result.error?.status === 'FETCH_ERROR') {

}

Обработка parsing ошибок

if (result.error?.status === 'PARSING_ERROR') {

}

Обработка timeout ошибок

if (result.error?.status === 'TIMEOUT_ERROR') {

}

Универсальная production-архитектура

На практике крупные приложения часто используют следующую структуру:

const rawBaseQuery = fetchBaseQuery({
    baseUrl: '/api',
    prepareHeaders,
});

const baseQueryWithAuth =
    withAuth(rawBaseQuery);

const baseQueryWithRetry =
    withRetry(baseQueryWithAuth);

const baseQueryWithLogger =
    withLogger(baseQueryWithRetry);

export const api = createApi({
    reducerPath: 'api',
    baseQuery: baseQueryWithLogger,
    endpoints: () => ({}),
});

Подобный подход делает систему:

  • расширяемой;
  • тестируемой;
  • предсказуемой;
  • изолированной;
  • переиспользуемой;
  • удобной для поддержки;
  • пригодной для enterprise-приложений.