Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

match() throws an error if no matching validator is found. Fixes #64. #67

Merged
merged 2 commits into from
Feb 22, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -228,18 +228,18 @@ Object][openapi-path-item-object]:
`RequestHandler` is an express middleware function with the signature
`(req: Request, res: Response, next: NextFunction): any;`.

#### `match(): RequestHandler`
#### `match(options: MatchOptions = { allowNoMatch: false }): RequestHandler`

Returns an express middleware function which calls `validate()` based on the
request method and path. Using this function removes the need to specify
`validate()` middleware for each express endpoint individually.

Note that behaviour is different to `validate()` for routes where no schema is
specified: `validate()` will throw an exception if no matching route
specification is found in the OpenAPI schema. `match()` will not throw an
exception in this case; the request is simply not validated. Be careful to
ensure you OpenAPI schema contains validators for each endpoint if using
`match()`.
`match()` throws an error if matching route specification is not found. This
ensures all requests are validated.

Use `match({ allowNoMatch: true})` if you want to skip validation for routes
that are not mentioned in the OpenAPI schema. Use with caution: It allows
requests to be handled without any validation.

The following examples achieve the same result:

Expand Down
16 changes: 14 additions & 2 deletions src/OpenApiValidator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ export interface PathRegexpObject {
regex: RegExp;
}

export interface MatchOptions {
allowNoMatch?: boolean;
}

export default class OpenApiValidator {
private _ajv: Ajv.Ajv;

Expand Down Expand Up @@ -151,7 +155,9 @@ export default class OpenApiValidator {
return validate;
}

public match(): RequestHandler {
public match(
options: MatchOptions = { allowNoMatch: false },
): RequestHandler {
const paths: PathRegexpObject[] = _.keys(this._document.paths).map(
path => ({
path,
Expand All @@ -160,10 +166,16 @@ export default class OpenApiValidator {
);
const matchAndValidate: RequestHandler = (req, res, next) => {
const match = paths.find(({ regex }) => regex.test(req.path));
const method = req.method.toLowerCase() as Operation;
if (match) {
const method = req.method.toLowerCase() as Operation;
this.validate(method, match.path)(req, res, next);
} else if (!options.allowNoMatch) {
const err = new Error(
`Path=${req.path} with method=${method} not found from OpenAPI document`,
);
next(err);
} else {
// match not required
next();
}
};
Expand Down
29 changes: 26 additions & 3 deletions test/OpenApiValidator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -617,7 +617,7 @@ describe("OpenApiValidator", () => {
expect(validateHandler).toBeCalled();
});

test("match() - does not call validate() if request does not match", async () => {
test("match() - does not call validate() if request does not match and yields error", async () => {
const validator = new OpenApiValidator(openApiDocument);
const match = validator.match();

Expand All @@ -626,11 +626,34 @@ describe("OpenApiValidator", () => {

const req = {
...baseReq,
method: "POST",
path: "/no-match",
};

// eslint-disable-next-line @typescript-eslint/no-empty-function
match(req, {} as Response, () => {});
const nextMock = jest.fn();

match(req, {} as Response, nextMock);
expect(validateMock).not.toHaveBeenCalled();
expect(nextMock).toHaveBeenCalledWith(expect.any(Error));
});

test("match({ allowNoMatch: true }) - does not call validate() if request does not match and does not yield error", async () => {
const validator = new OpenApiValidator(openApiDocument);
const match = validator.match({ allowNoMatch: true });

const validateMock = jest.fn();
validator.validate = validateMock;

const req = {
...baseReq,
method: "POST",
path: "/no-match",
};

const nextMock = jest.fn();

match(req, {} as Response, nextMock);
expect(validateMock).not.toHaveBeenCalled();
expect(nextMock).toHaveBeenCalledWith();
});
});
29 changes: 29 additions & 0 deletions test/integration/__snapshots__/integration.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -139,3 +139,32 @@ Object {
},
}
`;

exports[`Integration tests with real app requests against /match are validated correctly 2`] = `
Object {
"error": Object {
"data": Array [
Object {
"dataPath": ".body",
"keyword": "required",
"message": "should have required property 'input'",
"params": Object {
"missingProperty": "input",
},
"schemaPath": "#/properties/body/required",
},
],
"message": "Error while validating request: request.body should have required property 'input'",
"name": "ValidationError",
},
}
`;

exports[`Integration tests with real app requests against /no-match cause an error 1`] = `
Object {
"error": Object {
"message": "Path=/no-match with method=post not found from OpenAPI document",
"name": "Error",
},
}
`;
8 changes: 3 additions & 5 deletions test/integration/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

import cookieParser from "cookie-parser";
import express from "express";
import { OpenApiValidator } from "../../dist"; // eslint-disable-line
import { OpenApiValidator, ValidationError } from "../../dist"; // eslint-disable-line
import openApiDocument from "../open-api-document";

const app: express.Express = express();
Expand All @@ -34,9 +34,7 @@ app.post("/match/:optional?", validator.match(), (req, res, _next) => {
res.json({ output: req.params.optional || req.body.input });
});

app.post("/no-match", validator.match(), (req, res, _next) => {
res.json({ extra: req.body.anything });
});
app.post("/no-match", validator.match());

app.get(
"/parameters",
Expand Down Expand Up @@ -74,7 +72,7 @@ app.get(
);

const errorHandler: express.ErrorRequestHandler = (err, req, res, _next) => {
res.status(err.statusCode).json({
res.status(err instanceof ValidationError ? err.statusCode : 500).json({
error: {
name: err.name,
message: err.message,
Expand Down
15 changes: 12 additions & 3 deletions test/integration/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,14 +66,23 @@ describe("Integration tests with real app", () => {
expect(res.status).toBe(200);
expect(res.body).toEqual({ output: "works-with-url-param" });
expect(validate(res)).toBeUndefined();

res = await request(app)
.post("/match/works-with-url-param")
.send({});
expect(validate(res)).toBeUndefined();
expect(res.status).toBe(400);
expect(res.body).toHaveProperty("error");
expect(res.body).toMatchSnapshot();
});

test("requests against /no-match are not validated", async () => {
test("requests against /no-match cause an error", async () => {
const res = await request(app)
.post("/no-match")
.send({ anything: "anything" });
expect(res.status).toBe(200);
expect(res.body).toEqual({ extra: "anything" });
expect(res.status).toBe(500);
expect(res.body).toHaveProperty("error");
expect(res.body).toMatchSnapshot();
});

test("path parameters are validated", async () => {
Expand Down
34 changes: 34 additions & 0 deletions test/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,40 @@ paths:
- output
default:
$ref: "#/components/responses/Error"
/match/{optional}:
post:
description: Echo url-param back
requestBody:
content:
application/json:
schema:
type: object
properties:
input:
type: string
required:
- input
parameters:
- name: optional
in: path
required: true
schema:
type: string
responses:
"200":
description: Response
content:
application/json:
schema:
type: object
properties:
output:
type: string
required:
- output
default:
$ref: "#/components/responses/Error"

/internal-ref:
post:
responses:
Expand Down