Привет! Меня зовут Андрей, я Backend Node.JS разработчик в одной из зарубежных компаний, занимающихся разработкой системы для администрирования офисов. Наше приложение и его веб-версия предоставляют арендодателям возможность отслеживать заполненность офиса, обеспечивать подключение IoT-устройств для отслеживания, например, количества еды в холодильниках или остатка воды в кулерах, выдавать пропуски для сотрудников в своё здание и много чего другого. Одним из важнейших узлов в этой системе является API как для внутренних пользователей, использующих приложение или веб-сайт, так и для клиентов, использующих наше Whitelabel решение. Всего в нашей системе зарегистрировано более двух сотен API эндпоинтов, для построения которых мы использовали фреймворк NestJS. Если вы по какой-то причине ещё не слышали про Nest, то я настоятельно рекомендую ознакомиться со статьёй NestJS - тот самый, настоящий бэкенд на nodejs. Одной из основных и наиболее значимых особенностей NestJS является нативная поддержка декораторов, что в свою очередь позволяет создавать эндпоинты декларативно.
@Get('/v2/:key')
@HttpCode(HttpStatus.OK)
async getContentFromCacheByKey(
@Param('key') key: string,
): Promise<GenericRedisResponse> {
const result = await this.cacheService.get(key);
if (!result) {
throw new NotFoundException(`There is no key ${key} in cache`);
}
return result;
}
Особенно польза декораторов становится заметна когда возникает необходимость принимать различные типы запросов по одному и тому же пути. Например, когда нам необходимо не только "брать" данные по ключу из кэша, но и сохранять данные под нужным нам ключом. Путь остаётся прежним, меняется лишь декоратор и содержимое метода.
@Post('/v2/:key')
@HttpCode(HttpStatus.NO_CONTENT)
async getContentFromCacheByKey(
@Param('key') key: string,
@Body() body: GenericRedisBody,
): Promise<void> {
await this.cacheService.set(key, body.data, body.ex, body.validFor);
}
Это очень удобно хотя бы потому, что отпадает необходимость создавать витиеватые методы с запутанными условными операторами. Не говоря уже об удобстве юнит-тестирования.
Несмотря на то, что днём я разрабатываю на NestJS, ночью я трансформируюсь в ярого фаната NextJS и стараюсь переписать на нём горсть из своих pet-проектов. К сожалению, в NextJS не реализована нативная поддержка декораторов для API, однако недавно с удивлением для себя я обнаружил, что кто-то пытается привнести это новшество в NextJS и это именно то, о чём я собираюсь сегодня рассказать.
@storyofams/next-api-decorators
Добавляет поддержку декораторов для API routes в NextJS. Написан на TypeScript, покрыт тестами на 100%, но ещё совсем молод и не очень популярен. Этой статьёй я попытаюсь исправить ситуацию и помочь людям поближе познакомиться с декларативным подходом к написанию API эндпоинтов в NextJS. Начать предлагаю с рассмотрения простого набора эндпоинтов для манипуляции с пользователями:
// pages/api/user.ts
class User {
// GET /api/user
@Get()
async fetchUser(@Query('id') id: string) {
const user = await DB.findUserById(id);
if (!user) {
throw new NotFoundException('User not found.');
}
return user;
}
// POST /api/user
@Post()
@HttpCode(201)
async createUser(@Body(ValidationPipe) body: CreateUserDto) {
return await DB.createUser(body.email);
}
}
export default createHandler(User);
Для сравнения, всё то же самое, но императивно:
export default async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method === 'GET') {
const user = await DB.findUserById(req.query.id);
if (!user) {
return res.status(404).json({
statusCode: 404,
message: 'User not found'
})
}
return res.json(user);
} else if (req.method === 'POST') {
// Very primitive e-mail address validation.
if (!req.body.email || (req.body.email && !req.body.email.includes('@'))) {
return res.status(400).json({
statusCode: 400,
message: 'Invalid e-mail address.'
})
}
const user = await DB.createUser(req.body.email);
return res.status(201).json(user);
}
res.status(404).json({
statusCode: 404,
message: 'Not Found'
});
}
На этом функциональность не заканчивается. Вы можете использовать декораторы для установления определённых заголовков как для единичных обработчиков, так и для всего набора в классе:
@SetHeader('Content-Type', 'text/plain')
class UserHandler {
@Get()
users(@Header('Referer') referer: string) {
return `Your referer is ${referer}`;
}
@Get('/json')
@SetHeader('Content-Type', 'application/json')
users(@Header('Referer') referer: string) {
return { referer };
}
}
Более того, добавляется поддержка валидации для полей тела запроса! Точь в точь как в NestJS. Для этого вам необходимо установить пакет class-validator и описать class members с использованием декораторов:
import { IsNotEmpty, IsEmail } from 'class-validator';
export class CreateUserDTO {
@IsEmail()
email: string;
@IsNotEmpty()
fullName: string;
}
В случае когда как минимум одно из полей не проходит валидацию, ваш эндпоинт вернёт ошибку 422 Unprocessable Entity
. При этом валидировать можно не только тело запроса, но и параметры и query элементы:
@Get('/users')
@Query('isActive', ParseBooleanPipe({ nullable: true })) isActive?: boolean
В данном случае позволяется опускание аргумента isActive, однако если он в URL присутствует, то он обязательно должен быть типа boolean. Это же применимо и к параметрам:
@Get('/users/:userId')
@Param('userId', ParseNumberPipe) userId: string,
Помимо валидации параметров и аргументов возможно так же проводить различного рода проверки в middleware, и применимо это как к единичным обработчикам, так и ко всему набору обработчиков. NestJS разработчики знакомы с таким понятием, как Guards (TLDR: позволяет определить необходимо ли дальнейшее исполнение кода в обработчике или же стоит прервать выполнение досрочно). Например, guard, обеспечивающий проверку валидности JWT-токена ещё до того, как будет запущен код в самом эндпоинте:
const JwtAuthGuard = createMiddlewareDecorator(
(req: NextApiRequest, res: NextApiResponse, next: NextFunction) => {
if (!validateJwt(req)) {
throw new UnauthorizedException();
// или
return next(new UnauthorizedException());
}
next();
}
);
class SecureHandler {
@Get()
@JwtAuthGuard() // здесь используется объявленный ранее обработчик
public securedData(): string {
return 'Secret data';
}
}
Для использования middleware для всего набора обработчиков используется декоратор useMiddleware:
@UseMiddleware(() => ...)
class User {
// ...
Внимательные читатели могли заметить, что в одном из примеров ранее используется кастомное исключение UnauthorizedException
. Пакет экспортирует небольшой набор заранее подготовленных исключений, что позволяет не указывать код ответа вручную, а лишь прописывать необходимые ошибки в ответе сервера. Доступны следующие исключения:
Статус код | Сообщение по умолчанию | |
---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Имеется даже возможность объявлять свои собственные обработчики ошибок на уровне эндпоинта с использованием декоратора Catch. Полезно когда вы знаете о заведомой нестабильности эндпоинта (например, внешний API-сервис иногда вываливается в 503 ошибку и вам необходимо подготовить определённую структуру ответа вместо вывода встроенного исключения):
import { Catch } from '@storyofams/next-api-decorators';
function exceptionHandler(
error: unknown,
req: NextApiRequest,
res: NextApiResponse
) {
const message = error instanceof Error ? error.message : 'An unknown error occurred.';
res.status(200).json({ success: false, error: message });
}
@Catch(exceptionHandler)
class Events {
@Get()
public events() {
return 'Our events';
}
}
Весь этот набор инструментов позволяет описывать эндпоинты максимально декларативно и разбивать код на более мелкие компоненты, а это в свою очередь значительно упрощает юнит-тестирование и отладку, что критически важно для сохранения психического здоровья и времени разработчика :)
Ознакомиться с документацией можно здесь, посмотреть исходники можно тут. Спасибо что дочитали до конца и не растеряли интерес по пути!
apapacy
Все это хорошо. Но например я всегда воспринимал Next.js как фреймворк для фронтенда. Это точно хорошая идея разрабатывать не нем API?
JamesJGoodwin Автор
Наверное, я немного запутал Вас своим вступлением о разработке API на основном месте работы. NextJS не подходит для разработки API, но рано или поздно любому сайту понадобится пара-тройка эндпоинтов для интерактивных элементов, если это только не статичный лэндинг.
dimuska139
Не очень понял, а что мешает использовать NextJS и NestJS одновременно? Ту же пару-тройку эндпоинтов для интерактивных элементов реализовать в NestJS - и в getInitialProps (или как там в NextJS) делать запросы с ним.
JamesJGoodwin Автор
Во-первых, Вы добавляете лишнее звено в цепочку запросов и увеличиваете общее время получения ответа. Во-вторых, Ваш API на NestJS нужно куда-то деплоить, подключать к нему мониторинг и создавать отдельный репозиторий для shared типов для избежания дублирования кода, следить за версионированием, обновлять в нескольких репозиториях и тригерить редеплой коммитами в мастер. В целом, я поддерживаю идею использования наиболее подходящих инструментов для решения разных задач, но в то же время мне кажется, что NextJS создавался как monorepo фреймворк с целью упрощения процесса создания сайтов.
А ещё не очень понял как вы реализуете автокомплит через getInitialProps?
dimuska139
У меня есть пет-проект, где я использую связку NestJS + NextJS. NestJS там - это API, взаимодействующее с базой данных. Это же API использует и админка для проекта. Пользовательская (публичная) часть проекта должна хорошо индексироваться поисковыми роботами, поэтому написана не просто на React, а с использованием NextJS. Только те данные, которые нужно отдавать роботам (например, текст статьи), доставляются по API (из NestJS-приложения) в getInitialProps - соответственно Google/Yandex на таких страницах видит отрендеренную готовую html-страницу.
Если данные не обязательно отдавать роботам, то делаю просто обычные ajax-запросы (не в getInitialProps). То есть если нужен автокомплит, то, конечно, это обычный ajax-запрос, который я выполняю не в getInitialProps, а просто делаю его по какому-то событию текстового автокомплит-поля (например, onChange).
Ну так любой бэкенд надо куда-то деплоить. Чтобы серверный рендеринг в NextJS работал, его тоже нужно куда-то деплоить, разве нет?
Нет, не добавляю. Когда поисковый робот запрашивает страницу, NextJS в getInitialProps делает запрос к API (NestJS), подставляет данные, полученные из API, в шаблон - и отдаёт этот html поисковому роботу. Где ж тут лишнее звено?
JamesJGoodwin Автор
Мне кажется, что мы перешли от обсуждения конкретной имплементации к обсуждению инфраструктурных вопросов и решению несуществующих проблем и мне бы не хотелось углубляться в это. В NextJS предусмотрен и задокументирован механизм создания API эндпоинтов. Использовать его или нет — персональный выбор каждого отдельно взятого инженера в сложившихся для него условиях. Я в свою очередь лишь продемонстрировал один из способов упрощения взаимодействия с программным интерфейсом фреймворка для создания API эндпоинтов в пределах NextJS. Относительно Вашего решения ни в коем случае не имею ничего против.
apapacy
Вы упомянули о сложностях деплоя. Все жто ничего по стравнению со сложностью тестированрия и отладки новой библиотеки.