Стандарты
Backend REST API Design
Стандарты проектирования REST API на Nuxt Server (Nitro).
Стандарты проектирования REST API на Nuxt Server (Nitro).
Документация Nuxt:
- https://nuxt.com/docs/guide/directory-structure/server
- https://nuxt.com/docs/api/utils/define-event-handler
Подход
- File-based routing — структура папок = структура API
- HTTP метод в имени файла —
users.get.ts,users.post.ts - Единый формат ответов — консистентная структура для всех эндпоинтов
- Централизованная обработка ошибок — через
createError - Валидация на входе — Zod для body и query параметров
Именование файлов
HTTP методы
| Суффикс файла | HTTP метод | Пример файла | URL |
|---|---|---|---|
.get.ts | GET | users.get.ts | GET /api/users |
.post.ts | POST | users.post.ts | POST /api/users |
.put.ts | PUT | users/[id].put.ts | PUT /api/users/:id |
.patch.ts | PATCH | users/[id].patch.ts | PATCH /api/users/:id |
.delete.ts | DELETE | users/[id].delete.ts | DELETE /api/users/:id |
.ts | GET | health.ts | GET /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 коды ошибок
| Код | Описание | Использование |
|---|---|---|
| 400 | Bad Request | Некорректный формат запроса |
| 401 | Unauthorized | Отсутствует или невалидный токен |
| 403 | Forbidden | Нет прав на операцию |
| 404 | Not Found | Ресурс не найден |
| 409 | Conflict | Конфликт (дубликат, состояние) |
| 422 | Unprocessable Entity | Ошибки валидации данных |
| 500 | Internal 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.
Связанные стандарты
- Standard: Service Layer — абстракция бизнес-логики
- Standard: Error Handling — обработка ошибок
- ADR-012: Nuxt 4 Server Backend — архитектура backend
- ADR-015: REST API Caching — стратегии кэширования
- standard-api-client — клиентские запросы
- standard-prisma — работа с базой данных