Skip to content

Commit

Permalink
[Feat] Add linker
Browse files Browse the repository at this point in the history
  • Loading branch information
Boris Cherny committed Nov 26, 2020
1 parent 4346380 commit c48ccb0
Show file tree
Hide file tree
Showing 11 changed files with 190 additions and 73 deletions.
20 changes: 12 additions & 8 deletions ARCHITECTURE.md
Expand Up @@ -4,26 +4,30 @@ json-schema-to-typescript compiles files from JSONSchema to TypeScript in distin

TODO use an external validation library

#### 2. Normalizer
#### 2. Dereferencer

Normalizes input schemas so the parser can make more assumptions about schemas' properties and values.
Resolves referenced schemas (in the file, on the local filesystem, or over the network).

#### 3. Resolver
#### 3. Linker

Resolves referenced schemas (in the file, on the local filesystem, or over the network).
Adds links back from each node in a schema to its parent (available via the `Parent` symbol on each node), for convenience.

#### 4. Normalizer

Normalizes input schemas so the parser can make more assumptions about schemas' properties and values.

#### 4. Parser
#### 5. Parser

Parses JSONSchema to an intermediate representation for easy code generation.

#### 5. Optimizer
#### 6. Optimizer

Optimizes the IR to produce concise and readable TypeScript in step (6).

#### 6. Generator
#### 7. Generator

Converts the intermediate respresentation to TypeScript code.

#### 7. Formatter
#### 8. Formatter

Formats the code so it is properly indented, etc.
10 changes: 8 additions & 2 deletions src/index.ts
Expand Up @@ -13,6 +13,7 @@ import {dereference} from './resolver'
import {error, stripExtension, Try, log} from './utils'
import {validate} from './validator'
import {isDeepStrictEqual} from 'util'
import {link} from './linker'

export {EnumJSONSchema, JSONSchema, NamedEnumJSONSchema, CustomTypeJSONSchema} from './types/JSONSchema'

Expand Down Expand Up @@ -139,9 +140,14 @@ export async function compile(schema: JSONSchema4, name: string, options: Partia
}
}

const normalized = normalize(dereferenced, name, _options)
const linked = link(dereferenced)
if (process.env.VERBOSE) {
if (isDeepStrictEqual(dereferenced, normalized)) {
log('green', 'linker', time(), '✅ No change')
}

const normalized = normalize(linked, name, _options)
if (process.env.VERBOSE) {
if (isDeepStrictEqual(linked, normalized)) {
log('yellow', 'normalizer', time(), '✅ No change')
} else {
log('yellow', 'normalizer', time(), '✅ Result:', normalized)
Expand Down
42 changes: 42 additions & 0 deletions src/linker.ts
@@ -0,0 +1,42 @@
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,
processed = new Set<JSONSchema4Type | JSONSchema>()
): LinkedJSONSchema {
if (!Array.isArray(schema) && !isPlainObject(schema)) {
return schema as LinkedJSONSchema
}

// Handle cycles
if (processed.has(schema)) {
return schema as LinkedJSONSchema
}
processed.add(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 => link(child, schema, processed))
}

// Objects
for (const key in schema as JSONSchema) {
link((schema as JSONSchema)[key], schema, processed)
}

return schema as LinkedJSONSchema
}
23 changes: 12 additions & 11 deletions src/normalizer.ts
@@ -1,17 +1,17 @@
import {JSONSchema, JSONSchemaTypeName, NormalizedJSONSchema} from './types/JSONSchema'
import {JSONSchemaTypeName, LinkedJSONSchema, NormalizedJSONSchema, Parent} from './types/JSONSchema'
import {escapeBlockComment, justName, log, toSafeString, traverse} from './utils'
import {Options} from './'

type Rule = (schema: JSONSchema, rootSchema: JSONSchema, fileName: string, options: Options, isRoot: boolean) => void
type Rule = (schema: LinkedJSONSchema, fileName: string, options: Options) => void
const rules = new Map<string, Rule>()

function hasType(schema: JSONSchema, type: JSONSchemaTypeName) {
function hasType(schema: LinkedJSONSchema, type: JSONSchemaTypeName) {
return schema.type === type || (Array.isArray(schema.type) && schema.type.includes(type))
}
function isObjectType(schema: JSONSchema) {
function isObjectType(schema: LinkedJSONSchema) {
return schema.properties !== undefined || hasType(schema, 'object') || hasType(schema, 'any')
}
function isArrayType(schema: JSONSchema) {
function isArrayType(schema: LinkedJSONSchema) {
return schema.items !== undefined || hasType(schema, 'array') || hasType(schema, 'any')
}

Expand Down Expand Up @@ -51,7 +51,8 @@ rules.set('Default additionalProperties to true', schema => {
}
})

rules.set('Default top level `id`', (schema, _rootSchema, fileName, _options, isRoot) => {
rules.set('Default top level `id`', (schema, fileName) => {
const isRoot = schema[Parent] === null
if (isRoot && !schema.id) {
schema.id = toSafeString(justName(fileName))
}
Expand All @@ -61,7 +62,7 @@ rules.set('Escape closing JSDoc Comment', schema => {
escapeBlockComment(schema)
})

rules.set('Optionally remove maxItems and minItems', (schema, _rootSchema, _fileName, options) => {
rules.set('Optionally remove maxItems and minItems', (schema, _fileName, options) => {
if (options.ignoreMinAndMaxItems) {
if ('maxItems' in schema) {
delete schema.maxItems
Expand All @@ -72,7 +73,7 @@ rules.set('Optionally remove maxItems and minItems', (schema, _rootSchema, _file
}
})

rules.set('Normalise schema.minItems', (schema, _rootSchema, _fileName, options) => {
rules.set('Normalise schema.minItems', (schema, _fileName, options) => {
if (options.ignoreMinAndMaxItems) {
return
}
Expand All @@ -84,7 +85,7 @@ rules.set('Normalise schema.minItems', (schema, _rootSchema, _fileName, options)
// cannot normalise maxItems because maxItems = 0 has an actual meaning
})

rules.set('Normalize schema.items', (schema, _rootSchema, _fileName, options) => {
rules.set('Normalize schema.items', (schema, _fileName, options) => {
if (options.ignoreMinAndMaxItems) {
return
}
Expand Down Expand Up @@ -112,9 +113,9 @@ rules.set('Normalize schema.items', (schema, _rootSchema, _fileName, options) =>
return schema
})

export function normalize(rootSchema: JSONSchema, filename: string, options: Options): NormalizedJSONSchema {
export function normalize(rootSchema: LinkedJSONSchema, filename: string, options: Options): NormalizedJSONSchema {
rules.forEach((rule, key) => {
traverse(rootSchema, (schema, isRoot) => rule(schema, rootSchema, filename, options, isRoot), true)
traverse(rootSchema, schema => rule(schema, filename, options))
log('yellow', 'normalizer', `Applied rule: "${key}"`)
})
return rootSchema as NormalizedJSONSchema
Expand Down
32 changes: 18 additions & 14 deletions src/parser.ts
Expand Up @@ -15,20 +15,20 @@ import {
T_UNKNOWN,
T_UNKNOWN_ADDITIONAL_PROPERTIES
} from './types/AST'
import {JSONSchema, JSONSchemaWithDefinitions, SchemaSchema} from './types/JSONSchema'
import {JSONSchema as LinkedJSONSchema, JSONSchemaWithDefinitions, SchemaSchema} from './types/JSONSchema'
import {generateName, log} from './utils'

export type Processed = Map<JSONSchema | JSONSchema4Type, AST>
export type Processed = Map<LinkedJSONSchema | JSONSchema4Type, AST>

export type UsedNames = Set<string>

export function parse(
schema: JSONSchema | JSONSchema4Type,
schema: LinkedJSONSchema | JSONSchema4Type,
options: Options,
rootSchema = schema as JSONSchema,
rootSchema = schema as LinkedJSONSchema,
keyName?: string,
isSchema = true,
processed: Processed = new Map<JSONSchema | JSONSchema4Type, AST>(),
processed: Processed = new Map<LinkedJSONSchema | JSONSchema4Type, AST>(),
usedNames = new Set<string>()
): AST {
// If we've seen this node before, return it.
Expand Down Expand Up @@ -75,9 +75,9 @@ function parseLiteral(
}

function parseNonLiteral(
schema: JSONSchema,
schema: LinkedJSONSchema,
options: Options,
rootSchema: JSONSchema,
rootSchema: LinkedJSONSchema,
keyName: string | undefined,
keyNameFromDefinition: string | undefined,
set: (ast: AST) => AST,
Expand Down Expand Up @@ -270,7 +270,7 @@ function parseNonLiteral(
* Compute a schema name using a series of fallbacks
*/
function standaloneName(
schema: JSONSchema,
schema: LinkedJSONSchema,
keyNameFromDefinition: string | undefined,
usedNames: UsedNames
): string | undefined {
Expand All @@ -283,7 +283,7 @@ function standaloneName(
function newInterface(
schema: SchemaSchema,
options: Options,
rootSchema: JSONSchema,
rootSchema: LinkedJSONSchema,
processed: Processed,
usedNames: UsedNames,
keyName?: string,
Expand Down Expand Up @@ -321,7 +321,7 @@ function parseSuperTypes(
function newNamedInterface(
schema: SchemaSchema,
options: Options,
rootSchema: JSONSchema,
rootSchema: LinkedJSONSchema,
processed: Processed,
usedNames: UsedNames
): TNamedInterface {
Expand All @@ -339,7 +339,7 @@ function newNamedInterface(
function parseSchema(
schema: SchemaSchema,
options: Options,
rootSchema: JSONSchema,
rootSchema: LinkedJSONSchema,
processed: Processed,
usedNames: UsedNames,
parentSchemaName: string
Expand Down Expand Up @@ -425,12 +425,16 @@ via the \`definition\` "${key}".`
}
}

type Definitions = {[k: string]: JSONSchema}
type Definitions = {[k: string]: LinkedJSONSchema}

/**
* TODO: Memoize
*/
function getDefinitions(schema: JSONSchema, isSchema = true, processed = new Set<JSONSchema>()): Definitions {
function getDefinitions(
schema: LinkedJSONSchema,
isSchema = true,
processed = new Set<LinkedJSONSchema>()
): Definitions {
if (processed.has(schema)) {
return {}
}
Expand Down Expand Up @@ -462,6 +466,6 @@ function getDefinitions(schema: JSONSchema, isSchema = true, processed = new Set
/**
* TODO: Reduce rate of false positives
*/
function hasDefinitions(schema: JSONSchema): schema is JSONSchemaWithDefinitions {
function hasDefinitions(schema: LinkedJSONSchema): schema is JSONSchemaWithDefinitions {
return 'definitions' in schema
}
4 changes: 2 additions & 2 deletions src/typeOfSchema.ts
@@ -1,10 +1,10 @@
import {isPlainObject} from 'lodash'
import {JSONSchema, SchemaType} from './types/JSONSchema'
import {JSONSchema as LinkedJSONSchema, SchemaType} from './types/JSONSchema'

/**
* Duck types a JSONSchema schema or property to determine which kind of AST node to parse it into.
*/
export function typeOfSchema(schema: JSONSchema): SchemaType {
export function typeOfSchema(schema: LinkedJSONSchema): SchemaType {
if (schema.tsType) return 'CUSTOM_TYPE'
if (schema.allOf) return 'ALL_OF'
if (schema.anyOf) return 'ANY_OF'
Expand Down
63 changes: 49 additions & 14 deletions src/types/JSONSchema.ts
@@ -1,9 +1,24 @@
import { JSONSchema4, JSONSchema4TypeName } from 'json-schema'
import {JSONSchema4, JSONSchema4TypeName} from 'json-schema'

export type SchemaType = 'ALL_OF' | 'UNNAMED_SCHEMA' | 'ANY' | 'ANY_OF'
| 'BOOLEAN' | 'NAMED_ENUM' | 'NAMED_SCHEMA' | 'NULL' | 'NUMBER' | 'STRING'
| 'OBJECT' | 'ONE_OF' | 'TYPED_ARRAY' | 'REFERENCE' | 'UNION' | 'UNNAMED_ENUM'
| 'UNTYPED_ARRAY' | 'CUSTOM_TYPE'
export type SchemaType =
| 'ALL_OF'
| 'UNNAMED_SCHEMA'
| 'ANY'
| 'ANY_OF'
| 'BOOLEAN'
| 'NAMED_ENUM'
| 'NAMED_SCHEMA'
| 'NULL'
| 'NUMBER'
| 'STRING'
| 'OBJECT'
| 'ONE_OF'
| 'TYPED_ARRAY'
| 'REFERENCE'
| 'UNION'
| 'UNNAMED_ENUM'
| 'UNTYPED_ARRAY'
| 'CUSTOM_TYPE'

export type JSONSchemaTypeName = JSONSchema4TypeName

Expand All @@ -18,17 +33,37 @@ export interface JSONSchema extends JSONSchema4 {
tsType?: string
}

// const SCHEMA_PROPERTIES = [
// 'additionalItems', 'additionalProperties', 'items', 'definitions',
// 'properties', 'patternProperties', 'dependencies', 'allOf', 'anyOf',
// 'oneOf', 'not', 'required', '$schema', 'title', 'description',
// ]
export const Parent = Symbol('Parent')

// export function isSchema(a: any): a is SchemaSchema {
// return []
// }
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

additionalItems?: boolean | LinkedJSONSchema
additionalProperties: boolean | LinkedJSONSchema
items?: LinkedJSONSchema | LinkedJSONSchema[]
definitions?: {
[k: string]: LinkedJSONSchema
}
properties?: {
[k: string]: LinkedJSONSchema
}
patternProperties?: {
[k: string]: LinkedJSONSchema
}
dependencies?: {
[k: string]: LinkedJSONSchema | string[]
}
allOf?: LinkedJSONSchema[]
anyOf?: LinkedJSONSchema[]
oneOf?: LinkedJSONSchema[]
not?: LinkedJSONSchema
}

export interface NormalizedJSONSchema extends JSONSchema {
export interface NormalizedJSONSchema extends LinkedJSONSchema {
additionalItems?: boolean | NormalizedJSONSchema
additionalProperties: boolean | NormalizedJSONSchema
items?: NormalizedJSONSchema | NormalizedJSONSchema[]
Expand Down

0 comments on commit c48ccb0

Please sign in to comment.