Урок 09

Практика: 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