Практика: REST API
Собрали полноценный REST API на Next.js.
Структура
app/api/
├── posts/
│ ├── route.ts → GET, POST /api/posts
│ └── [id]/
│ └── route.ts → GET, PATCH, DELETE /api/posts/1
├── users/
│ ├── route.ts
│ └── [id]/route.ts
└── auth/
├── login/route.ts
└── register/route.ts
lib/
├── db.ts
├── auth.ts
└── schemas.ts
Схемы
// lib/schemas.ts
import { z } from 'zod';
export const PostCreateSchema = z.object({
title: z.string().min(3).max(200),
body: z.string().min(10),
published: z.boolean().default(false),
tags: z.array(z.string()).max(10).default([]),
});
export const PostUpdateSchema = PostCreateSchema.partial();
export const QuerySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
search: z.string().optional(),
published: z.coerce.boolean().optional(),
sort: z.enum(['newest', 'oldest', 'popular']).default('newest'),
});
Хелперы
// lib/api.ts
import { NextResponse } from 'next/server';
import { ZodError } from 'zod';
export function json<T>(data: T, status = 200) {
return NextResponse.json(data, { status });
}
export function error(message: string, status = 400, details?: unknown) {
return NextResponse.json({ error: message, details }, { status });
}
export function validationError(error: ZodError) {
return NextResponse.json(
{
error: 'Validation failed',
errors: error.flatten().fieldErrors,
},
{ status: 422 }
);
}
export function unauthorized() {
return error('Unauthorized', 401);
}
export function forbidden() {
return error('Forbidden', 403);
}
export function notFound() {
return error('Not found', 404);
}
Список постов
// app/api/posts/route.ts
import { db } from '@/lib/db';
import { QuerySchema, PostCreateSchema } from '@/lib/schemas';
import { getCurrentUser } from '@/lib/auth';
import { json, validationError, unauthorized } from '@/lib/api';
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const result = QuerySchema.safeParse(Object.fromEntries(searchParams));
if (!result.success) return validationError(result.error);
const { page, limit, search, published, sort } = result.data;
const where = {
...(published !== undefined && { published }),
...(search && {
OR: [
{ title: { contains: search, mode: 'insensitive' as const } },
{ body: { contains: search, mode: 'insensitive' as const } },
],
}),
};
const orderBy = {
newest: { createdAt: 'desc' as const },
oldest: { createdAt: 'asc' as const },
popular: { views: 'desc' as const },
}[sort];
const [posts, total] = await Promise.all([
db.post.findMany({
where,
orderBy,
skip: (page - 1) * limit,
take: limit,
include: { author: { select: { id: true, name: true } } },
}),
db.post.count({ where }),
]);
return json({
data: posts,
meta: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
});
}
export async function POST(request: Request) {
const user = await getCurrentUser();
if (!user) return unauthorized();
const body = await request.json();
const result = PostCreateSchema.safeParse(body);
if (!result.success) return validationError(result.error);
const post = await db.post.create({
data: {
...result.data,
slug: slugify(result.data.title),
authorId: user.id,
},
});
return json(post, 201);
}
function slugify(text: string) {
return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
}
Один пост
// app/api/posts/[id]/route.ts
import { db } from '@/lib/db';
import { PostUpdateSchema } from '@/lib/schemas';
import { getCurrentUser } from '@/lib/auth';
import { json, notFound, unauthorized, forbidden, validationError } from '@/lib/api';
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const post = await db.post.findUnique({
where: { id: Number(id) },
include: { author: { select: { id: true, name: true } } },
});
if (!post) return notFound();
return json(post);
}
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const user = await getCurrentUser();
if (!user) return unauthorized();
const { id } = await params;
const post = await db.post.findUnique({ where: { id: Number(id) } });
if (!post) return notFound();
if (post.authorId !== user.id && user.role !== 'admin') return forbidden();
const body = await request.json();
const result = PostUpdateSchema.safeParse(body);
if (!result.success) return validationError(result.error);
const updated = await db.post.update({
where: { id: Number(id) },
data: result.data,
});
return json(updated);
}
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const user = await getCurrentUser();
if (!user) return unauthorized();
const { id } = await params;
const post = await db.post.findUnique({ where: { id: Number(id) } });
if (!post) return notFound();
if (post.authorId !== user.id && user.role !== 'admin') return forbidden();
await db.post.delete({ where: { id: Number(id) } });
return new Response(null, { status: 204 });
}
Аутентификация
// app/api/auth/login/route.ts
import { db } from '@/lib/db';
import { verifyPassword } from '@/lib/password';
import { createToken } from '@/lib/auth';
import { json, error } from '@/lib/api';
import { z } from 'zod';
const LoginSchema = z.object({
email: z.string().email(),
password: z.string().min(1),
});
export async function POST(request: Request) {
const body = await request.json();
const result = LoginSchema.safeParse(body);
if (!result.success) {
return json({ errors: result.error.flatten() }, 422);
}
const { email, password } = result.data;
const user = await db.user.findUnique({ where: { email } });
if (!user || !(await verifyPassword(password, user.password))) {
return error('Invalid credentials', 401);
}
const token = await createToken({ userId: user.id });
const response = json({ user: { id: user.id, email: user.email } });
response.headers.set(
'Set-Cookie',
`token=${token}; HttpOnly; Path=/; Max-Age=604800; SameSite=Lax`
);
return response;
}
Использование
# Список
curl http://localhost:3000/api/posts?page=1&limit=10
# Создание
curl -X POST http://localhost:3000/api/posts \
-H "Content-Type: application/json" \
-H "Cookie: token=..." \
-d '{"title": "Новый", "body": "Текст поста"}'
# Обновление
curl -X PATCH http://localhost:3000/api/posts/1 \
-H "Content-Type: application/json" \
-H "Cookie: token=..." \
-d '{"title": "Обновлён"}'
# Удаление
curl -X DELETE http://localhost:3000/api/posts/1 \
-H "Cookie: token=..."
Итоги
- Zod для валидации
- Хелперы для ответов
- Проверка прав в каждом методе
- Пагинация через query
- Единый формат ответов
- Аутентификация через cookie