Skip to content
Open
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
20 changes: 10 additions & 10 deletions src/framework/ajv/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,19 @@ export class AjvOptions {
}

get request(): RequestValidatorOptions {
const { allErrors, allowUnknownQueryParameters, coerceTypes, removeAdditional } = <
ValidateRequestOpts
>this.options.validateRequests;
const {
allErrors,
allowUnknownQueryParameters,
coerceTypes,
removeAdditional,
onError,
} = <ValidateRequestOpts>this.options.validateRequests;
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this change necessary? Does the list include the complete set of user specified options? If not, it will change/ break existing behavior

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought that I did need it, yes, b/c otherwise the onError property isn't actually passed from the constructor.

Confirmed that removing it breaks the tests I added.

I thought this would be a no-op if you weren't defining this new property. I didn't break any existing tests, so I thought this was safe.

What am I missing?

return {
...this.baseOptions(),
allErrors,
allowUnknownQueryParameters,
coerceTypes,
onError,
removeAdditional,
};
}
Expand All @@ -47,13 +52,8 @@ export class AjvOptions {
}

private baseOptions(): Options {
const {
coerceTypes,
formats,
validateFormats,
serDes,
ajvFormats,
} = this.options;
const { coerceTypes, formats, validateFormats, serDes, ajvFormats } =
this.options;
const serDesMap = {};
for (const serDesObject of serDes) {
if (!serDesMap[serDesObject.format]) {
Expand Down
1 change: 1 addition & 0 deletions src/framework/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export type ValidateRequestOpts = {
allowUnknownQueryParameters?: boolean;
coerceTypes?: boolean | 'array';
removeAdditional?: boolean | 'all' | 'failing';
onError?: (err: InternalServerError, req: Request) => void;
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the intend of this change to introduce onError to requests, mirroring what exists currently (but only) for responses?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes that's right. My thinking is that we currently have three platforms hitting our APIs. I believe I've captured the the real-world usage of our APIs, but there are enough edge cases that I'd believe I've missed something. I'd like to be able to introduce validation slowly, basically making sure that we are correctly extracting the openapi interface before I begin actually serving 400s using the validator.

};

export type ValidateResponseOpts = {
Expand Down
26 changes: 20 additions & 6 deletions src/middlewares/openapi.request.validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export class RequestValidator {
delete this.apiDoc.components?.examples;
this.requestOpts.allowUnknownQueryParameters =
options.allowUnknownQueryParameters;
this.requestOpts.onError = options.onError;

this.ajv = createRequestAjv(
apiDoc,
Expand Down Expand Up @@ -157,11 +158,19 @@ export class RequestValidator {
mutator.modifyRequest(req);

if (!allowUnknownQueryParameters) {
this.processQueryParam(
req.query,
schemaProperties.query,
securityQueryParam,
);
try {
this.processQueryParam(
req.query,
schemaProperties.query,
securityQueryParam,
);
} catch (error) {
if (this.requestOpts.onError) {
this.requestOpts.onError(error, req);
} else {
throw error;
}
}
}

const schemaBody = <any>validator?.schemaBody;
Expand Down Expand Up @@ -218,7 +227,12 @@ export class RequestValidator {
message: message,
});
error.errors = err.errors;
throw error;
if (this.requestOpts.onError) {
this.requestOpts.onError(error, req);
next();
Copy link
Owner

@cdimascio cdimascio Aug 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we need to call next() here? we should expect onError handlers to always throw, correct? or is the intent to also allow clients to eat errors?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My thinking was that we should let clients eat the error, yeah.

In the case I'm implementing it for, eg, I want to basically know when express-openapi-validator says there's an error, log it, but not actually cause the client request to fail.

If the client wants an error, they can re-throw. This is how I've been using the response validator, and as far as I can tell it is working.

} else {
throw error;
}
}
};
}
Expand Down
145 changes: 145 additions & 0 deletions test/request.validation.on.error.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import * as path from 'path';
import { expect } from 'chai';
import * as request from 'supertest';
import { createApp } from './common/app';
import * as packageJson from '../package.json';
import { AppWithServer } from './common/app.common';

const apiSpecPath = path.join('test', 'resources', 'request.validation.yaml');

let onErrorArgs: any[] | null;

async function buildApp({
allowUnknownQueryParameters,
}: {
allowUnknownQueryParameters: boolean;
}): Promise<AppWithServer> {
return await createApp(
{
apiSpec: apiSpecPath,
validateResponses: false,
validateRequests: {
allowUnknownQueryParameters,
onError: function (_err, req) {
onErrorArgs = [_err, req];
if (req.query['limit'] === 'bad_type_throw') {
throw new Error('error in onError handler');
}
},
},
},
3005,
(app) => {
app.get(`${app.basePath}/pets`, (req, res) => {
debugger;
let json = [
{ id: '1', name: 'fido' },
{ id: '2', name: 'rex' },
{ id: '3', name: 'spot' },
];
if (req.query.limit === 'not_an_integer') {
json = [{ id: 'bad_limit', name: 'not an int' }];
} else if (req.query.unknown_param === '123') {
json = [{ id: 'unknown_param', name: 'unknown' }];
} else if (req.query.limit === 'bad_type_throw') {
json = [{ id: 'bad_limit_throw', name: 'name' }];
}
res.json(json);
});
app.use((err, _req, res, _next) => {
res.status(err.status ?? 500).json({
message: err.message,
code: err.status ?? 500,
});
});
},
false,
);
}

describe(packageJson.name, () => {
let app: AppWithServer;

before(async () => {
// set up express app
app = await buildApp({ allowUnknownQueryParameters: false });
});

afterEach(() => {
onErrorArgs = null;
});

after(() => {
app.server.close();
});

it('custom error handler invoked if has unknown query parameter', async () =>
request(app)
.get(`${app.basePath}/pets?limit=3&unknown_param=123`)
.expect(200)
.then((r: any) => {
const data = [{ id: 'unknown_param', name: 'unknown' }];
expect(r.body).to.eql(data);
expect(onErrorArgs?.length).to.equal(2);
expect(onErrorArgs![0].message).to.equal(
"Unknown query parameter 'unknown_param'",
);
expect(onErrorArgs![1].query).to.eql({
limit: 3,
unknown_param: '123',
});
}));

it('custom error handler not invoked if has unknown query parameter, but is allowed', async () => {
app.server.close();
app = await buildApp({ allowUnknownQueryParameters: true });

request(app)
.get(`${app.basePath}/pets?limit=3&unknown_param=123`)
.expect(200)
.then((r: any) => {
expect(r.body).is.an('array').with.length(3);
expect(onErrorArgs).to.equal(null);
});
});

it('custom error handler invoked if request query field has the wrong type', async () =>
request(app)
.get(`${app.basePath}/pets?limit=not_an_integer`)
.expect(200)
.then((r: any) => {
const data = [{ id: 'bad_limit', name: 'not an int' }];
expect(r.body).to.eql(data);
expect(onErrorArgs?.length).to.equal(2);
expect(onErrorArgs![0].message).to.equal(
'request/query/limit must be integer',
);
expect(onErrorArgs![1].query).to.eql({
limit: 'not_an_integer',
});
}));

it('custom error handler not invoked on valid response', async () =>
request(app)
.get(`${app.basePath}/pets?limit=3`)
.expect(200)
.then((r: any) => {
expect(r.body).is.an('array').with.length(3);
expect(onErrorArgs).to.equal(null);
}));

it('returns error if custom error handler throws', async () =>
request(app)
.get(`${app.basePath}/pets?limit=bad_type_throw`)
.expect(500)
.then((r: any) => {
expect(r.body.message).to.equal('error in onError handler');
expect(onErrorArgs!.length).to.equal(2);
expect(onErrorArgs![0].message).to.equal(
'request/query/limit must be integer',
);
expect(onErrorArgs![1].query).to.eql({
limit: 'bad_type_throw',
});
}));
});
Loading