Архитектура

$fetch.create для HTTP запросов

Контекст

Нужен единый способ работы с API с поддержкой авторизации и обработки ошибок.

Решение

Использовать $fetch.create() для создания преконфигурированного HTTP-клиента.

Документация Nuxt:

Обоснование

Критерий$fetch.create
Встроен в NuxtДа (ofetch)
ТипизацияОтличная
InterceptorsonRequest, onResponse, onResponseError
SSR/SPAРаботает везде
Bundle size0 KB (встроен)

Паттерн: Plugin + Composable

1. Plugin для создания $api instance

// app/plugins/api.ts
export default defineNuxtPlugin((nuxtApp) => {
  const { token, refreshToken, logout } = useSession()
  const config = useRuntimeConfig()

  const api = $fetch.create({
    baseURL: config.public.apiBase,

    onRequest({ options }) {
      if (token.value) {
        options.headers.set('Authorization', `Bearer ${token.value}`)
      }
    },

    async onResponseError({ response }) {
      if (response.status === 401) {
        const refreshed = await refreshToken()
        if (!refreshed) {
          await nuxtApp.runWithContext(() => navigateTo('/login'))
        }
      }
    },
  })

  return {
    provide: {
      api,
    },
  }
})

2. Composable useAPI

// app/composables/useAPI.ts
import type { UseFetchOptions } from 'nuxt/app'

export function useAPI<T>(
  url: string | (() => string),
  options?: UseFetchOptions<T>,
) {
  return useFetch(url, {
    ...options,
    $fetch: useNuxtApp().$api as typeof $fetch,
  })
}

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

// В компонентах — реактивный useFetch
const { data: users, pending } = await useAPI<User[]>('/users')

// Для мутаций — напрямую $api
const { $api } = useNuxtApp()
await $api('/users', { method: 'POST', body: userData })

4. useFetch vs useAsyncData

ФункцияКогдаОсобенности
useAPI() (обёртка useFetch)GET-запросы к APIАвтоматический ключ кэша по URL, реактивный URL
useAsyncData()Сложная логика получения данныхРучной ключ кэша, любая async-функция
$api (напрямую)Мутации (POST/PUT/DELETE)Без кэша, без SSR deduplication
// useFetch — когда URL является ключом кэша
const { data: users } = await useAPI<User[]>('/users')

// useAsyncData — когда нужна сложная логика или зависимые запросы
const { data: dashboard } = await useAsyncData('dashboard', async () => {
  const [stats, recent] = await Promise.all([
    $api('/stats'),
    $api('/activity?limit=10'),
  ])
  return { stats, recent }
})

// Зависимые запросы — watch для автообновления
const userId = ref<string>()
const { data: profile } = await useAPI<User>(
  () => `/users/${userId.value}`,
  { watch: [userId] }, // рефетч при смене userId
)

// Ленивая загрузка — не блокирует рендер
const { data: comments, pending } = await useAPI<Comment[]>('/comments', {
  lazy: true,
})

// Ручной рефетч — для refresh кнопок
const { data, refresh } = await useAPI<User[]>('/users')
// <UButton @click="refresh()">Обновить</UButton>

5. Пагинация

// app/composables/usePaginatedAPI.ts
export function usePaginatedAPI<T>(url: string, pageSize = 20) {
  const page = ref(1)
  const { data, pending, refresh } = useAPI<{ items: T[]; total: number }>(
    () => `${url}?page=${page.value}&limit=${pageSize}`,
    { watch: [page] },
  )

  const totalPages = computed(() =>
    Math.ceil((data.value?.total ?? 0) / pageSize),
  )

  return { data, pending, page, totalPages, refresh }
}

Альтернатива: useAuthFetch в auth-layer

Для auth-layer используем упрощённый паттерн:

// auth-layer/composables/useAuthFetch.ts
export function useAuthFetch() {
  const { token, refreshToken, logout } = useSession()
  const config = useRuntimeConfig()

  return $fetch.create({
    baseURL: config.public.apiBase,

    onRequest({ options }) {
      if (token.value) {
        options.headers = {
          ...options.headers,
          Authorization: `Bearer ${token.value}`,
        }
      }
    },

    async onResponseError({ response }) {
      if (response.status === 401) {
        const refreshed = await refreshToken()
        if (!refreshed) {
          logout()
        }
      }
    },
  })
}

Публичные vs Авторизованные запросы

// Публичные — $fetch напрямую
const services = await $fetch('/api/public/services')

// Авторизованные — useAuthFetch() или $api
const authFetch = useAuthFetch()
const users = await authFetch<User[]>('/users')

Composable на сущность

// app/composables/useOrders.ts
export function useOrders() {
  const authFetch = useAuthFetch()

  return {
    getAll: (params?: OrderParams) =>
      authFetch<PaginatedResponse<Order>>('/orders', { query: params }),

    getById: (id: string) =>
      authFetch<Order>(`/orders/${id}`),

    create: (data: CreateOrderDto) =>
      authFetch<Order>('/orders', { method: 'POST', body: data }),

    update: (id: string, data: UpdateOrderDto) =>
      authFetch<Order>(`/orders/${id}`, { method: 'PATCH', body: data }),

    delete: (id: string) =>
      authFetch(`/orders/${id}`, { method: 'DELETE' }),
  }
}

Конфигурация

// nuxt.config.ts
export default defineNuxtConfig({
  runtimeConfig: {
    public: {
      apiBase: process.env.NUXT_PUBLIC_API_BASE || 'http://localhost:3001/api',
    },
  },
})

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

import { FetchError } from 'ofetch'

try {
  await authFetch('/orders', { method: 'POST', body: data })
} catch (error) {
  if (error instanceof FetchError) {
    if (error.statusCode === 422) {
      // Validation errors
      const errors = error.data?.errors
    }
    if (error.statusCode === 409) {
      // Conflict
    }
  }
}

Последствия

Положительные

  • Нативное решение Nuxt (ofetch)
  • $fetch.create для преконфигурации
  • Автоматический token refresh
  • Типизация из коробки
  • runWithContext для навигации в async callbacks

Отрицательные

  • Нет (встроенное решение)

Ссылки

Связанные решения