Тема
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
- private request получает текущий token;
- backend отвечает 401;
- client запускает или присоединяется к текущему refresh promise;
- новый token сохраняется через
tokenProvider.setToken(); - исходный request повторяется один раз;
- повторный 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
}Основные поля:
| Поле | Значение |
|---|---|
status | HTTP status или undefined для network error |
code | Axios/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;createApialias;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.