Skip to content
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

[1/x] generalize linker to annotator #609

Open
wants to merge 3 commits into
base: master
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
4 changes: 2 additions & 2 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)? |
Expand Down
57 changes: 57 additions & 0 deletions src/annotator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import {isPlainObject} from 'lodash'
import {AnnotatedJSONSchema, IsSchema, JSONSchema, Parent, isAnnotated} from './types/JSONSchema'
import {isSchemaLike} from './utils'

const annotators = new Set<(schema: JSONSchema, parent: JSONSchema | null) => void>()

annotators.add(function annotateParent(schema, parent) {
Object.defineProperty(schema, Parent, {
enumerable: false,
value: parent,
writable: false,
})
})

annotators.add(function annotateSchemas(schema) {
Object.defineProperty(schema, IsSchema, {
enumerable: false,
value: isSchemaLike(schema),
writable: false,
})
})

/**
* Traverses over the schema, assigning to each
* node metadata that will be used downstream.
*/
export function annotate(schema: JSONSchema): AnnotatedJSONSchema {
function go(s: JSONSchema, parent: JSONSchema | null): void {
if (!Array.isArray(s) && !isPlainObject(s)) {
return
}

// Handle cycles
if (isAnnotated(s)) {
return
}

// Run annotators
annotators.forEach(f => {
f(s, parent)
})

// Handle arrays
if (Array.isArray(s)) {
s.forEach(_ => go(_, s))
}

// Handle objects
for (const key in s) {
go(s[key], s)
}
}

go(schema, null)

return schema as AnnotatedJSONSchema
}
16 changes: 8 additions & 8 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -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) => string | undefined
/**
* Root directory for resolving [`$ref`](https://tools.ietf.org/id/draft-pbryan-zyp-json-ref-03.html)s.
*/
Expand Down Expand Up @@ -150,7 +150,7 @@ export async function compile(schema: JSONSchema4, name: string, options: Partia
// Initial clone to avoid mutating the input
const _schema = cloneDeep(schema)

const {dereferencedPaths, dereferencedSchema} = await dereference(_schema, _options)
const dereferencedSchema = await dereference(_schema, _options)
if (process.env.VERBOSE) {
if (isDeepStrictEqual(_schema, dereferencedSchema)) {
log('green', 'dereferencer', time(), '✅ No change')
Expand All @@ -159,12 +159,12 @@ export async function compile(schema: JSONSchema4, name: string, options: Partia
}
}

const linked = link(dereferencedSchema)
const annotated = annotate(dereferencedSchema)
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()
Expand All @@ -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, name, _options)
log('yellow', 'normalizer', time(), '✅ Result:', normalized)

const parsed = parse(normalized, _options)
Expand Down
37 changes: 0 additions & 37 deletions src/linker.ts

This file was deleted.

161 changes: 124 additions & 37 deletions src/normalizer.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,30 @@
import {JSONSchemaTypeName, LinkedJSONSchema, NormalizedJSONSchema, Parent} from './types/JSONSchema'
import {appendToDescription, escapeBlockComment, isSchemaLike, justName, toSafeString, traverse} from './utils'
import {
JSONSchemaTypeName,
AnnotatedJSONSchema,
NormalizedJSONSchema,
JSONSchema,
Parent,
Ref,
IsSchema,
} from './types/JSONSchema'
import {appendToDescription, escapeBlockComment, justName, toSafeString, traverse} from './utils'
import {Options} from './'
import {DereferencedPaths} from './resolver'
import {isDeepStrictEqual} from 'util'
import {typesOfSchema} from './typesOfSchema'

type Rule = (
schema: LinkedJSONSchema,
fileName: string,
options: Options,
key: string | null,
dereferencedPaths: DereferencedPaths,
) => void
type Rule = (schema: AnnotatedJSONSchema, fileName: string, options: Options, key: string | null) => void
const rules = new Map<string, Rule>()

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
}

Expand Down Expand Up @@ -62,9 +64,10 @@
})

rules.set('Transform id to $id', (schema, fileName) => {
if (!isSchemaLike(schema)) {
if (!schema[IsSchema]) {
return
}

if (schema.id && schema.$id && schema.id !== schema.$id) {
throw ReferenceError(
`Schema must define either id or $id, not both. Given id=${schema.id}, $id=${schema.$id} in ${fileName}`,
Expand All @@ -76,32 +79,28 @@
}
})

rules.set('Add an $id to anything that needs it', (schema, fileName, _options, _key, dereferencedPaths) => {
if (!isSchemaLike(schema)) {
rules.set('Add an $id to each top-level schema', (schema, fileName) => {
if (schema.$id || schema[Parent]) {
return
}

// Top-level schema
if (!schema.$id && !schema[Parent]) {
schema.$id = toSafeString(justName(fileName))
if (!schema[IsSchema]) {
return
}

// Sub-schemas with references
if (!isArrayType(schema) && !isObjectType(schema)) {
schema.$id = toSafeString(justName(fileName))
})

rules.set('Add an $id to each referenced schema', schema => {
if (schema.$id) {
return
}

// We'll infer from $id and title downstream
// TODO: Normalize upstream
const dereferencedName = dereferencedPaths.get(schema)
if (!schema.$id && !schema.title && dereferencedName) {
schema.$id = toSafeString(justName(dereferencedName))
if (!schema[Ref]) {
return
}

if (dereferencedName) {
dereferencedPaths.delete(schema)
}
schema.$id = toSafeString(justName(schema[Ref]))
})

rules.set('Escape closing JSDoc comment', schema => {
Expand Down Expand Up @@ -218,6 +217,35 @@
}
})

rules.set(
"Add an $id to each $def that doesn't have one, if unreachableDefinitions is enabled",
(schema, _, options) => {
if (!options.unreachableDefinitions) {
return
}

if (schema.$id) {
return
}

const parent = schema[Parent]
if (!parent) {
return
}

const grandparent = parent[Parent]
if (!grandparent) {
return
}

if (Object.keys(grandparent).find(_ => grandparent[_] === parent) !== '$defs') {
return
}

schema.$id = toSafeString(Object.keys(parent).find(_ => parent[_] === schema)!)
},
)

rules.set('Transform const to singleton enum', schema => {
if (schema.const !== undefined) {
schema.enum = [schema.const]
Expand All @@ -231,12 +259,71 @@
}
})

export function normalize(
rootSchema: LinkedJSONSchema,
dereferencedPaths: DereferencedPaths,
filename: string,
options: Options,
): NormalizedJSONSchema {
rules.forEach(rule => traverse(rootSchema, (schema, key) => rule(schema, filename, options, key, dereferencedPaths)))
rules.set('Add an $id to each named enum', schema => {
if (!schema[IsSchema]) {
return
}

if (schema.$id) {
return
}

if (!typesOfSchema(schema).includes('NAMED_ENUM')) {
return
}

const parent = schema[Parent]
const keyName = Object.keys(parent).find(_ => parent[_] === schema)

// Special case: generate nicer names for additionalProperties enums
if (parent[IsSchema] && keyName === 'additionalProperties') {
const grandparent = parent[Parent]
const parentKeyName = Object.keys(grandparent).find(_ => grandparent[_] === parent)!
schema.$id = toSafeString(parentKeyName) + toSafeString(keyName)
return
}

schema.$id = toSafeString(justName(keyName))
})

rules.set('Make implicit unions explicit', schema => {
if (!Array.isArray(schema.type)) {
return
}

// TODO: Optimize this case too
if (schema.oneOf) {
return
}

const oneOf = schema.type.map(t => {

Check failure on line 299 in src/normalizer.ts

View workflow job for this annotation

GitHub Actions / build (16.x, windows-latest)

'oneOf' is declared but its value is never read.

Check failure on line 299 in src/normalizer.ts

View workflow job for this annotation

GitHub Actions / build (20.x, windows-latest)

'oneOf' is declared but its value is never read.

Check failure on line 299 in src/normalizer.ts

View workflow job for this annotation

GitHub Actions / build (20.x, ubuntu-latest)

'oneOf' is declared but its value is never read.

Check failure on line 299 in src/normalizer.ts

View workflow job for this annotation

GitHub Actions / build (16.x, ubuntu-latest)

'oneOf' is declared but its value is never read.

Check failure on line 299 in src/normalizer.ts

View workflow job for this annotation

GitHub Actions / build (18.x, ubuntu-latest)

'oneOf' is declared but its value is never read.

Check failure on line 299 in src/normalizer.ts

View workflow job for this annotation

GitHub Actions / build (20.x, macOS-latest)

'oneOf' is declared but its value is never read.

Check failure on line 299 in src/normalizer.ts

View workflow job for this annotation

GitHub Actions / build (21.x, windows-latest)

'oneOf' is declared but its value is never read.

Check failure on line 299 in src/normalizer.ts

View workflow job for this annotation

GitHub Actions / build (20.x, windows-latest)

'oneOf' is declared but its value is never read.

Check failure on line 299 in src/normalizer.ts

View workflow job for this annotation

GitHub Actions / build (17.x, windows-latest)

'oneOf' is declared but its value is never read.

Check failure on line 299 in src/normalizer.ts

View workflow job for this annotation

GitHub Actions / build (16.x, macOS-latest)

'oneOf' is declared but its value is never read.

Check failure on line 299 in src/normalizer.ts

View workflow job for this annotation

GitHub Actions / build (17.x, ubuntu-latest)

'oneOf' is declared but its value is never read.

Check failure on line 299 in src/normalizer.ts

View workflow job for this annotation

GitHub Actions / build (16.x, macOS-latest)

'oneOf' is declared but its value is never read.

Check failure on line 299 in src/normalizer.ts

View workflow job for this annotation

GitHub Actions / build (21.x, macOS-latest)

'oneOf' is declared but its value is never read.

Check failure on line 299 in src/normalizer.ts

View workflow job for this annotation

GitHub Actions / build (20.x, macOS-latest)

'oneOf' is declared but its value is never read.

Check failure on line 299 in src/normalizer.ts

View workflow job for this annotation

GitHub Actions / build (21.x, macOS-latest)

'oneOf' is declared but its value is never read.

Check failure on line 299 in src/normalizer.ts

View workflow job for this annotation

GitHub Actions / build (20.x, ubuntu-latest)

'oneOf' is declared but its value is never read.

Check failure on line 299 in src/normalizer.ts

View workflow job for this annotation

GitHub Actions / build (17.x, ubuntu-latest)

'oneOf' is declared but its value is never read.

Check failure on line 299 in src/normalizer.ts

View workflow job for this annotation

GitHub Actions / build (16.x, ubuntu-latest)

'oneOf' is declared but its value is never read.

Check failure on line 299 in src/normalizer.ts

View workflow job for this annotation

GitHub Actions / build (18.x, ubuntu-latest)

'oneOf' is declared but its value is never read.

Check failure on line 299 in src/normalizer.ts

View workflow job for this annotation

GitHub Actions / build (18.x, macOS-latest)

'oneOf' is declared but its value is never read.

Check failure on line 299 in src/normalizer.ts

View workflow job for this annotation

GitHub Actions / build (18.x, windows-latest)

'oneOf' is declared but its value is never read.

Check failure on line 299 in src/normalizer.ts

View workflow job for this annotation

GitHub Actions / build (19.x, macOS-latest)

'oneOf' is declared but its value is never read.

Check failure on line 299 in src/normalizer.ts

View workflow job for this annotation

GitHub Actions / build (17.x, macOS-latest)

'oneOf' is declared but its value is never read.

Check failure on line 299 in src/normalizer.ts

View workflow job for this annotation

GitHub Actions / build (21.x, windows-latest)

'oneOf' is declared but its value is never read.

Check failure on line 299 in src/normalizer.ts

View workflow job for this annotation

GitHub Actions / build (18.x, windows-latest)

'oneOf' is declared but its value is never read.

Check failure on line 299 in src/normalizer.ts

View workflow job for this annotation

GitHub Actions / build (17.x, windows-latest)

'oneOf' is declared but its value is never read.

Check failure on line 299 in src/normalizer.ts

View workflow job for this annotation

GitHub Actions / build (18.x, macOS-latest)

'oneOf' is declared but its value is never read.

Check failure on line 299 in src/normalizer.ts

View workflow job for this annotation

GitHub Actions / build (19.x, ubuntu-latest)

'oneOf' is declared but its value is never read.

Check failure on line 299 in src/normalizer.ts

View workflow job for this annotation

GitHub Actions / build (19.x, windows-latest)

'oneOf' is declared but its value is never read.

Check failure on line 299 in src/normalizer.ts

View workflow job for this annotation

GitHub Actions / build (16.x, windows-latest)

'oneOf' is declared but its value is never read.

Check failure on line 299 in src/normalizer.ts

View workflow job for this annotation

GitHub Actions / build (19.x, windows-latest)

'oneOf' is declared but its value is never read.

Check failure on line 299 in src/normalizer.ts

View workflow job for this annotation

GitHub Actions / build (17.x, macOS-latest)

'oneOf' is declared but its value is never read.

Check failure on line 299 in src/normalizer.ts

View workflow job for this annotation

GitHub Actions / build (19.x, ubuntu-latest)

'oneOf' is declared but its value is never read.

Check failure on line 299 in src/normalizer.ts

View workflow job for this annotation

GitHub Actions / build (19.x, macOS-latest)

'oneOf' is declared but its value is never read.

Check failure on line 299 in src/normalizer.ts

View workflow job for this annotation

GitHub Actions / build (21.x, ubuntu-latest)

'oneOf' is declared but its value is never read.

Check failure on line 299 in src/normalizer.ts

View workflow job for this annotation

GitHub Actions / build (21.x, ubuntu-latest)

'oneOf' is declared but its value is never read.
switch (t) {
case 'object': {
const s: AnnotatedJSONSchema = {
[IsSchema]: true,
[Parent]: schema,
[Ref]: schema[Ref],
}
move(schema, s, 'patternProperties')
move(schema, s, 'properties')
move(schema, s, 'required')
if (schema[Ref]) {
delete schema[Ref]
}
return s
}
}
})
})

function move<A extends object>(from: A, to: A, key: keyof A): void {
if (key in from) {
to[key] = from[key]
delete from[key]
}
}

export function normalize(rootSchema: AnnotatedJSONSchema, filename: string, options: Options): NormalizedJSONSchema {
rules.forEach(rule => traverse(rootSchema, (schema, key) => rule(schema, filename, options, key)))
return rootSchema as NormalizedJSONSchema
}
Loading
Loading