diff --git a/packages/parser/json-schema-parser/src/jsonSchema/202012/actions/traverseJsonSchema.spec.ts b/packages/parser/json-schema-parser/src/jsonSchema/202012/actions/traverseJsonSchema.spec.ts new file mode 100644 index 0000000..392c7e9 --- /dev/null +++ b/packages/parser/json-schema-parser/src/jsonSchema/202012/actions/traverseJsonSchema.spec.ts @@ -0,0 +1,223 @@ +import { afterAll, beforeAll, describe, expect, it, jest } from '@jest/globals'; + +import { + JsonRootSchemaObject, + JsonSchema, +} from '@cuaklabs/json-schema-types/2020-12'; + +import { JsonRootSchemaFixtures } from '../fixtures/JsonRootSchemaFixtures'; +import { TraverseJsonSchemaCallbackParams } from '../models/TraverseJsonSchemaCallbackParams'; +import { traverseJsonSchema } from './traverseJsonSchema'; + +describe(traverseJsonSchema.name, () => { + let callbackMock: jest.Mock< + (params: TraverseJsonSchemaCallbackParams) => void + >; + + beforeAll(() => { + callbackMock = jest.fn(); + }); + + describe('when called', () => { + beforeAll(() => { + traverseJsonSchema({ schema: JsonRootSchemaFixtures.any }, callbackMock); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + it('should call callback() with the schema', () => { + const expectedTraverseJsonSchemaCallbackParams: TraverseJsonSchemaCallbackParams = + { + jsonPointer: '', + parentJsonPointer: undefined, + parentSchema: undefined, + schema: JsonRootSchemaFixtures.any, + }; + + expect(callbackMock).toHaveBeenCalledTimes(1); + expect(callbackMock).toHaveBeenCalledWith( + expectedTraverseJsonSchemaCallbackParams, + ); + }); + }); + + describe.each<[string, JsonRootSchemaObject]>([ + ['$defs', JsonRootSchemaFixtures.with$DefsOne], + ['dependentSchemas', JsonRootSchemaFixtures.withDependentSchemasOne], + ['patternProperties', JsonRootSchemaFixtures.withPatternProperiesOne], + ['properties', JsonRootSchemaFixtures.withProperiesOne], + ])( + '(key to schema map) having a schema with "%s"', + (schemaKey: string, schemaFixture: JsonRootSchemaObject): void => { + beforeAll(() => { + traverseJsonSchema({ schema: schemaFixture }, callbackMock); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + it('should call callback() with the schema', () => { + const expectedTraverseJsonSchemaCallbackParams: TraverseJsonSchemaCallbackParams = + { + jsonPointer: '', + parentJsonPointer: undefined, + parentSchema: undefined, + schema: schemaFixture, + }; + + expect(callbackMock).toHaveBeenNthCalledWith( + 1, + expectedTraverseJsonSchemaCallbackParams, + ); + }); + + it('should call callback() with every subschema', () => { + const subschemaMap: Record = schemaFixture[ + schemaKey + ] as Record; + + const subschemaMapEntries: [string, JsonSchema][] = + Object.entries(subschemaMap); + + expect(callbackMock).toHaveBeenCalledTimes( + subschemaMapEntries.length + 1, + ); + + for (const [ + index, + [subschemaKey, subschema], + ] of subschemaMapEntries.entries()) { + const expectedTraverseJsonSchemaCallbackParams: TraverseJsonSchemaCallbackParams = + { + jsonPointer: `/${schemaKey}/${subschemaKey}`, + parentJsonPointer: '', + parentSchema: schemaFixture, + schema: subschema, + }; + + expect(callbackMock).toHaveBeenNthCalledWith( + index + 2, + expectedTraverseJsonSchemaCallbackParams, + ); + } + }); + }, + ); + + describe.each<[string, JsonRootSchemaObject]>([ + ['allOf', JsonRootSchemaFixtures.withAllOfTwo], + ['anyOf', JsonRootSchemaFixtures.withAnyOfTwo], + ['oneOf', JsonRootSchemaFixtures.withOneOfTwo], + ['prefixItems', JsonRootSchemaFixtures.withPrefixItemsOne], + ])( + '(schema array) having a schema with "%s"', + (schemaKey: string, schemaFixture: JsonRootSchemaObject): void => { + beforeAll(() => { + traverseJsonSchema({ schema: schemaFixture }, callbackMock); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + it('should call callback() with the schema', () => { + const expectedTraverseJsonSchemaCallbackParams: TraverseJsonSchemaCallbackParams = + { + jsonPointer: '', + parentJsonPointer: undefined, + parentSchema: undefined, + schema: schemaFixture, + }; + + expect(callbackMock).toHaveBeenNthCalledWith( + 1, + expectedTraverseJsonSchemaCallbackParams, + ); + }); + + it('should call callback() with every subschema', () => { + const schemaArrays: JsonSchema[] = schemaFixture[ + schemaKey + ] as JsonSchema[]; + + expect(callbackMock).toHaveBeenCalledTimes(schemaArrays.length + 1); + + for (const [subschemaIndex, subschema] of schemaArrays.entries()) { + const expectedTraverseJsonSchemaCallbackParams: TraverseJsonSchemaCallbackParams = + { + jsonPointer: `/${schemaKey}/${subschemaIndex}`, + parentJsonPointer: '', + parentSchema: schemaFixture, + schema: subschema, + }; + + expect(callbackMock).toHaveBeenNthCalledWith( + subschemaIndex + 2, + expectedTraverseJsonSchemaCallbackParams, + ); + } + }); + }, + ); + + describe.each<[string, JsonRootSchemaObject]>([ + ['additionalProperties', JsonRootSchemaFixtures.withAdditionalProperties], + ['contains', JsonRootSchemaFixtures.withContains], + ['else', JsonRootSchemaFixtures.withElse], + ['if', JsonRootSchemaFixtures.withIf], + ['items', JsonRootSchemaFixtures.withItems], + ['not', JsonRootSchemaFixtures.withNot], + ['propertyNames', JsonRootSchemaFixtures.withProperyNames], + ['then', JsonRootSchemaFixtures.withThen], + ['unevaluatedItems', JsonRootSchemaFixtures.withUnevaluatedItems], + ['unevaluatedProperties', JsonRootSchemaFixtures.withUnevaluatedProperties], + ])( + '(schema) having a schema with "%s"', + (schemaKey: string, schemaFixture: JsonRootSchemaObject): void => { + beforeAll(() => { + traverseJsonSchema({ schema: schemaFixture }, callbackMock); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + it('should call callback() with the schema', () => { + const expectedTraverseJsonSchemaCallbackParams: TraverseJsonSchemaCallbackParams = + { + jsonPointer: '', + parentJsonPointer: undefined, + parentSchema: undefined, + schema: schemaFixture, + }; + + expect(callbackMock).toHaveBeenNthCalledWith( + 1, + expectedTraverseJsonSchemaCallbackParams, + ); + }); + + it('should call callback() with the subschema', () => { + const subschema: JsonSchema = schemaFixture[schemaKey] as JsonSchema; + + expect(callbackMock).toHaveBeenCalledTimes(2); + + const expectedTraverseJsonSchemaCallbackParams: TraverseJsonSchemaCallbackParams = + { + jsonPointer: `/${schemaKey}`, + parentJsonPointer: '', + parentSchema: schemaFixture, + schema: subschema, + }; + + expect(callbackMock).toHaveBeenNthCalledWith( + 2, + expectedTraverseJsonSchemaCallbackParams, + ); + }); + }, + ); +}); diff --git a/packages/parser/json-schema-parser/src/jsonSchema/202012/actions/traverseJsonSchema.ts b/packages/parser/json-schema-parser/src/jsonSchema/202012/actions/traverseJsonSchema.ts new file mode 100644 index 0000000..6135fc4 --- /dev/null +++ b/packages/parser/json-schema-parser/src/jsonSchema/202012/actions/traverseJsonSchema.ts @@ -0,0 +1,146 @@ +import { + JsonRootSchemaObject, + JsonSchema, + JsonRootSchemaKnownPropertiesObject, +} from '@cuaklabs/json-schema-types/2020-12'; + +import { TraverseJsonSchemaCallbackParams } from '../models/TraverseJsonSchemaCallbackParams'; +import { TraverseJsonSchemaParams } from '../models/TraverseJsonSchemaParams'; + +type JsonRootSchemaSchemaProperty = + | JsonSchema + | JsonSchema[] + | Record; + +type JsonRootSchemaSchemaPropertyHandler = ( + params: TraverseJsonSchemaCallbackParams, + childSchema: JsonRootSchemaSchemaProperty, + key: string, + callback: (params: TraverseJsonSchemaCallbackParams) => void, +) => void; + +const jsonRootSchemaObjectPropertyToHandlerMap: { + [TKey in keyof JsonRootSchemaKnownPropertiesObject]?: ( + params: TraverseJsonSchemaCallbackParams, + childSchema: Exclude, + key: string, + callback: (params: TraverseJsonSchemaCallbackParams) => void, + ) => void; +} = { + $defs: traverseDirectChildSchemaMap, + additionalProperties: traverseDirectChildSchema, + allOf: traverseDirectChildSchemaArray, + anyOf: traverseDirectChildSchemaArray, + contains: traverseDirectChildSchema, + dependentSchemas: traverseDirectChildSchemaMap, + else: traverseDirectChildSchema, + if: traverseDirectChildSchema, + items: traverseDirectChildSchema, + not: traverseDirectChildSchema, + oneOf: traverseDirectChildSchemaArray, + patternProperties: traverseDirectChildSchemaMap, + prefixItems: traverseDirectChildSchemaArray, + properties: traverseDirectChildSchemaMap, + propertyNames: traverseDirectChildSchema, + then: traverseDirectChildSchema, + unevaluatedItems: traverseDirectChildSchema, + unevaluatedProperties: traverseDirectChildSchema, +}; + +export function traverseJsonSchema( + params: TraverseJsonSchemaParams, + callback: (params: TraverseJsonSchemaCallbackParams) => void, +): void { + traverseJsonSchemaFromParams( + { + jsonPointer: params.jsonPointer ?? '', + parentJsonPointer: undefined, + parentSchema: undefined, + schema: params.schema, + }, + callback, + ); +} + +function traverseJsonSchemaFromParams( + params: TraverseJsonSchemaCallbackParams, + callback: (params: TraverseJsonSchemaCallbackParams) => void, +): void { + callback(params); + + if (params.schema !== true && params.schema !== false) { + for (const key of Object.keys(params.schema)) { + const handler: JsonRootSchemaSchemaPropertyHandler | undefined = + jsonRootSchemaObjectPropertyToHandlerMap[ + key as keyof JsonRootSchemaKnownPropertiesObject + ] as JsonRootSchemaSchemaPropertyHandler | undefined; + + if (handler !== undefined) { + handler( + params, + params.schema[key] as JsonRootSchemaSchemaProperty, + key, + callback, + ); + } + } + } +} + +function traverseDirectChildSchema( + params: TraverseJsonSchemaCallbackParams, + childSchema: JsonSchema, + key: string, + callback: (params: TraverseJsonSchemaCallbackParams) => void, +): void { + const traverseChildSchemaCallbackParams: TraverseJsonSchemaCallbackParams = { + jsonPointer: `${params.jsonPointer}/${escapeJsonPtr(key)}`, + parentJsonPointer: params.jsonPointer, + parentSchema: params.schema, + schema: childSchema, + }; + + traverseJsonSchemaFromParams(traverseChildSchemaCallbackParams, callback); +} + +function traverseDirectChildSchemaArray( + params: TraverseJsonSchemaCallbackParams, + childSchemas: JsonSchema[], + key: string, + callback: (params: TraverseJsonSchemaCallbackParams) => void, +): void { + for (const [index, schema] of childSchemas.entries()) { + const traverseChildSchemaCallbackParams: TraverseJsonSchemaCallbackParams = + { + jsonPointer: `${params.jsonPointer}/${escapeJsonPtr(key)}/${index}`, + parentJsonPointer: params.jsonPointer, + parentSchema: params.schema, + schema, + }; + + traverseJsonSchemaFromParams(traverseChildSchemaCallbackParams, callback); + } +} + +function traverseDirectChildSchemaMap( + params: TraverseJsonSchemaCallbackParams, + schemasMap: Record, + key: string, + callback: (params: TraverseJsonSchemaCallbackParams) => void, +): void { + for (const [mapKey, schema] of Object.entries(schemasMap)) { + const traverseChildSchemaCallbackParams: TraverseJsonSchemaCallbackParams = + { + jsonPointer: `${params.jsonPointer}/${escapeJsonPtr(key)}/${mapKey}`, + parentJsonPointer: params.jsonPointer, + parentSchema: params.schema, + schema, + }; + + traverseJsonSchemaFromParams(traverseChildSchemaCallbackParams, callback); + } +} + +function escapeJsonPtr(str: string): string { + return str.replace(/~/g, '~0').replace(/\//g, '~1'); +} diff --git a/packages/parser/json-schema-parser/src/jsonSchema/202012/models/TraverseJsonSchemaCallbackParams.ts b/packages/parser/json-schema-parser/src/jsonSchema/202012/models/TraverseJsonSchemaCallbackParams.ts new file mode 100644 index 0000000..f568241 --- /dev/null +++ b/packages/parser/json-schema-parser/src/jsonSchema/202012/models/TraverseJsonSchemaCallbackParams.ts @@ -0,0 +1,11 @@ +import { + JsonRootSchema, + JsonSchema, +} from '@cuaklabs/json-schema-types/2020-12'; + +export interface TraverseJsonSchemaCallbackParams { + jsonPointer: string; + parentJsonPointer: string | undefined; + parentSchema: JsonSchema | undefined; + schema: JsonSchema | JsonRootSchema; +} diff --git a/packages/parser/json-schema-parser/src/jsonSchema/202012/models/TraverseJsonSchemaParams.ts b/packages/parser/json-schema-parser/src/jsonSchema/202012/models/TraverseJsonSchemaParams.ts new file mode 100644 index 0000000..62b04b0 --- /dev/null +++ b/packages/parser/json-schema-parser/src/jsonSchema/202012/models/TraverseJsonSchemaParams.ts @@ -0,0 +1,6 @@ +import { JsonSchema } from '@cuaklabs/json-schema-types/2020-12'; + +export interface TraverseJsonSchemaParams { + jsonPointer?: string; + schema: JsonSchema; +}