Pull to refresh

Декларативный API на Next.JS — реальность?

Reading time5 min
Views6K

Привет! Меня зовут Андрей, я 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. Пакет экспортирует небольшой набор заранее подготовленных исключений, что позволяет не указывать код ответа вручную, а лишь прописывать необходимые ошибки в ответе сервера. Доступны следующие исключения:

Статус код

Сообщение по умолчанию

BadRequestException

400

'Bad Request'

UnauthorizedException

401

'Unauthorized'

NotFoundException

404

'Not Found'

PayloadTooLargeException

413

'Payload Too Large'

UnprocessableEntityException

422

'Unprocessable Entity'

InternalServerErrorException

500

'Internal Server Error'

Имеется даже возможность объявлять свои собственные обработчики ошибок на уровне эндпоинта с использованием декоратора 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';
  }
}

Весь этот набор инструментов позволяет описывать эндпоинты максимально декларативно и разбивать код на более мелкие компоненты, а это в свою очередь значительно упрощает юнит-тестирование и отладку, что критически важно для сохранения психического здоровья и времени разработчика :)

Ознакомиться с документацией можно здесь, посмотреть исходники можно тут. Спасибо что дочитали до конца и не растеряли интерес по пути!

Tags:
Hubs:
Total votes 3: ↑2 and ↓1+1
Comments7

Articles