@readme/openapi-parser
Advanced tools
Comparing version 1.2.1 to 2.0.0
@@ -96,7 +96,4 @@ const { ono } = require('@jsdevtools/ono'); | ||
const flattened = new Map(); | ||
const instances = errors.sort((a, b) => { | ||
return a.instancePath.match(/\//g).length > b.instancePath.match(/\//g).length ? 1 : -1; | ||
}); | ||
instances.forEach(err => { | ||
errors.forEach(err => { | ||
// These two errors appear when a child schema of them has a problem and instead of polluting the user with | ||
@@ -109,19 +106,24 @@ // indecipherable noise we should instead relay the more specific error to them. If this is all that's present in | ||
// If this is our first run through let's initialize our dataset and move along. | ||
if (!flattened.size) { | ||
flattened.set(err.instancePath, err); | ||
return; | ||
} | ||
if (flattened.has(err.instancePath)) { | ||
} else if (flattened.has(err.instancePath)) { | ||
// If we already have an error recorded for this `instancePath` we can ignore it because we (likely) already have | ||
// recorded the more specific error. | ||
return; | ||
} | ||
const lookup = err.instancePath.split('/').slice(0, -1).join('/'); | ||
if (!flattened.has(lookup)) { | ||
// If this error hasn't already been recorded, maybe it's an error against the same `instancePath` stack, in which | ||
// case we should ignore it because the more specific error has already been recorded. | ||
let shouldRecordError = true; | ||
flattened.forEach(flat => { | ||
if (flat.instancePath.includes(err.instancePath)) { | ||
shouldRecordError = false; | ||
} | ||
}); | ||
if (shouldRecordError) { | ||
flattened.set(err.instancePath, err); | ||
return; | ||
} | ||
flattened.delete(lookup); | ||
flattened.set(err.instancePath, err); | ||
}); | ||
@@ -128,0 +130,0 @@ |
@@ -1,348 +0,16 @@ | ||
const util = require('../util'); | ||
const { ono } = require('@jsdevtools/ono'); | ||
const swaggerMethods = require('@apidevtools/swagger-methods'); | ||
const validateSwagger = require('./spec/swagger'); | ||
const validateOpenAPI = require('./spec/openapi'); | ||
const primitiveTypes = ['array', 'boolean', 'integer', 'number', 'string']; | ||
const schemaTypes = ['array', 'boolean', 'integer', 'number', 'string', 'object', 'null', undefined]; | ||
module.exports = validateSpec; | ||
/** | ||
* Validates parts of the Swagger 2.0 spec that aren't covered by the Swagger 2.0 JSON Schema. | ||
* Validates either a Swagger 2.0 or OpenAPI 3.x API definition against cases that aren't covered by their JSON Schema | ||
* definitions. | ||
* | ||
* @param {SwaggerObject} api | ||
*/ | ||
function validateSpec(api) { | ||
module.exports = function validateSpec(api) { | ||
if (api.openapi) { | ||
// We don't (yet) support validating against the OpenAPI spec | ||
return; | ||
return validateOpenAPI(api); | ||
} | ||
const paths = Object.keys(api.paths || {}); | ||
const operationIds = []; | ||
for (const pathName of paths) { | ||
const path = api.paths[pathName]; | ||
const pathId = `/paths${pathName}`; | ||
if (path && pathName.indexOf('/') === 0) { | ||
validatePath(api, path, pathId, operationIds); | ||
} | ||
} | ||
const definitions = Object.keys(api.definitions || {}); | ||
for (const definitionName of definitions) { | ||
const definition = api.definitions[definitionName]; | ||
const definitionId = `/definitions/${definitionName}`; | ||
validateRequiredPropertiesExist(definition, definitionId); | ||
} | ||
} | ||
/** | ||
* Validates the given path. | ||
* | ||
* @param {SwaggerObject} api - The entire Swagger API object | ||
* @param {object} path - A Path object, from the Swagger API | ||
* @param {string} pathId - A value that uniquely identifies the path | ||
* @param {string} operationIds - An array of collected operationIds found in other paths | ||
*/ | ||
function validatePath(api, path, pathId, operationIds) { | ||
for (const operationName of swaggerMethods) { | ||
const operation = path[operationName]; | ||
const operationId = `${pathId}/${operationName}`; | ||
if (operation) { | ||
const declaredOperationId = operation.operationId; | ||
if (declaredOperationId) { | ||
if (operationIds.indexOf(declaredOperationId) === -1) { | ||
operationIds.push(declaredOperationId); | ||
} else { | ||
throw ono.syntax(`Validation failed. Duplicate operation id '${declaredOperationId}'`); | ||
} | ||
} | ||
validateParameters(api, path, pathId, operation, operationId); | ||
const responses = Object.keys(operation.responses || {}); | ||
for (const responseName of responses) { | ||
const response = operation.responses[responseName]; | ||
const responseId = `${operationId}/responses/${responseName}`; | ||
validateResponse(responseName, response || {}, responseId); | ||
} | ||
} | ||
} | ||
} | ||
/** | ||
* Validates the parameters for the given operation. | ||
* | ||
* @param {SwaggerObject} api - The entire Swagger API object | ||
* @param {object} path - A Path object, from the Swagger API | ||
* @param {string} pathId - A value that uniquely identifies the path | ||
* @param {object} operation - An Operation object, from the Swagger API | ||
* @param {string} operationId - A value that uniquely identifies the operation | ||
*/ | ||
function validateParameters(api, path, pathId, operation, operationId) { | ||
const pathParams = path.parameters || []; | ||
const operationParams = operation.parameters || []; | ||
// Check for duplicate path parameters | ||
try { | ||
checkForDuplicates(pathParams); | ||
} catch (e) { | ||
throw ono.syntax(e, `Validation failed. ${pathId} has duplicate parameters`); | ||
} | ||
// Check for duplicate operation parameters | ||
try { | ||
checkForDuplicates(operationParams); | ||
} catch (e) { | ||
throw ono.syntax(e, `Validation failed. ${operationId} has duplicate parameters`); | ||
} | ||
// Combine the path and operation parameters, | ||
// with the operation params taking precedence over the path params | ||
const params = pathParams.reduce((combinedParams, value) => { | ||
const duplicate = combinedParams.some(param => { | ||
return param.in === value.in && param.name === value.name; | ||
}); | ||
if (!duplicate) { | ||
combinedParams.push(value); | ||
} | ||
return combinedParams; | ||
}, operationParams.slice()); | ||
validateBodyParameters(params, operationId); | ||
validatePathParameters(params, pathId, operationId); | ||
validateParameterTypes(params, api, operation, operationId); | ||
} | ||
/** | ||
* Validates body and formData parameters for the given operation. | ||
* | ||
* @param {object[]} params - An array of Parameter objects | ||
* @param {string} operationId - A value that uniquely identifies the operation | ||
*/ | ||
function validateBodyParameters(params, operationId) { | ||
const bodyParams = params.filter(param => { | ||
return param.in === 'body'; | ||
}); | ||
const formParams = params.filter(param => { | ||
return param.in === 'formData'; | ||
}); | ||
// There can only be one "body" parameter | ||
if (bodyParams.length > 1) { | ||
throw ono.syntax( | ||
`Validation failed. ${operationId} has ${bodyParams.length} body parameters. Only one is allowed.` | ||
); | ||
} else if (bodyParams.length > 0 && formParams.length > 0) { | ||
// "body" params and "formData" params are mutually exclusive | ||
throw ono.syntax( | ||
`Validation failed. ${operationId} has body parameters and formData parameters. Only one or the other is allowed.` | ||
); | ||
} | ||
} | ||
/** | ||
* Validates path parameters for the given path. | ||
* | ||
* @param {object[]} params - An array of Parameter objects | ||
* @param {string} pathId - A value that uniquely identifies the path | ||
* @param {string} operationId - A value that uniquely identifies the operation | ||
*/ | ||
function validatePathParameters(params, pathId, operationId) { | ||
// Find all {placeholders} in the path string | ||
const placeholders = pathId.match(util.swaggerParamRegExp) || []; | ||
// Check for duplicates | ||
for (let i = 0; i < placeholders.length; i++) { | ||
for (let j = i + 1; j < placeholders.length; j++) { | ||
if (placeholders[i] === placeholders[j]) { | ||
throw ono.syntax(`Validation failed. ${operationId} has multiple path placeholders named ${placeholders[i]}`); | ||
} | ||
} | ||
} | ||
// eslint-disable-next-line no-param-reassign | ||
params = params.filter(param => { | ||
return param.in === 'path'; | ||
}); | ||
for (const param of params) { | ||
if (param.required !== true) { | ||
throw ono.syntax( | ||
'Validation failed. Path parameters cannot be optional. ' + | ||
`Set required=true for the "${param.name}" parameter at ${operationId}` | ||
); | ||
} | ||
const match = placeholders.indexOf(`{${param.name}}`); | ||
if (match === -1) { | ||
throw ono.syntax( | ||
`Validation failed. ${operationId} has a path parameter named "${param.name}", ` + | ||
`but there is no corresponding {${param.name}} in the path string` | ||
); | ||
} | ||
placeholders.splice(match, 1); | ||
} | ||
if (placeholders.length > 0) { | ||
throw ono.syntax(`Validation failed. ${operationId} is missing path parameter(s) for ${placeholders}`); | ||
} | ||
} | ||
/** | ||
* Validates data types of parameters for the given operation. | ||
* | ||
* @param {object[]} params - An array of Parameter objects | ||
* @param {object} api - The entire Swagger API object | ||
* @param {object} operation - An Operation object, from the Swagger API | ||
* @param {string} operationId - A value that uniquely identifies the operation | ||
*/ | ||
function validateParameterTypes(params, api, operation, operationId) { | ||
for (const param of params) { | ||
const parameterId = `${operationId}/parameters/${param.name}`; | ||
let schema; | ||
let validTypes; | ||
switch (param.in) { | ||
case 'body': | ||
schema = param.schema; | ||
validTypes = schemaTypes; | ||
break; | ||
case 'formData': | ||
schema = param; | ||
validTypes = primitiveTypes.concat('file'); | ||
break; | ||
default: | ||
schema = param; | ||
validTypes = primitiveTypes; | ||
} | ||
validateSchema(schema, parameterId, validTypes); | ||
validateRequiredPropertiesExist(schema, parameterId); | ||
if (schema.type === 'file') { | ||
// "file" params must consume at least one of these MIME types | ||
const formData = /multipart\/(.*\+)?form-data/; // eslint-disable-line unicorn/no-unsafe-regex | ||
const urlEncoded = /application\/(.*\+)?x-www-form-urlencoded/; // eslint-disable-line unicorn/no-unsafe-regex | ||
const consumes = operation.consumes || api.consumes || []; | ||
const hasValidMimeType = consumes.some(consume => { | ||
return formData.test(consume) || urlEncoded.test(consume); | ||
}); | ||
if (!hasValidMimeType) { | ||
throw ono.syntax( | ||
`Validation failed. ${operationId} has a file parameter, so it must consume multipart/form-data ` + | ||
'or application/x-www-form-urlencoded' | ||
); | ||
} | ||
} | ||
} | ||
} | ||
/** | ||
* Checks the given parameter list for duplicates, and throws an error if found. | ||
* | ||
* @param {object[]} params - An array of Parameter objects | ||
*/ | ||
function checkForDuplicates(params) { | ||
for (let i = 0; i < params.length - 1; i++) { | ||
const outer = params[i]; | ||
for (let j = i + 1; j < params.length; j++) { | ||
const inner = params[j]; | ||
if (outer.name === inner.name && outer.in === inner.in) { | ||
throw ono.syntax(`Validation failed. Found multiple ${outer.in} parameters named "${outer.name}"`); | ||
} | ||
} | ||
} | ||
} | ||
/** | ||
* Validates the given response object. | ||
* | ||
* @param {string} code - The HTTP response code (or "default") | ||
* @param {object} response - A Response object, from the Swagger API | ||
* @param {string} responseId - A value that uniquely identifies the response | ||
*/ | ||
function validateResponse(code, response, responseId) { | ||
if (code !== 'default' && (code < 100 || code > 599)) { | ||
throw ono.syntax(`Validation failed. ${responseId} has an invalid response code (${code})`); | ||
} | ||
const headers = Object.keys(response.headers || {}); | ||
for (const headerName of headers) { | ||
const header = response.headers[headerName]; | ||
const headerId = `${responseId}/headers/${headerName}`; | ||
validateSchema(header, headerId, primitiveTypes); | ||
} | ||
if (response.schema) { | ||
const validTypes = schemaTypes.concat('file'); | ||
if (validTypes.indexOf(response.schema.type) === -1) { | ||
throw ono.syntax( | ||
`Validation failed. ${responseId} has an invalid response schema type (${response.schema.type})` | ||
); | ||
} else { | ||
validateSchema(response.schema, `${responseId}/schema`, validTypes); | ||
} | ||
} | ||
} | ||
/** | ||
* Validates the given Swagger schema object. | ||
* | ||
* @param {object} schema - A Schema object, from the Swagger API | ||
* @param {string} schemaId - A value that uniquely identifies the schema object | ||
* @param {string[]} validTypes - An array of the allowed schema types | ||
*/ | ||
function validateSchema(schema, schemaId, validTypes) { | ||
if (validTypes.indexOf(schema.type) === -1) { | ||
throw ono.syntax(`Validation failed. ${schemaId} has an invalid type (${schema.type})`); | ||
} | ||
if (schema.type === 'array' && !schema.items) { | ||
throw ono.syntax(`Validation failed. ${schemaId} is an array, so it must include an "items" schema`); | ||
} | ||
} | ||
/** | ||
* Validates that the declared properties of the given Swagger schema object actually exist. | ||
* | ||
* @param {object} schema - A Schema object, from the Swagger API | ||
* @param {string} schemaId - A value that uniquely identifies the schema object | ||
*/ | ||
function validateRequiredPropertiesExist(schema, schemaId) { | ||
/** | ||
* Recursively collects all properties of the schema and its ancestors. They are added to the props object. | ||
*/ | ||
function collectProperties(schemaObj, props) { | ||
if (schemaObj.properties) { | ||
for (const property in schemaObj.properties) { | ||
// eslint-disable-next-line no-prototype-builtins | ||
if (schemaObj.properties.hasOwnProperty(property)) { | ||
// eslint-disable-next-line no-param-reassign | ||
props[property] = schemaObj.properties[property]; | ||
} | ||
} | ||
} | ||
if (schemaObj.allOf) { | ||
for (const parent of schemaObj.allOf) { | ||
collectProperties(parent, props); | ||
} | ||
} | ||
} | ||
if (schema.required && Array.isArray(schema.required)) { | ||
const props = {}; | ||
collectProperties(schema, props); | ||
for (const requiredProperty of schema.required) { | ||
if (!props[requiredProperty]) { | ||
throw ono.syntax( | ||
`Validation failed. Property '${requiredProperty}' listed as required but does not exist in '${schemaId}'` | ||
); | ||
} | ||
} | ||
} | ||
} | ||
return validateSwagger(api); | ||
}; |
{ | ||
"name": "@readme/openapi-parser", | ||
"version": "1.2.1", | ||
"version": "2.0.0", | ||
"description": "Swagger 2.0 and OpenAPI 3.x parser and validator for Node and browsers", | ||
@@ -37,2 +37,5 @@ "keywords": [ | ||
], | ||
"engines": { | ||
"node": "^12 || ^14 || ^16" | ||
}, | ||
"scripts": { | ||
@@ -53,8 +56,8 @@ "clean": "shx rm -rf .nyc_output coverage", | ||
"@babel/polyfill": "^7.12.1", | ||
"@commitlint/cli": "^15.0.0", | ||
"@commitlint/config-conventional": "^15.0.0", | ||
"@commitlint/cli": "^16.0.1", | ||
"@commitlint/config-conventional": "^16.0.0", | ||
"@jsdevtools/host-environment": "^2.1.2", | ||
"@jsdevtools/karma-config": "^3.1.7", | ||
"@readme/eslint-config": "^8.0.2", | ||
"@types/node": "^16.11.0", | ||
"@types/node": "^17.0.5", | ||
"chai": "^4.3.4", | ||
@@ -68,3 +71,3 @@ "eslint": "^8.3.0", | ||
"nyc": "^15.1.0", | ||
"openapi-types": "^9.3.0", | ||
"openapi-types": "^10.0.0", | ||
"prettier": "^2.5.0", | ||
@@ -79,3 +82,3 @@ "shx": "^0.3.2", | ||
"@jsdevtools/ono": "^7.1.3", | ||
"@readme/better-ajv-errors": "^1.1.0", | ||
"@readme/better-ajv-errors": "^1.4.0", | ||
"@readme/json-schema-ref-parser": "^1.0.0", | ||
@@ -82,0 +85,0 @@ "ajv": "^8.6.3", |
76649
11
1336