Skip to content
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
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

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",
}
}
Loading