From 62a0c586d802c51f788cd49ac50da49a0b58107c Mon Sep 17 00:00:00 2001 From: techntools <71767293+techntools@users.noreply.github.com> Date: Tue, 16 Jul 2024 03:58:08 +0530 Subject: [PATCH] feat: Add support for ajv-keywords (#61) --- README.md | 28 +++++++++++- index.js | 4 +- lib/validate.js | 3 ++ package.json | 1 + test/_validate.js | 108 ++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 140 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index e7c2d36..1a8973b 100644 --- a/README.md +++ b/README.md @@ -166,7 +166,7 @@ app.get('/:foo', oapi.path({ }) ``` -### `OpenApiMiddleware.validPath([definition])` +### `OpenApiMiddleware.validPath([definition [, pathOpts]])` Registers a path with the OpenAPI document, also ensures incoming requests are valid against the schema. The path `definition` is an [`OperationObject`](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#operationObject) @@ -175,7 +175,7 @@ can be used in an express app and will call `next(err) if the incoming request i The error is created with (`http-errors`)[https://www.npmjs.com/package/http-errors], and then is augmented with information about the schema and validation errors. Validation uses (`avj`)[https://www.npmjs.com/package/ajv], -and `err.validationErrors` is the format exposed by that package. +and `err.validationErrors` is the format exposed by that package. Pass { keywords: [] } as pathOpts to support custom validation based on [ajv-keywords](https://www.npmjs.com/package/ajv-keywords). **Example:** @@ -215,6 +215,30 @@ app.get('/:foo', oapi.validPath({ schema: err.validationSchema }) }) + +app.get('/zoom', oapi.validPath({ + ... + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + name: { type: 'string', not: { regexp: '/^[A-Z]/' } } + } + } + } + } + }, + ... +}, { keywords: ['regexp'] }), (err, req, res, next) => { + res.status(err.status).json({ + error: err.message, + validation: err.validationErrors, + schema: err.validationSchema + }) +}) ``` ### `OpenApiMiddleware.component(type[, name[, definition]])` diff --git a/index.js b/index.js index 12bbad4..a072077 100644 --- a/index.js +++ b/index.js @@ -63,11 +63,11 @@ module.exports = function ExpressOpenApi (_routePrefix, _doc, _opts) { } // Validate path middleware - middleware.validPath = function (schema = {}) { + middleware.validPath = function (schema = {}, pathOpts = {}) { let validate function validSchemaMiddleware (req, res, next) { if (!validate) { - validate = makeValidator(middleware, getSchema(validSchemaMiddleware), opts) + validate = makeValidator(middleware, getSchema(validSchemaMiddleware), { ...pathOpts, ...opts }) } return validate(req, res, next) } diff --git a/lib/validate.js b/lib/validate.js index 96646c0..430a32c 100644 --- a/lib/validate.js +++ b/lib/validate.js @@ -1,6 +1,7 @@ 'use strict' const Ajv = require('ajv') const addFormats = require('ajv-formats') +const addKeywords = require('ajv-keywords') const httpErrors = require('http-errors') const merge = require('merge-deep') @@ -93,6 +94,8 @@ module.exports = function makeValidatorMiddleware (middleware, schema, opts) { strict: opts.strict === true ? opts.strict : false }) addFormats(ajv) + + if (opts.keywords) { addKeywords(ajv, opts.keywords) } } if (!validate) { diff --git a/package.json b/package.json index 377018c..b4d63ec 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "dependencies": { "ajv": "^8.12.0", "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0", "http-errors": "^2.0.0", "merge-deep": "^3.0.3", "path-to-regexp": "^6.2.1", diff --git a/test/_validate.js b/test/_validate.js index 6f2b204..e11ed9a 100644 --- a/test/_validate.js +++ b/test/_validate.js @@ -107,6 +107,114 @@ module.exports = function () { assert.strictEqual(res4.statusCode, 400) assert.strictEqual(res4.body.validationErrors[0].instancePath, '/body/birthday') + + app.put('/zoom', oapi.validPath({ + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + name: { type: 'string', not: { regexp: '/^[A-Z]/' } } + } + } + } + } + }, + responses: { + 200: { + description: 'Successful response', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + goodbye: { type: 'string', enum: ['moon'] } + } + } + } + } + } + } + }, { keywords: ['regexp'] }), (req, res) => { + res.status(200).json({ + goodbye: 'moon', + num: req.query.num + }) + }, (err, req, res, next) => { + assert(err) + res.status(err.statusCode).json(err) + }) + + const res5 = await supertest(app) + .put('/zoom') + .send({ + hello: 'world', + foo: 'bar', + name: 'abc' + }) + + assert.strictEqual(res5.statusCode, 200) + + const res6 = await supertest(app) + .put('/zoom') + .send({ + hello: 'world', + foo: 'bar', + name: 'Abc' + }) + + assert.strictEqual(res6.statusCode, 400) + assert.strictEqual(res6.body.validationErrors[0].instancePath, '/body/name') + + app.get('/me', oapi.validPath({ + parameters: [{ + name: 'q', + in: 'query', + schema: { + type: 'string', + regexp: { + pattern: '^o', + flags: 'i' + } + } + }], + responses: { + 200: { + description: 'Successful response', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + goodbye: { type: 'string', enum: ['moon'] } + } + } + } + } + } + } + }, { keywords: ['regexp'] }), (req, res) => { + res.status(200).json({ + goodbye: 'moon' + }) + }, (err, req, res, next) => { + assert(err) + res.status(err.statusCode).json(err) + }) + + const res7 = await supertest(app) + .get('/me?q=123') + + assert.strictEqual(res7.statusCode, 400) + assert.strictEqual(res7.body.validationErrors[0].instancePath, '/query/q') + + const res8 = await supertest(app) + .get('/me?q=oops') + + assert.strictEqual(res8.statusCode, 200) + assert.strictEqual(res8.body.goodbye, 'moon') }) test('coerce types on req', async function () {