Skip to content

Commit f97fb2f

Browse files
authored
Merge pull request #15 from restfulhead/fixes
feat: add option to exclude missing query parameters from strict validation
2 parents 4d3b26f + d1204d0 commit f97fb2f

File tree

14 files changed

+195
-116
lines changed

14 files changed

+195
-116
lines changed

.vscode/settings.json

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,6 @@
77
"azureFunctions.projectLanguageModel": 4,
88
"azureFunctions.projectSubpath": "packages/ajv-openapi-request-response-validator",
99
"azureFunctions.preDeployTask": "npm prune (functions)",
10-
11-
// Place your settings in this file to overwrite default and user settings.
12-
{
1310
"git.ignoreLimitWarning": true,
1411
"typescript.referencesCodeLens.enabled": true,
1512
"typescript.preferences.importModuleSpecifier": "relative",
@@ -27,7 +24,11 @@
2724
"[json]": {
2825
"editor.formatOnSave": true
2926
},
30-
"eslint.workingDirectories": [{ "mode": "auto" }],
27+
"eslint.workingDirectories": [
28+
{
29+
"pattern": "./packages/*/"
30+
}
31+
],
3132
"eslint.options": {
3233
"resolvePluginsRelativeTo": "."
3334
},

config/.eslintrc.js

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,6 @@ module.exports = {
1717
rules: {
1818
'unused-imports/no-unused-imports': 'error',
1919
'require-await': 'error',
20-
},
21-
settings: {
2220
'@typescript-eslint/adjacent-overload-signatures': 'error',
2321
'@typescript-eslint/array-type': ['error'],
2422
'@typescript-eslint/await-thenable': 'error',

packages/ajv-openapi-request-response-validator/src/ajv-factory.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
// eslint-disable-next-line @typescript-eslint/naming-convention
12
import AjvDraft4 from 'ajv-draft-04'
23
import { Options } from 'ajv'
34
import addFormats from 'ajv-formats'
@@ -9,7 +10,7 @@ import { AjvExtras, DEFAULT_AJV_EXTRAS, DEFAULT_AJV_SETTINGS } from './ajv-opts'
910
* @param validatorOpts - Optional additional validator options
1011
* @param ajvExtras - Optional additional Ajv features
1112
*/
12-
export function createAjvInstance(ajvOpts: Options = DEFAULT_AJV_SETTINGS, ajvExtras: AjvExtras = DEFAULT_AJV_EXTRAS) {
13+
export function createAjvInstance(ajvOpts: Options = DEFAULT_AJV_SETTINGS, ajvExtras: AjvExtras = DEFAULT_AJV_EXTRAS): AjvDraft4 {
1314
const ajv = new AjvDraft4({ ...DEFAULT_AJV_SETTINGS, ...ajvOpts })
1415
if (ajvExtras?.addStandardFormats === true) {
1516
addFormats(ajv)

packages/ajv-openapi-request-response-validator/src/ajv-openapi-request-response-validator.ts

Lines changed: 86 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import Ajv, { ErrorObject, ValidateFunction } from 'ajv'
44
import OpenapiRequestCoercer from 'openapi-request-coercer'
55
import { Logger, dummyLogger } from 'ts-log'
66
import { OpenAPIV3 } from 'openapi-types'
7+
import { merge, openApiMergeRules } from 'allof-merge'
78
import {
89
convertDatesToISOString,
910
ErrorObj,
@@ -19,7 +20,6 @@ import {
1920
unserializeParameters,
2021
STRICT_COERCION_STRATEGY,
2122
} from './openapi-validator'
22-
import { merge, openApiMergeRules } from 'allof-merge'
2323

2424
const REQ_BODY_COMPONENT_PREFIX_LENGTH = 27 // #/components/requestBodies/PetBody
2525
const PARAMS_COMPONENT_PREFIX_LENGH = 24 // #/components/parameters/offsetParam
@@ -99,18 +99,30 @@ export class AjvOpenApiValidator {
9999
validatorOpts?: ValidatorOpts
100100
) {
101101
this.validatorOpts = validatorOpts ? { ...DEFAULT_VALIDATOR_OPTS, ...validatorOpts } : DEFAULT_VALIDATOR_OPTS
102-
if (this.validatorOpts.logger == undefined) {
102+
if (this.validatorOpts.logger === undefined) {
103103
this.validatorOpts.logger = dummyLogger
104104
}
105105

106106
this.initialize(spec, this.validatorOpts.coerceTypes)
107107
}
108108

109+
/**
110+
* Validates query parameters against the specification. Unless otherwise configured, parameters are coerced to the schema's type.
111+
*
112+
* @param path - The path of the request
113+
* @param method - The HTTP method of the request
114+
* @param origParams - The query parameters to validate
115+
* @param strict - If true, parameters not defined in the specification will cause a validation error
116+
* @param strictExclusions - An array of query parameters to exclude from strict mode
117+
* @param logger - A logger instance
118+
* @returns An object containing the normalized query parameters and an array of validation errors
119+
*/
109120
validateQueryParams(
110121
path: string,
111122
method: string,
112123
origParams: Record<string, Primitive> | URLSearchParams,
113124
strict = true,
125+
strictExclusions: string[] = [],
114126
logger?: Logger
115127
): { normalizedParams: Record<string, Primitive>; errors: ErrorObj[] | undefined } {
116128
const parameterDefinitions = this.paramsValidators.filter((p) => p.path === path?.toLowerCase() && p.method === method?.toLowerCase())
@@ -138,45 +150,47 @@ export class AjvOpenApiValidator {
138150
}
139151

140152
for (const key in params) {
141-
const value = params[key]
142-
const paramDefinitionIndex = parameterDefinitions.findIndex((p) => p.param.name === key?.toLowerCase())
143-
if (paramDefinitionIndex < 0) {
144-
if (strict) {
145-
errResponse.push({
146-
status: HttpStatus.BAD_REQUEST,
147-
code: `${EC_VALIDATION}-invalid-query-parameter`,
148-
title: 'The query parameter is not supported.',
149-
source: {
150-
parameter: key,
151-
},
152-
})
153+
if (Object.prototype.hasOwnProperty.call(params, key)) {
154+
const value = params[key]
155+
const paramDefinitionIndex = parameterDefinitions.findIndex((p) => p.param.name === key?.toLowerCase())
156+
if (paramDefinitionIndex < 0) {
157+
if (strict && (!Array.isArray(strictExclusions) || !strictExclusions.includes(key))) {
158+
errResponse.push({
159+
status: HttpStatus.BAD_REQUEST,
160+
code: `${EC_VALIDATION}-invalid-query-parameter`,
161+
title: 'The query parameter is not supported.',
162+
source: {
163+
parameter: key,
164+
},
165+
})
166+
} else {
167+
log.debug(`Query parameter '${key}' not specified and strict mode is disabled -> ignoring it (${method} ${path})`)
168+
}
153169
} else {
154-
log.debug(`Query parameter '${key}' not specified and strict mode is disabled -> ignoring it (${method} ${path})`)
155-
}
156-
} else {
157-
const paramDefinition = parameterDefinitions.splice(paramDefinitionIndex, 1)[0]
170+
const paramDefinition = parameterDefinitions.splice(paramDefinitionIndex, 1)[0]
158171

159-
const rejectEmptyValues = !(paramDefinition.param.allowEmptyValue === true)
160-
if (rejectEmptyValues && (value === undefined || value === null || String(value).trim() === '')) {
161-
errResponse.push({
162-
status: HttpStatus.BAD_REQUEST,
163-
code: `${EC_VALIDATION}-query-parameter`,
164-
title: 'The query parameter must not be empty.',
165-
source: {
166-
parameter: key,
167-
},
168-
})
169-
} else {
170-
const validator = paramDefinition.validator
171-
if (!validator) {
172-
throw new Error('The validator needs to be iniatialized first')
173-
}
172+
const rejectEmptyValues = !(paramDefinition.param.allowEmptyValue === true)
173+
if (rejectEmptyValues && (value === undefined || value === null || String(value).trim() === '')) {
174+
errResponse.push({
175+
status: HttpStatus.BAD_REQUEST,
176+
code: `${EC_VALIDATION}-query-parameter`,
177+
title: 'The query parameter must not be empty.',
178+
source: {
179+
parameter: key,
180+
},
181+
})
182+
} else {
183+
const validator = paramDefinition.validator
184+
if (!validator) {
185+
throw new Error('The validator needs to be iniatialized first')
186+
}
174187

175-
const res = validator(value)
188+
const res = validator(value)
176189

177-
if (!res) {
178-
const validationErrors = mapValidatorErrors(validator.errors, HttpStatus.BAD_REQUEST)
179-
errResponse = errResponse.concat(validationErrors)
190+
if (!res) {
191+
const validationErrors = mapValidatorErrors(validator.errors, HttpStatus.BAD_REQUEST)
192+
errResponse = errResponse.concat(validationErrors)
193+
}
180194
}
181195
}
182196
}
@@ -200,6 +214,16 @@ export class AjvOpenApiValidator {
200214
return { normalizedParams: params, errors: errResponse.length ? errResponse : undefined }
201215
}
202216

217+
/**
218+
* Validates the request body against the specification.
219+
*
220+
* @param path - The path of the request
221+
* @param method - The HTTP method of the request
222+
* @param data - The request body to validate
223+
* @param strict - If true and a request body is present, then there must be a request body defined in the specification for validation to continue
224+
* @param logger - A logger
225+
* @returns - An array of validation errors
226+
*/
203227
validateRequestBody(path: string, method: string, data: unknown, strict = true, logger?: Logger): ErrorObj[] | undefined {
204228
const validator = this.requestBodyValidators.find((v) => v.path === path?.toLowerCase() && v.method === method?.toLowerCase())
205229
const log = logger ? logger : this.validatorOpts.logger
@@ -233,6 +257,17 @@ export class AjvOpenApiValidator {
233257
return undefined
234258
}
235259

260+
/**
261+
* Validates the response body against the specification.
262+
*
263+
* @param path - The path of the request
264+
* @param method - The HTTP method of the request
265+
* @param status - The HTTP status code of the response
266+
* @param data - The response body to validate
267+
* @param strict - If true and a response body is present, then there must be a response body defined in the specification for validation to continue
268+
* @param logger - A logger
269+
* @returns - An array of validation errors
270+
*/
236271
validateResponseBody(
237272
path: string,
238273
method: string,
@@ -282,7 +317,7 @@ export class AjvOpenApiValidator {
282317
if (hasComponentSchemas(spec)) {
283318
Object.keys(spec.components.schemas).forEach((key) => {
284319
const schema = spec.components.schemas[key]
285-
if (this.validatorOpts.setAdditionalPropertiesToFalse === true) {
320+
if (this.validatorOpts.setAdditionalPropertiesToFalse) {
286321
if (!isValidReferenceObject(schema) && schema.additionalProperties === undefined && schema.discriminator === undefined) {
287322
schema.additionalProperties = false
288323
}
@@ -359,22 +394,22 @@ export class AjvOpenApiValidator {
359394
path: path.toLowerCase(),
360395
method: method.toLowerCase() as string,
361396
validator,
362-
required: required,
397+
required,
363398
})
364399
}
365400
}
366401

367402
if (operation.responses) {
368403
Object.keys(operation.responses).forEach((key) => {
369-
const response = operation.responses[key]
404+
const opResponse = operation.responses[key]
370405

371406
let schema: OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject | undefined
372407

373-
if (isValidReferenceObject(response)) {
408+
if (isValidReferenceObject(opResponse)) {
374409
let errorStr: string | undefined
375410

376-
if (response.$ref.length > RESPONSE_COMPONENT_PREFIX_LENGTH) {
377-
const respName = response.$ref.substring(RESPONSE_COMPONENT_PREFIX_LENGTH)
411+
if (opResponse.$ref.length > RESPONSE_COMPONENT_PREFIX_LENGTH) {
412+
const respName = opResponse.$ref.substring(RESPONSE_COMPONENT_PREFIX_LENGTH)
378413
if (spec.components?.responses && spec.components?.responses[respName]) {
379414
const response = spec.components?.responses[respName]
380415
if (!isValidReferenceObject(response)) {
@@ -384,10 +419,10 @@ export class AjvOpenApiValidator {
384419
errorStr = `A reference was not expected here: '${response.$ref}'`
385420
}
386421
} else {
387-
errorStr = `Unable to find response reference '${response.$ref}'`
422+
errorStr = `Unable to find response reference '${opResponse.$ref}'`
388423
}
389424
} else {
390-
errorStr = `Unable to follow response reference '${response.$ref}'`
425+
errorStr = `Unable to follow response reference '${opResponse.$ref}'`
391426
}
392427
if (errorStr) {
393428
if (this.validatorOpts.strict) {
@@ -396,8 +431,8 @@ export class AjvOpenApiValidator {
396431
this.validatorOpts.logger.warn(errorStr)
397432
}
398433
}
399-
} else if (response.content) {
400-
schema = this.getJsonContent(response.content)?.schema
434+
} else if (opResponse.content) {
435+
schema = this.getJsonContent(opResponse.content)?.schema
401436
}
402437

403438
if (schema) {
@@ -509,7 +544,7 @@ export class AjvOpenApiValidator {
509544
} else if (content['application/json; charset=utf-8']) {
510545
return content['application/json; charset=utf-8']
511546
} else {
512-
const key = Object.keys(content).find((key) => key.toLowerCase().startsWith('application/json;'))
547+
const key = Object.keys(content).find((k) => k.toLowerCase().startsWith('application/json;'))
513548
return key ? content[key] : undefined
514549
}
515550
}
@@ -519,7 +554,9 @@ export class AjvOpenApiValidator {
519554

520555
// eslint-disable-next-line @typescript-eslint/no-explicit-any
521556
return pathParts.reduce((current: any, part) => {
522-
if (part === '#' || part === '') return current
557+
if (part === '#' || part === '') {
558+
return current
559+
}
523560
return current ? current[part] : undefined
524561
}, document)
525562
}

packages/ajv-openapi-request-response-validator/src/openapi-validator.ts

Lines changed: 22 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,9 @@ export function convertDatesToISOString<T>(data: T): DateToISOString<T> {
122122
// eslint-disable-next-line @typescript-eslint/no-explicit-any
123123
const result: any = Array.isArray(data) ? [] : {}
124124
for (const key in data) {
125-
result[key] = convertDatesToISOString(data[key])
125+
if (Object.prototype.hasOwnProperty.call(data, key)) {
126+
result[key] = convertDatesToISOString(data[key])
127+
}
126128
}
127129
return result
128130
}
@@ -134,23 +136,26 @@ export function unserializeParameters(parameters: Record<string, Primitive>): Re
134136
// eslint-disable-next-line @typescript-eslint/no-explicit-any
135137
const result: Record<string, any> = {}
136138
for (const key in parameters) {
137-
const value = parameters[key]
138-
let target = result
139-
const splitKey = key.split('[')
140-
const lastKeyIndex = splitKey.length - 1
141-
142-
splitKey.forEach((part, index) => {
143-
const cleanPart = part.replace(']', '')
144-
145-
if (index === lastKeyIndex) {
146-
target[cleanPart] = value
147-
} else {
148-
if (!target[cleanPart]) target[cleanPart] = {}
149-
target = target[cleanPart]
150-
}
151-
})
139+
if (Object.prototype.hasOwnProperty.call(parameters, key)) {
140+
const value = parameters[key]
141+
let target = result
142+
const splitKey = key.split('[')
143+
const lastKeyIndex = splitKey.length - 1
144+
145+
splitKey.forEach((part, index) => {
146+
const cleanPart = part.replace(']', '')
147+
148+
if (index === lastKeyIndex) {
149+
target[cleanPart] = value
150+
} else {
151+
if (!target[cleanPart]) {
152+
target[cleanPart] = {}
153+
}
154+
target = target[cleanPart]
155+
}
156+
})
157+
}
152158
}
153-
154159
return result
155160
}
156161

packages/ajv-openapi-request-response-validator/test/.eslintrc.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,9 @@ module.exports = {
55
sourceType: 'module',
66
tsconfigRootDir: __dirname,
77
},
8-
extends: ["../.eslintrc.js"]
8+
extends: ["../.eslintrc.js"],
9+
rules: {
10+
"@typescript-eslint/naming-convention": "off",
11+
"@typescript-eslint/no-non-null-assertion": "off",
12+
}
913
}

packages/ajv-openapi-request-response-validator/test/unit/ajv-validator-api.spec.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ for (const file of files) {
6767
const testName = path.basename(file, '.js.txt').replace(/-/g, ' ')
6868
const fixtureContent = fs.readFileSync(path.resolve(fixtureDir, file), { encoding: 'utf-8' })
6969
try {
70+
// eslint-disable-next-line no-eval
7071
const fixture: TestFixture = eval(fixtureContent)
7172
if (!onlyInclude || onlyInclude.length === 0 || onlyInclude.find((name) => file.includes(name))) {
7273
testCases[testName] = fixture
@@ -115,14 +116,14 @@ describe('The api validator', () => {
115116

116117
if (fixture.validateArgs.paths) {
117118
const params = fixture.request.query ? fixture.request.query : {}
118-
for (const [path, method] of Object.entries(fixture.validateArgs.paths)) {
119+
for (const [methodPath, method] of Object.entries(fixture.validateArgs.paths)) {
119120
if (method) {
120121
for (const [methodName, methodDef] of Object.entries(method)) {
121122
if (Object.values(OpenAPIV3.HttpMethods).includes(methodName as OpenAPIV3.HttpMethods)) {
122123
const operation: OpenAPIV3.OperationObject<object> = methodDef
123124
if (operation.parameters) {
124125
const result = validator.validateQueryParams(
125-
path,
126+
methodPath,
126127
methodName,
127128
params,
128129
fixture.requestOpts?.strictQueryParamValidation ?? true
@@ -135,7 +136,7 @@ describe('The api validator', () => {
135136
}
136137
if (operation.requestBody && fixture.request.body) {
137138
const result = validator.validateRequestBody(
138-
path,
139+
methodPath,
139140
methodName,
140141
fixture.request.body,
141142
fixture.requestOpts?.strictRequestBodyValidation ?? true

0 commit comments

Comments
 (0)