From 41136925c2a84aac22907606f23ce80394a1fa81 Mon Sep 17 00:00:00 2001 From: mtso <8432061+mtso@users.noreply.github.com> Date: Thu, 17 Apr 2025 08:36:51 +0000 Subject: [PATCH 1/5] feat(functions): add unicodeRegExp option to schema function --- docs/reference/functions.md | 11 +-- .../functions/src/__tests__/schema.test.ts | 84 +++++++++++++++++++ packages/functions/src/optionSchemas.ts | 6 ++ packages/functions/src/schema/ajv.ts | 68 +++++++++++---- packages/functions/src/schema/index.ts | 8 +- packages/rulesets/package.json | 2 +- .../rulesets/src/oas/functions/oasExample.ts | 6 ++ .../rulesets/src/oas/functions/oasSchema.ts | 5 ++ packages/rulesets/src/oas/index.ts | 5 ++ 9 files changed, 170 insertions(+), 25 deletions(-) diff --git a/docs/reference/functions.md b/docs/reference/functions.md index 742c6efab..c633988b4 100644 --- a/docs/reference/functions.md +++ b/docs/reference/functions.md @@ -164,11 +164,12 @@ Use JSON Schema (draft 4, 6, 7, 2019-09, or 2020-12) to treat the contents of th -| name | description | type | required? | -| --------- | ---------------------------------------------------------------------- | ---------------------------------------------------------------------- | --------- | -| schema | a valid JSON Schema document | `JSONSchema` | yes | -| dialect | the JSON Schema draft used by function. "auto" by default | `'auto', 'draft4', 'draft6', 'draft7', 'draft2019-09', 'draft2020-12'` | no | -| allErrors | returns all errors when `true`; otherwise only returns the first error | `boolean` | no | +| name | description | type | required? | +| ------------- | -------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | --------- | +| schema | a valid JSON Schema document | `JSONSchema` | yes | +| dialect | the JSON Schema draft used by function. "auto" by default | `'auto', 'draft4', 'draft6', 'draft7', 'draft2019-09', 'draft2020-12'` | no | +| allErrors | returns all errors when `true`; otherwise only returns the first error | `boolean` | no | +| unicodeRegExp | uses unicode flag "u" with "pattern" and "patternProperties". "false" by default | `boolean` | no | diff --git a/packages/functions/src/__tests__/schema.test.ts b/packages/functions/src/__tests__/schema.test.ts index ec45b5fd0..ad4d2caa4 100644 --- a/packages/functions/src/__tests__/schema.test.ts +++ b/packages/functions/src/__tests__/schema.test.ts @@ -274,6 +274,79 @@ describe('Core Functions / Schema', () => { }); }); + describe('when schema defines a string pattern', () => { + describe('and contains a unicode character class', () => { + const input = 'é'; + const schema = { + type: 'string', + pattern: '^[\\p{L}]$', + }; + + it('and the unicodeRegExp option is false', async () => { + expect(await runSchema(input, { schema, unicodeRegExp: false })).toEqual([ + { + message: 'String must match pattern "^[\\p{L}]$"', + path: [], + }, + ]); + }); + + it('and the omitted unicodeRegExp option defaults to false', async () => { + expect(await runSchema(input, { schema })).toEqual([ + { + message: 'String must match pattern "^[\\p{L}]$"', + path: [], + }, + ]); + }); + + it('and the unicodeRegExp option is true', async () => { + expect(await runSchema(input, { schema, unicodeRegExp: true })).toEqual([]); + }); + }); + + describe('and the regular expression contains a questionable escape', () => { + const schema = { + type: 'string', + pattern: '^[\\_-]$', + }; + + it('and the unicodeRegExp option is defaulted to false', async () => { + expect(await runSchema('_', { schema })).toEqual([]); + }); + + it('and the unicodeRegExp option is defaulted to false so that the backslash is a pattern mismatch', async () => { + expect(await runSchema('\\', { schema })).toEqual([ + { + message: 'String must match pattern "^[\\_-]$"', + path: [], + }, + ]); + }); + + it('and the unicodeRegExp option is true', async () => { + expect(await runSchema('\\', { schema, unicodeRegExp: true })).toEqual([ + { + message: 'Invalid regular expression: /^[\\_-]$/u: Invalid escape', + path: [], + }, + ]); + }); + }); + + it('and uses a unicode character class in patternProperties and the unicodeRegExp option is true', async () => { + const schema = { + type: 'object', + patternProperties: { + '^[\\p{L}]$': { + type: 'string', + }, + }, + }; + expect(await runSchema({ [`é`]: 'The letter é' }, { schema, unicodeRegExp: false })).toEqual([]); + }); + }); + describe('when schema defines common formats', () => { const schema = { type: 'string', @@ -466,6 +539,7 @@ describe('Core Functions / Schema', () => { { schema: { type: 'object' } }, { schema: { type: 'string' }, dialect: 'auto' }, { schema: { type: 'string' }, allErrors: true }, + { schema: { type: 'string' }, unicodeRegExp: true }, { schema: { type: 'string' }, dialect: 'draft2019-09', allErrors: false }, { schema: { type: 'string' }, @@ -546,6 +620,16 @@ describe('Core Functions / Schema', () => { ), ], ], + [ + { schema: { type: 'object' }, unicodeRegExp: null }, + [ + new RulesetValidationError( + 'invalid-function-options', + '"schema" function and its "unicodeRegExp" option accepts only the following types: boolean', + ['rules', 'my-rule', 'then', 'functionOptions', 'unicodeRegExp'], + ), + ], + ], ])('given invalid %p options, should throw', async (opts, errors) => { await expect(runSchema([], opts)).rejects.toThrowAggregateError(new AggregateError(errors)); }); diff --git a/packages/functions/src/optionSchemas.ts b/packages/functions/src/optionSchemas.ts index 9377b8eb6..81a815c99 100644 --- a/packages/functions/src/optionSchemas.ts +++ b/packages/functions/src/optionSchemas.ts @@ -187,6 +187,12 @@ export const optionSchemas: Record = { default: false, description: 'Returns all errors when true; otherwise only returns the first error.', }, + unicodeRegExp: { + type: 'boolean', + default: false, + description: + 'Use unicode flag "u" with "pattern" and "patternProperties" when true; otherwise do not use flag "u". Defaults to false.', + }, prepareResults: { 'x-internal': true, }, diff --git a/packages/functions/src/schema/ajv.ts b/packages/functions/src/schema/ajv.ts index 635ca2d96..008e58c5d 100644 --- a/packages/functions/src/schema/ajv.ts +++ b/packages/functions/src/schema/ajv.ts @@ -1,5 +1,6 @@ import { default as AjvBase, ValidateFunction, SchemaObject } from 'ajv'; import type AjvCore from 'ajv/dist/core'; +import type { Options as AjvOptions } from 'ajv/dist/core'; import Ajv2019 from 'ajv/dist/2019'; import Ajv2020 from 'ajv/dist/2020'; import AjvDraft4 from 'ajv-draft-04'; @@ -10,6 +11,16 @@ import * as draft4MetaSchema from './draft4.json'; import { Options } from './index'; +/** + * The limited set of Ajv options used in the schema validators. + */ +type ValidationOptions = Pick; + +/** + * A unique key for Ajv options. + */ +type AjvInstanceKey = string; + const logger = { warn(...args: unknown[]): void { const firstArg = args[0]; @@ -25,18 +36,26 @@ const logger = { error: console.error, }; -function createAjvInstance(Ajv: typeof AjvCore, allErrors: boolean): AjvCore { +/** + * Creates a new Ajv JSON schema validator instance with the given dialect constructor and validation options. + * @param Ajv The Ajv constructor for a particular schema language. + * @param validationOptions The validation options to override in the Ajv validator instance. + * @returns + */ +function createAjvInstance(Ajv: typeof AjvCore, validationOptions: ValidationOptions): AjvCore { + const defaultAllErrors = false; const ajv = new Ajv({ - allErrors, + allErrors: defaultAllErrors, meta: true, messages: true, strict: false, allowUnionTypes: true, logger, unicodeRegExp: false, + ...validationOptions, }); addFormats(ajv); - if (allErrors) { + if (validationOptions.allErrors ?? defaultAllErrors) { ajvErrors(ajv); } @@ -48,23 +67,40 @@ function createAjvInstance(Ajv: typeof AjvCore, allErrors: boolean): AjvCore { return ajv; } -function _createAjvInstances(Ajv: typeof AjvCore): { default: AjvCore; allErrors: AjvCore } { - let _default: AjvCore; - let _allErrors: AjvCore; +const instanceKey = (validationOptions: ValidationOptions): AjvInstanceKey => { + const parts = [ + validationOptions.allErrors ?? false ? 'allErrors' : 'default', + validationOptions.unicodeRegExp ?? false ? 'unicodeRegExp' : 'noUnicodeRegExp', + ]; + return parts.join('-'); +}; + +/** + * Creates a manager that lazily loads Ajv validator instances given runtime validation options. + */ +function _createAjvInstances(Ajv: typeof AjvCore): { getInstance: (validationOptions: ValidationOptions) => AjvCore } { + const _instances = new Map(); return { - get default(): AjvCore { - _default ??= createAjvInstance(Ajv, false); - return _default; - }, - get allErrors(): AjvCore { - _allErrors ??= createAjvInstance(Ajv, true); - return _allErrors; + getInstance(validationOptions: ValidationOptions): AjvCore { + const key = instanceKey(validationOptions); + const instance = _instances.get(key); + if (instance !== void 0) { + return instance; + } else { + const newInstance = createAjvInstance(Ajv, validationOptions); + _instances.set(key, newInstance); + return newInstance; + } }, }; } -type AssignAjvInstance = (schema: SchemaObject, dialect: string, allErrors: boolean) => ValidateFunction; +type AssignAjvInstance = ( + schema: SchemaObject, + dialect: string, + validationOptions: ValidationOptions, +) => ValidateFunction; export function createAjvInstances(): AssignAjvInstance { const ajvInstances: Partial, ReturnType>> = { @@ -76,9 +112,9 @@ export function createAjvInstances(): AssignAjvInstance { const compiledSchemas = new WeakMap>(); - return function (schema, dialect, allErrors): ValidateFunction { + return function (schema, dialect, validationOptions: ValidationOptions): ValidateFunction { const instances = (ajvInstances[dialect] ?? ajvInstances.auto) as ReturnType; - const ajv = instances[allErrors ? 'allErrors' : 'default']; + const ajv = instances.getInstance(validationOptions); const $id = schema.$id; diff --git a/packages/functions/src/schema/index.ts b/packages/functions/src/schema/index.ts index 955c799b9..5153fd114 100644 --- a/packages/functions/src/schema/index.ts +++ b/packages/functions/src/schema/index.ts @@ -4,13 +4,14 @@ import { detectDialect } from '@stoplight/spectral-formats'; import { createAjvInstances } from './ajv'; import MissingRefError from 'ajv/dist/compile/ref_error'; import { createRulesetFunction, IFunctionResult, JSONSchema, RulesetFunctionContext } from '@stoplight/spectral-core'; -import { isError } from 'lodash'; +import { isError, pick } from 'lodash'; import { optionSchemas } from '../optionSchemas'; export type Options = { schema: Record | JSONSchema; allErrors?: boolean; + unicodeRegExp?: boolean; dialect?: 'auto' | 'draft4' | 'draft6' | 'draft7' | 'draft2019-09' | 'draft2020-12'; prepareResults?(errors: ErrorObject[]): void; }; @@ -40,13 +41,14 @@ export default createRulesetFunction( const results: IFunctionResult[] = []; // we already access a resolved object in src/functions/schema-path.ts - const { allErrors = false, schema: schemaObj } = opts; + const { schema: schemaObj } = opts; try { const dialect = (opts.dialect === void 0 || opts.dialect === 'auto' ? detectDialect(schemaObj) : opts?.dialect) ?? 'draft7'; - const validator = assignAjvInstance(schemaObj, dialect, allErrors); + const validationOptions = pick(opts, ['allErrors', 'unicodeRegExp']); + const validator = assignAjvInstance(schemaObj, dialect, validationOptions); if (validator?.(targetVal) === false && Array.isArray(validator.errors)) { opts.prepareResults?.(validator.errors); diff --git a/packages/rulesets/package.json b/packages/rulesets/package.json index 14fde7df0..d4e65dc89 100644 --- a/packages/rulesets/package.json +++ b/packages/rulesets/package.json @@ -23,7 +23,7 @@ "@stoplight/json": "^3.17.0", "@stoplight/spectral-core": "^1.19.4", "@stoplight/spectral-formats": "^1.8.1", - "@stoplight/spectral-functions": "^1.9.1", + "@stoplight/spectral-functions": "^1.9.3", "@stoplight/spectral-runtime": "^1.1.2", "@stoplight/types": "^13.6.0", "@types/json-schema": "^7.0.7", diff --git a/packages/rulesets/src/oas/functions/oasExample.ts b/packages/rulesets/src/oas/functions/oasExample.ts index c51144e83..558aa59a5 100644 --- a/packages/rulesets/src/oas/functions/oasExample.ts +++ b/packages/rulesets/src/oas/functions/oasExample.ts @@ -9,6 +9,7 @@ export type Options = { oasVersion: 2 | 3; schemaField: string; type: 'media' | 'schema'; + unicodeRegExp?: boolean; }; type HasRequiredProperties = traverse.SchemaObject & { @@ -234,6 +235,10 @@ export default createRulesetFunction, Options>( type: { enum: ['media', 'schema'], }, + unicodeRegExp: { + type: 'boolean', + default: false, + }, }, additionalProperties: false, }, @@ -242,6 +247,7 @@ export default createRulesetFunction, Options>( const formats = context.document.formats; const schemaOpts: SchemaOptions = { schema: opts.schemaField === '$' ? targetVal : (targetVal[opts.schemaField] as SchemaOptions['schema']), + unicodeRegExp: opts.unicodeRegExp, }; let results: Optional = void 0; diff --git a/packages/rulesets/src/oas/functions/oasSchema.ts b/packages/rulesets/src/oas/functions/oasSchema.ts index c5c1aae5c..d211f97b5 100644 --- a/packages/rulesets/src/oas/functions/oasSchema.ts +++ b/packages/rulesets/src/oas/functions/oasSchema.ts @@ -7,6 +7,7 @@ import { isPlainObject, pointerToPath } from '@stoplight/json'; export type Options = { schema: Record; + unicodeRegExp?: boolean; }; function rewriteNullable(schema: SchemaObject, errors: ErrorObject[]): void { @@ -28,6 +29,10 @@ export default createRulesetFunction( schema: { type: 'object', }, + unicodeRegExp: { + type: 'boolean', + default: false, + }, }, additionalProperties: false, }, diff --git a/packages/rulesets/src/oas/index.ts b/packages/rulesets/src/oas/index.ts index 62057d456..b61a71e5a 100644 --- a/packages/rulesets/src/oas/index.ts +++ b/packages/rulesets/src/oas/index.ts @@ -168,6 +168,7 @@ const ruleset = { type: 'array', uniqueItems: true, }, + unicodeRegExp: false, }, }, }, @@ -509,6 +510,7 @@ const ruleset = { schemaField: '$', oasVersion: 2, type: 'schema', + unicodeRegExp: false, }, }, }, @@ -525,6 +527,7 @@ const ruleset = { schemaField: 'schema', oasVersion: 2, type: 'media', + unicodeRegExp: false, }, }, }, @@ -681,6 +684,7 @@ const ruleset = { schemaField: 'schema', oasVersion: 3, type: 'media', + unicodeRegExp: false, }, }, }, @@ -702,6 +706,7 @@ const ruleset = { schemaField: '$', oasVersion: 3, type: 'schema', + unicodeRegExp: false, }, }, }, From 9e2b0e6ca68c22d14fbbfddac6663e84f10d7e95 Mon Sep 17 00:00:00 2001 From: mtso <8432061+mtso@users.noreply.github.com> Date: Thu, 17 Apr 2025 12:24:40 +0000 Subject: [PATCH 2/5] docs(functions): update unicodeRegExp description --- docs/reference/functions.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/reference/functions.md b/docs/reference/functions.md index c633988b4..d62e237eb 100644 --- a/docs/reference/functions.md +++ b/docs/reference/functions.md @@ -164,12 +164,12 @@ Use JSON Schema (draft 4, 6, 7, 2019-09, or 2020-12) to treat the contents of th -| name | description | type | required? | -| ------------- | -------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | --------- | -| schema | a valid JSON Schema document | `JSONSchema` | yes | -| dialect | the JSON Schema draft used by function. "auto" by default | `'auto', 'draft4', 'draft6', 'draft7', 'draft2019-09', 'draft2020-12'` | no | -| allErrors | returns all errors when `true`; otherwise only returns the first error | `boolean` | no | -| unicodeRegExp | uses unicode flag "u" with "pattern" and "patternProperties". "false" by default | `boolean` | no | +| name | description | type | required? | +| ------------- | --------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | --------- | +| schema | a valid JSON Schema document | `JSONSchema` | yes | +| dialect | the JSON Schema draft used by function. "auto" by default | `'auto', 'draft4', 'draft6', 'draft7', 'draft2019-09', 'draft2020-12'` | no | +| allErrors | returns all errors when `true`; otherwise only returns the first error | `boolean` | no | +| unicodeRegExp | uses unicode flag "u" with "pattern" and "patternProperties" when `true`; otherwise does not use the "u" flag. "false" by default | `boolean` | no | From f64167bc35f8af44e9a755d3179afb47eb23be6b Mon Sep 17 00:00:00 2001 From: mtso <8432061+mtso@users.noreply.github.com> Date: Thu, 17 Apr 2025 12:50:07 +0000 Subject: [PATCH 3/5] fix(functions): add missing yarn.lock --- yarn.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/yarn.lock b/yarn.lock index 1320f1a09..f2d192766 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3033,7 +3033,7 @@ __metadata: languageName: unknown linkType: soft -"@stoplight/spectral-functions@*, @stoplight/spectral-functions@>=1, @stoplight/spectral-functions@^1.9.1, @stoplight/spectral-functions@workspace:packages/functions": +"@stoplight/spectral-functions@*, @stoplight/spectral-functions@>=1, @stoplight/spectral-functions@^1.9.1, @stoplight/spectral-functions@^1.9.3, @stoplight/spectral-functions@workspace:packages/functions": version: 0.0.0-use.local resolution: "@stoplight/spectral-functions@workspace:packages/functions" dependencies: @@ -3139,7 +3139,7 @@ __metadata: "@stoplight/path": ^1.3.2 "@stoplight/spectral-core": ^1.19.4 "@stoplight/spectral-formats": ^1.8.1 - "@stoplight/spectral-functions": ^1.9.1 + "@stoplight/spectral-functions": ^1.9.3 "@stoplight/spectral-parsers": "*" "@stoplight/spectral-ref-resolver": ^1.0.4 "@stoplight/spectral-runtime": ^1.1.2 From 4981268e2d717690e3c81f805678b1b8f0268f09 Mon Sep 17 00:00:00 2001 From: mtso <8432061+mtso@users.noreply.github.com> Date: Thu, 17 Apr 2025 13:22:43 +0000 Subject: [PATCH 4/5] fix(functions): update schema test to assert SyntaxError message string contains --- packages/functions/src/__tests__/schema.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/functions/src/__tests__/schema.test.ts b/packages/functions/src/__tests__/schema.test.ts index ad4d2caa4..822801e18 100644 --- a/packages/functions/src/__tests__/schema.test.ts +++ b/packages/functions/src/__tests__/schema.test.ts @@ -324,10 +324,10 @@ describe('Core Functions / Schema', () => { ]); }); - it('and the unicodeRegExp option is true', async () => { + it('and the unicodeRegExp option is true triggers a SyntaxError', async () => { expect(await runSchema('\\', { schema, unicodeRegExp: true })).toEqual([ { - message: 'Invalid regular expression: /^[\\_-]$/u: Invalid escape', + message: expect.stringContaining('Invalid regular expression: /' + schema.pattern + '/'), path: [], }, ]); From a66cd159fc7dd5f87eb7eeb983067d647e16b044 Mon Sep 17 00:00:00 2001 From: mtso <8432061+mtso@users.noreply.github.com> Date: Thu, 17 Apr 2025 13:37:02 +0000 Subject: [PATCH 5/5] test(functions): update syntax error test with matcher for invalid escape text --- packages/functions/src/__tests__/schema.test.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/functions/src/__tests__/schema.test.ts b/packages/functions/src/__tests__/schema.test.ts index 822801e18..375300f29 100644 --- a/packages/functions/src/__tests__/schema.test.ts +++ b/packages/functions/src/__tests__/schema.test.ts @@ -325,12 +325,17 @@ describe('Core Functions / Schema', () => { }); it('and the unicodeRegExp option is true triggers a SyntaxError', async () => { - expect(await runSchema('\\', { schema, unicodeRegExp: true })).toEqual([ + const expected = [ { message: expect.stringContaining('Invalid regular expression: /' + schema.pattern + '/'), path: [], }, - ]); + { + message: expect.stringContaining('Invalid escape'), + path: [], + }, + ]; + expect(await runSchema('\\', { schema, unicodeRegExp: true })).toEqual(expect.arrayContaining(expected)); }); });