Skip to content

feat(functions): add unicodeRegExp option to schema core function #2809

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 5 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions docs/reference/functions.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,11 +164,12 @@ Use JSON Schema (draft 4, 6, 7, 2019-09, or 2020-12) to treat the contents of th

<!-- title: functionOptions -->

| 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" when `true`; otherwise does not use the "u" flag. "false" by default | `boolean` | no |

<!-- title: example -->

Expand Down
89 changes: 89 additions & 0 deletions packages/functions/src/__tests__/schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,84 @@ 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 triggers a SyntaxError', async () => {
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));
});
});

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',
Expand Down Expand Up @@ -466,6 +544,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' },
Expand Down Expand Up @@ -546,6 +625,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));
});
Expand Down
6 changes: 6 additions & 0 deletions packages/functions/src/optionSchemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,12 @@ export const optionSchemas: Record<string, CustomFunctionOptionsSchema> = {
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,
},
Expand Down
68 changes: 52 additions & 16 deletions packages/functions/src/schema/ajv.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<AjvOptions, 'allErrors' | 'unicodeRegExp'>;

/**
* A unique key for Ajv options.
*/
type AjvInstanceKey = string;

const logger = {
warn(...args: unknown[]): void {
const firstArg = args[0];
Expand All @@ -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);
}

Expand All @@ -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<AjvInstanceKey, AjvCore>();

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<Record<NonNullable<Options['dialect']>, ReturnType<typeof _createAjvInstances>>> = {
Expand All @@ -76,9 +112,9 @@ export function createAjvInstances(): AssignAjvInstance {

const compiledSchemas = new WeakMap<AjvCore, WeakMap<SchemaObject, ValidateFunction>>();

return function (schema, dialect, allErrors): ValidateFunction {
return function (schema, dialect, validationOptions: ValidationOptions): ValidateFunction {
const instances = (ajvInstances[dialect] ?? ajvInstances.auto) as ReturnType<typeof _createAjvInstances>;
const ajv = instances[allErrors ? 'allErrors' : 'default'];
const ajv = instances.getInstance(validationOptions);

const $id = schema.$id;

Expand Down
8 changes: 5 additions & 3 deletions packages/functions/src/schema/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> | JSONSchema;
allErrors?: boolean;
unicodeRegExp?: boolean;
dialect?: 'auto' | 'draft4' | 'draft6' | 'draft7' | 'draft2019-09' | 'draft2020-12';
prepareResults?(errors: ErrorObject[]): void;
};
Expand Down Expand Up @@ -40,13 +41,14 @@ export default createRulesetFunction<unknown, Options>(
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);
Expand Down
2 changes: 1 addition & 1 deletion packages/rulesets/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 6 additions & 0 deletions packages/rulesets/src/oas/functions/oasExample.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export type Options = {
oasVersion: 2 | 3;
schemaField: string;
type: 'media' | 'schema';
unicodeRegExp?: boolean;
};

type HasRequiredProperties = traverse.SchemaObject & {
Expand Down Expand Up @@ -234,6 +235,10 @@ export default createRulesetFunction<Record<string, unknown>, Options>(
type: {
enum: ['media', 'schema'],
},
unicodeRegExp: {
type: 'boolean',
default: false,
},
},
additionalProperties: false,
},
Expand All @@ -242,6 +247,7 @@ export default createRulesetFunction<Record<string, unknown>, 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<IFunctionResult[]> = void 0;
Expand Down
5 changes: 5 additions & 0 deletions packages/rulesets/src/oas/functions/oasSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { isPlainObject, pointerToPath } from '@stoplight/json';

export type Options = {
schema: Record<string, unknown>;
unicodeRegExp?: boolean;
};

function rewriteNullable(schema: SchemaObject, errors: ErrorObject[]): void {
Expand All @@ -28,6 +29,10 @@ export default createRulesetFunction<unknown, Options>(
schema: {
type: 'object',
},
unicodeRegExp: {
type: 'boolean',
default: false,
},
},
additionalProperties: false,
},
Expand Down
5 changes: 5 additions & 0 deletions packages/rulesets/src/oas/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ const ruleset = {
type: 'array',
uniqueItems: true,
},
unicodeRegExp: false,
},
},
},
Expand Down Expand Up @@ -509,6 +510,7 @@ const ruleset = {
schemaField: '$',
oasVersion: 2,
type: 'schema',
unicodeRegExp: false,
},
},
},
Expand All @@ -525,6 +527,7 @@ const ruleset = {
schemaField: 'schema',
oasVersion: 2,
type: 'media',
unicodeRegExp: false,
},
},
},
Expand Down Expand Up @@ -681,6 +684,7 @@ const ruleset = {
schemaField: 'schema',
oasVersion: 3,
type: 'media',
unicodeRegExp: false,
},
},
},
Expand All @@ -702,6 +706,7 @@ const ruleset = {
schemaField: '$',
oasVersion: 3,
type: 'schema',
unicodeRegExp: false,
},
},
},
Expand Down
Loading