Стандарты

Backend REST API Design

Стандарты проектирования REST API на Nuxt Server (Nitro).

Стандарты проектирования REST API на Nuxt Server (Nitro).

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

Подход

  • File-based routing — структура папок = структура API
  • HTTP метод в имени файлаusers.get.ts, users.post.ts
  • Единый формат ответов — консистентная структура для всех эндпоинтов
  • Централизованная обработка ошибок — через createError
  • Валидация на входе — Zod для body и query параметров

Именование файлов

HTTP методы

Суффикс файлаHTTP методПример файлаURL
.get.tsGETusers.get.tsGET /api/users
.post.tsPOSTusers.post.tsPOST /api/users
.put.tsPUTusers/[id].put.tsPUT /api/users/:id
.patch.tsPATCHusers/[id].patch.tsPATCH /api/users/:id
.delete.tsDELETEusers/[id].delete.tsDELETE /api/users/:id
.tsGEThealth.tsGET /api/health

Структура папок

server/api/
├── auth/
│   ├── login.post.ts         → POST /api/auth/login
│   ├── logout.post.ts        → POST /api/auth/logout
│   └── me.get.ts             → GET /api/auth/me
├── users/
│   ├── index.get.ts          → GET /api/users
│   ├── index.post.ts         → POST /api/users
│   ├── [id].get.ts           → GET /api/users/:id
│   ├── [id].patch.ts         → PATCH /api/users/:id
│   ├── [id].delete.ts        → DELETE /api/users/:id
│   └── [id]/
│       └── orders.get.ts     → GET /api/users/:id/orders
├── orders/
│   ├── index.get.ts          → GET /api/orders
│   ├── index.post.ts         → POST /api/orders
│   └── [id]/
│       ├── index.get.ts      → GET /api/orders/:id
│       └── status.patch.ts   → PATCH /api/orders/:id/status
└── health.ts                 → GET /api/health

Правила именования

// Коллекция — index.method.ts
server/api/users/index.get.ts     // GET /api/users
server/api/users/index.post.ts    // POST /api/users

// Единичный ресурс — [id].method.ts
server/api/users/[id].get.ts      // GET /api/users/:id
server/api/users/[id].patch.ts    // PATCH /api/users/:id

// Вложенные ресурсы — [id]/resource.method.ts
server/api/users/[id]/avatar.put.ts   // PUT /api/users/:id/avatar

// Действия (actions) — resource/action.post.ts
server/api/orders/[id]/cancel.post.ts // POST /api/orders/:id/cancel

Event Handler Pattern

Базовый шаблон

// server/api/users.get.ts
export default defineEventHandler(async (event) => {
  // 1. Получение параметров
  const query = getQuery(event)

  // 2. Бизнес-логика
  const users = await prisma.user.findMany()

  // 3. Возврат данных
  return users
})

Шаблон с параметрами пути

// server/api/users/[id].get.ts
export default defineEventHandler(async (event) => {
  const id = getRouterParam(event, 'id')

  const user = await prisma.user.findUnique({
    where: { id }
  })

  if (!user) {
    throw createError({
      statusCode: 404,
      statusMessage: 'User not found'
    })
  }

  return user
})

Шаблон с телом запроса

// server/api/users.post.ts
export default defineEventHandler(async (event) => {
  const body = await readBody(event)

  const user = await prisma.user.create({
    data: body
  })

  // 201 Created для POST
  setResponseStatus(event, 201)

  return user
})

Структура ответов

Успешные ответы

Единичный ресурс

// GET /api/users/:id
export default defineEventHandler(async (event) => {
  const id = getRouterParam(event, 'id')

  const user = await prisma.user.findUnique({
    where: { id },
    select: {
      id: true,
      email: true,
      name: true,
      createdAt: true
    }
  })

  if (!user) {
    throw createError({ statusCode: 404, statusMessage: 'User not found' })
  }

  // Возвращаем объект напрямую
  return user
})

Ответ:

{
  "id": "clx123...",
  "email": "john@example.com",
  "name": "John Doe",
  "createdAt": "2025-01-28T12:00:00.000Z"
}

Коллекция с пагинацией

// GET /api/users?page=1&limit=20
export default defineEventHandler(async (event) => {
  const query = getQuery(event)
  const page = Number(query.page) || 1
  const limit = Math.min(Number(query.limit) || 20, 100) // max 100
  const skip = (page - 1) * limit

  const [users, total] = await Promise.all([
    prisma.user.findMany({
      skip,
      take: limit,
      orderBy: { createdAt: 'desc' }
    }),
    prisma.user.count()
  ])

  return {
    data: users,
    meta: {
      total,
      page,
      perPage: limit,
      lastPage: Math.ceil(total / limit)
    }
  }
})

Ответ:

{
  "data": [
    { "id": "clx123...", "email": "john@example.com", "name": "John" },
    { "id": "clx456...", "email": "jane@example.com", "name": "Jane" }
  ],
  "meta": {
    "total": 150,
    "page": 1,
    "perPage": 20,
    "lastPage": 8
  }
}

Создание ресурса (201 Created)

// POST /api/users
export default defineEventHandler(async (event) => {
  const body = await readBody(event)

  const user = await prisma.user.create({
    data: body,
    select: { id: true, email: true, name: true }
  })

  setResponseStatus(event, 201)

  return user
})

Удаление ресурса (204 No Content)

// DELETE /api/users/:id
export default defineEventHandler(async (event) => {
  const id = getRouterParam(event, 'id')

  await prisma.user.delete({ where: { id } })

  setResponseStatus(event, 204)
  return null
})

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

Стандартная структура ошибки

throw createError({
  statusCode: 400,           // HTTP код
  statusMessage: 'Bad Request', // Краткое описание
  data: {                    // Дополнительные данные (опционально)
    errors: {
      email: ['Invalid email format'],
      name: ['Name is required']
    }
  }
})

Ответ:

{
  "statusCode": 400,
  "statusMessage": "Bad Request",
  "data": {
    "errors": {
      "email": ["Invalid email format"],
      "name": ["Name is required"]
    }
  }
}

HTTP коды ошибок

КодОписаниеИспользование
400Bad RequestНекорректный формат запроса
401UnauthorizedОтсутствует или невалидный токен
403ForbiddenНет прав на операцию
404Not FoundРесурс не найден
409ConflictКонфликт (дубликат, состояние)
422Unprocessable EntityОшибки валидации данных
500Internal Server ErrorСерверная ошибка

Примеры ошибок

// 404 — ресурс не найден
throw createError({
  statusCode: 404,
  statusMessage: 'User not found'
})

// 401 — не авторизован
throw createError({
  statusCode: 401,
  statusMessage: 'Unauthorized',
  data: { message: 'Token expired' }
})

// 403 — нет прав
throw createError({
  statusCode: 403,
  statusMessage: 'Forbidden',
  data: { message: 'Admin access required' }
})

// 409 — конфликт
throw createError({
  statusCode: 409,
  statusMessage: 'Conflict',
  data: { message: 'Email already exists' }
})

// 422 — ошибки валидации
throw createError({
  statusCode: 422,
  statusMessage: 'Validation Error',
  data: {
    errors: {
      email: ['Invalid email format'],
      password: ['Password must be at least 8 characters']
    }
  }
})

Валидация

Zod для валидации

// server/utils/validators.ts
import { z } from 'zod'

export const createUserSchema = z.object({
  email: z.string().email('Invalid email format'),
  name: z.string().min(2, 'Name must be at least 2 characters'),
  password: z.string().min(8, 'Password must be at least 8 characters')
})

export const updateUserSchema = createUserSchema.partial()

export type CreateUserInput = z.infer<typeof createUserSchema>
export type UpdateUserInput = z.infer<typeof updateUserSchema>

Валидация body

// server/api/users.post.ts
import { createUserSchema } from '~/server/utils/validators'

export default defineEventHandler(async (event) => {
  const body = await readBody(event)

  // Валидация
  const result = createUserSchema.safeParse(body)

  if (!result.success) {
    throw createError({
      statusCode: 422,
      statusMessage: 'Validation Error',
      data: {
        errors: result.error.flatten().fieldErrors
      }
    })
  }

  // result.data — типизированные данные
  const user = await prisma.user.create({
    data: result.data
  })

  setResponseStatus(event, 201)
  return user
})

Валидация query параметров

// server/utils/validators.ts
export const paginationSchema = z.object({
  page: z.coerce.number().positive().default(1),
  limit: z.coerce.number().positive().max(100).default(20),
  sort: z.enum(['asc', 'desc']).default('desc')
})

export const userFiltersSchema = paginationSchema.extend({
  search: z.string().optional(),
  role: z.enum(['user', 'admin']).optional()
})
// server/api/users.get.ts
import { userFiltersSchema } from '~/server/utils/validators'

export default defineEventHandler(async (event) => {
  const query = getQuery(event)

  const result = userFiltersSchema.safeParse(query)

  if (!result.success) {
    throw createError({
      statusCode: 400,
      statusMessage: 'Invalid query parameters',
      data: { errors: result.error.flatten().fieldErrors }
    })
  }

  const { page, limit, sort, search, role } = result.data

  const where = {
    ...(search && {
      OR: [
        { name: { contains: search, mode: 'insensitive' } },
        { email: { contains: search, mode: 'insensitive' } }
      ]
    }),
    ...(role && { role })
  }

  const [users, total] = await Promise.all([
    prisma.user.findMany({
      where,
      skip: (page - 1) * limit,
      take: limit,
      orderBy: { createdAt: sort }
    }),
    prisma.user.count({ where })
  ])

  return {
    data: users,
    meta: { total, page, perPage: limit, lastPage: Math.ceil(total / limit) }
  }
})

Утилита для валидации

// server/utils/validate.ts
import { z } from 'zod'
import type { H3Event } from 'h3'

export async function validateBody<T extends z.ZodSchema>(
  event: H3Event,
  schema: T
): Promise<z.infer<T>> {
  const body = await readBody(event)
  const result = schema.safeParse(body)

  if (!result.success) {
    throw createError({
      statusCode: 422,
      statusMessage: 'Validation Error',
      data: { errors: result.error.flatten().fieldErrors }
    })
  }

  return result.data
}

export function validateQuery<T extends z.ZodSchema>(
  event: H3Event,
  schema: T
): z.infer<T> {
  const query = getQuery(event)
  const result = schema.safeParse(query)

  if (!result.success) {
    throw createError({
      statusCode: 400,
      statusMessage: 'Invalid query parameters',
      data: { errors: result.error.flatten().fieldErrors }
    })
  }

  return result.data
}
// Использование
export default defineEventHandler(async (event) => {
  const data = await validateBody(event, createUserSchema)
  // data уже типизирован
})

Типы ответов API

// server/types/api.ts

// Пагинированный ответ
export interface PaginatedResponse<T> {
  data: T[]
  meta: {
    total: number
    page: number
    perPage: number
    lastPage: number
  }
}

// Ответ с ошибкой
export interface ApiErrorResponse {
  statusCode: number
  statusMessage: string
  data?: {
    message?: string
    errors?: Record<string, string[]>
  }
}

// Успешный ответ для действий
export interface ActionResponse {
  success: boolean
  message?: string
}

Middleware для аутентификации

// server/middleware/auth.ts
export default defineEventHandler((event) => {
  // Публичные маршруты
  const publicRoutes = [
    '/api/auth/login',
    '/api/auth/register',
    '/api/health'
  ]

  if (publicRoutes.some(route => event.path.startsWith(route))) {
    return
  }

  // Проверяем токен
  const authHeader = getHeader(event, 'authorization')

  if (!authHeader?.startsWith('Bearer ')) {
    throw createError({
      statusCode: 401,
      statusMessage: 'Unauthorized'
    })
  }

  const token = authHeader.slice(7)

  try {
    const payload = verifyJwtToken(token)
    event.context.userId = payload.userId
    event.context.userRole = payload.role
  } catch {
    throw createError({
      statusCode: 401,
      statusMessage: 'Invalid token'
    })
  }
})
// Использование в эндпоинте
export default defineEventHandler(async (event) => {
  // userId доступен из контекста после middleware
  const userId = event.context.userId

  const user = await prisma.user.findUnique({
    where: { id: userId }
  })

  return user
})

Gotchas (Важные нюансы)

1. Обязательный export default

// Правильно
export default defineEventHandler(async (event) => {
  return { ok: true }
})

// НЕПРАВИЛЬНО — вернёт 404!
export const handler = defineEventHandler(async (event) => {
  return { ok: true }
})

2. Приоритет маршрутов

Конкретные маршруты имеют приоритет над динамическими:

server/api/users/me.get.ts      ← Обрабатывается первым
server/api/users/[id].get.ts    ← Обрабатывается для остальных ID

3. Методы по умолчанию

Файл без суффикса метода обрабатывает только GET:

// server/api/health.ts → только GET /api/health
export default defineEventHandler(() => ({ status: 'ok' }))

4. Неправильный метод не возвращает 405

При вызове POST на .get.ts файл Nitro может вернуть неожиданный ответ вместо 405 Method Not Allowed.

Чек-лист

  • Файлы API в server/api/ с правильными суффиксами методов
  • export default defineEventHandler() в каждом файле
  • Хэндлер тонкий — бизнес-логика в сервисном слое
  • Валидация входных данных через Zod (схемы в shared/schemas/)
  • HTTP коды: 201 для POST, 204 для DELETE, 4xx для ошибок
  • Пагинация для коллекций с meta объектом
  • Обработка 404 для несуществующих ресурсов
  • Ошибки через AppError в сервисах (не createError)
  • Middleware для аутентификации
  • Cache-Control заголовки для GET-эндпоинтов (где применимо)

Response Headers (кэширование)

Для публичных GET-эндпоинтов устанавливай Cache-Control:

// server/api/products/index.get.ts
export default defineEventHandler(async (event) => {
  // Кэш на 5 минут для публичных списков
  setHeader(event, 'Cache-Control', 'public, max-age=300, s-maxage=300')

  return ProductService.list()
})

// server/api/users/[id].get.ts — приватные данные
export default defineEventHandler(async (event) => {
  // Не кэшировать приватные данные
  setHeader(event, 'Cache-Control', 'private, no-cache')

  const id = getRouterParam(event, 'id')!
  return UserService.getById(id)
})

Подробнее о стратегиях кэширования: ADR-015: REST API Caching.

Связанные стандарты