From e241c017ddaedd8206878e70862c81bc3a786e0d Mon Sep 17 00:00:00 2001 From: Boris Cherny Date: Tue, 25 Jun 2024 15:23:54 +0200 Subject: [PATCH] generalize linker to annotator --- ARCHITECTURE.md | 4 +- README.md | 2 +- src/annotator.ts | 41 +++++++++++++ src/index.ts | 14 ++--- src/linker.ts | 37 ------------ src/normalizer.ts | 14 ++--- src/types/JSONSchema.ts | 42 ++++++------- src/utils.ts | 76 +++++++++++------------- src/validator.ts | 4 +- test/e2e/basics.ts | 4 +- test/test.ts | 4 +- test/{testLinker.ts => testAnnotator.ts} | 6 +- test/testNormalizer.ts | 9 ++- test/testUtils.ts | 29 +++++---- 14 files changed, 146 insertions(+), 140 deletions(-) create mode 100644 src/annotator.ts delete mode 100644 src/linker.ts rename test/{testLinker.ts => testAnnotator.ts} (79%) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 9a8394f9..d67f81f4 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -8,9 +8,9 @@ TODO use an external validation library Resolves referenced schemas (in the file, on the local filesystem, or over the network). -#### 3. Linker +#### 3. Annotator -Adds links back from each node in a schema to its parent (available via the `Parent` symbol on each node), for convenience. +Annotates the JSON schema with metadata that will be used later on. For example, this step adds links back from each node in a schema to its parent (available via the `Metadata` symbol on each node), for convenience. #### 4. Normalizer diff --git a/README.md b/README.md index d9e50cdb..431b450c 100644 --- a/README.md +++ b/README.md @@ -134,7 +134,7 @@ See [server demo](example) and [browser demo](https://github.com/bcherny/json-sc |-|-|-|-| | additionalProperties | boolean | `true` | Default value for `additionalProperties`, when it is not explicitly set | | bannerComment | string | `"/* eslint-disable */\n/**\n* This file was automatically generated by json-schema-to-typescript.\n* DO NOT MODIFY IT BY HAND. Instead, modify the source JSON Schema file,\n* and run json-schema-to-typescript to regenerate this file.\n*/"` | Disclaimer comment prepended to the top of each generated file | -| customName | `(LinkedJSONSchema, string \| undefined) => string \| undefined` | `undefined` | Custom function to provide a type name for a given schema +| customName | `(JSONSchema, string \| undefined) => string \| undefined` | `undefined` | Custom function to provide a type name for a given schema | cwd | string | `process.cwd()` | Root directory for resolving [`$ref`](https://tools.ietf.org/id/draft-pbryan-zyp-json-ref-03.html)s | | declareExternallyReferenced | boolean | `true` | Declare external schemas referenced via `$ref`? | | enableConstEnums | boolean | `true` | Prepend enums with [`const`](https://www.typescriptlang.org/docs/handbook/enums.html#computed-and-constant-members)? | diff --git a/src/annotator.ts b/src/annotator.ts new file mode 100644 index 00000000..1cdaa7dc --- /dev/null +++ b/src/annotator.ts @@ -0,0 +1,41 @@ +import {isPlainObject} from 'lodash' +import {DereferencedPaths} from './resolver' +import {AnnotatedJSONSchema, JSONSchema, Parent, isAnnotated} from './types/JSONSchema' + +/** + * Traverses over the schema, assigning to each + * node metadata that will be used downstream. + */ +export function annotate( + schema: JSONSchema, + dereferencedPaths: DereferencedPaths, + parent: JSONSchema | null = null, +): AnnotatedJSONSchema { + if (!Array.isArray(schema) && !isPlainObject(schema)) { + return schema as AnnotatedJSONSchema + } + + // Handle cycles + if (isAnnotated(schema)) { + return schema + } + + // Add a reference to this schema's parent + Object.defineProperty(schema, Parent, { + enumerable: false, + value: parent, + writable: false, + }) + + // Arrays + if (Array.isArray(schema)) { + schema.forEach(child => annotate(child, dereferencedPaths, schema)) + } + + // Objects + for (const key in schema) { + annotate(schema[key], dereferencedPaths, schema) + } + + return schema as AnnotatedJSONSchema +} diff --git a/src/index.ts b/src/index.ts index 1aa67be0..9bc37d70 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,9 +13,9 @@ import {dereference} from './resolver' import {error, stripExtension, Try, log, parseFileAsJSONSchema} from './utils' import {validate} from './validator' import {isDeepStrictEqual} from 'util' -import {link} from './linker' +import {annotate} from './annotator' import {validateOptions} from './optionValidator' -import {JSONSchema as LinkedJSONSchema} from './types/JSONSchema' +import {AnnotatedJSONSchema} from './types/JSONSchema' export {EnumJSONSchema, JSONSchema, NamedEnumJSONSchema, CustomTypeJSONSchema} from './types/JSONSchema' @@ -35,7 +35,7 @@ export interface Options { /** * Custom function to provide a type name for a given schema */ - customName?: (schema: LinkedJSONSchema, keyNameFromDefinition: string | undefined) => string | undefined + customName?: (schema: AnnotatedJSONSchema, keyNameFromDefinition: string | undefined) => string | undefined /** * Root directory for resolving [`$ref`](https://tools.ietf.org/id/draft-pbryan-zyp-json-ref-03.html)s. */ @@ -159,12 +159,12 @@ export async function compile(schema: JSONSchema4, name: string, options: Partia } } - const linked = link(dereferencedSchema) + const annotated = annotate(dereferencedSchema, dereferencedPaths) if (process.env.VERBOSE) { - log('green', 'linker', time(), '✅ No change') + log('green', 'annotater', time(), '✅ No change') } - const errors = validate(linked, name) + const errors = validate(annotated, name) if (errors.length) { errors.forEach(_ => error(_)) throw new ValidationError() @@ -173,7 +173,7 @@ export async function compile(schema: JSONSchema4, name: string, options: Partia log('green', 'validator', time(), '✅ No change') } - const normalized = normalize(linked, dereferencedPaths, name, _options) + const normalized = normalize(annotated, dereferencedPaths, name, _options) log('yellow', 'normalizer', time(), '✅ Result:', normalized) const parsed = parse(normalized, _options) diff --git a/src/linker.ts b/src/linker.ts deleted file mode 100644 index eb76ae51..00000000 --- a/src/linker.ts +++ /dev/null @@ -1,37 +0,0 @@ -import {JSONSchema, Parent, LinkedJSONSchema} from './types/JSONSchema' -import {isPlainObject} from 'lodash' -import {JSONSchema4Type} from 'json-schema' - -/** - * Traverses over the schema, giving each node a reference to its - * parent node. We need this for downstream operations. - */ -export function link(schema: JSONSchema4Type | JSONSchema, parent: JSONSchema4Type | null = null): LinkedJSONSchema { - if (!Array.isArray(schema) && !isPlainObject(schema)) { - return schema as LinkedJSONSchema - } - - // Handle cycles - if ((schema as JSONSchema).hasOwnProperty(Parent)) { - return schema as LinkedJSONSchema - } - - // Add a reference to this schema's parent - Object.defineProperty(schema, Parent, { - enumerable: false, - value: parent, - writable: false, - }) - - // Arrays - if (Array.isArray(schema)) { - schema.forEach(child => link(child, schema)) - } - - // Objects - for (const key in schema as JSONSchema) { - link((schema as JSONSchema)[key], schema) - } - - return schema as LinkedJSONSchema -} diff --git a/src/normalizer.ts b/src/normalizer.ts index 157d97f2..1009fb2a 100644 --- a/src/normalizer.ts +++ b/src/normalizer.ts @@ -1,11 +1,11 @@ -import {JSONSchemaTypeName, LinkedJSONSchema, NormalizedJSONSchema, Parent} from './types/JSONSchema' +import {JSONSchemaTypeName, AnnotatedJSONSchema, NormalizedJSONSchema, JSONSchema, Parent} from './types/JSONSchema' import {appendToDescription, escapeBlockComment, isSchemaLike, justName, toSafeString, traverse} from './utils' import {Options} from './' import {DereferencedPaths} from './resolver' import {isDeepStrictEqual} from 'util' type Rule = ( - schema: LinkedJSONSchema, + schema: AnnotatedJSONSchema, fileName: string, options: Options, key: string | null, @@ -13,16 +13,16 @@ type Rule = ( ) => void const rules = new Map() -function hasType(schema: LinkedJSONSchema, type: JSONSchemaTypeName) { +function hasType(schema: JSONSchema, type: JSONSchemaTypeName) { return schema.type === type || (Array.isArray(schema.type) && schema.type.includes(type)) } -function isObjectType(schema: LinkedJSONSchema) { +function isObjectType(schema: JSONSchema) { return schema.properties !== undefined || hasType(schema, 'object') || hasType(schema, 'any') } -function isArrayType(schema: LinkedJSONSchema) { +function isArrayType(schema: JSONSchema) { return schema.items !== undefined || hasType(schema, 'array') || hasType(schema, 'any') } -function isEnumTypeWithoutTsEnumNames(schema: LinkedJSONSchema) { +function isEnumTypeWithoutTsEnumNames(schema: JSONSchema) { return schema.type === 'string' && schema.enum !== undefined && schema.tsEnumNames === undefined } @@ -232,7 +232,7 @@ rules.set('Add tsEnumNames to enum types', (schema, _, options) => { }) export function normalize( - rootSchema: LinkedJSONSchema, + rootSchema: AnnotatedJSONSchema, dereferencedPaths: DereferencedPaths, filename: string, options: Options, diff --git a/src/types/JSONSchema.ts b/src/types/JSONSchema.ts index f9be9524..89917e29 100644 --- a/src/types/JSONSchema.ts +++ b/src/types/JSONSchema.ts @@ -42,32 +42,28 @@ export interface JSONSchema extends JSONSchema4 { export const Parent = Symbol('Parent') -export interface LinkedJSONSchema extends JSONSchema { - /** - * A reference to this schema's parent node, for convenience. - * `null` when this is the root schema. - */ - [Parent]: LinkedJSONSchema | null +export interface AnnotatedJSONSchema extends JSONSchema { + [Parent]: AnnotatedJSONSchema - additionalItems?: boolean | LinkedJSONSchema - additionalProperties?: boolean | LinkedJSONSchema - items?: LinkedJSONSchema | LinkedJSONSchema[] + additionalItems?: boolean | AnnotatedJSONSchema + additionalProperties?: boolean | AnnotatedJSONSchema + items?: AnnotatedJSONSchema | AnnotatedJSONSchema[] definitions?: { - [k: string]: LinkedJSONSchema + [k: string]: AnnotatedJSONSchema } properties?: { - [k: string]: LinkedJSONSchema + [k: string]: AnnotatedJSONSchema } patternProperties?: { - [k: string]: LinkedJSONSchema + [k: string]: AnnotatedJSONSchema } dependencies?: { - [k: string]: LinkedJSONSchema | string[] + [k: string]: AnnotatedJSONSchema | string[] } - allOf?: LinkedJSONSchema[] - anyOf?: LinkedJSONSchema[] - oneOf?: LinkedJSONSchema[] - not?: LinkedJSONSchema + allOf?: AnnotatedJSONSchema[] + anyOf?: AnnotatedJSONSchema[] + oneOf?: AnnotatedJSONSchema[] + not?: AnnotatedJSONSchema } /** @@ -75,8 +71,8 @@ export interface LinkedJSONSchema extends JSONSchema { * * Note: `definitions` and `id` are removed by the normalizer. Use `$defs` and `$id` instead. */ -export interface NormalizedJSONSchema extends Omit { - [Parent]: NormalizedJSONSchema | null +export interface NormalizedJSONSchema extends Omit { + [Parent]: NormalizedJSONSchema additionalItems?: boolean | NormalizedJSONSchema additionalProperties: boolean | NormalizedJSONSchema @@ -134,14 +130,18 @@ export const getRootSchema = memoize((schema: NormalizedJSONSchema): NormalizedJ return getRootSchema(parent) }) -export function isBoolean(schema: LinkedJSONSchema | JSONSchemaType): schema is boolean { +export function isBoolean(schema: AnnotatedJSONSchema | JSONSchemaType): schema is boolean { return schema === true || schema === false } -export function isPrimitive(schema: LinkedJSONSchema | JSONSchemaType): schema is JSONSchemaType { +export function isPrimitive(schema: AnnotatedJSONSchema | JSONSchemaType): schema is JSONSchemaType { return !isPlainObject(schema) } export function isCompound(schema: JSONSchema): boolean { return Array.isArray(schema.type) || 'anyOf' in schema || 'oneOf' in schema } + +export function isAnnotated(schema: JSONSchema): schema is AnnotatedJSONSchema { + return schema.hasOwnProperty(Parent) +} diff --git a/src/utils.ts b/src/utils.ts index 9e26a35a..5dc37050 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,6 +1,6 @@ import {deburr, isPlainObject, trim, upperFirst} from 'lodash' import {basename, dirname, extname, normalize, sep, posix} from 'path' -import {JSONSchema, LinkedJSONSchema, NormalizedJSONSchema, Parent} from './types/JSONSchema' +import {JSONSchema, AnnotatedJSONSchema, NormalizedJSONSchema, Parent} from './types/JSONSchema' import {JSONSchema4} from 'json-schema' import yaml from 'js-yaml' @@ -51,30 +51,10 @@ const BLACKLISTED_KEYS = new Set([ 'not', ]) -function traverseObjectKeys( - obj: Record, - callback: (schema: LinkedJSONSchema, key: string | null) => void, - processed: Set, -) { - Object.keys(obj).forEach(k => { - if (obj[k] && typeof obj[k] === 'object' && !Array.isArray(obj[k])) { - traverse(obj[k], callback, processed, k) - } - }) -} - -function traverseArray( - arr: LinkedJSONSchema[], - callback: (schema: LinkedJSONSchema, key: string | null) => void, - processed: Set, -) { - arr.forEach((s, k) => traverse(s, callback, processed, k.toString())) -} - -export function traverse( - schema: LinkedJSONSchema, - callback: (schema: LinkedJSONSchema, key: string | null) => void, - processed = new Set(), +export function traverse( + schema: A, + callback: (schema: A, key: string | null) => void, + processed = new Set(), key?: string, ): void { // Handle recursive schemas @@ -86,49 +66,49 @@ export function traverse( callback(schema, key ?? null) if (schema.anyOf) { - traverseArray(schema.anyOf, callback, processed) + traverseArray(schema.anyOf as any) // TODO } if (schema.allOf) { - traverseArray(schema.allOf, callback, processed) + traverseArray(schema.allOf as any) // TODO } if (schema.oneOf) { - traverseArray(schema.oneOf, callback, processed) + traverseArray(schema.oneOf as any) // TODO } if (schema.properties) { - traverseObjectKeys(schema.properties, callback, processed) + traverseObject(schema.properties as any) // TODO } if (schema.patternProperties) { - traverseObjectKeys(schema.patternProperties, callback, processed) + traverseObject(schema.patternProperties as any) // TODO } if (schema.additionalProperties && typeof schema.additionalProperties === 'object') { - traverse(schema.additionalProperties, callback, processed) + traverse(schema.additionalProperties as any, callback, processed) // TODO } if (schema.items) { const {items} = schema if (Array.isArray(items)) { - traverseArray(items, callback, processed) + traverseArray(items as any) // TODO } else { - traverse(items, callback, processed) + traverse(items as any, callback, processed) // TODO } } if (schema.additionalItems && typeof schema.additionalItems === 'object') { - traverse(schema.additionalItems, callback, processed) + traverse(schema.additionalItems as any, callback, processed) // TODO } if (schema.dependencies) { if (Array.isArray(schema.dependencies)) { - traverseArray(schema.dependencies, callback, processed) + traverseArray(schema.dependencies) } else { - traverseObjectKeys(schema.dependencies as LinkedJSONSchema, callback, processed) + traverseObject(schema.dependencies as JSONSchema) } } if (schema.definitions) { - traverseObjectKeys(schema.definitions, callback, processed) + traverseObject(schema.definitions as any) // TODO } if (schema.$defs) { - traverseObjectKeys(schema.$defs, callback, processed) + traverseObject(schema.$defs) } if (schema.not) { - traverse(schema.not, callback, processed) + traverse(schema.not as any, callback, processed) // TODO } // technically you can put definitions on any key @@ -137,9 +117,21 @@ export function traverse( .forEach(key => { const child = schema[key] if (child && typeof child === 'object') { - traverseObjectKeys(child, callback, processed) + traverseObject(child) } }) + + function traverseArray(arr: A[]) { + arr.forEach((s, k) => traverse(s, callback, processed, k.toString())) + } + + function traverseObject(obj: Record) { + Object.keys(obj).forEach(k => { + if (obj[k] && typeof obj[k] === 'object' && !Array.isArray(obj[k])) { + traverse(obj[k], callback, processed, k) + } + }) + } } /** @@ -289,7 +281,7 @@ export function pathTransform(outputPath: string, inputPath: string, filePath: s * * Mutates `schema`. */ -export function maybeStripDefault(schema: LinkedJSONSchema): LinkedJSONSchema { +export function maybeStripDefault(schema: JSONSchema): JSONSchema { if (!('default' in schema)) { return schema } @@ -358,7 +350,7 @@ export function appendToDescription(existingDescription: string | undefined, ... return values.join('\n') } -export function isSchemaLike(schema: any): schema is LinkedJSONSchema { +export function isSchemaLike(schema: any): schema is AnnotatedJSONSchema { if (!isPlainObject(schema)) { return false } diff --git a/src/validator.ts b/src/validator.ts index 69fb390d..232f19e0 100644 --- a/src/validator.ts +++ b/src/validator.ts @@ -1,4 +1,4 @@ -import {JSONSchema, LinkedJSONSchema} from './types/JSONSchema' +import {JSONSchema, AnnotatedJSONSchema} from './types/JSONSchema' import {traverse} from './utils' type Rule = (schema: JSONSchema) => boolean | void @@ -42,7 +42,7 @@ rules.set('deprecated must be a boolean', schema => { return typeOfDeprecated === 'boolean' || typeOfDeprecated === 'undefined' }) -export function validate(schema: LinkedJSONSchema, filename: string): string[] { +export function validate(schema: AnnotatedJSONSchema, filename: string): string[] { const errors: string[] = [] rules.forEach((rule, ruleName) => { traverse(schema, (schema, key) => { diff --git a/test/e2e/basics.ts b/test/e2e/basics.ts index 72ec8032..81e426bd 100644 --- a/test/e2e/basics.ts +++ b/test/e2e/basics.ts @@ -1,4 +1,6 @@ -export const input = { +import {JSONSchema} from '../../src/types/JSONSchema' + +export const input: JSONSchema = { title: 'Example Schema', type: 'object', properties: { diff --git a/test/test.ts b/test/test.ts index 76944331..72ba82f8 100644 --- a/test/test.ts +++ b/test/test.ts @@ -2,7 +2,7 @@ import {run as runCLITests} from './testCLI' import {run as runCompileFromFileTests} from './testCompileFromFile' import {hasOnly, run as runE2ETests} from './testE2E' import {run as runIdempotenceTests} from './testIdempotence' -import {run as runLinkerTests} from './testLinker' +import {run as runAnnotatorTests} from './testAnnotator' import {run as runNormalizerTests} from './testNormalizer' import {run as runUtilsTests} from './testUtils' @@ -12,7 +12,7 @@ if (!hasOnly()) { runCompileFromFileTests() runCLITests() runIdempotenceTests() - runLinkerTests() + runAnnotatorTests() runNormalizerTests() runUtilsTests() } diff --git a/test/testLinker.ts b/test/testAnnotator.ts similarity index 79% rename from test/testLinker.ts rename to test/testAnnotator.ts index a704b849..19e7a416 100644 --- a/test/testLinker.ts +++ b/test/testAnnotator.ts @@ -1,11 +1,11 @@ import test from 'ava' -import {link} from '../src/linker' +import {annotate} from '../src/annotator' import {Parent} from '../src/types/JSONSchema' import {input} from './e2e/basics' export function run() { - test("linker should link to each node's parent schema", t => { - const schema = link(input) as any + test("annotator should link to each node's parent schema", t => { + const schema = annotate(input, new WeakMap()) as any t.is(schema[Parent], null) t.is(schema.properties[Parent], schema) t.is(schema.properties.firstName[Parent], schema.properties) diff --git a/test/testNormalizer.ts b/test/testNormalizer.ts index 55fa6ca9..7866e84d 100644 --- a/test/testNormalizer.ts +++ b/test/testNormalizer.ts @@ -2,7 +2,7 @@ import test from 'ava' import {readdirSync} from 'fs' import {join} from 'path' import {JSONSchema, Options, DEFAULT_OPTIONS} from '../src' -import {link} from '../src/linker' +import {annotate} from '../src/annotator' import {normalize} from '../src/normalizer' interface JSONTestCase { @@ -21,7 +21,12 @@ export function run() { .map(_ => [_, require(_)] as [string, JSONTestCase]) .forEach(([filename, json]: [string, JSONTestCase]) => { test(json.name, t => { - const normalized = normalize(link(json.in), new WeakMap(), filename, json.options ?? DEFAULT_OPTIONS) + const normalized = normalize( + annotate(json.in, new WeakMap()), + new WeakMap(), + filename, + json.options ?? DEFAULT_OPTIONS, + ) t.deepEqual(json.out, normalized) }) }) diff --git a/test/testUtils.ts b/test/testUtils.ts index ee252bd1..67661783 100644 --- a/test/testUtils.ts +++ b/test/testUtils.ts @@ -1,5 +1,5 @@ import test from 'ava' -import {link} from '../src/linker' +import {annotate} from '../src/annotator' import {pathTransform, generateName, isSchemaLike} from '../src/utils' export function run() { @@ -22,20 +22,23 @@ export function run() { t.is(generateName('a', usedNames), 'A3') }) test('isSchemaLike', t => { - const schema = link({ - title: 'Example Schema', - type: 'object', - properties: { - firstName: { - type: 'string', - }, - lastName: { - id: 'lastName', - type: 'string', + const schema = annotate( + { + title: 'Example Schema', + type: 'object', + properties: { + firstName: { + type: 'string', + }, + lastName: { + id: 'lastName', + type: 'string', + }, }, + required: ['firstName', 'lastName'], }, - required: ['firstName', 'lastName'], - }) + new WeakMap(), + ) t.is(isSchemaLike(schema), true) t.is(isSchemaLike([]), false) t.is(isSchemaLike(schema.properties), false)