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

feat: add option to exclude missing query parameters from strict validation #15

Merged
merged 2 commits into from
Mar 18, 2024
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
9 changes: 5 additions & 4 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,6 @@
"azureFunctions.projectLanguageModel": 4,
"azureFunctions.projectSubpath": "packages/ajv-openapi-request-response-validator",
"azureFunctions.preDeployTask": "npm prune (functions)",

// Place your settings in this file to overwrite default and user settings.
{
"git.ignoreLimitWarning": true,
"typescript.referencesCodeLens.enabled": true,
"typescript.preferences.importModuleSpecifier": "relative",
Expand All @@ -27,7 +24,11 @@
"[json]": {
"editor.formatOnSave": true
},
"eslint.workingDirectories": [{ "mode": "auto" }],
"eslint.workingDirectories": [
{
"pattern": "./packages/*/"
}
],
"eslint.options": {
"resolvePluginsRelativeTo": "."
},
Expand Down
2 changes: 0 additions & 2 deletions config/.eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,6 @@ module.exports = {
rules: {
'unused-imports/no-unused-imports': 'error',
'require-await': 'error',
},
settings: {
'@typescript-eslint/adjacent-overload-signatures': 'error',
'@typescript-eslint/array-type': ['error'],
'@typescript-eslint/await-thenable': 'error',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// eslint-disable-next-line @typescript-eslint/naming-convention
import AjvDraft4 from 'ajv-draft-04'
import { Options } from 'ajv'
import addFormats from 'ajv-formats'
Expand All @@ -9,7 +10,7 @@ import { AjvExtras, DEFAULT_AJV_EXTRAS, DEFAULT_AJV_SETTINGS } from './ajv-opts'
* @param validatorOpts - Optional additional validator options
* @param ajvExtras - Optional additional Ajv features
*/
export function createAjvInstance(ajvOpts: Options = DEFAULT_AJV_SETTINGS, ajvExtras: AjvExtras = DEFAULT_AJV_EXTRAS) {
export function createAjvInstance(ajvOpts: Options = DEFAULT_AJV_SETTINGS, ajvExtras: AjvExtras = DEFAULT_AJV_EXTRAS): AjvDraft4 {
const ajv = new AjvDraft4({ ...DEFAULT_AJV_SETTINGS, ...ajvOpts })
if (ajvExtras?.addStandardFormats === true) {
addFormats(ajv)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import Ajv, { ErrorObject, ValidateFunction } from 'ajv'
import OpenapiRequestCoercer from 'openapi-request-coercer'
import { Logger, dummyLogger } from 'ts-log'
import { OpenAPIV3 } from 'openapi-types'
import { merge, openApiMergeRules } from 'allof-merge'
import {
convertDatesToISOString,
ErrorObj,
Expand All @@ -19,7 +20,6 @@ import {
unserializeParameters,
STRICT_COERCION_STRATEGY,
} from './openapi-validator'
import { merge, openApiMergeRules } from 'allof-merge'

const REQ_BODY_COMPONENT_PREFIX_LENGTH = 27 // #/components/requestBodies/PetBody
const PARAMS_COMPONENT_PREFIX_LENGH = 24 // #/components/parameters/offsetParam
Expand Down Expand Up @@ -99,18 +99,30 @@ export class AjvOpenApiValidator {
validatorOpts?: ValidatorOpts
) {
this.validatorOpts = validatorOpts ? { ...DEFAULT_VALIDATOR_OPTS, ...validatorOpts } : DEFAULT_VALIDATOR_OPTS
if (this.validatorOpts.logger == undefined) {
if (this.validatorOpts.logger === undefined) {
this.validatorOpts.logger = dummyLogger
}

this.initialize(spec, this.validatorOpts.coerceTypes)
}

/**
* Validates query parameters against the specification. Unless otherwise configured, parameters are coerced to the schema's type.
*
* @param path - The path of the request
* @param method - The HTTP method of the request
* @param origParams - The query parameters to validate
* @param strict - If true, parameters not defined in the specification will cause a validation error
* @param strictExclusions - An array of query parameters to exclude from strict mode
* @param logger - A logger instance
* @returns An object containing the normalized query parameters and an array of validation errors
*/
validateQueryParams(
path: string,
method: string,
origParams: Record<string, Primitive> | URLSearchParams,
strict = true,
strictExclusions: string[] = [],
logger?: Logger
): { normalizedParams: Record<string, Primitive>; errors: ErrorObj[] | undefined } {
const parameterDefinitions = this.paramsValidators.filter((p) => p.path === path?.toLowerCase() && p.method === method?.toLowerCase())
Expand Down Expand Up @@ -138,45 +150,47 @@ export class AjvOpenApiValidator {
}

for (const key in params) {
const value = params[key]
const paramDefinitionIndex = parameterDefinitions.findIndex((p) => p.param.name === key?.toLowerCase())
if (paramDefinitionIndex < 0) {
if (strict) {
errResponse.push({
status: HttpStatus.BAD_REQUEST,
code: `${EC_VALIDATION}-invalid-query-parameter`,
title: 'The query parameter is not supported.',
source: {
parameter: key,
},
})
if (Object.prototype.hasOwnProperty.call(params, key)) {
const value = params[key]
const paramDefinitionIndex = parameterDefinitions.findIndex((p) => p.param.name === key?.toLowerCase())
if (paramDefinitionIndex < 0) {
if (strict && (!Array.isArray(strictExclusions) || !strictExclusions.includes(key))) {
errResponse.push({
status: HttpStatus.BAD_REQUEST,
code: `${EC_VALIDATION}-invalid-query-parameter`,
title: 'The query parameter is not supported.',
source: {
parameter: key,
},
})
} else {
log.debug(`Query parameter '${key}' not specified and strict mode is disabled -> ignoring it (${method} ${path})`)
}
} else {
log.debug(`Query parameter '${key}' not specified and strict mode is disabled -> ignoring it (${method} ${path})`)
}
} else {
const paramDefinition = parameterDefinitions.splice(paramDefinitionIndex, 1)[0]
const paramDefinition = parameterDefinitions.splice(paramDefinitionIndex, 1)[0]

const rejectEmptyValues = !(paramDefinition.param.allowEmptyValue === true)
if (rejectEmptyValues && (value === undefined || value === null || String(value).trim() === '')) {
errResponse.push({
status: HttpStatus.BAD_REQUEST,
code: `${EC_VALIDATION}-query-parameter`,
title: 'The query parameter must not be empty.',
source: {
parameter: key,
},
})
} else {
const validator = paramDefinition.validator
if (!validator) {
throw new Error('The validator needs to be iniatialized first')
}
const rejectEmptyValues = !(paramDefinition.param.allowEmptyValue === true)
if (rejectEmptyValues && (value === undefined || value === null || String(value).trim() === '')) {
errResponse.push({
status: HttpStatus.BAD_REQUEST,
code: `${EC_VALIDATION}-query-parameter`,
title: 'The query parameter must not be empty.',
source: {
parameter: key,
},
})
} else {
const validator = paramDefinition.validator
if (!validator) {
throw new Error('The validator needs to be iniatialized first')
}

const res = validator(value)
const res = validator(value)

if (!res) {
const validationErrors = mapValidatorErrors(validator.errors, HttpStatus.BAD_REQUEST)
errResponse = errResponse.concat(validationErrors)
if (!res) {
const validationErrors = mapValidatorErrors(validator.errors, HttpStatus.BAD_REQUEST)
errResponse = errResponse.concat(validationErrors)
}
}
}
}
Expand All @@ -200,6 +214,16 @@ export class AjvOpenApiValidator {
return { normalizedParams: params, errors: errResponse.length ? errResponse : undefined }
}

/**
* Validates the request body against the specification.
*
* @param path - The path of the request
* @param method - The HTTP method of the request
* @param data - The request body to validate
* @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
* @param logger - A logger
* @returns - An array of validation errors
*/
validateRequestBody(path: string, method: string, data: unknown, strict = true, logger?: Logger): ErrorObj[] | undefined {
const validator = this.requestBodyValidators.find((v) => v.path === path?.toLowerCase() && v.method === method?.toLowerCase())
const log = logger ? logger : this.validatorOpts.logger
Expand Down Expand Up @@ -233,6 +257,17 @@ export class AjvOpenApiValidator {
return undefined
}

/**
* Validates the response body against the specification.
*
* @param path - The path of the request
* @param method - The HTTP method of the request
* @param status - The HTTP status code of the response
* @param data - The response body to validate
* @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
* @param logger - A logger
* @returns - An array of validation errors
*/
validateResponseBody(
path: string,
method: string,
Expand Down Expand Up @@ -282,7 +317,7 @@ export class AjvOpenApiValidator {
if (hasComponentSchemas(spec)) {
Object.keys(spec.components.schemas).forEach((key) => {
const schema = spec.components.schemas[key]
if (this.validatorOpts.setAdditionalPropertiesToFalse === true) {
if (this.validatorOpts.setAdditionalPropertiesToFalse) {
if (!isValidReferenceObject(schema) && schema.additionalProperties === undefined && schema.discriminator === undefined) {
schema.additionalProperties = false
}
Expand Down Expand Up @@ -359,22 +394,22 @@ export class AjvOpenApiValidator {
path: path.toLowerCase(),
method: method.toLowerCase() as string,
validator,
required: required,
required,
})
}
}

if (operation.responses) {
Object.keys(operation.responses).forEach((key) => {
const response = operation.responses[key]
const opResponse = operation.responses[key]

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

if (isValidReferenceObject(response)) {
if (isValidReferenceObject(opResponse)) {
let errorStr: string | undefined

if (response.$ref.length > RESPONSE_COMPONENT_PREFIX_LENGTH) {
const respName = response.$ref.substring(RESPONSE_COMPONENT_PREFIX_LENGTH)
if (opResponse.$ref.length > RESPONSE_COMPONENT_PREFIX_LENGTH) {
const respName = opResponse.$ref.substring(RESPONSE_COMPONENT_PREFIX_LENGTH)
if (spec.components?.responses && spec.components?.responses[respName]) {
const response = spec.components?.responses[respName]
if (!isValidReferenceObject(response)) {
Expand All @@ -384,10 +419,10 @@ export class AjvOpenApiValidator {
errorStr = `A reference was not expected here: '${response.$ref}'`
}
} else {
errorStr = `Unable to find response reference '${response.$ref}'`
errorStr = `Unable to find response reference '${opResponse.$ref}'`
}
} else {
errorStr = `Unable to follow response reference '${response.$ref}'`
errorStr = `Unable to follow response reference '${opResponse.$ref}'`
}
if (errorStr) {
if (this.validatorOpts.strict) {
Expand All @@ -396,8 +431,8 @@ export class AjvOpenApiValidator {
this.validatorOpts.logger.warn(errorStr)
}
}
} else if (response.content) {
schema = this.getJsonContent(response.content)?.schema
} else if (opResponse.content) {
schema = this.getJsonContent(opResponse.content)?.schema
}

if (schema) {
Expand Down Expand Up @@ -509,7 +544,7 @@ export class AjvOpenApiValidator {
} else if (content['application/json; charset=utf-8']) {
return content['application/json; charset=utf-8']
} else {
const key = Object.keys(content).find((key) => key.toLowerCase().startsWith('application/json;'))
const key = Object.keys(content).find((k) => k.toLowerCase().startsWith('application/json;'))
return key ? content[key] : undefined
}
}
Expand All @@ -519,7 +554,9 @@ export class AjvOpenApiValidator {

// eslint-disable-next-line @typescript-eslint/no-explicit-any
return pathParts.reduce((current: any, part) => {
if (part === '#' || part === '') return current
if (part === '#' || part === '') {
return current
}
return current ? current[part] : undefined
}, document)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,9 @@ export function convertDatesToISOString<T>(data: T): DateToISOString<T> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result: any = Array.isArray(data) ? [] : {}
for (const key in data) {
result[key] = convertDatesToISOString(data[key])
if (Object.prototype.hasOwnProperty.call(data, key)) {
result[key] = convertDatesToISOString(data[key])
}
}
return result
}
Expand All @@ -134,23 +136,26 @@ export function unserializeParameters(parameters: Record<string, Primitive>): Re
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result: Record<string, any> = {}
for (const key in parameters) {
const value = parameters[key]
let target = result
const splitKey = key.split('[')
const lastKeyIndex = splitKey.length - 1

splitKey.forEach((part, index) => {
const cleanPart = part.replace(']', '')

if (index === lastKeyIndex) {
target[cleanPart] = value
} else {
if (!target[cleanPart]) target[cleanPart] = {}
target = target[cleanPart]
}
})
if (Object.prototype.hasOwnProperty.call(parameters, key)) {
const value = parameters[key]
let target = result
const splitKey = key.split('[')
const lastKeyIndex = splitKey.length - 1

splitKey.forEach((part, index) => {
const cleanPart = part.replace(']', '')

if (index === lastKeyIndex) {
target[cleanPart] = value
} else {
if (!target[cleanPart]) {
target[cleanPart] = {}
}
target = target[cleanPart]
}
})
}
}

return result
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,9 @@ module.exports = {
sourceType: 'module',
tsconfigRootDir: __dirname,
},
extends: ["../.eslintrc.js"]
extends: ["../.eslintrc.js"],
rules: {
"@typescript-eslint/naming-convention": "off",
"@typescript-eslint/no-non-null-assertion": "off",
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ for (const file of files) {
const testName = path.basename(file, '.js.txt').replace(/-/g, ' ')
const fixtureContent = fs.readFileSync(path.resolve(fixtureDir, file), { encoding: 'utf-8' })
try {
// eslint-disable-next-line no-eval
const fixture: TestFixture = eval(fixtureContent)
if (!onlyInclude || onlyInclude.length === 0 || onlyInclude.find((name) => file.includes(name))) {
testCases[testName] = fixture
Expand Down Expand Up @@ -115,14 +116,14 @@ describe('The api validator', () => {

if (fixture.validateArgs.paths) {
const params = fixture.request.query ? fixture.request.query : {}
for (const [path, method] of Object.entries(fixture.validateArgs.paths)) {
for (const [methodPath, method] of Object.entries(fixture.validateArgs.paths)) {
if (method) {
for (const [methodName, methodDef] of Object.entries(method)) {
if (Object.values(OpenAPIV3.HttpMethods).includes(methodName as OpenAPIV3.HttpMethods)) {
const operation: OpenAPIV3.OperationObject<object> = methodDef
if (operation.parameters) {
const result = validator.validateQueryParams(
path,
methodPath,
methodName,
params,
fixture.requestOpts?.strictQueryParamValidation ?? true
Expand All @@ -135,7 +136,7 @@ describe('The api validator', () => {
}
if (operation.requestBody && fixture.request.body) {
const result = validator.validateRequestBody(
path,
methodPath,
methodName,
fixture.request.body,
fixture.requestOpts?.strictRequestBodyValidation ?? true
Expand Down
Loading