如何使用 Joi 验证 Node API 模式

介绍

假设您正在开发一个 API 终端,以创建一个新用户。 用户数据(如姓名,姓名,年龄出生日期)需要被包含在请求中。 当您预期数字值时,错误地输入用户名为年龄字段的值时,将是不理想的。 当您预期特定日期格式时,在出生日期字段中输入用户的生日时,也将是不理想的。

如果您在构建 Node 应用程序时曾使用过 ORM (object-relational mapping),例如 Sequelize, Knex, Mongoose (对于 MongoDB) - 您将知道可以为您的模型计划设置验证限制,这使得在将数据持续到数据库之前更容易处理和验证 _application 级数据。

在本教程中,您将了解如何使用 Joi 验证模块来验证请求级别的数据,您可以通过查看 API 参考 来了解有关如何使用 Joi 和支持的方案类型的更多信息。

在本教程结束时,您应该能够做到以下几点:

  • 创建请求数据参数验证方案
  • 处理验证错误并提供适当的反馈
  • 创建中间软件来拦截和验证请求

前提条件

要完成本教程,您将需要:

本教程是用 Node v14.2.0, npm v6.14.5 和 joi v13.0.2 验证的。

步骤1 - 创建项目

对于本教程,你会假装你正在构建一个学校门户,你想创建API终端:

  • /people:添加新学生和教师
  • /auth/edit:为教师设置登录凭证
  • /fees/pay:为学生付费

您将使用 Express创建此教程的 REST API 来测试您的 Joi 方案。

首先,打开命令行终端并创建一个新的项目目录:

1mkdir joi-schema-validation

然后导航到该目录:

1cd joi-schema-validation

运行以下命令来设置新项目:

1npm init -y

并安装所需的依赖:

在项目根目录中创建一个名为app.js的新文件,以设置 Express 应用程序:

1nano app.js

这里是应用程序的启动设置。

首先,需要快递,摩根体分辨器:

1[label app.js]
2// load app dependencies
3const express = require('express');
4const logger = require('morgan');
5const bodyParser = require('body-parser');

然后,启动app:

 1[label app.js]
 2// ...
 3
 4const app = express();
 5const port = process.env.NODE_ENV || 3000;
 6
 7// app configurations
 8app.set('port', port);
 9
10// establish http server connection
11app.listen(port, () => { console.log(`App running on port ${port}`) });

接下来,将morgan日志和body-parser中间件添加到您的应用程序的请求管道:

 1[label app.js]
 2// ...
 3
 4const app = express();
 5const port = process.env.NODE_ENV || 3000;
 6
 7// app configurations
 8app.set('port', port);
 9
10// load app middlewares
11app.use(logger('dev'));
12app.use(bodyParser.json());
13app.use(bodyParser.urlencoded({ extended: false }));
14
15// establish http server connection
16app.listen(port, () => { console.log(`App running on port ${port}`) });

这些中间件检索和解析当前的应用程序/json应用程序/x-www-form-urlencoded请求的 HTTP 请求体,并在请求路由处理中间件的req.body中提供它们。

然后添加路线:

 1[label app.js]
 2// ...
 3
 4const Routes = require('./routes');
 5
 6const app = express();
 7const port = process.env.NODE_ENV || 3000;
 8
 9// app configurations
10app.set('port', port);
11
12// load app middlewares
13app.use(logger('dev'));
14app.use(bodyParser.json());
15app.use(bodyParser.urlencoded({ extended: false }));
16
17// load our API routes
18app.use('/', Routes);
19
20// establish http server connection
21app.listen(port, () => { console.log(`App running on port ${port}`) });

您的「app.js」文件目前已完成。

处理终点

从应用程序设置中,您指定了您正在从routes.js文件中获取路线。

让我们在您的项目根目录中创建该文件:

1nano routes.js

要求表达并处理请求,答案为成功和请求中的数据:

 1[label routes.js]
 2const express = require('express');
 3const router = express.Router();
 4
 5// generic route handler
 6const genericHandler = (req, res, next) => {
 7  res.json({
 8    status: 'success',
 9    data: req.body
10  });
11};
12
13module.exports = router;

接下来,为auth/edit fees/pay设置终端:

 1[label routes.js]
 2// ...
 3
 4// create a new teacher or student
 5router.post('/people', genericHandler);
 6
 7// change auth credentials for teachers
 8router.post('/auth/edit', genericHandler);
 9
10// accept fee payments for students
11router.post('/fees/pay', genericHandler);
12
13module.exports = router;

现在,当一个 POST 请求击中这些终端时,您的应用程序将使用genericHandler并发送回复。

最后,将一个开始脚本添加到您的package.json文件的脚本部分:

1nano package.json

它应该看起来像这样:

1[label package.json]
2// ...
3"scripts": {
4  "start": "node app.js"
5},
6// ...

运行应用程序,查看您迄今为止所拥有的内容,并确保一切正常工作:

1npm start

您应该看到一个消息,如:应用程序在端口 3000 上运行。记住该服务正在运行的端口号码,并将应用程序在背景下运行。

测试终点

您可以使用 Postman 等应用程序测试 API 终端。

<$>[注] 注: 如果这是你第一次使用邮政,这里有一些步骤,如何使用它为本教程:

  • 开始创建一个新的请求.
  • 将请求类型设置为 POST(默认情况下,它可以设置为 GET)。
  • 填写 Enter request URL字段以服务器位置(在大多数情况下,它应该是: localhost:3000)和终端点(在这种情况下: /people)。
  • 选择 Body.
  • 将您的编码类型设置为 Raw(默认情况下,它可能设置为 none)。

然后点击发送以查看答案。 <$>

让我们考虑一个场景,其中一个管理员正在为一位名为Glad Chinda的老师创建一个新帐户。

提供此示例请求:

1{
2    "type": "TEACHER",
3    "firstname": "Glad",
4    "lastname": "Chinda"
5}

您将收到以下示例答案:

1[secondary_label Output]
2{
3    "status": "success",
4    "data": {
5        "type": "TEACHER",
6        "firstname": "Glad",
7        "lastname": "Chinda"
8    }
9}

您将收到成功状态,您提交的数据将在回复中被捕获,这验证您的应用程序是否按预期工作。

步骤2 – 使用Joi验证规则进行实验

一个简单的例子可以帮助你想象你将在以后的步骤中实现什么。

在此示例中,您将使用 Joi 创建验证规则,以验证新用户创建请求的电子邮件、电话号码和生日。

让我们在app.js文件中添加一个测试终端点:

1nano app.js

添加以下代码片段:

 1[label app.js]
 2// ...
 3
 4app.use('/', Routes);
 5
 6app.post('/test', (req, res, next) => {
 7  const Joi = require('joi');
 8
 9  const data = req.body;
10
11  const schema = Joi.object().keys({
12    email: Joi.string().email().required(),
13    phone: Joi.string().regex(/^\d{3}-\d{3}-\d{4}$/).required(),
14    birthday: Joi.date().max('1-1-2004').iso()
15  });
16});
17
18// establish http server connection
19app.listen(port, () => { console.log(`App running on port ${port}`) });

这个代码添加了一个新的/test终端,它定义了请求体的数据,并为电子邮件,电话生日定义了schema的Joi规则。

电子邮件的限制包括:

  • 必须是有效的电子邮件字符串
  • 必须是

电话的限制包括:

  • 它必须是一个字符串的格式 XXX-XXX-XXXX
  • 它必须是

生日的限制包括:

*它必须是 ISO 8601格式的有效日期 *它不能在2004年1月1日之后 *它不需要

接下来,处理通过和失败的验证:

 1[label app.js]
 2// ...
 3
 4app.use('/', Routes);
 5
 6app.post('/test', (req, res, next) => {
 7  // ...
 8
 9  Joi.validate(data, schema, (err, value) => {
10    const id = Math.ceil(Math.random() * 9999999);
11
12    if (err) {
13      res.status(422).json({
14        status: 'error',
15        message: 'Invalid request data',
16        data: data
17      });
18    } else {
19      res.json({
20        status: 'success',
21        message: 'User created successfully',
22        data: Object.assign({id}, value)
23      });
24    }
25  });
26});
27
28// establish http server connection
29app.listen(port, () => { console.log(`App running on port ${port}`) });

此代码取数据并对方案进行验证。

如果电子邮件电话生日的任何规则都失败,则会生成422错误,出现错误状态和无效请求数据的消息。

如果电子邮件电话生日的所有规则都通过,则会产生一个成功状态的响应和成功创建用户的消息。

现在,你可以测试示例路线。

重新启动应用程序,从终端执行以下命令:

1npm start

您可以使用邮件人来测试示例路线POST /test

配置您的请求:

1POST localhost:3000/test
2Body
3Raw
4JSON

将数据添加到 JSON 字段:

1{
2    "email": "[email protected]",
3    "phone": "555-555-5555",
4    "birthday": "2004-01-01"
5}

你应该看到类似于以下答案的东西:

 1[secondary_label Output]
 2{
 3    "status": "success",
 4    "message": "User created successfully",
 5    "data": {
 6        "id": 1234567,
 7        "email": "[email protected]",
 8        "phone": "555-555-5555",
 9        "birthday": "2004-01-01T00:00:00.000Z"
10    }
11}

以下是实现这一点的演示视频:

[youtube necriO0nWXA 480 854 ]

由于每个限制返回一个方案实例,可以通过 方法链接链接多个限制来定义更具体的验证规则。

建议使用Joi.object()Joi.object().keys()创建对象方案. 使用这两种方法时,您可以使用一些额外的约束来进一步控制对象中允许的键,而使用对象字面方法将无法做到这一点。

有时,你可能希望一个值要么是一个字符串或数字,要么是其他的东西。这就是替代方案出现的地方。你可以使用Joi.alternatives()来定义替代方案。

请参阅 API 参考以详细说明所有可供您使用的限制。

步骤 3 – 创建 API 方案

在熟悉 Joi 中的限制和方案后,您现在可以为 API 路径创建验证方案。

在项目路线目录中创建一个名为schemas.js的新文件:

1nano schemas.js

開始要求Joi:

1[label schemas.js]
2// load Joi module
3const Joi = require('joi');

终端和数据

在这种情况下,管理员正在为教师和学生创建帐户 API 将需要一个ID,类型,名称和可能的年龄,如果他们是学生。

id:将是一个 UUID v4 格式的字符串:

1Joi.string().guid({version: 'uuidv4'})

’type’:将是‘STUDENT’或‘TEACHER’的字符串,验证将接受任何案例,但将强迫‘uppercase()’:

1Joi.string().valid('STUDENT', 'TEACHER').uppercase().required()

age:将是大于 6 的整数或字符串,并且字符串还可以包含的缩短格式(如y,yryrs`):

1Joi.alternatives().try([
2  Joi.number().integer().greater(6).required(),
3  Joi.string().replace(/^([7-9]|[1-9]\d+)(y|yr|yrs)?$/i, '$1').required()
4]);

firstname, lastname, fullname:将是一个字母字符串。 验证将接受任何情况,但将强迫 uppercase():

字母字符串为姓名姓名:

1Joi.string().regex(/^[A-Z]+$/).uppercase()

一个分开的空间全名:

1Joi.string().regex(/^[A-Z]+ [A-Z]+$/i).uppercase()

如果指定全名,则必须省略全名后名。如果指定第一名,则必须指定后名

1.xor('firstname', 'fullname')
2.and('firstname', 'lastname')
3.without('fullname', ['firstname', 'lastname'])

把它們整合起來,『peopleDataSchema』將看起來像這樣:

 1[label schemas.js]
 2// ...
 3
 4const personID = Joi.string().guid({version: 'uuidv4'});
 5
 6const name = Joi.string().regex(/^[A-Z]+$/).uppercase();
 7
 8const ageSchema = Joi.alternatives().try([
 9  Joi.number().integer().greater(6).required(),
10  Joi.string().replace(/^([7-9]|[1-9]\d+)(y|yr|yrs)?$/i, '$1').required()
11]);
12
13const personDataSchema = Joi.object().keys({
14  id: personID.required(),
15  firstname: name,
16  lastname: name,
17  fullname: Joi.string().regex(/^[A-Z]+ [A-Z]+$/i).uppercase(),
18  type: Joi.string().valid('STUDENT', 'TEACHER').uppercase().required(),
19
20  age: Joi.when('type', {
21    is: 'STUDENT',
22    then: ageSchema.required(),
23    otherwise: ageSchema
24  })
25})
26.xor('firstname', 'fullname')
27.and('firstname', 'lastname')
28.without('fullname', ['firstname', 'lastname']);

/auth/edit 终端点和 authDataSchema

/auth/edit终端将使用authDataSchema。在这种情况下,教师正在更新其帐户的电子邮件和密码,API将需要一个id,email,passwordconfirmPassword

id:将使用personDataSchema之前定义的验证。

email:将是一个有效的电子邮件地址. 验证将接受任何情况,但将强迫 lowercase()

1Joi.string().email().lowercase().required()

密码:至少要有7字符的字符串:

1Joi.string().min(7).required().strict()

confirmPassword:将是一个引用password的字符串,以确保两者匹配:

1Joi.string().valid(Joi.ref('password')).required().strict()

把所有这些都放在一起,‘authDataSchema’将看起来像这样:

1[label schemas.js]
2// ...
3
4const authDataSchema = Joi.object({
5  teacherId: personID.required(),
6  email: Joi.string().email().lowercase().required(),
7  password: Joi.string().min(7).required().strict(),
8  confirmPassword: Joi.string().valid(Joi.ref('password')).required().strict()
9});

「/fees/pay」終點和「feesDataSchema」

在这种情况下,学生正在提交他们的信用卡信息来支付一笔钱,并且还记录了交易时间标记。

id:将使用personDataSchema之前定义的验证。

:要么是整数,要么是浮点数. 该值必须是大于1的正数. 如果给出浮点数,则精度缩小至最大为2:

1Joi.number().positive().greater(1).precision(2).required()

cardNumber:将是一个符合 Luhn 算法的有效数字的字符串:

1Joi.string().creditCard().required()

completedAt:将以JavaScript格式的日期时间标记:

1Joi.date().timestamp().required()

把它們整合起來,『feesDataSchema』將看起來像這樣:

1[label schemas.js]
2// ...
3
4const feesDataSchema = Joi.object({
5  studentId: personID.required(),
6  amount: Joi.number().positive().greater(1).precision(2).required(),
7  cardNumber: Joi.string().creditCard().required(),
8  completedAt: Joi.date().timestamp().required()
9});

最后,导出具有与方案相关的终端点的对象:

1[label schemas.js]
2// ...
3
4// export the schemas
5module.exports = {
6  '/people': personDataSchema,
7  '/auth/edit': authDataSchema,
8  '/fees/pay': feesDataSchema
9};

现在,您已为 API 终端创建方案,并将其导出到具有终端作为密钥的对象中。

步骤 4 – 创建 Schema 验证中间件

让我们创建一个中间件,它会拦截每个请求到您的 API 终端,并验证请求数据,然后将控制权交给路由器。

在项目根目录中创建一个名为middlewares的新文件夹:

1mkdir middlewares

然后,在里面创建一个名为SchemaValidator.js的新文件:

1nano middlewares/SchemaValidator.js

该文件应包含下列代码,用于 schema 验证中间软件。

 1[label middlewares/SchemaValidator.js]
 2const _ = require('lodash');
 3const Joi = require('joi');
 4const Schemas = require('../schemas');
 5
 6module.exports = (useJoiError = false) => {
 7  // useJoiError determines if we should respond with the base Joi error
 8  // boolean: defaults to false
 9  const _useJoiError = _.isBoolean(useJoiError) && useJoiError;
10
11  // enabled HTTP methods for request data validation
12  const _supportedMethods = ['post', 'put'];
13
14  // Joi validation options
15  const _validationOptions = {
16    abortEarly: false,  // abort after the last validation error
17    allowUnknown: true, // allow unknown keys that will be ignored
18    stripUnknown: true  // remove unknown keys from the validated data
19  };
20
21  // return the validation middleware
22  return (req, res, next) => {
23    const route = req.route.path;
24    const method = req.method.toLowerCase();
25
26    if (_.includes(_supportedMethods, method) && _.has(Schemas, route)) {
27      // get schema for the current route
28      const _schema = _.get(Schemas, route);
29
30      if (_schema) {
31        // Validate req.body using the schema and validation options
32        return Joi.validate(req.body, _schema, _validationOptions, (err, data) => {
33          if (err) {
34            // Joi Error
35            const JoiError = {
36              status: 'failed',
37              error: {
38                original: err._object,
39                // fetch only message and type from each error
40                details: _.map(err.details, ({message, type}) => ({
41                  message: message.replace(/['"]/g, ''),
42                  type
43                }))
44              }
45            };
46
47            // Custom Error
48            const CustomError = {
49              status: 'failed',
50              error: 'Invalid request data. Please review request and try again.'
51            };
52
53            // Send back the JSON error response
54            res.status(422).json(_useJoiError ? JoiError : CustomError);
55          } else {
56            // Replace req.body with the data after Joi validation
57            req.body = data;
58            next();
59          }
60        });
61      }
62    }
63    next();
64  };
65};

在这里,您已将 Lodash 与 Joi 和方案一起加载到中间件模块中,您还正在导出一个工厂函数,该函数接受一个参数并返回方案验证中间件。

工厂函数的参数是boolean值,当true时,表示应该使用Joi验证错误;否则在中间件中的错误中使用了自定义通用错误。

您还将中间件定义为只处理POSTPUT请求。 其他任何请求方法都将被中间件忽略。 您也可以根据您的意愿配置中间件,添加其他方法,例如DELETE,可以使用请求体。

中间软件使用与我们之前定义的方案对象的当前路由密钥相匹配的方案来验证请求数据. 验证是使用Joi.validate()方法进行的,具有以下签名:

  • 数据:要验证的数据,在我们的情况下是 req.body.
  • schema:用来验证数据的方案.
  • options:指定验证选项的 object. 以下是我们使用的验证选项:
  • callback:在验证后将被调用的函数。 它需要两个参数。 第一种是Joi ValidationError 对象,如果有验证错误,或者如果没有错误,则是 null。 第二种参数是输出数据。

最后,在Joi.validate()的回复函数中,如果有错误,您将格式化错误返回为 JSON 响应,并使用422 HTTP 状态代码,或者简单地将req.body重写为验证输出数据,然后将控制权传给下一个中间软件。

现在,你可以在你的路线上使用中间件:

1nano routes.js

更改routes.js文件如下:

 1[label routes.js]
 2const express = require('express');
 3const router = express.Router();
 4
 5const SchemaValidator = require('./middlewares/SchemaValidator');
 6const validateRequest = SchemaValidator(true);
 7
 8// generic route handler
 9const genericHandler = (req, res, next) => {
10  res.json({
11    status: 'success',
12    data: req.body
13  });
14};
15
16// create a new teacher or student
17router.post('/people', validateRequest, genericHandler);
18
19// change auth credentials for teachers
20router.post('/auth/edit', validateRequest, genericHandler);
21
22// accept fee payments for students
23router.post('/fees/pay', validateRequest, genericHandler);
24
25module.exports = router;

让我们运行您的应用程序来测试您的应用程序:

1npm start

这些是您可以使用的样本测试数据来测试终点,您可以随意编辑它们。

<$>[注] 注: 要生成 UUID v4 字符串,您可以使用 Node UUID 模块或在线 UUID 生成器(https://www.uuidgenerator.net/)。

终点

在这种情况下,一个管理员正在输入一个名叫John Doe的12岁新学生:

1{
2    "id": "a967f52a-6aa5-401d-b760-35eef7c68b32",
3    "type": "Student",
4    "firstname": "John",
5    "lastname": "Doe",
6    "age": "12yrs"
7}

POST/people成功响应的例子:

 1[secondary_label Output]
 2{
 3    "status": "success",
 4    "data": {
 5        "id": "a967f52a-6aa5-401d-b760-35eef7c68b32",
 6        "type": "STUDENT",
 7        "firstname": "JOHN",
 8        "lastname": "DOE",
 9        "age": "12"
10    }
11}

在此失败场景中,管理员未为所需的年龄字段提供值:

 1[secondary_label Output]
 2{
 3    "status": "failed",
 4    "error": {
 5        "original": {
 6            "id": "a967f52a-6aa5-401d-b760-35eef7c68b32",
 7            "type": "Student",
 8            "fullname": "John Doe",
 9        },
10        "details": [
11            {
12                "message": "age is required",
13                "type": "any.required"
14            }
15        ]
16    }
17}

/auth/edit 终点

在这种情况下,老师正在更新他们的电子邮件和密码:

1{
2    "teacherId": "e3464323-22c1-4e31-9ac5-9bde207d61d2",
3    "email": "[email protected]",
4    "password": "password",
5    "confirmPassword": "password"
6}

例如「POST /auth / edit」的成功回應:

 1[secondary_label Output]
 2{
 3    "status": "success",
 4    "data": {
 5        "teacherId": "e3464323-22c1-4e31-9ac5-9bde207d61d2",
 6        "email": "[email protected]",
 7        "password": "password",
 8        "confirmPassword": "password"
 9    }
10}

在此失败的场景中,老师提供了无效的电子邮件地址和错误的确认密码:

 1[secondary_label Output]
 2{
 3    "status": "failed",
 4    "error": {
 5        "original": {
 6            "teacherId": "e3464323-22c1-4e31-9ac5-9bde207d61d2",
 7            "email": "email_address",
 8            "password": "password",
 9            "confirmPassword": "Password"
10        },
11        "details": [
12            {
13                "message": "email must be a valid email",
14                "type": "string.email"
15            },
16            {
17                "message": "confirmPassword must be of [ref:password]",
18                "type": "any.allowOnly"
19            }
20        ]
21    }
22}

/ 费 / 付款 终端

在这种情况下,学生正在使用信用卡支付费用,并记录交易的时间标记:

<$>[注] 注: 用于测试,请使用 4242424242424242 作为有效的信用卡号码。

1{
2    "studentId": "c77b8a6e-9d26-428a-9df1-e852473f886f",
3    "amount": 134.9875,
4    "cardNumber": "4242424242424242",
5    "completedAt": 1512064288409
6}

POST/fees/pay成功响应的例子:

 1[secondary_label Output]
 2{
 3    "status": "success",
 4    "data": {
 5        "studentId": "c77b8a6e-9d26-428a-9df1-e852473f886f",
 6        "amount": 134.99,
 7        "cardNumber": "4242424242424242",
 8        "completedAt": "2017-11-30T17:51:28.409Z"
 9    }
10}

在此失败的场景中,学生提供了无效的信用卡号码:

 1[secondary_label Output]
 2{
 3    "status": "failed",
 4    "error": {
 5        "original": {
 6            "studentId": "c77b8a6e-9d26-428a-9df1-e852473f886f",
 7            "amount": 134.9875,
 8            "cardNumber": "5678901234567890",
 9            "completedAt": 1512064288409
10        },
11        "details": [
12            {
13                "message": "cardNumber must be a credit card",
14                "type": "string.creditCard"
15            }
16        ]
17    }
18}

您可以用不同的值完成测试您的应用程序,以观察成功和失败的验证。

结论

在本教程中,您已经创建了使用 Joi 验证数据收集的方案,并在您的 HTTP 请求管道上使用自定义方案验证中间软件处理了请求数据验证。

具有一致的数据确保它在您的应用程序中引用时以可靠和预期的方式行为。

对于本教程的完整代码样本,请查看 GitHub 上的 joi-schema-validation-sourcecode 存储库。

Published At
Categories with 技术
Tagged with
comments powered by Disqus