codekie/openapi-examples-validator

View on GitHub
src/index.js

Summary

Maintainability
C
7 hrs
Test Coverage
/**
 * Entry-point for the validator-API
 */

const
    merge = require('lodash.merge'),
    flatten = require('lodash.flatten'),
    flatMap = require('lodash.flatmap'),
    jsonPointer = require('json-pointer'),
    fs = require('fs'),
    path = require('path'),
    glob = require('glob'),
    yaml = require('yaml'),
    { JSONPath: jsonPath } = require('jsonpath-plus'),
    refParser = require('json-schema-ref-parser'),
    { createError } = require('errno').custom,
    ResultType = require('./const/result-type'),
    { getValidatorFactory, compileValidate } = require('./validator'),
    Determiner = require('./impl'),
    { ApplicationError, ErrorType } = require('./application-error'),
    { createValidationResponse, dereferenceJsonSchema } = require('./utils');

// CONSTANTS

const SYM__INTERNAL = Symbol('internal'),
    PROP__SCHEMAS_WITH_EXAMPLES = 'schemasWithExamples',
    FILE_EXTENSIONS__YAML = [
        'yaml',
        'yml'
    ];

// STATICS

/**
 * ErrorJsonPathNotFound
 * @typedef {{
 *      cause: {
 *          [params]: {
 *              [path]: string
 *          }
 *      }
 * }} ErrorJsonPathNotFound
 * @augments CustomError
 */

/**
 * @constructor
 * @augments CustomError
 * @returns {ErrorJsonPathNotFound}
 */
const ErrorJsonPathNotFound = createError(ErrorType.jsonPathNotFound);

// PUBLIC API

module.exports = {
    'default': validateExamples,
    validateFile,
    validateExample,
    validateExamplesByMap
};

// IMPLEMENTATION DETAILS

// Type definitions

/**
 * ValidationStatistics
 * @typedef {{
 *      schemasWithExamples: number,
 *      examplesTotal: number,
 *      examplesWithoutSchema: number,
 *      [matchingFilePathsMapping]: number
 * }} ValidationStatistics
 */

/**
 * ValidationResponse
 * @typedef {{
 *      valid: boolean,
 *      statistics: ValidationStatistics,
 *      errors: Array.<ApplicationError>
 * }} ValidationResponse
 */

/**
 * @callback ValidationHandler
 * @param {ValidationStatistics}    statistics
 * @returns {Array.<ApplicationError>}
 */

// Public

/**
 * Validates OpenAPI-spec with embedded examples.
 * @param {Object}  openapiSpec OpenAPI-spec
 * @param {boolean} [noAdditionalProperties=false]  Don't allow properties that are not defined in the schema
 * @param {boolean} [allPropertiesRequired=false]   Make all properties required
 * @param {Array.<string>} [ignoreFormats]          List of datatype formats that shall be ignored (to prevent
 *                                                  "unsupported format" errors). If an Array with only one string is
 *                                                  provided where the formats are separated with `\n`, the entries
 *                                                  will be expanded to a new array containing all entries.
 * @returns {ValidationResponse}
 */
async function validateExamples(openapiSpec, { noAdditionalProperties, ignoreFormats, allPropertiesRequired } = {}) {
    const impl = Determiner.getImplementation(openapiSpec);
    openapiSpec = await refParser.dereference(openapiSpec);
    openapiSpec = impl.prepare(openapiSpec, { noAdditionalProperties, allPropertiesRequired });
    let pathsExamples = impl.getJsonPathsToExamples()
        .reduce((res, pathToExamples) => {
            return res.concat(_pathToPointer(pathToExamples, openapiSpec));
        }, []);
    return _validateExamplesPaths({ impl }, pathsExamples, openapiSpec, { ignoreFormats });
}

/**
 * Validates OpenAPI-spec with embedded examples.
 * @param {string}  filePath                        File-path to the OpenAPI-spec
 * @param {boolean} [noAdditionalProperties=false]  Don't allow properties that are not defined in the schema
 * @param {boolean} [allPropertiesRequired=false]   Make all properties required
 * @param {Array.<string>} [ignoreFormats]          List of datatype formats that shall be ignored (to prevent
 *                                                  "unsupported format" errors). If an Array with only one string is
 *                                                  provided where the formats are separated with `\n`, the entries
 *                                                  will be expanded to a new array containing all entries.
 * @returns {ValidationResponse}
 */
async function validateFile(filePath, { noAdditionalProperties, ignoreFormats, allPropertiesRequired } = {}) {
    let openapiSpec = null;
    try {
        openapiSpec = await _parseSpec(filePath);
    } catch (err) {
        return createValidationResponse({ errors: [ApplicationError.create(err)] });
    }
    return validateExamples(openapiSpec, { noAdditionalProperties, ignoreFormats, allPropertiesRequired });
}

/**
 * Validates examples by mapping-files.
 * @param {string}  filePathSchema              File-path to the OpenAPI-spec
 * @param {string}  globMapExternalExamples     File-path (globs are supported) to the mapping-file containing JSON-
 *                                              paths to schemas as key and a single file-path or Array of file-paths
 *                                              to external examples
 * @param {boolean} [cwdToMappingFile=false]    Change working directory for resolving the example-paths (relative to
 *                                              the mapping-file)
 * @param {boolean} [noAdditionalProperties=false] Don't allow properties that are not defined in the schema
 * @param {boolean} [allPropertiesRequired=false]  Make all properties required
 * @param {Array.<string>} [ignoreFormats]      List of datatype formats that shall be ignored (to prevent
 *                                              "unsupported format" errors). If an Array with only one string is
 *                                              provided where the formats are separated with `\n`, the entries
 *                                              will be expanded to a new array containing all entries.
 * @returns {ValidationResponse}
 */
async function validateExamplesByMap(filePathSchema, globMapExternalExamples,
    { cwdToMappingFile, noAdditionalProperties, ignoreFormats, allPropertiesRequired } = {}
) {
    let matchingFilePathsMapping = 0;
    const filePathsMaps = glob.sync(
        globMapExternalExamples,
        // Using `nonull`-option to explicitly create an app-error if there's no match for `globMapExternalExamples`
        { nonull: true }
    );
    let responses = [];
    // for..of here, to support sequential execution of async calls. This is required, since dereferencing the
    // `openapiSpec` is not concurrency-safe
    for (const filePathMapExternalExamples of filePathsMaps) {
        let mapExternalExamples = null,
            openapiSpec = null;
        try {
            mapExternalExamples = JSON.parse(fs.readFileSync(filePathMapExternalExamples, 'utf-8'));
            openapiSpec = await _parseSpec(filePathSchema);
            openapiSpec = Determiner.getImplementation(openapiSpec)
                .prepare(openapiSpec, { noAdditionalProperties, allPropertiesRequired });
        } catch (err) {
            responses.push(createValidationResponse({ errors: [ApplicationError.create(err)] }));
            continue;
        }
        // Not using `glob`'s response-length, because it is `1` if there's no match for `globMapExternalExamples`.
        // Instead, increment on every match
        matchingFilePathsMapping++;
        responses.push(
            _validate(
                statistics => {
                    return _handleExamplesByMapValidation(
                        openapiSpec, mapExternalExamples, statistics, {
                            cwdToMappingFile,
                            dirPathMapExternalExamples: path.dirname(filePathMapExternalExamples),
                            ignoreFormats
                        }
                    ).map(
                        (/** @type ApplicationError */ error) => Object.assign(error, {
                            mapFilePath: path.normalize(filePathMapExternalExamples)
                        })
                    );
                }
            )
        );
    }
    return merge(
        responses.reduce((res, response) => {
            if (!res) {
                return response;
            }
            return _mergeValidationResponses(res, response);
        }, null),
        { statistics: { matchingFilePathsMapping } }
    );
}

/**
 * Validates a single external example.
 * @param {String}  filePathSchema                  File-path to the OpenAPI-spec
 * @param {String}  pathSchema                      JSON-path to the schema
 * @param {String}  filePathExample                 File-path to the external example-file
 * @param {boolean} [noAdditionalProperties=false]  Don't allow properties that are not described in the schema
 * @param {boolean} [allPropertiesRequired=false]   Make all properties required
 * @param {Array.<string>} [ignoreFormats]          List of datatype formats that shall be ignored (to prevent
 *                                                  "unsupported format" errors). If an Array with only one string is
 *                                                  provided where the formats are separated with `\n`, the entries
 *                                                  will be expanded to a new array containing all entries.
 * @returns {ValidationResponse}
 */
async function validateExample(filePathSchema, pathSchema, filePathExample, {
    noAdditionalProperties,
    ignoreFormats,
    allPropertiesRequired
} = {}) {
    let example = null,
        schema = null,
        openapiSpec = null;
    try {
        example = JSON.parse(fs.readFileSync(filePathExample, 'utf-8'));
        openapiSpec = await _parseSpec(filePathSchema);
        openapiSpec = Determiner.getImplementation(openapiSpec)
            .prepare(openapiSpec, { noAdditionalProperties, allPropertiesRequired });
        schema = _extractSchema(_getSchmaPointer(pathSchema, openapiSpec), openapiSpec);
    } catch (err) {
        return createValidationResponse({ errors: [ApplicationError.create(err)] });
    }
    return _validate(
        statistics => _validateExample({
            createValidator: _initValidatorFactory(openapiSpec, { ignoreFormats }),
            schema,
            example,
            statistics,
            filePathExample
        })
    );
}

// Private

/**
 * Parses the OpenAPI-spec (supports JSON and YAML)
 * @param {String}  filePath    File-path to the OpenAPI-spec
 * @returns {object}    Parsed OpenAPI-spec
 * @private
 */
async function _parseSpec(filePath) {
    const isYaml = _isFileTypeYaml(filePath);
    let jsonSchema;

    if (isYaml) {
        try {
            jsonSchema = yaml.parse(fs.readFileSync(filePath, 'utf-8'));
        } catch (e) {
            const { name, message } = e;
            throw new ApplicationError(ErrorType.parseError, { message: `${name}: ${message}` });
        }
    } else {
        jsonSchema = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
    }

    return await dereferenceJsonSchema(filePath, jsonSchema);
}

/**
 * Determines whether the filePath is pointing to a YAML-file
 * @param {String}  filePath    File-path to the OpenAPI-spec
 * @returns {boolean}   `true`, if the file is a YAML-file
 * @private
 */
function _isFileTypeYaml(filePath) {
    const extension = filePath.split('.').pop();
    return FILE_EXTENSIONS__YAML.includes(extension);
}

/**
 * Top-level validator. Prepares common values, required for the validation, then calles the validator and prepares
 * the result for the output.
 * @param {ValidationHandler}   validationHandler       The handler which performs the validation. It will receive the
 *                                                      statistics-object as argument and has to return an Array of
 *                                                      errors (or an empty Array, when all examples are valid)
 * @returns {ValidationResponse}
 * @private
 */
function _validate(validationHandler) {
    const statistics = _initStatistics(),
        errors = validationHandler(statistics);
    return createValidationResponse({ errors, statistics });
}

/**
 * Validates examples by a mapping-file.
 * @param {Object}                  openapiSpec                     OpenAPI-spec
 * @param {Object}                  mapExternalExamples             Mapping-file containing JSON-paths to schemas as
 *                                                                  key and a single file-path or Array of file-paths
 *                                                                  to external examples
 * @param {ValidationStatistics}    statistics                      Validation-statistics
 * @param {boolean}                 [cwdToMappingFile=false]        Change working directory for resolving the example-
 *                                                                  paths (relative to the mapping-file)
 * @param {string}                  [dirPathMapExternalExamples]    The directory-path of the mapping-file
 * @param {Array.<string>} [ignoreFormats]          List of datatype formats that shall be ignored (to prevent
 *                                                  "unsupported format" errors). If an Array with only one string is
 *                                                  provided where the formats are separated with `\n`, the entries
 *                                                  will be expanded to a new array containing all entries.
 * @returns {Array.<ApplicationError>}
 * @private
 */
function _handleExamplesByMapValidation(openapiSpec, mapExternalExamples, statistics,
    { cwdToMappingFile = false, dirPathMapExternalExamples, ignoreFormats }
) {
    return flatMap(Object.entries(mapExternalExamples), ([pathSchema, filePathsExample]) => {
        let schema = null;
        try {
            schema = _extractSchema(_getSchmaPointer(pathSchema, openapiSpec), openapiSpec);
        } catch (/** @type ErrorJsonPathNotFound */ err) {
            // If the schema can't be found, don't even attempt to process the examples
            return ApplicationError.create(err);
        }
        return flatMap(
            flatten([filePathsExample]),
            filePathExample => {
                let examples = [];
                try {
                    const resolvedFilePathExample = cwdToMappingFile
                        ? path.join(dirPathMapExternalExamples, filePathExample)
                        : filePathExample;
                    const globResolvedFilePathExample = glob.sync(resolvedFilePathExample);
                    if (globResolvedFilePathExample.length === 0) {
                        return [ApplicationError.create({
                            type: ErrorType.jsENOENT,
                            message: `No such file or directory: '${resolvedFilePathExample}'`,
                            path: resolvedFilePathExample
                        })];
                    }
                    for (const filePathExample of globResolvedFilePathExample) {
                        examples.push({
                            path: path.normalize(filePathExample),
                            content: JSON.parse(fs.readFileSync(filePathExample, 'utf-8'))
                        });
                    }
                } catch (err) {
                    return [ApplicationError.create(err)];
                }
                return flatMap(examples, example => _validateExample({
                    createValidator: _initValidatorFactory(openapiSpec, { ignoreFormats }),
                    schema,
                    example: example.content,
                    statistics,
                    filePathExample: example.path
                }));
            }
        );
    });
}


/**
 * Merges two `ValidationResponses` together and returns the merged result. The passed `ValidationResponse`s won't be
 * modified.
 * @param {ValidationResponse} response1
 * @param {ValidationResponse} response2
 * @returns {ValidationResponse}
 * @private
 */
function _mergeValidationResponses(response1, response2) {
    return createValidationResponse({
        errors: response1.errors.concat(response2.errors),
        statistics: Object.entries(response1.statistics)
            .reduce((res, [key, val]) => {
                if (PROP__SCHEMAS_WITH_EXAMPLES === key) {
                    [
                        response1,
                        response2
                    ].forEach(response => {
                        const schemasWithExample = response.statistics[SYM__INTERNAL][PROP__SCHEMAS_WITH_EXAMPLES]
                            .values();
                        for (let schema of schemasWithExample) {
                            res[SYM__INTERNAL][PROP__SCHEMAS_WITH_EXAMPLES].add(schema);
                        }
                    });
                    return res;
                }
                res[key] = val + response2.statistics[key];
                return res;
            }, _initStatistics())
    });
}

/**
 * Extract JSON-pointer(s) for specific path from a OpenAPI-spec
 * @param {String}  path  JSON-path in the OpenAPI-Spec
 * @param {Object}  openapiSpec         OpenAPI-spec
 * @returns {Array.<String>} JSON-pointers to matching elements
 * @private
 */
function _pathToPointer(path, openapiSpec) {
    return jsonPath({
        json: openapiSpec,
        path: path,
        resultType: ResultType.pointer
    });
}
/**
 * Extract JSON-pointer(s) for specific path from a OpenAPI-spec
 * @param {String}  path  JSON-path in the OpenAPI-Spec
 * @param {Object}  openapiSpec         OpenAPI-spec
 * @returns {String} JSON-pointer to schema or throws error
 * @private
 */
function _getSchmaPointer(pathSchema, openapiSpec) {
    const schemaPointers = _pathToPointer(pathSchema, openapiSpec);
    if (schemaPointers.length === 0) {
        _pathToSchemaNotFoundError(pathSchema);
    }
    if (schemaPointers.length > 1) {
        return [ApplicationError.create({
            type: ErrorType.jsonPathNotFound,
            message: `Path to schema cannot identify unique schema: '${pathSchema}'`,
            path: pathSchema
        })];
    }
    return schemaPointers[0];
}

/**
 * Validates examples at the given paths in the OpenAPI-spec.
 * @param {Object}          impl            Spec-dependant validator
 * @param {Array.<String>}  pathsExamples   JSON-paths to examples
 * @param {Object}          openapiSpec     OpenAPI-spec
 * @param {Array.<string>} [ignoreFormats]  List of datatype formats that shall be ignored (to prevent
 *                                          "unsupported format" errors). If an Array with only one string is
 *                                          provided where the formats are separated with `\n`, the entries
 *                                          will be expanded to a new array containing all entries.
 * @returns {ValidationResponse}
 * @private
 */
function _validateExamplesPaths({ impl }, pathsExamples, openapiSpec, { ignoreFormats }) {
    const statistics = _initStatistics(),
        validationResult = {
            valid: true,
            statistics,
            errors: []
        },
        createValidator = _initValidatorFactory(openapiSpec, { ignoreFormats });
    let validationMap;
    try {
        // Create mapping between JSON-schemas and examples
        validationMap = impl.buildValidationMap(pathsExamples);
    } catch (error) {
        // Throw unexpected errors
        if (!(error instanceof ApplicationError)) {
            throw error;
        }
        // Add known errors and stop
        validationResult.valid = false;
        validationResult.errors.push(error);
        return validationResult;
    }
    // Start validation
    const schemaPointers = Object.keys(validationMap);
    schemaPointers.forEach(schemaPointer => {
        _validateSchema({
            openapiSpec, createValidator, schemaPointer, validationMap, statistics,
            validationResult
        });
    });
    return validationResult;
}

/**
 * Validates a single schema.
 * @param {Object}                  openapiSpec         OpenAPI-spec
 * @param {ajv}                     createValidator     Factory, to create JSON-schema validator
 * @param {string}                  schemaPointer          JSON-pointer to schema (for request- or response-property)
 * @param {Object.<String, String>} validationMap Map with schema-pointers as key and example-pointers as value
 * @param {Object}                  statistics          Object to contain statistics metrics
 * @param {Object}                  validationResult    Container, for the validation-results
 * @private
 */
function _validateSchema({
    openapiSpec, createValidator, schemaPointer, validationMap, statistics,
    validationResult
}) {
    const errors = validationResult.errors;
    validationMap[schemaPointer].forEach(pathExample => {
        const example = _getByPointer(pathExample, openapiSpec),
            // Examples with missing schemas may occur and those are considered valid
            schema = _extractSchema(schemaPointer, openapiSpec, true);
        const curErrors = _validateExample({
            createValidator,
            schema,
            example,
            statistics
        }).map(error => {
            error.examplePath = pathExample;
            return error;
        });
        if (!curErrors.length) {
            return;
        }
        validationResult.valid = false;
        errors.splice(errors.length - 1, 0, ...curErrors);
    });
}

/**
 * Creates a container-object for the validation statistics.
 * @returns {ValidationStatistics}
 * @private
 */
function _initStatistics() {
    const statistics = {
        [SYM__INTERNAL]: {
            [PROP__SCHEMAS_WITH_EXAMPLES]: new Set()
        },
        examplesTotal: 0,
        examplesWithoutSchema: 0
    };
    Object.defineProperty(statistics, PROP__SCHEMAS_WITH_EXAMPLES, {
        enumerable: true,
        get: () => statistics[SYM__INTERNAL][PROP__SCHEMAS_WITH_EXAMPLES].size
    });
    return statistics;
}

/**
 * Extract object by the given JSON-pointer
 * @param {String}      pointer JSON-pointer
 * @param {Object}      json JSON to extract the object(s) from
 * @returns {Object}    Extracted object
 */
function _getByPointer(pointer, json) {
    try {
        return jsonPointer.get(json, pointer);
    } catch (_) {
        return undefined;
    }
}

/**
 * Validates example against the schema. The precondition for this function to work is that the example exists at the
 * given path.
 * `pathExample` and `filePathExample` are exclusively mandatory.
 * itself
 * @param {Function}    createValidator     Factory, to create JSON-schema validator
 * @param {Object}      schema              JSON-schema
 * @param {Object}      example             Example to validate
 * @param {Object}      statistics          Object to contain statistics metrics
 * @param {String}      [filePathExample]   File-path to the example file
 * @returns {Array.<Object>} Array with errors. Empty array, if examples are valid
 * @private
 */
function _validateExample({ createValidator, schema, example, statistics, filePathExample }) {
    const
        errors = [];
    statistics.examplesTotal++;
    // No schema, no validation (Examples without schema are considered valid)
    if (!schema) {
        statistics.examplesWithoutSchema++;
        return errors;
    }
    statistics[SYM__INTERNAL][PROP__SCHEMAS_WITH_EXAMPLES].add(schema);
    const validate = compileValidate(createValidator(), schema);
    if (validate(example)) {
        return errors;
    }
    return errors.concat(...validate.errors.map(ApplicationError.create))
        .map(error => {
            if (!filePathExample) {
                return error;
            }
            error.exampleFilePath = filePathExample;
            return error;
        });
}

/**
 * Create a new instance of a JSON schema validator
 * @returns {ajv}
 * @private
 */
function _initValidatorFactory(specSchema, { ignoreFormats }) {
    return getValidatorFactory(specSchema, {
        schemaId: 'auto',
        discriminator: true,
        strict: false,
        allErrors: true,
        formats: ignoreFormats && ignoreFormats.reduce((result, entry) => {
            result[entry] = () => true;
            return result;
        }, {})
    });
}

/**
 * Extracts the schema in the OpenAPI-spec at the given JSON-pointer.
 * @param   {string}    schemaPointer                          JSON-pointer to the schema
 * @param   {Object}    openapiSpec                         OpenAPI-spec
 * @param   {boolean}   [suppressErrorIfNotFound=false]     Don't throw `ErrorJsonPathNotFound` if the response does not
 *                                                          exist at the given JSON-pointer
 * @returns {Object|Array.<Object>|undefined} Matching schema(s)
 * @throws  {ErrorJsonPathNotFound} Thrown, when there is no schema at the given path and
 *                                  `suppressErrorIfNotFound` is false
 * @private
 */
function _extractSchema(schemaPointer, openapiSpec, suppressErrorIfNotFound = false) {
    const schema = _getByPointer(schemaPointer, openapiSpec);
    if (!suppressErrorIfNotFound && !schema) {
        _pathToSchemaNotFoundError(schemaPointer);
    }
    return schema;
}

function _pathToSchemaNotFoundError(schemaPointer) {
    throw new ErrorJsonPathNotFound(`Path to schema can't be found: '${schemaPointer}'`, {
        params: {
            path: schemaPointer
        }
    });
}