diff --git a/common/changes/@rushstack/node-core-library/rf-jsonschema-add-custom-formats_2024-09-10-03-15.json b/common/changes/@rushstack/node-core-library/rf-jsonschema-add-custom-formats_2024-09-10-03-15.json new file mode 100644 index 00000000000..339b3b8649d --- /dev/null +++ b/common/changes/@rushstack/node-core-library/rf-jsonschema-add-custom-formats_2024-09-10-03-15.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/node-core-library", + "comment": "Add a `customFormats` option to `JsonSchema`.", + "type": "minor" + } + ], + "packageName": "@rushstack/node-core-library" +} \ No newline at end of file diff --git a/common/reviews/api/node-core-library.api.md b/common/reviews/api/node-core-library.api.md index 4c7786d3668..842a74830bb 100644 --- a/common/reviews/api/node-core-library.api.md +++ b/common/reviews/api/node-core-library.api.md @@ -423,6 +423,12 @@ export interface IJsonFileStringifyOptions extends IJsonFileParseOptions { prettyFormatting?: boolean; } +// @public +export interface IJsonSchemaCustomFormat { + type: T extends string ? 'string' : T extends number ? 'number' : never; + validate: (data: T) => boolean; +} + // @public export interface IJsonSchemaErrorInfo { details: string; @@ -436,6 +442,7 @@ export type IJsonSchemaFromObjectOptions = IJsonSchemaLoadOptions; // @public export interface IJsonSchemaLoadOptions { + customFormats?: Record | IJsonSchemaCustomFormat>; dependentSchemas?: JsonSchema[]; schemaVersion?: JsonSchemaVersion; } diff --git a/libraries/node-core-library/src/JsonSchema.ts b/libraries/node-core-library/src/JsonSchema.ts index fd68ad65302..dcbe8e81052 100644 --- a/libraries/node-core-library/src/JsonSchema.ts +++ b/libraries/node-core-library/src/JsonSchema.ts @@ -25,6 +25,24 @@ interface ISchemaWithId { */ export type JsonSchemaVersion = 'draft-04' | 'draft-07'; +/** + * A definition for a custom format to consider during validation. + * @public + */ +export interface IJsonSchemaCustomFormat { + /** + * The base JSON type. + */ + type: T extends string ? 'string' : T extends number ? 'number' : never; + + /** + * A validation function for the format. + * @param data - The raw field data to validate. + * @returns whether the data is valid according to the format. + */ + validate: (data: T) => boolean; +} + /** * Callback function arguments for {@link JsonSchema.validateObjectWithCallback} * @public @@ -94,6 +112,13 @@ export interface IJsonSchemaLoadOptions { * or does not match an expected URL, the default version will be used. */ schemaVersion?: JsonSchemaVersion; + + /** + * Any custom formats to consider during validation. Some standard formats are supported + * out-of-the-box (e.g. emails, uris), but additional formats can be defined here. You could + * for example define generic numeric formats (e.g. uint8) or domain-specific formats. + */ + customFormats?: Record | IJsonSchemaCustomFormat>; } /** @@ -141,6 +166,9 @@ export class JsonSchema { private _validator: ValidateFunction | undefined = undefined; private _schemaObject: JsonObject | undefined = undefined; private _schemaVersion: JsonSchemaVersion | undefined = undefined; + private _customFormats: + | Record | IJsonSchemaCustomFormat> + | undefined = undefined; private constructor() {} @@ -163,6 +191,7 @@ export class JsonSchema { if (options) { schema._dependentSchemas = options.dependentSchemas || []; schema._schemaVersion = options.schemaVersion; + schema._customFormats = options.customFormats; } return schema; @@ -181,6 +210,7 @@ export class JsonSchema { if (options) { schema._dependentSchemas = options.dependentSchemas || []; schema._schemaVersion = options.schemaVersion; + schema._customFormats = options.customFormats; } return schema; @@ -308,6 +338,11 @@ export class JsonSchema { // Enable json-schema format validation // https://ajv.js.org/packages/ajv-formats.html addFormats(validator); + if (this._customFormats) { + for (const [name, format] of Object.entries(this._customFormats)) { + validator.addFormat(name, { ...format, async: false }); + } + } const collectedSchemas: JsonSchema[] = []; const seenObjects: Set = new Set(); diff --git a/libraries/node-core-library/src/index.ts b/libraries/node-core-library/src/index.ts index 70396912fbc..54fb6509b7a 100644 --- a/libraries/node-core-library/src/index.ts +++ b/libraries/node-core-library/src/index.ts @@ -65,6 +65,7 @@ export { } from './JsonFile'; export { type IJsonSchemaErrorInfo, + type IJsonSchemaCustomFormat, type IJsonSchemaFromFileOptions, type IJsonSchemaFromObjectOptions, type IJsonSchemaLoadOptions, diff --git a/libraries/node-core-library/src/test/JsonSchema.test.ts b/libraries/node-core-library/src/test/JsonSchema.test.ts index b4a82de4576..e2d537056ae 100644 --- a/libraries/node-core-library/src/test/JsonSchema.test.ts +++ b/libraries/node-core-library/src/test/JsonSchema.test.ts @@ -119,4 +119,32 @@ describe(JsonSchema.name, () => { expect(errorDetails).toMatchSnapshot(); }); }); + + test('successfully applies custom formats', () => { + const schemaWithCustomFormat = JsonSchema.fromLoadedObject( + { + title: 'Test Custom Format', + type: 'object', + properties: { + exampleNumber: { + type: 'number', + format: 'uint8' + } + }, + additionalProperties: false, + required: ['exampleNumber'] + }, + { + schemaVersion: 'draft-07', + customFormats: { + uint8: { + type: 'number', + validate: (data) => data >= 0 && data <= 255 + } + } + } + ); + expect(() => schemaWithCustomFormat.validateObject({ exampleNumber: 10 }, '')).not.toThrow(); + expect(() => schemaWithCustomFormat.validateObject({ exampleNumber: 1000 }, '')).toThrow(); + }); });