如何使用 Joi 进行节点 API 模式验证

介绍

假设您正在使用 API 端点来创建新用户。用户数据诸如firstnamelastnameage,和birthdate-将会需要被包括在请求中。age当您期望数字值时,用户错误地输入他们的姓名作为字段的值是不可取的。birthdate当您期望特定日期格式时,用户在该字段中输入他们的生日也是不可取的。您不希望不良数据通过您的应用程序。您可以使用数据验证来解决这个问题

如果您在构建 Node 应用程序时使用过ORM(对象关系映射) ——例如 Sequelize、Knex、Mongoose(用于 MongoDB)——您就会知道可以为模型模式设置验证约束。这使得在将数据持久化到数据库之前,更容易在应用程序级别处理和验证数据在构建 API 时,数据通常来自对某些端点的 HTTP 请求,并且可能很快就会出现能够在请求级别验证数据的需求

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

在本教程结束时,您应该能够执行以下操作:

  • 为请求数据参数创建验证架构
  • 处理验证错误并提供适当的反馈
  • 创建一个中间件来拦截和验证请求

先决条件

要完成本教程,您需要:

本教程已通过 Node v14.2.0、npmv6.14.5 和joiv13.0.2 验证。

步骤 1 — 设置项目

在本教程中,您将假设您正在构建一个学校门户并且想要创建 API 端点:

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

您将使用Express为本教程创建 REST API以测试您的 Joi 模式。

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

  • mkdir joi-schema-validation

然后导航到该目录:

  • cd joi-schema-validation

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

  • npm init -y

并安装所需的依赖项:

  • npm install body-parser@1.18.2<6> express@4.16.2 joi@13.0.2 lodash@4.17.4 morgan@1.9.0<^>

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

  • nano app.js

这是该应用程序的入门设置。

首先,要求expressmorgan以及body-parser

应用程序.js
// load app dependencies
const express = require('express');
const logger = require('morgan');
const bodyParser = require('body-parser');

然后,初始化app

应用程序.js
// ...

const app = express();
const port = process.env.NODE_ENV || 3000;

// app configurations
app.set('port', port);

// establish http server connection
app.listen(port, () => { console.log(`App running on port ${port}`) });

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

应用程序.js
// ...

const app = express();
const port = process.env.NODE_ENV || 3000;

// app configurations
app.set('port', port);

// load app middlewares
app.use(logger('dev'));
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: false }));

// establish http server connection
app.listen(port, () => { console.log(`App running on port ${port}`) });

这些中间件获取并解析当前 HTTP 请求application/jsonapplication/x-www-form-urlencoded请求的正文,并使它们在req.body请求的路由处理中间件中可用

然后,添加Routes

应用程序.js
// ...

const Routes = require('./routes');

const app = express();
const port = process.env.NODE_ENV || 3000;

// app configurations
app.set('port', port);

// load app middlewares
app.use(logger('dev'));
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: false }));

// load our API routes
app.use('/', Routes);

// establish http server connection
app.listen(port, () => { console.log(`App running on port ${port}`) });

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

处理端点

在您的应用程序设置中,您指定要从routes.js文件中获取路由

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

  • nano routes.js

需要express和处理与的响应请求"success",并在请求中的数据:

路由.js
const express = require('express');
const router = express.Router();

// generic route handler
const genericHandler = (req, res, next) => {
  res.json({
    status: 'success',
    data: req.body
  });
};

module.exports = router;

其次,建立端点peopleauth/edit以及fees/pay

路由.js
// ...

// create a new teacher or student
router.post('/people', genericHandler);

// change auth credentials for teachers
router.post('/auth/edit', genericHandler);

// accept fee payments for students
router.post('/fees/pay', genericHandler);

module.exports = router;

现在,当 POST 请求命中这些端点中的任何一个时,您的应用程序将使用genericHandler并发送响应。

最后,将start脚本添加文件scripts部分package.json

  • nano package.json

它应该是这样的:

包.json
// ...
"scripts": {
  "start": "node app.js"
},
// ...

运行该应用程序以查看您目前拥有的内容以及一切正常:

  • npm start

您应该看到一条消息,如:"App running on port 3000"记下运行服务的端口号。让您的应用程序在后台运行。

测试端点

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

注意:如果这是您第一次使用 Postman,以下是有关如何在本教程中使用它的一些步骤:

  • 从创建新请求开始。
  • 将您的请求类型设置为POST(默认情况下,它可能设置为GET)。
  • 用服务器位置(在大多数情况下,应该是:)和端点(在这种情况下:)填写输入请求 URL字段localhost:3000/people
  • 选择正文
  • 将您的编码类型设置为Raw(默认情况下,它可能设置为none)。
  • 将格式设置为JSON(默认情况下,它可能设置为Text)。
  • 输入你的数据。

然后单击发送以查看响应。

让我们考虑一个场景,管理员为名为“Glad Chinda”的老师创建一个新帐户。

提供此示例请求:

{
    "type": "TEACHER",
    "firstname": "Glad",
    "lastname": "Chinda"
}

您将收到此示例响应:

Output
{ "status": "success", "data": { "type": "TEACHER", "firstname": "Glad", "lastname": "Chinda" } }

您将收到一个"success"状态,您提交的数据会在响应中捕获。这将验证您的应用程序是否按预期工作。

步骤 2 — 试验 Joi 验证规则

一个简化的示例可能有助于让您了解在后续步骤中将实现的目标。

在此示例中,您将使用 Joi 创建验证规则,以验证创建新用户的请求的电子邮件、电话号码和生日。如果验证失败,您将返回一个错误。否则,您将返回用户数据。

让我们testapp.js文件添加一个端点

  • nano app.js

添加以下代码片段:

应用程序.js
// ...

app.use('/', Routes);

app.post('/test', (req, res, next) => {
  const Joi = require('joi');

  const data = req.body;

  const schema = Joi.object().keys({
    email: Joi.string().email().required(),
    phone: Joi.string().regex(/^\d{3}-\d{3}-\d{4}$/).required(),
    birthday: Joi.date().max('1-1-2004').iso()
  });
});

// establish http server connection
app.listen(port, () => { console.log(`App running on port ${port}`) });

此代码添加了一个新/test端点。data从请求正文中定义它定义schema与穰规则emailphonebirthday

的约束email包括:

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

的约束phone包括:

  • 它必须是一个带有数字的字符串,格式为 XXX-XXX-XXXX
  • 它必须是必需的

的约束birthday包括:

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

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

应用程序.js
// ...

app.use('/', Routes);

app.post('/test', (req, res, next) => {
  // ...

  Joi.validate(data, schema, (err, value) => {
    const id = Math.ceil(Math.random() * 9999999);

    if (err) {
      res.status(422).json({
        status: 'error',
        message: 'Invalid request data',
        data: data
      });
    } else {
      res.json({
        status: 'success',
        message: 'User created successfully',
        data: Object.assign({id}, value)
      });
    }
  });
});

// establish http server connection
app.listen(port, () => { console.log(`App running on port ${port}`) });

此代码采用data并针对 进行验证schema

如果任何用于规则emailphonebirthday失败,与的状态所产生的422误差"error"和的消息"Invalid request data"

如果所有的为规则emailphonebirthday通,与的状态产生的响应"success"和一条消息"User created successfully"

现在,您可以测试示例路由。

通过从终端运行以下命令再次启动应用程序:

  • npm start

您可以使用 Postman 来测试示例路由POST /test

配置您的请求:

POST localhost:3000/test
Body
Raw
JSON

将您的数据添加到 JSON 字段:

{
    "email": "[email protected]",
    "phone": "555-555-5555",
    "birthday": "2004-01-01"
}

您应该会看到类似于以下响应的内容:

Output
{ "status": "success", "message": "User created successfully", "data": { "id": 1234567, "email": "[email protected]", "phone": "555-555-5555", "birthday": "2004-01-01T00:00:00.000Z" } }

这是完成此操作的演示视频:

您可以为基本架构指定更多验证约束,以控制被视为有效的值类型。由于每个约束返回一个模式实例,因此可以通过方法链接多个约束链接在一起以定义更具体的验证规则。

建议您使用Joi.object()或 来创建对象模式Joi.object().keys()使用这两种方法中的任何一种时,您都可以使用一些附加约束来进一步控制对象中允许的键,而使用对象字面量方法则无法做到这一点。

有时,您可能希望值​​可以是字符串或数字或其他内容。这就是替代模式发挥作用的地方。您可以使用Joi.alternatives(). 它继承自any()模式,因此required()可以与它一起使用约束之类的

有关所有可用约束的详细文档,请参阅API 参考

第 3 步 – 创建 API 模式

在熟悉 Joi 中的约束和模式后,您现在可以为 API 路由创建验证模式。

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

  • nano schemas.js

首先要求 Joi:

架构.js
// load Joi module
const Joi = require('joi');

people 端点和 personDataSchema

/people终端将使用personDataSchema在这种情况下,管理员正在为教师和学生创建帐户。如果他们是学生idAPI 将需要typename、 和可能的age

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

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

type: 将是STUDENT的字符串TEACHER验证将接受任何情况,但会强制uppercase()

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

age: 将是一个整数或一个值大于 的字符串6并且字符串还可以包含“year”的缩写格式(如“y”、“yr”和“yrs”):

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

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

对于字母字符的字符串firstnamelastname

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

一个空格分隔fullname

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

如果fullname指定,则firstnameandlastname必须省略。如果firstname已指定,则lastname还必须指定。任一fullnamefirstname必须指定:

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

将它们放在一起,peopleDataSchema将类似于:

架构.js
// ...

const personID = Joi.string().guid({version: 'uuidv4'});

const name = Joi.string().regex(/^[A-Z]+$/).uppercase();

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

const personDataSchema = Joi.object().keys({
  id: personID.required(),
  firstname: name,
  lastname: name,
  fullname: Joi.string().regex(/^[A-Z]+ [A-Z]+$/i).uppercase(),
  type: Joi.string().valid('STUDENT', 'TEACHER').uppercase().required(),

  age: Joi.when('type', {
    is: 'STUDENT',
    then: ageSchema.required(),
    otherwise: ageSchema
  })
})
.xor('firstname', 'fullname')
.and('firstname', 'lastname')
.without('fullname', ['firstname', 'lastname']);

/auth/edit 端点和 authDataSchema

/auth/edit终端将使用authDataSchema在这种情况下,教师正在更新其帐户的电子邮件和密码。该API将希望的idemailpassword,和confirmPassword

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

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

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

password: 将是一个至少包含7字符的字符串

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

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

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

将它们放在一起,authDataSchema将类似于:

架构.js
// ...

const authDataSchema = Joi.object({
  teacherId: personID.required(),
  email: Joi.string().email().lowercase().required(),
  password: Joi.string().min(7).required().strict(),
  confirmPassword: Joi.string().valid(Joi.ref('password')).required().strict()
});

/fees/pay 端点和 feesDataSchema

/fees/pay终端将使用feesDataSchema在这个场景中,一个学生正在提交他们的信用卡信息来支付一笔钱,交易时间戳也被记录下来。该API将希望的idamountcardNumber,和completedAt

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

amount: 将是整数或浮点数。该值必须是大于 的正数1如果给出浮点数,精度将被截断到最大值2

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

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

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

completedAt: 将是 JavaScript 格式的日期时间戳:

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

将它们放在一起,feesDataSchema将类似于:

架构.js
// ...

const feesDataSchema = Joi.object({
  studentId: personID.required(),
  amount: Joi.number().positive().greater(1).precision(2).required(),
  cardNumber: Joi.string().creditCard().required(),
  completedAt: Joi.date().timestamp().required()
});

最后,导出具有与模式关联的端点的对象:

架构.js
// ...

// export the schemas
module.exports = {
  '/people': personDataSchema,
  '/auth/edit': authDataSchema,
  '/fees/pay': feesDataSchema
};

现在,您已经为 API 端点创建了架构,并将它们导出到以端点为键的对象中。

第 4 步 – 创建架构验证中间件

让我们创建一个中间件,它将拦截对 API 端点的每个请求,并在将控制权移交给路由处理程序之前验证请求数据。

middlewares在项目根目录下新建一个文件夹

  • mkdir middlewares

然后,在其中创建一个名为的新文件SchemaValidator.js

  • nano middlewares/SchemaValidator.js

该文件应包含架构验证中间件的以下代码。

中间件/SchemaValidator.js
const _ = require('lodash');
const Joi = require('joi');
const Schemas = require('../schemas');

module.exports = (useJoiError = false) => {
  // useJoiError determines if we should respond with the base Joi error
  // boolean: defaults to false
  const _useJoiError = _.isBoolean(useJoiError) && useJoiError;

  // enabled HTTP methods for request data validation
  const _supportedMethods = ['post', 'put'];

  // Joi validation options
  const _validationOptions = {
    abortEarly: false,  // abort after the last validation error
    allowUnknown: true, // allow unknown keys that will be ignored
    stripUnknown: true  // remove unknown keys from the validated data
  };

  // return the validation middleware
  return (req, res, next) => {
    const route = req.route.path;
    const method = req.method.toLowerCase();

    if (_.includes(_supportedMethods, method) && _.has(Schemas, route)) {
      // get schema for the current route
      const _schema = _.get(Schemas, route);

      if (_schema) {
        // Validate req.body using the schema and validation options
        return Joi.validate(req.body, _schema, _validationOptions, (err, data) => {
          if (err) {
            // Joi Error
            const JoiError = {
              status: 'failed',
              error: {
                original: err._object,
                // fetch only message and type from each error
                details: _.map(err.details, ({message, type}) => ({
                  message: message.replace(/['"]/g, ''),
                  type
                }))
              }
            };

            // Custom Error
            const CustomError = {
              status: 'failed',
              error: 'Invalid request data. Please review request and try again.'
            };

            // Send back the JSON error response
            res.status(422).json(_useJoiError ? JoiError : CustomError);
          } else {
            // Replace req.body with the data after Joi validation
            req.body = data;
            next();
          }
        });
      }
    }
    next();
  };
};

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

工厂函数的参数是一个boolean值,当 时true,表示应该使用 Joi 验证错误;否则,自定义通用错误用于中间件中的错误。false如果未指定或给出非布尔值,则默认为

您还定义了仅处理POSTPUT请求的中间件中间件将跳过所有其他请求方法。如果您愿意,您也可以配置它,添加其他类似的方法DELETE可以采用请求正文。

中间件使用与Schemas我们之前定义对象中的当前路由键匹配的模式来验证请求数据。验证是使用该Joi.validate()方法完成的,具有以下签名:

  • data:要验证的数据在我们的案例中是req.body.
  • schema:用于验证数据的模式。
  • options:object指定验证选项的一个。以下是我们使用的验证选项:
  • callback:function验证后将调用的回调它需要两个参数。ValidationError如果存在验证错误或null没有错误,第一个是 Joi对象第二个参数是输出数据。

最后,在回调函数中,如果有错误,Joi.validate()您将格式化的错误作为带有422HTTP 状态代码的 JSON 响应返回,或者您只需req.body用验证输出数据覆盖,然后将控制权传递给下一个中间件。

现在,您可以在路由上使用中间件:

  • nano routes.js

修改routes.js文件如下:

路由.js
const express = require('express');
const router = express.Router();

const SchemaValidator = require('./middlewares/SchemaValidator');
const validateRequest = SchemaValidator(true);

// generic route handler
const genericHandler = (req, res, next) => {
  res.json({
    status: 'success',
    data: req.body
  });
};

// create a new teacher or student
router.post('/people', validateRequest, genericHandler);

// change auth credentials for teachers
router.post('/auth/edit', validateRequest, genericHandler);

// accept fee payments for students
router.post('/fees/pay', validateRequest, genericHandler);

module.exports = router;

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

  • npm start

这些是可用于测试端点的示例测试数据。您可以随意编辑它们。

注意:要生成 UUID v4 字符串,您可以使用 Node UUID 模块在线 UUID Generator

/people 端点

在这个场景中,管理员正在将一个 12 岁的名叫 John Doe 的新学生输入到系统中:

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

POST /people成功响应示例

Output
{ "status": "success", "data": { "id": "a967f52a-6aa5-401d-b760-35eef7c68b32", "type": "STUDENT", "firstname": "JOHN", "lastname": "DOE", "age": "12" } }

在这个失败的场景中,管理员没有为必填age字段提供值

Output
{ "status": "failed", "error": { "original": { "id": "a967f52a-6aa5-401d-b760-35eef7c68b32", "type": "Student", "fullname": "John Doe", }, "details": [ { "message": "age is required", "type": "any.required" } ] } }

/auth/edit 端点

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

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

POST /auth/edit成功响应示例

Output
{ "status": "success", "data": { "teacherId": "e3464323-22c1-4e31-9ac5-9bde207d61d2", "email": "[email protected]", "password": "password", "confirmPassword": "password" } }

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

Output
{ "status": "failed", "error": { "original": { "teacherId": "e3464323-22c1-4e31-9ac5-9bde207d61d2", "email": "email_address", "password": "password", "confirmPassword": "Password" }, "details": [ { "message": "email must be a valid email", "type": "string.email" }, { "message": "confirmPassword must be of [ref:password]", "type": "any.allowOnly" } ] } }

/fees/pay 端点

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

注意:出于测试目的,请4242424242424242用作有效的信用卡号。此号码已被 Stripe 等服务指定用于测试目的。

{
    "studentId": "c77b8a6e-9d26-428a-9df1-e852473f886f",
    "amount": 134.9875,
    "cardNumber": "4242424242424242",
    "completedAt": 1512064288409
}

POST /fees/pay成功响应示例

Output
{ "status": "success", "data": { "studentId": "c77b8a6e-9d26-428a-9df1-e852473f886f", "amount": 134.99, "cardNumber": "4242424242424242", "completedAt": "2017-11-30T17:51:28.409Z" } }

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

Output
{ "status": "failed", "error": { "original": { "studentId": "c77b8a6e-9d26-428a-9df1-e852473f886f", "amount": 134.9875, "cardNumber": "5678901234567890", "completedAt": 1512064288409 }, "details": [ { "message": "cardNumber must be a credit card", "type": "string.creditCard" } ] } }

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

结论

在本教程中,您创建了使用 Joi 验证数据集合的模式,并使用 HTTP 请求管道上的自定义模式验证中间件处理了请求数据验证。

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

有关本教程的完整代码示例,请查看joi-schema-validation-sourcecodeGitHub 上存储库。

觉得文章有用?

点个广告表达一下你的爱意吧 !😁