NestJS + Prisma 构建 REST API 系列教程(二):输入验证 & 类型转换

2,666 阅读10分钟

欢迎来到 NestJS、Prisma 和 PostgresQL 构建 REST API 系列的第二篇教程。在本篇教程中,你将学会在 API 中如何执行输入验证和类型转换。

如果没有看过本系列的第一篇文章,可以移步: 使用 NestJS + Prisma 构建 REST API

简介

在本系列的第一篇教程中,你已经创建了一个新的 NestJS 项目并集成了 Prisma、PostgreSQL 和 Swagger。然后,你为一个博客应用的后端构建了一个初级的 REST API。

在这一部分中,你将学习如何验证输入数据,使其符合你的 API 规范。执行输入验证是为了确保只有正确格式的客户端数据才能通过你的 API。这是一个验证发送到 Web 应用程序的任何数据正确性的最佳实践。这有助于防止格式错误的数据滥用你的 API。

你也将学习如何执行输入数据的类型转换。输入转换是一种技术,它能让你在路由处理器处理该请求之前拦截并转换从客户端发送的数据。这对于将数据转换为适当的类型、将默认字段应用于缺失字段、清理输入等很有用。

开发环境

要跟着本教程进行开发,你将需要:

  • 已安装 Node.js。
  • 已安装 Docker 或 PostgreSQL。
  • 已安装 Prisma VSCode 插件。(可选)
  • 访问 Unix shell(如 Linux 和 macOS 中的终端/shell)以运行本系列中提供的命令。(可选)

提示:

  1. 可选的 Prisma VSCode 插件能为 Prisma 添加一些很棒的智能感知和语法高亮。
  2. 如果你没有 Unix shell(例如,你在 windows 机器上开发),你仍可以跟上,不过你需要修改一下适合本机的命令。

克隆代码库

本教程内容会接着本系列中第一章内容。它包含了一个使用 NestJS 构建的初级 REST API。在开始阅读本篇教程之前,我建议你先完成第一篇教程的学习。

本教程内容可以在这个 GitHub 仓库begin-validation 分支查看。首先,克隆代码库并切换到 begin-validation 分支上:

git clone -b begin-validation git@github.com:TasinIshmam/blog-backend-rest-api-nestjs-prisma.git

现在,执行以下操作来启动:

  1. 导航到克隆的文件夹:
cd blog-backend-rest-api-nestjs-prisma
  1. 安装依赖:
npm install
  1. 用 docker 启动 PostgreSQL 数据库:
docker-compose up -d
  1. 应用数据库迁移:
npx prisma migrate dev
  1. 启动项目:
npm run start:dev

提示:第四步同时也会生成 Prisma Client 和数据中的种子文件。

现在,你应该可以在 http://localhost:3000/api/ 访问 API 文档了。

项目结构和源文件

你克隆的仓库应该是以下结构:

median
  ├── node_modules
  ├── prisma
  │   ├── migrations
  │   ├── schema.prisma
  │   └── seed.ts
  ├── src
  │   ├── app.controller.spec.ts
  │   ├── app.controller.ts
  │   ├── app.module.ts
  │   ├── app.service.ts
  │   ├── main.ts
  │   ├── articles
  │   └── prisma
  ├── test
  │   ├── app.e2e-spec.ts
  │   └── jest-e2e.json
  ├── README.md
  ├── .env
  ├── docker-compose.yml
  ├── nest-cli.json
  ├── package-lock.json
  ├── package.json
  ├── tsconfig.build.json
  └── tsconfig.json

该仓库中值得注意的文件和目录是:

  • src 目录包含的是应用程序的源码。有三个模块:
    • app 模块位于 src 目录的根目录下,它是应用的入口。它负责启动 web 服务。
    • prisma 模块包含了 Prisma 客户端,数据库查询构建器。
    • articles 模块定义了 /articles 路由的端点和相关的业务逻辑。
  • prisma 模块有以下内容:
    • schema.prisma 文件定义数据库 schema。
    • migrations 文件夹包含了数据库迁移记录。
    • seed.ts 文件包含一个脚本,该脚本用虚拟数据来初始化你的开发环境数据库。
  • docker-compose.yml 文件定义了 PostgreSQL 数据库 Docker 镜像。
  • .env 文件包含了 PostgreSQL 数据库的数据库连接字符串。

提示:要了解这些组件的更多信息,可以前往本系列教程的第一章。

执行输入验证

要执行输入验证,你将使用 NestJS 管道。管道对路由处理器正在处理的参数进行操作。Nest 在路由处理器之前调用管道,然后管道接收发往路由处理器的参数。管道可以做一些事情,像验证输入数据,给输入数据添加字段等等。管道有点像中间件,但是管道的作用范围仅限于处理输入参数。NestJS 提供了一些开箱即用的管道,但你也可以创建自己的自定义管道

管道有两个典型的用例:

  • 验证:评估输入数据,如果有效,则将其原样传递;否则,当数据不正确时抛出一个异常。
  • 转型:把输入数据转换为所需的格式(例如,从字符串到整型)。

NestJS 验证管道将检查传递给路由的参数。如果参数有效,管道会直接把参数传递给路由器处理,而不做任何修改。但是,如果参数违反了任何一个指定的验证规则,管道都会抛出异常。

以下两个图表展示了管道对于任意的 /example 路由是如何工作的。

valid-args.png

invalid-args.png

在本节中,你将专注于验证用例。

设置全局 ValidationPipe

要执行输入验证,你将用到 NestJS 内置的 ValidationPipeValidationPipe 提供了一个方便的方法来为所有传入客户端的有效负载强制验证规则,这些验证规则是用来自 class-calidator 的装饰器定义的。

要使用此功能,你需要在项目中添加两个包:

npm install class-validator class-transformer

class-validator 包提供的装饰器是为了验证输入数据,class-transformer 包提供的装饰器是为了转换输入数据为所需的格式。这两个包都能与 NestJS 管道很好地集成在一起。

现在,在 main.ts 文件中导入 ValidationPipe 并使用 app.useGlobalPipes 方法使其在应用中全局可用:

// src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
+import { ValidationPipe } from '@nestjs/common';

async function bootstrap() {
    const app = await NestFactory.create(AppModule);
    
    + app.useGlobalPipes(new ValidationPipe());
    
    const config = new DocumentBuilder()
        .setTitle('Median')
        .setDescription('The Median API description')
        .setVersion('0.1')
        .build();
    const document = SwaggerModule.createDocument(app, config);
    SwaggerModule.setup('api', app, document);
    
    await app.listen(3000);
}
bootstrap();

CreateArticleDto 添加验证规则

你将使用 class-validator 包给 CreateArticleDto 添加验证装饰器。你将应用以下规则到 CreateArticleDto

  1. title 不能为空或者少于 5 个字符串。
  2. description 最大长度为 300。
  3. bodydescription 不能为空。
  4. titledescriptionbody 的类型必须是 stringpusblished 的类型必须是 boolean

打开 src/articles/dto/create-article.dto.ts 文件并将内容替换为:

// src/articles/dto/create-article.dto.ts


import { ApiProperty } from '@nestjs/swagger';
import {
  IsBoolean,
  IsNotEmpty,
  IsOptional,
  IsString,
  MaxLength,
  MinLength,
} from 'class-validator';


export class CreateArticleDto {
  @IsString()
  @IsNotEmpty()
  @MinLength(5)
  @ApiProperty()
  title: string;


  @IsString()
  @IsOptional()
  @IsNotEmpty()
  @MaxLength(300)
  @ApiProperty({ required: false })
  description?: string;


  @IsString()
  @IsNotEmpty()
  @ApiProperty()
  body: string;


  @IsBoolean()
  @IsOptional()
  @ApiProperty({ required: false, default: false })
  published?: boolean = false;
}

这些规则将会被 ValidationPipe 提取并自动应用到路由处理器上。使用装饰器来验证的好处之一就是 CreateArticleDto 仍然是 POST /articles 端点的所有参数的唯一真实来源。因此你不需要再去定义一个单独的验证类。

测试你现有的验证规则。尝试使用一个非常短的占位符标题通过 POST /articles 端点创建一篇文章:

{
  "title": "Temp",
  "description": "Learn about input validation",
  "body": "Input validation is...",
  "published": false
}

你应该看到一个 HTTP 400 错误响应,并且在响应体中会带有哪些验证规则出错的细节。

validation-error.png

此图解释了 ValidationPipe 在后台对 /articles 路由的无效输入所做的工作:

invalid-args-specific.png

从客户端请求中剥离不必要的属性

CreateArticleDTO 定义了创建新文章时必须要发送给 POST /articles 端点的属性。UpdateArticleDTO 执行相同的操作,但是它针对的是 PATCH /articles/{id} 端点。

现在,对于这两个端点,可以发送 DTO 中未定义的其他属性。这可能会导致无法预料的错误或安全问题。例如,你可以手动传递无效的 createAtupdateAt 值到 POST /articles 端点。由于 TypeScript 类型信息在运行时不可用,因此你的应用将无法识别这些字段在 DTO 中不可用。

举个例子,尝试发送以下请求到 POST /articles 端点:

{
  "title": "example-title",
  "description": "example-description",
  "body": "example-body",
  "published": true,
  "createdAt": "2015-06-08T18:20:29.309Z",
  "updatedAt": "2012-06-02T18:20:29.310Z"
}

inject-dates.png

在这种方法中,你可以注入无效值。在这里,你创建了一篇文章,它的 updatedAt 要早于 createdAt,这没有意义。

为避免这些,你需要从客户端请求中过滤掉这些不必要的字段/属性。幸运的是,NestJS 也为此提供了开箱即用的方法。你需要做的就是在应用中初始化 ValidationPipe 时传递 whitelist: true 选项。

// src/main.ts


import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { ValidationPipe } from '@nestjs/common';


async function bootstrap() {
  const app = await NestFactory.create(AppModule);


+   app.useGlobalPipes(new ValidationPipe({ whitelist: true }));


  const config = new DocumentBuilder()
    .setTitle('Median')
    .setDescription('The Median API description')
    .setVersion('0.1')
    .build();
  const document = SwaggerModule.createDocument(app, config);
  SwaggerModule.setup('api', app, document);


  await app.listen(3000);
}
bootstrap();

将此选项设置为 true,ValidationPipe 会自动移除所有 non-whitelisted 属性,“non-whitelisted” 意思是没有任何验证装饰器的属性。重要的是要注意该选项将会过滤所有没有验证装饰器的属性,即使是 DTO 中定义的属性也不例外。

现在,传奇给请求的任何其他字段/属性都将被 NestJS 自动剥离,从而防止之前出现的漏洞利用。

提示:NestJS ValidationPipe 是高度可配置的。所有配置项都记录在 NestJS 文档中。如有需要,你可以为应用构建自定义验证管道

使用 ParseIntPipe 转换动态 URL 路径

在你的 API 中,你当前在 GET /articles/{id}PATCH /articles/{id}DELETE /articles/{id} 端点接收的 id 参数都是作为路径的一部分。NestJS 从 URL 路径中解析的 id 参数是一个字符串。然后,在传递给 ArticlesService 之前,该字符串被转换为应用程序代码中的一个数字。例如,看一下 DELETE /articles/{id} 路由处理器:

// src/articles/articles.controller.ts


@Delete(':id')
@ApiOkResponse({ type: ArticleEntity })
remove(@Param('id') id: string) {   // id is parsed as a string
  return this.articlesService.remove(+id); // id is converted to number using the expression '+id'
}

因为 id 被定义为一个字符串类型,在生成的 API 文档中,Swagger API 同样把这个参数记录为字符串。这是不直观和不正确的。

id-string.png

为了替代在路由处理器中手动转型,你可以使用 NestJS 管道自动把 id 转换为数字。将内置的 ParseIntPipe 添加到这三个端点的控制器路由处理器中:

// src/articles/articles.controller.ts


import {
  Controller,
  Get,
  Post,
  Body,
  Patch,
  Param,
  Delete,
  NotFoundException,
+ ParseIntPipe,
} from '@nestjs/common';


export class ArticlesController {
  // ...


  @Get(':id')
  @ApiOkResponse({ type: ArticleEntity })
+  findOne(@Param('id', ParseIntPipe) id: number) {
+    return this.articlesService.findOne(id);
  }


  @Patch(':id')
  @ApiCreatedResponse({ type: ArticleEntity })
  update(
+   @Param('id', ParseIntPipe) id: number,
    @Body() updateArticleDto: UpdateArticleDto,
  ) {
+    return this.articlesService.update(id, updateArticleDto);
  }


  @Delete(':id')
  @ApiOkResponse({ type: ArticleEntity })
+  remove(@Param('id', ParseIntPipe) id: number) {
+    return this.articlesService.remove(id);
  }
}
COPY
 

ParseIntPipe 将拦截字符串类型的 id 参数并在传递给相应的路由处理器之前自动解析为数字。这样一来,Swagger 中的 id 参数也会被正确记录为数字,一举多得。 id-string.png

总结和最终评语

祝贺你!在本教程中,你使用了现有的 REST API 并且:

  • 使用 ValidationPipe 进行集成验证。
  • 剥离客户端请求中的非必要属性。
  • 集成 ParseIntPipe 来解析 string 路径变量并转换为 number

你也许注意到了 NestJS 重度依赖装饰器。这是一个有意的设计选择。NestJS 旨在通过大量利用装饰器来解决各种横切关注点,从而提高代码的可读性和模块化。因此,控制器和服务方法不需要使用臃肿的模版代码来执行验证、缓存、日志记录等操作。

你可以在 GitHub 代码库end-validation 分支找到教程中的完整代码。如果你发现问题,请随意在仓库中发起问题或提交 PR。

【全文完】

原文作者:Tasin Ishmam Backend web developer

原文地址:www.prisma.io/blog/nestjs…

原文发表于:2022年6月19日