UNPKG

stac-node-validator

Version:
306 lines (279 loc) 8.72 kB
const versions = require('compare-versions'); const { createAjv, isHttpUrl, loadSchema, normalizePath, isObject } = require('./utils'); const defaultLoader = require('./loader/default'); // eslint-disable-next-line no-unused-vars -- used as JSDoc type const BaseValidator = require('./baseValidator'); /** * @typedef Config * @type {Object} * @property {function|null} [loader=null] A function that loads the JSON from the given files. * @property {string|null} [schemas=null] Validate against schemas in a local or remote STAC folder. * @property {Object.<string, string>} [schemaMap={}] Validate against a specific local schema (e.g. an external extension). Provide the schema URI as key and the local path as value. * @property {boolean} [strict=false] Enable strict mode in validation for schemas and numbers (as defined by ajv for options `strictSchema`, `strictNumbers` and `strictTuples * @property {Object.<string, function>} [schemaVersions={}] A map of JSON Schema draft versions to Ajv classes. Supported keys: '2019-09' and '2020-12'. When provided, schemas using these drafts will be validated with the corresponding Ajv class. Example: `{ '2019-09': require('ajv/dist/2019'), '2020-12': require('ajv/dist/2020') }` * @property {BaseValidator} [customValidator=null] A validator with custom rules. */ /** * @typedef Report * @type {Object} * @property {string} id * @property {string} type * @property {string} version * @property {boolean} valid * @property {boolean} skipped * @property {Array.<string>} messages * @property {Array.<Report>} children * @property {Results} results * @property {boolean} apiList * @property {string|null} source */ /** * @typedef Results * @type {Object} * @property {Array.<Error>} core * @property {Object.<string, Array.<Error>>} extensions * @property {Array.<Error>} custom */ /** * @returns {Report} */ function createReport() { let result = { id: null, type: null, version: null, valid: null, skipped: false, messages: [], children: [], results: { core: [], extensions: {}, custom: [], }, apiList: false, source: null, }; return result; } async function loadAndReport(config, data, report) { try { data = await config.loader(data); } catch (error) { report.valid = false; report.type = 'File'; report.results.core.push({ instancePath: '', message: error.message, }); } return data; } /** * @param {Array.<Object>|Array.<string>|Object|string} data The data to validate * @param {Config} config The configuration object * @returns {Report|null} */ async function validate(data, config) { const defaultConfig = { loader: defaultLoader, schemas: null, schemaMap: {}, strict: false, }; config = Object.assign({}, defaultConfig, config); config.ajv = createAjv(config); if (config.customValidator) { config.ajv = await config.customValidator.createAjv(config.ajv); } let report = createReport(); if (typeof data === 'string') { report.source = data; report.id = normalizePath(data); data = await loadAndReport(config, data, report); } if (isObject(data)) { report.id = report.id || data.id; report.version = data.stac_version; report.type = data.type; if (Array.isArray(data.collections)) { data = data.collections; report.apiList = true; if (config.verbose) { report.messages.push( `The file is a CollectionCollection. Validating all ${data.length} collections, but ignoring the other parts of the response.`, ); } } else if (Array.isArray(data.features)) { data = data.features; report.apiList = true; if (config.verbose) { report.messages.push( `The file is a ItemCollection. Validating all ${data.length} items, but ignoring the other parts of the response.`, ); } } else { return validateOne(data, config, report); } } if (Array.isArray(data) && data.length > 0) { for (const obj of data) { const subreport = await validateOne(obj, config); report.children.push(subreport); } return summarizeResults(report); } else { return report; } } /** * @param {Object|string} data The data to validate (file path or loaded object) * @param {Config} config The configuration object * @param {Report} [report] Parent report * @returns {Report} */ async function validateOne(data, config, report = null) { if (!report) { report = createReport(); } if (typeof data === 'string') { report.source = data; report.id = normalizePath(data); data = await loadAndReport(config, data, report); if (report.valid === false) { return report; } } else if (!report.id) { report.id = data.id; } report.version = data.stac_version; report.type = data.type; if (config.customValidator) { data = await config.customValidator.afterLoading(data, report, config); } if (typeof config.lintFn === 'function') { report = await config.lintFn(report, config); } if (config.customValidator) { const bypass = await config.customValidator.bypassValidation(data, report, config); if (bypass) { return bypass; } } // Check stac_version if (typeof data.stac_version !== 'string') { report.skipped = true; report.messages.push('No STAC version found'); return report; } else if (versions.compare(data.stac_version, '1.0.0-rc.1', '<')) { report.skipped = true; report.messages.push('Can only validate STAC version >= 1.0.0-rc.1'); return report; } // Check type field switch (data.type) { case 'FeatureCollection': report.skipped = true; report.messages.push('STAC ItemCollections not supported yet'); return report; case 'Catalog': case 'Collection': case 'Feature': // pass break; default: report.valid = false; report.results.core.push({ instancePath: '/type', message: "Can't detect type of the STAC object. Is the 'type' field missing or invalid?", }); return report; } // Validate against the core schemas await validateSchema('core', data.type, data, report, config); // Get all extension schemas to validate against let schemas = []; if (Array.isArray(data.stac_extensions)) { schemas = schemas.concat(data.stac_extensions); // Convert shortcuts supported in 1.0.0 RC1 into schema URLs if (versions.compare(data.stac_version, '1.0.0-rc.1', '=')) { schemas = schemas.map((ext) => ext.replace( /^(eo|projection|scientific|view)$/, 'https://schemas.stacspec.org/v1.0.0-rc.1/extensions/$1/json-schema/schema.json', ), ); } } for (const schema of schemas) { await validateSchema('extensions', schema, data, report, config); } if (config.customValidator) { let stac; try { const { default: create } = await import('stac-js'); stac = create(data, false, false); } catch (error) { stac = data; } await config.customValidator.testFn( report, async (report, test) => await config.customValidator.afterValidation(stac, test, report, config), ); } return report; } async function validateSchema(key, schema, data, report, config) { // Get schema identifier/uri let schemaId; switch (schema) { case 'Feature': schema = 'Item'; // falls through case 'Catalog': case 'Collection': let type = schema.toLowerCase(); schemaId = `https://schemas.stacspec.org/v${report.version}/${type}-spec/json-schema/${type}.json`; break; default: // extension if (isHttpUrl(schema)) { schemaId = schema; } } // Validate const setValidity = (errors = []) => { if (report.valid !== false) { report.valid = errors.length === 0; } if (key === 'core') { report.results.core = errors; } else { report.results.extensions[schema] = errors; } }; try { if (key !== 'core' && !schemaId) { throw new Error("'stac_extensions' must contain a valid HTTP(S) URL to a schema."); } const validate = await loadSchema(config, schemaId); const valid = validate(data); if (valid) { setValidity(); } else { setValidity(validate.errors); } } catch (error) { setValidity([ { message: error.message, }, ]); } } function summarizeResults(report) { if (report.children.length > 0) { report.valid = Boolean(report.children.every((result) => result.valid)); } return report; } module.exports = validate;