Skip to content

createAxiosClient и API-слой

Назначение

createAxiosClient создаёт настроенный AxiosInstance для data-слоя приложения. Он решает transport-задачи:

  • base URL, timeout, credentials и общие headers;
  • Bearer access token для private client;
  • один refresh request для нескольких одновременных 401;
  • сохранение нового token через ITokenProvider;
  • один повтор исходного запроса после успешного refresh;
  • terminal callbacks для 401, 403, 5xx и refresh failure;
  • корректный Content-Type для JSON и FormData.

Он не хранит состояние экрана. loading, data, error и status принадлежат MobX-store.

mermaid
flowchart LR
    Store["MobX feature store"] --> Handler["query-layer store"]
    Handler --> Feature["Feature API"]
    Feature --> Client["createAxiosClient"]
    Client --> Backend["Backend"]

Public client

ts
import type { AxiosRequestConfig } from "axios"
import { createAxiosClient } from "@dubium/query-layer"

import { BASE_URL } from "../shared/config"

const baseConfig: AxiosRequestConfig = {
    baseURL: BASE_URL,
    timeout: 15_000,
    withCredentials: true,
}

export const publicApi = createAxiosClient(baseConfig)

Используйте его для registration, login, password recovery и публичных справочников.

ITokenProvider

ts
import { makeAutoObservable } from "mobx"
import type { ITokenLike, ITokenProvider } from "@dubium/query-layer"

class TokenStore implements ITokenProvider {
    encodedToken: string | null = null

    constructor() {
        makeAutoObservable(this, {}, { autoBind: true })
    }

    getToken(): ITokenLike | null {
        return this.encodedToken ? { encoded: this.encodedToken } : null
    }

    setToken(token: ITokenLike | null): void {
        this.encodedToken = token?.encoded ?? null
    }

    clear(): void {
        this.encodedToken = null
    }
}

export const tokenStore = new TokenStore()

Provider может быть синхронным или асинхронным. Client запрашивает актуальный token перед каждым private request.

Refresh API

Refresh-функция получает отдельный Axios client без auth response interceptor. Поэтому refresh endpoint не создаёт бесконечную 401-рекурсию.

ts
import type { AxiosInstance, AxiosResponse } from "axios"
import { object, parse, string } from "valibot"

const RefreshTokenSchema = object({
    accessToken: string(),
})

interface RefreshTokenResponse {
    accessToken: string
}

export const refreshTokenApi = async (
    client: AxiosInstance,
    authorization: string,
): Promise<AxiosResponse<RefreshTokenResponse>> => {
    const response = await client.post<unknown>("/auth/refresh", undefined, {
        headers: { Authorization: authorization },
    })

    return {
        ...response,
        data: parse(RefreshTokenSchema, response.data),
    }
}

Private client

ts
import { createAxiosClient } from "@dubium/query-layer"

import { refreshTokenApi, tokenStore } from "./token"

export const authApi = createAxiosClient({
    ...baseConfig,

    isPrivate: true,
    tokenProvider: tokenStore,

    refreshToken: async ({ client, failedRequest }) => {
        const authorization = failedRequest.headers.get("Authorization")
        if (typeof authorization !== "string" || authorization.length === 0) {
            return null
        }

        const response = await refreshTokenApi(client, authorization)
        return { encoded: response.data.accessToken }
    },

    retryAfterRefresh: true,

    onRefreshFailed: (error) => {
        console.error("Failed to refresh access token", error)
    },

    onUnauthorizedFinal: () => {
        tokenStore.clear()
    },
})

Последовательность 401

  1. private request получает текущий token;
  2. backend отвечает 401;
  3. client запускает или присоединяется к текущему refresh promise;
  4. новый token сохраняется через tokenProvider.setToken();
  5. исходный request повторяется один раз;
  6. повторный 401 считается terminal: циклического retry нет.

Если одновременно завершились 401 десять запросов одного authApi, выполняется один refresh request.

Граница передачи Bearer-токена

Private client автоматически передаёт token только относительным запросам и absolute URL с origin текущей страницы или настроенного baseURL. Это защищает от утечки токена, если endpoint случайно вернул или принял чужой absolute URL.

Для намеренного обращения к дополнительному доверенному origin задайте явный allowlist:

ts
const authApi = createAxiosClient({
  baseURL: "https://api.example.com",
  allowedAuthOrigins: ["https://uploads.example.com"],
  isPrivate: true,
  tokenProvider: tokenStore,
})

Значения allowedAuthOrigins должны быть валидными HTTP(S) URL. Явный Authorization, переданный самим вызывающим кодом, client не заменяет.

Feature API с валидацией

ts
import type { AxiosRequestConfig, AxiosResponse } from "axios"
import { object, parse, string } from "valibot"

import { authApi } from "../../api"

const UserSchema = object({
    id: string(),
    name: string(),
})

export interface User {
    id: string
    name: string
}

export const getUserApi = async (id: string, config?: AxiosRequestConfig): Promise<AxiosResponse<User>> => {
    const response = await authApi.get<unknown>("/users/" + id, config)

    return {
        ...response,
        data: parse(UserSchema, response.data),
    }
}

Feature API не содержит MobX state. Его можно unit-тестировать отдельно.

Выполнение через FetchStore

API-функцию вызывает MobX-store, а не React-компонент. Domain wrapper даёт предметные имена и скрывает универсальный handler:

ts
import { makeAutoObservable } from "mobx"
import { FetchStore } from "@dubium/query-layer"

export class UserDetailsStore {
    private readonly requestHandler = new FetchStore<User, [string]>({
        queryKey: (id) => ["users", "details", id],
        request: (id, config) => getUserApi(id, config),
        options: { staleTime: 60_000 },
    })

    constructor() {
        makeAutoObservable<this, "requestHandler">(this, { requestHandler: false }, { autoBind: true })
    }

    get user(): User | null {
        return this.requestHandler.data ?? null
    }

    async fetch(id: string): Promise<User | null> {
        const response = await this.requestHandler.fetch(id)
        return response?.data ?? null
    }
}

Именно store запускает getUserApi. UI вызывает userDetailsStore.fetch(id) и читает предметные getters. UI не получает API-функцию или Axios client. Полный разбор находится на странице FetchStore.

Нормализованные ошибки

ts
import type { INormalizedAxiosError } from "@dubium/query-layer"

get requestError(): INormalizedAxiosError | null {
  return this.requestHandler.error
}

Основные поля:

ПолеЗначение
statusHTTP status или undefined для network error
codeAxios/backend error code
messageнормализованное сообщение
payloadисходное тело ошибки для domain schema
isAxiosErrorбыла ли исходная ошибка ошибкой Axios
rawисходная ошибка; не показывайте её пользователю напрямую

Domain store должен валидировать payload собственной error schema, как в registration-примере пользователя.

Terminal callbacks

ts
const api = createAxiosClient({
    ...baseConfig,

    onForbidden: (error) => {
        console.warn("Forbidden request", error)
    },

    onServerError: (error) => {
        console.error("Server error", error)
    },

    onRefreshFailed: (error) => {
        console.error("Access-token refresh failed", error)
    },

    onUnauthorizedFinal: () => {
        tokenStore.clear()
        window.location.assign("/login")
    },
})

Callbacks не заменяют store error. Их sync/async исключения изолируются и не подменяют исходную HTTP/refresh ошибку. Они предназначены для глобальных реакций: logout, error reporting и route transition.

Lifecycle client

Глобальные public/auth clients обычно живут всё время работы приложения. Для короткого lifecycle можно освободить interceptors:

ts
import { disposeAxiosClient } from "@dubium/query-layer"

disposeAxiosClient(publicApi)
disposeAxiosClient(authApi)

Повторный вызов безопасен.

Что не экспортируется

Пакет намеренно не экспортирует:

  • createManagedAxiosClient;
  • createApi alias;
  • createAxiosQuery;
  • createAxiosMutation;
  • raw request executor;
  • public QueryClient и observers.

Единственный transport factory — createAxiosClient. Единственные публичные исполнители бизнес-запросов — MobX stores.

Граница импортов

js
{
  files: ["src/ui/**/*.{ts,tsx}", "src/pages/**/*.{ts,tsx}"],
  rules: {
    "no-restricted-imports": ["error", {
      patterns: [
        "**/data/api",
        "**/data/**/api/**"
      ]
    }]
  }
}

Разрешённый dependency flow:

UI → model/store → feature API → publicApi/authApi.