diff --git a/README.md b/README.md index 89c5002..71c6433 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ Supports [draft-04/06/07/2019-09/2020-12](doc/Specification-support.md) and the [`discriminator` OpenAPI keyword](./doc/Discriminator-support.md). * Can assign defaults and/or remove additional properties when schema allows to do that safely. Throws at build time if those options are used with schemas that don't allow to do that safely. +* Can use used as a [schema linter](./doc/Linter.md). ## Installation diff --git a/doc/Linter.md b/doc/Linter.md new file mode 100644 index 0000000..fa8681a --- /dev/null +++ b/doc/Linter.md @@ -0,0 +1,30 @@ +# Linter mode + +**[Experimental]** + +`@exodus/schemasafe` can also function in a linter mode, collecting all schema +errors instead of throwing on the first one. + +Usage: + +```cjs +const { lint } = require('@exodus/schemasafe') +const fs = require('fs') +const path = require('path') + +const dir = 'schemas/json' +const files = fs.readdirSync(dir).sort().map(x => path.join(dir, x)) +const schemas = files.map(x => [x, JSON.parse(fs.readFileSync(x, 'utf-8'))]) + +for (const [name, schema] of schemas) { + const errors = lint(schema) // lint(schema, { mode: 'strong' }) + for (const e of errors) { + console.log(`${name}: ${e.message}`) + } +} +``` + +Other [options](./Options.md) are similar to `parser()` and `validator()` modes. + +**Warning:** Exact output messages/details are experimental and might change in +non-major versions. diff --git a/doc/Options.md b/doc/Options.md index 9f6e217..ea80b23 100644 --- a/doc/Options.md +++ b/doc/Options.md @@ -27,7 +27,11 @@ keyword and raise an error at compile time unless `allowUnusedKeywords` option is enabled. * `dryRun` — `false` by default.\ - Don't produce a validator, just verify the schema. + Don't produce a validator, just verify the schema and throw on first error. + + * `lint` — `false` by default.\ + Don't produce a validator, just verify the schema and collect all errors. + Same as [Linter mode](./Linter.md). * `$schemaDefault` — `null` by default.\ Can not be used if `requireSchema` is on. diff --git a/index.d.ts b/index.d.ts index 51c94b4..212c770 100644 --- a/index.d.ts +++ b/index.d.ts @@ -107,6 +107,7 @@ interface ValidatorOptions { allErrors?: boolean contentValidation?: boolean dryRun?: boolean + lint?: boolean allowUnusedKeywords?: boolean allowUnreachable?: boolean requireSchema?: boolean @@ -137,18 +138,28 @@ interface Parse { toJSON(): Schema } +interface LintError { + message: string + keywordLocation: string + schema: Schema +} + declare const validator: (schema: Schema, options?: ValidatorOptions) => Validate declare const parser: (schema: Schema, options?: ValidatorOptions) => Parse +declare const lint: (schema: Schema, options?: ValidatorOptions) => LintError[] + export { validator, parser, + lint, Validate, ValidationError, ValidatorOptions, ParseResult, Parse, + LintError, Json, Schema, } diff --git a/src/compile.js b/src/compile.js index 95aa813..a34f705 100644 --- a/src/compile.js +++ b/src/compile.js @@ -77,6 +77,7 @@ const compileSchema = (schema, root, opts, scope, basePathRoot = '') => { allErrors = false, contentValidation, dryRun, // unused, just for rest siblings + lint: lintOnly = false, allowUnusedKeywords = opts.mode === 'lax', allowUnreachable = opts.mode === 'lax', requireSchema = opts.mode === 'strong', @@ -215,9 +216,21 @@ const compileSchema = (schema, root, opts, scope, basePathRoot = '') => { } const errorIf = (condition, errorArgs) => fun.if(condition, () => error(errorArgs)) + if (lintOnly && !scope.lintErrors) scope.lintErrors = [] // we can do this as we don't build functions in lint-only mode const fail = (msg, value) => { const comment = value !== undefined ? ` ${JSON.stringify(value)}` : '' - throw new Error(`${msg}${comment} at ${joinPath(basePathRoot, toPointer(schemaPath))}`) + const keywordLocation = joinPath(basePathRoot, toPointer(schemaPath)) + const message = `${msg}${comment} at ${keywordLocation}` + if (lintOnly) return scope.lintErrors.push({ message, keywordLocation, schema }) // don't fail if we are just collecting all errors + throw new Error(message) + } + const patternTestSafe = (pat, key) => { + try { + return patternTest(pat, key) + } catch (e) { + fail(e.message) + return format('false') // for lint-only mode + } } const enforce = (ok, ...args) => ok || fail(...args) const laxMode = (ok, ...args) => enforce(mode === 'lax' || ok, ...args) @@ -271,8 +284,13 @@ const compileSchema = (schema, root, opts, scope, basePathRoot = '') => { // opt-out on null is explicit in both places here, don't set default consume(prop, ...ruleTypes) if (handler !== null) { - const condition = handler(node[prop]) - if (condition !== null) errorIf(condition, { path: [prop], ...errorArgs }) + try { + const condition = handler(node[prop]) + if (condition !== null) errorIf(condition, { path: [prop], ...errorArgs }) + } catch (e) { + if (lintOnly && !e.message.startsWith('[opt] ')) fail(e.message) // for lint-only mode, but not processing special re-run errors + throw e + } } return true } @@ -507,7 +525,7 @@ const compileSchema = (schema, root, opts, scope, basePathRoot = '') => { const additionalCondition = (key, properties, patternProperties) => safeand( ...properties.map((p) => format('%s !== %j', key, p)), - ...patternProperties.map((p) => safenot(patternTest(p, key))) + ...patternProperties.map((p) => safenot(patternTestSafe(p, key))) ) const lintRequired = (properties, patterns) => { const regexps = patterns.map((p) => new RegExp(p, 'u')) @@ -586,7 +604,7 @@ const compileSchema = (schema, root, opts, scope, basePathRoot = '') => { handle('pattern', ['string'], (pattern) => { enforceRegex(pattern) evaluateDelta({ fullstring: true }) - return noopRegExps.has(pattern) ? null : safenot(patternTest(pattern, name)) + return noopRegExps.has(pattern) ? null : safenot(patternTestSafe(pattern, name)) }) enforce(node.contentSchema !== false, 'contentSchema cannot be set to false') @@ -806,7 +824,7 @@ const compileSchema = (schema, root, opts, scope, basePathRoot = '') => { forObjectKeys(current, (sub, key) => { for (const p of Object.keys(patternProperties)) { enforceRegex(p, node.propertyNames || {}) - fun.if(patternTest(p, key), () => { + fun.if(patternTestSafe(p, key), () => { rule(sub, patternProperties[p], subPath('patternProperties', p)) }) } @@ -1088,7 +1106,7 @@ const compileSchema = (schema, root, opts, scope, basePathRoot = '') => { if (node.unevaluatedItems === false) consume('unevaluatedItems', 'boolean') } else if (node.unevaluatedItems || node.unevaluatedItems === false) { if (isDynamic(stat).items) { - if (!opts[optDynamic]) throw new Error('Dynamic unevaluated tracing is not enabled') + if (!opts[optDynamic]) throw new Error('[opt] Dynamic unevaluated tracing not enabled') const limit = format('Math.max(%d, ...%s)', stat.items, dyn.items) const extra = (i) => format('%s.includes(%s)', dyn.item, i) additionalItems('unevaluatedItems', limit, getMeta().containsEvaluates ? extra : null) @@ -1105,7 +1123,7 @@ const compileSchema = (schema, root, opts, scope, basePathRoot = '') => { } else if (node.unevaluatedProperties || node.unevaluatedProperties === false) { const notStatic = (key) => additionalCondition(key, stat.properties, stat.patterns) if (isDynamic(stat).properties) { - if (!opts[optDynamic]) throw new Error('Dynamic unevaluated tracing is not enabled') + if (!opts[optDynamic]) throw new Error('[opt] Dynamic unevaluated tracing not enabled') scope.propertyIn = functions.propertyIn const notDynamic = (key) => format('!propertyIn(%s, %s)', key, dyn.props) const condition = (key) => safeand(notStatic(key), notDynamic(key)) @@ -1159,7 +1177,10 @@ const compileSchema = (schema, root, opts, scope, basePathRoot = '') => { handle('$ref', ['string'], ($ref) => { const resolved = resolveReference(root, schemas, $ref, basePath()) const [sub, subRoot, path] = resolved[0] || [] - if (!sub && sub !== false) fail('failed to resolve $ref:', $ref) + if (!sub && sub !== false) { + fail('failed to resolve $ref:', $ref) + if (lintOnly) return null // failures are just collected in linter mode and don't throw, this makes a ref noop + } if (sub.type) { // This could be done better, but for now we check only the direct type in the $ref const type = Array.isArray(sub.type) ? sub.type : [sub.type] @@ -1177,7 +1198,7 @@ const compileSchema = (schema, root, opts, scope, basePathRoot = '') => { if (node.$ref) return // ref overrides any sibling keywords for older schemas } handle('$recursiveRef', ['string'], ($recursiveRef) => { - if (!opts[optRecAnchors]) throw new Error('Recursive anchors are not enabled') + if (!opts[optRecAnchors]) throw new Error('[opt] Recursive anchors are not enabled') enforce($recursiveRef === '#', 'Behavior of $recursiveRef is defined only for "#"') // Resolve to recheck that recursive ref is enabled const resolved = resolveReference(root, schemas, '#', basePath()) @@ -1189,7 +1210,7 @@ const compileSchema = (schema, root, opts, scope, basePathRoot = '') => { return applyRef(nrec, { path: ['$recursiveRef'] }) }) handle('$dynamicRef', ['string'], ($dynamicRef) => { - if (!opts[optDynAnchors]) throw new Error('Dynamic anchors are not enabled') + if (!opts[optDynAnchors]) throw new Error('[opt] Dynamic anchors are not enabled') laxMode(/^[^#]*#[a-zA-Z0-9_-]+$/.test($dynamicRef), 'Unsupported $dynamicRef format') const dynamicTail = $dynamicRef.replace(/^[^#]+/, '') const resolved = resolveReference(root, schemas, $dynamicRef, basePath()) @@ -1305,10 +1326,12 @@ const compileSchema = (schema, root, opts, scope, basePathRoot = '') => { fun.write('}') - validate = fun.makeFunction(scope) - validate[evaluatedStatic] = stat - delete scope[funname] // more logical key order - scope[funname] = validate + if (!lintOnly) { + validate = fun.makeFunction(scope) + validate[evaluatedStatic] = stat + delete scope[funname] // more logical key order + scope[funname] = validate + } return funname } @@ -1321,12 +1344,12 @@ const compile = (schemas, opts) => { } catch (e) { // For performance, we try to build the schema without dynamic tracing first, then re-run with // it enabled if needed. Enabling it without need can give up to about 40% performance drop. - if (!opts[optDynamic] && e.message === 'Dynamic unevaluated tracing is not enabled') + if (!opts[optDynamic] && e.message === '[opt] Dynamic unevaluated tracing not enabled') return compile(schemas, { ...opts, [optDynamic]: true }) // Also enable dynamic and recursive refs only if needed - if (!opts[optDynAnchors] && e.message === 'Dynamic anchors are not enabled') + if (!opts[optDynAnchors] && e.message === '[opt] Dynamic anchors are not enabled') return compile(schemas, { ...opts, [optDynAnchors]: true }) - if (!opts[optRecAnchors] && e.message === 'Recursive anchors are not enabled') + if (!opts[optRecAnchors] && e.message === '[opt] Recursive anchors are not enabled') return compile(schemas, { ...opts, [optRecAnchors]: true }) throw e } diff --git a/src/index.js b/src/index.js index 2ebd3de..71433dd 100644 --- a/src/index.js +++ b/src/index.js @@ -32,6 +32,7 @@ const validator = ( const options = { mode, ...opts, schemas: buildSchemas(schemas, arg), isJSON: willJSON } const { scope, refs } = compile(arg, options) // only a single ref if (opts.dryRun) return + if (opts.lint) return scope.lintErrors const fun = genfun() if (parse) { scope.parseWrap = opts.includeErrors ? parseWithErrors : parseWithoutErrors @@ -89,4 +90,9 @@ const parser = function(schema, { parse = true, ...opts } = {}) { return validator(schema, { parse, ...opts }) } -module.exports = { validator, parser } +const lint = function(schema, { lint: lintOption = true, ...opts } = {}) { + if (!lintOption) throw new Error('can not disable lint option in lint()') + return validator(schema, { lint: lintOption, ...opts }) +} + +module.exports = { validator, parser, lint }