diff --git a/docs/docs/usage-examples.md b/docs/docs/usage-examples.md index c551bb0d..4db4a19b 100644 --- a/docs/docs/usage-examples.md +++ b/docs/docs/usage-examples.md @@ -28,10 +28,12 @@ By default, v8r queries [Schema Store](https://www.schemastore.org/) to detect a ```bash # if v8r can't auto-detect a schema for your file.. $ v8r feature.geojson +ℹ Processing ./feature.geojson ✖ Could not find a schema to validate feature.geojson # ..you can specify one using the --schema flag $ v8r feature.geojson --schema https://json.schemastore.org/geojson +ℹ Processing ./feature.geojson ℹ Validating feature.geojson against schema from https://json.schemastore.org/geojson ... ✔ feature.geojson is valid ``` @@ -50,11 +52,29 @@ Using the `--schema` flag will validate all files matched by the glob pattern ag "fileMatch": ["*.geojson"] } ] } ``` -```bash +``` $ v8r feature.geojson -c my-catalog.json +ℹ Processing ./feature.geojson ℹ Found schema in my-catalog.json ... ℹ Validating feature.geojson against schema from https://json.schemastore.org/geojson ... ✔ feature.geojson is valid ``` This can be used to specify different custom schemas for multiple file patterns. + +## Files Containing Multiple Documents + +A single YAML file can contain [multiple documents](https://www.yaml.info/learn/document.html). v8r is able to parse and validate these files. In this situation: + +- All documents within the file are assumed to conform to the same schema. It is not possible to validate documents within the same file against different schemas +- Documents within the file are referred to as `multi-doc.yml[0]`, `multi-doc.yml[1]`, etc + +``` +$ v8r catalog-info.yaml +ℹ Processing ./catalog-info.yaml +ℹ Found schema in https://www.schemastore.org/api/json/catalog.json ... +ℹ Validating ./catalog-info.yaml against schema from https://json.schemastore.org/catalog-info.json ... +✔ ./catalog-info.yaml[0] is valid + +✔ ./catalog-info.yaml[1] is valid +``` diff --git a/src/cli.js b/src/cli.js index ed34d0be..ca6a1c40 100644 --- a/src/cli.js +++ b/src/cli.js @@ -10,6 +10,7 @@ import { getCatalogs, getMatchForFilename } from "./catalogs.js"; import { getFiles } from "./glob.js"; import { getFromUrlOrFile } from "./io.js"; import logger from "./logger.js"; +import { getDocumentLocation } from "./output-formatters.js"; import { parseFile } from "./parser.js"; const EXIT = { @@ -33,61 +34,122 @@ function getFlatCache() { return flatCache.load("v8r", CACHE_DIR); } -async function validateFile(filename, config, plugins, cache) { - logger.info(`Processing ${filename}`); +async function validateDocument( + fileLocation, + documentIndex, + document, + schemaLocation, + schema, + strictMode, + cache, + resolver, +) { let result = { - fileLocation: filename, - schemaLocation: null, + fileLocation, + documentIndex, + schemaLocation, valid: null, errors: [], code: null, }; + try { + const { valid, errors } = await validate( + document, + schema, + strictMode, + cache, + resolver, + ); + result.valid = valid; + result.errors = errors; + + const documentLocation = getDocumentLocation(result); + if (valid) { + logger.success(`${documentLocation} is valid\n`); + } else { + logger.error(`${documentLocation} is invalid\n`); + } + + result.code = valid ? EXIT.VALID : EXIT.INVALID; + return result; + } catch (e) { + logger.error(`${e.message}\n`); + result.code = EXIT.ERROR; + return result; + } +} + +async function validateFile(filename, config, plugins, cache) { + logger.info(`Processing ${filename}`); + + let schema, schemaLocation, documents, strictMode, resolver; + try { const catalogs = getCatalogs(config); const catalogMatch = config.schema ? {} : await getMatchForFilename(catalogs, filename, cache); - const schemaLocation = config.schema || catalogMatch.location; - result.schemaLocation = schemaLocation; - const schema = await getFromUrlOrFile(schemaLocation, cache); + schemaLocation = config.schema || catalogMatch.location; + schema = await getFromUrlOrFile(schemaLocation, cache); logger.info( `Validating ${filename} against schema from ${schemaLocation} ...`, ); - const data = parseFile( + documents = parseFile( plugins, await fs.promises.readFile(filename, "utf8"), filename, catalogMatch.parser, ); - const strictMode = config.verbose >= 2 ? "log" : false; - const resolver = isUrl(schemaLocation) + strictMode = config.verbose >= 2 ? "log" : false; + resolver = isUrl(schemaLocation) ? (location) => getFromUrlOrFile(location, cache) : (location) => getFromUrlOrFile(location, cache, path.dirname(schemaLocation)); - const { valid, errors } = await validate( - data, + } catch (e) { + logger.error(`${e.message}\n`); + return [ + { + fileLocation: filename, + documentIndex: null, + schemaLocation: schemaLocation || null, + valid: null, + errors: [], + code: EXIT.ERROR, + }, + ]; + } + + let results = []; + for (let i = 0; i < documents.length; i++) { + const documentIndex = documents.length === 1 ? null : i; + const result = await validateDocument( + filename, + documentIndex, + documents[i], + schemaLocation, schema, strictMode, cache, resolver, ); - result.valid = valid; - result.errors = errors; - if (valid) { - logger.success(`${filename} is valid\n`); - } else { - logger.error(`${filename} is invalid\n`); - } - result.code = valid ? EXIT.VALID : EXIT.INVALID; - return result; - } catch (e) { - logger.error(`${e.message}\n`); - result.code = EXIT.ERROR; - return result; + results.push(result); + + for (const plugin of plugins) { + const message = plugin.getSingleResultLogMessage( + result, + filename, + config.format, + ); + if (message != null) { + logger.log(message); + break; + } + } } + return results; } function resultsToStatusCode(results, ignoreErrors) { @@ -122,21 +184,8 @@ function Validator() { let results = []; for (const filename of filenames) { - const result = await validateFile(filename, config, plugins, cache); - results.push(result); - - for (const plugin of plugins) { - const message = plugin.getSingleResultLogMessage( - result, - filename, - config.format, - ); - if (message != null) { - logger.log(message); - break; - } - } - + const fileResults = await validateFile(filename, config, plugins, cache); + results = results.concat(fileResults); cache.resetCounters(); } diff --git a/src/cli.spec.js b/src/cli.spec.js index 1c7852d1..8c134851 100644 --- a/src/cli.spec.js +++ b/src/cli.spec.js @@ -414,6 +414,24 @@ describe("CLI", function () { }); }); + it("should validate yaml files containing multiple documents", function () { + return cli({ + patterns: ["./testfiles/files/multi-doc.yaml"], + schema: "./testfiles/schemas/schema.json", + }).then((result) => { + assert.equal(result, 99); + assert( + logContainsSuccess("./testfiles/files/multi-doc.yaml[0] is valid"), + ); + assert( + logContainsSuccess("./testfiles/files/multi-doc.yaml[1] is valid"), + ); + assert( + logContainsError("./testfiles/files/multi-doc.yaml[2] is invalid"), + ); + }); + }); + it("should validate json5 files", function () { return cli({ patterns: ["./testfiles/files/valid.json5"], @@ -998,7 +1016,7 @@ describe("CLI", function () { tearDown(); }); - it("should log errors in text format when format is text", async function () { + it("should log errors in text format when format is text (single doc)", async function () { return cli({ patterns: [ "{./testfiles/files/valid.json,./testfiles/files/invalid.json,./testfiles/files/not-supported.txt}", @@ -1014,7 +1032,21 @@ describe("CLI", function () { }); }); - it("should output json report when format is json", async function () { + it("should log errors in text format when format is text (multi doc)", async function () { + return cli({ + patterns: ["./testfiles/files/multi-doc.yaml"], + schema: "./testfiles/schemas/schema.json", + format: "text", + }).then(() => { + assert( + logger.stdout.includes( + "./testfiles/files/multi-doc.yaml[2]#/num must be number\n", + ), + ); + }); + }); + + it("should output json report when format is json (single doc)", async function () { return cli({ patterns: [ "{./testfiles/files/valid.json,./testfiles/files/invalid.json,./testfiles/files/not-supported.txt}", @@ -1038,6 +1070,7 @@ describe("CLI", function () { }, ], fileLocation: "./testfiles/files/invalid.json", + documentIndex: null, schemaLocation: "./testfiles/schemas/schema.json", valid: false, }, @@ -1045,6 +1078,7 @@ describe("CLI", function () { code: 1, errors: [], fileLocation: "./testfiles/files/not-supported.txt", + documentIndex: null, schemaLocation: "./testfiles/schemas/schema.json", valid: null, }, @@ -1052,9 +1086,58 @@ describe("CLI", function () { code: 0, errors: [], fileLocation: "./testfiles/files/valid.json", + documentIndex: null, + schemaLocation: "./testfiles/schemas/schema.json", + valid: true, + }, + ], + }; + assert.deepStrictEqual(JSON.parse(logger.stdout[0]), expected); + }); + }); + + it("should output json report when format is json (multi doc)", async function () { + return cli({ + patterns: ["./testfiles/files/multi-doc.yaml"], + schema: "./testfiles/schemas/schema.json", + format: "json", + }).then(() => { + const expected = { + results: [ + { + code: 0, + errors: [], + fileLocation: "./testfiles/files/multi-doc.yaml", + documentIndex: 0, + schemaLocation: "./testfiles/schemas/schema.json", + valid: true, + }, + { + code: 0, + errors: [], + fileLocation: "./testfiles/files/multi-doc.yaml", + documentIndex: 1, schemaLocation: "./testfiles/schemas/schema.json", valid: true, }, + { + code: 99, + errors: [ + { + instancePath: "/num", + keyword: "type", + message: "must be number", + params: { + type: "number", + }, + schemaPath: "#/properties/num/type", + }, + ], + fileLocation: "./testfiles/files/multi-doc.yaml", + documentIndex: 2, + schemaLocation: "./testfiles/schemas/schema.json", + valid: false, + }, ], }; assert.deepStrictEqual(JSON.parse(logger.stdout[0]), expected); diff --git a/src/output-formatters.js b/src/output-formatters.js index 6b69dab1..4d02a4f6 100644 --- a/src/output-formatters.js +++ b/src/output-formatters.js @@ -1,13 +1,20 @@ import Ajv from "ajv"; -function formatErrors(filename, errors) { +function getDocumentLocation(result) { + if (result.documentIndex == null) { + return result.fileLocation; + } + return `${result.fileLocation}[${result.documentIndex}]`; +} + +function formatErrors(location, errors) { const ajv = new Ajv(); return ( ajv.errorsText(errors, { separator: "\n", - dataVar: filename + "#", + dataVar: location + "#", }) + "\n" ); } -export { formatErrors }; +export { formatErrors, getDocumentLocation }; diff --git a/src/parser.js b/src/parser.js index b6a84c1f..d2848e4b 100644 --- a/src/parser.js +++ b/src/parser.js @@ -4,14 +4,20 @@ import { Document } from "./plugins.js"; function parseFile(plugins, contents, filename, parser) { for (const plugin of plugins) { - const result = plugin.parseInputFile(contents, filename, parser); - if (result != null) { - if (!(result instanceof Document)) { - throw new Error( - `Plugin ${plugin.constructor.name} returned an unexpected type from parseInputFile hook. Expected Document, got ${typeof result}`, - ); + const parsedFile = plugin.parseInputFile(contents, filename, parser); + + if (parsedFile != null) { + const maybeDocuments = Array.isArray(parsedFile) + ? parsedFile + : [parsedFile]; + for (const doc of maybeDocuments) { + if (!(doc instanceof Document)) { + throw new Error( + `Plugin ${plugin.constructor.name} returned an unexpected type from parseInputFile hook. Expected Document, got ${typeof doc}`, + ); + } } - return result.document; + return maybeDocuments.map((md) => md.document); } } diff --git a/src/plugins.js b/src/plugins.js index fa2470fb..03431ee6 100644 --- a/src/plugins.js +++ b/src/plugins.js @@ -32,7 +32,8 @@ class BasePlugin { * If `parseInputFile` returns anything other than undefined, that return * value will be used and no further plugins will be invoked. If * `parseInputFile` returns undefined, v8r will move on to the next plugin in - * the stack. + * the stack. The result of successfully parsing a file can either be a single + * Document object or an array of Document objects. * * @param {string} contents - The unparsed file content. * @param {string} fileLocation - The file path. Filenames are resolved and @@ -43,7 +44,7 @@ class BasePlugin { * @param {string | undefined} parser - If the user has specified a parser to * use for this file in a custom schema, this will be passed to * `parseInputFile` in the `parser` param. - * @returns {Document | undefined} Parsed file contents + * @returns {Document | Document[] | undefined} Parsed file contents */ // eslint-disable-next-line no-unused-vars parseInputFile(contents, fileLocation, parser) { @@ -205,6 +206,12 @@ async function loadAllPlugins(userPlugins) { * This means relative paths in the current directory will be prefixed with * `./` (or `.\` on Windows) even if this was not present in the input * filename or pattern. + * @property {number | null} documentIndex - Some file formats allow multiple + * documents to be embedded in one file (e.g: + * [yaml](https://www.yaml.info/learn/document.html)). In these cases, + * `documentIndex` identifies is used to identify the sub document within the + * file. `documentIndex` will be `null` when there is a one-to-one + * relationship between file and document. * @property {string | null} schemaLocation - Location of the schema used to * validate this file if one could be found. `null` if no schema was found. * @property {boolean | null} valid - Result of the validation (true/false) if a diff --git a/src/plugins.spec.js b/src/plugins.spec.js index 0b5d43bc..ec54b2f1 100644 --- a/src/plugins.spec.js +++ b/src/plugins.spec.js @@ -86,9 +86,9 @@ describe("resolveUserPlugins", function () { }); describe("parseInputFile", function () { - it("throws when parseInputFile returns unexpected type", async function () { + it("throws when parseInputFile returns unexpected object", async function () { const plugins = await loadAllPlugins([ - "../testfiles/plugins/bad-parse-method.js", + "../testfiles/plugins/bad-parse-method1.js", ]); assert.throws( () => parseFile(plugins.allLoadedPlugins, "{}", "foo.json", null), @@ -99,4 +99,18 @@ describe("parseInputFile", function () { }, ); }); + + it("throws when parseInputFile returns unexpected array", async function () { + const plugins = await loadAllPlugins([ + "../testfiles/plugins/bad-parse-method2.js", + ]); + assert.throws( + () => parseFile(plugins.allLoadedPlugins, "{}", "foo.json", null), + { + name: "Error", + message: + "Plugin v8r-plugin-test-bad-parse-method returned an unexpected type from parseInputFile hook. Expected Document, got string", + }, + ); + }); }); diff --git a/src/plugins/output-text.js b/src/plugins/output-text.js index 39bb1442..86bcf42d 100644 --- a/src/plugins/output-text.js +++ b/src/plugins/output-text.js @@ -1,5 +1,5 @@ import { BasePlugin } from "../plugins.js"; -import { formatErrors } from "../output-formatters.js"; +import { formatErrors, getDocumentLocation } from "../output-formatters.js"; class TextOutput extends BasePlugin { static name = "v8r-plugin-text-output"; @@ -10,7 +10,7 @@ class TextOutput extends BasePlugin { getSingleResultLogMessage(result, fileLocation, format) { if (result.valid === false && format === "text") { - return formatErrors(fileLocation, result.errors); + return formatErrors(getDocumentLocation(result), result.errors); } } } diff --git a/src/plugins/parser-yaml.js b/src/plugins/parser-yaml.js index 6d59e71e..98e02634 100644 --- a/src/plugins/parser-yaml.js +++ b/src/plugins/parser-yaml.js @@ -10,10 +10,10 @@ class YamlParser extends BasePlugin { parseInputFile(contents, fileLocation, parser) { if (parser === "yaml") { - return new Document(yaml.load(contents)); + return yaml.loadAll(contents).map((doc) => new Document(doc)); } else if (parser == null) { if (fileLocation.endsWith(".yaml") || fileLocation.endsWith(".yml")) { - return new Document(yaml.load(contents)); + return yaml.loadAll(contents).map((doc) => new Document(doc)); } } } diff --git a/testfiles/files/multi-doc.yaml b/testfiles/files/multi-doc.yaml new file mode 100644 index 00000000..38c5ec57 --- /dev/null +++ b/testfiles/files/multi-doc.yaml @@ -0,0 +1,9 @@ +--- +num: 4 +--- +num: 200 +--- +num: foo + +# this yaml file contains 3 documents +# the first two are valid, the third is not diff --git a/testfiles/plugins/bad-parse-method.js b/testfiles/plugins/bad-parse-method1.js similarity index 82% rename from testfiles/plugins/bad-parse-method.js rename to testfiles/plugins/bad-parse-method1.js index 7966265b..7132c6af 100644 --- a/testfiles/plugins/bad-parse-method.js +++ b/testfiles/plugins/bad-parse-method1.js @@ -1,6 +1,6 @@ import { BasePlugin } from "v8r"; -export default class ValidTestPlugin extends BasePlugin { +export default class BadParseMethod1TestPlugin extends BasePlugin { static name = "v8r-plugin-test-bad-parse-method"; // eslint-disable-next-line no-unused-vars diff --git a/testfiles/plugins/bad-parse-method2.js b/testfiles/plugins/bad-parse-method2.js new file mode 100644 index 00000000..21f06271 --- /dev/null +++ b/testfiles/plugins/bad-parse-method2.js @@ -0,0 +1,12 @@ +import { BasePlugin, Document } from "v8r"; + +export default class BadParseMethod2TestPlugin extends BasePlugin { + static name = "v8r-plugin-test-bad-parse-method"; + + // eslint-disable-next-line no-unused-vars + parseInputFile(contents, fileLocation, parser) { + // if we are returning an array + // all objects in the array should be a Document + return [new Document({}), "foobar"]; + } +}