Skip to content

Commit

Permalink
fix: generate same title one types
Browse files Browse the repository at this point in the history
If two schemas are (a) identical and (b) have the same explicit title, then emit just one type to
represent the schema

fix bcherny#510
  • Loading branch information
fuying.yfy committed Feb 24, 2023
1 parent 6e3fbca commit fe0951a
Show file tree
Hide file tree
Showing 6 changed files with 99 additions and 44 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ See [server demo](example) and [browser demo](https://github.com/bcherny/json-sc
| unreachableDefinitions | boolean | `false` | Generates code for `$defs` that aren't referenced by the schema. |
| strictIndexSignatures | boolean | `false` | Append all index signatures with `\| undefined` so that they are strictly typed. |
| $refOptions | object | `{}` | [$RefParser](https://github.com/BigstickCarpet/json-schema-ref-parser) Options, used when resolving `$ref`s |
| sameExplicitTitle | boolean | `false` | Generate same title one types. If two schemas are (a) identical and (b) have the same explicit title, then emit just one type to represent the schema |
## CLI

A CLI utility is provided with this package.
Expand Down
71 changes: 55 additions & 16 deletions src/generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,54 +29,79 @@ export function generate(ast: AST, options = DEFAULT_OPTIONS): string {
) // trailing newline
}

function declareEnums(ast: AST, options: Options, processed = new Set<AST>()): string {
function declareEnums(ast: AST, options: Options, processed = new Set<AST>(), usedNames = new Set<string>()): string {
if (processed.has(ast)) {
return ''
}

if (options.sameExplicitTitle && typeof ast.standaloneName !== 'undefined') {
if (usedNames.has(ast.standaloneName)) {
return ''
}

usedNames.add(ast.standaloneName)
}

processed.add(ast)
let type = ''

switch (ast.type) {
case 'ENUM':
return generateStandaloneEnum(ast, options) + '\n'
case 'ARRAY':
return declareEnums(ast.params, options, processed)
return declareEnums(ast.params, options, processed, usedNames)
case 'UNION':
case 'INTERSECTION':
return ast.params.reduce((prev, ast) => prev + declareEnums(ast, options, processed), '')
return ast.params.reduce((prev, ast) => prev + declareEnums(ast, options, processed, usedNames), '')
case 'TUPLE':
type = ast.params.reduce((prev, ast) => prev + declareEnums(ast, options, processed), '')
type = ast.params.reduce((prev, ast) => prev + declareEnums(ast, options, processed, usedNames), '')
if (ast.spreadParam) {
type += declareEnums(ast.spreadParam, options, processed)
type += declareEnums(ast.spreadParam, options, processed, usedNames)
}
return type
case 'INTERFACE':
return getSuperTypesAndParams(ast).reduce((prev, ast) => prev + declareEnums(ast, options, processed), '')
return getSuperTypesAndParams(ast).reduce(
(prev, ast) => prev + declareEnums(ast, options, processed, usedNames),
''
)
default:
return ''
}
}

function declareNamedInterfaces(ast: AST, options: Options, rootASTName: string, processed = new Set<AST>()): string {
function declareNamedInterfaces(
ast: AST,
options: Options,
rootASTName: string,
processed = new Set<AST>(),
usedNames = new Set<string>()
): string {
if (processed.has(ast)) {
return ''
}

if (options.sameExplicitTitle && typeof ast.standaloneName !== 'undefined') {
if (usedNames.has(ast.standaloneName)) {
return ''
}

usedNames.add(ast.standaloneName)
}

processed.add(ast)
let type = ''

switch (ast.type) {
case 'ARRAY':
type = declareNamedInterfaces((ast as TArray).params, options, rootASTName, processed)
type = declareNamedInterfaces((ast as TArray).params, options, rootASTName, processed, usedNames)
break
case 'INTERFACE':
type = [
hasStandaloneName(ast) &&
(ast.standaloneName === rootASTName || options.declareExternallyReferenced) &&
generateStandaloneInterface(ast, options),
getSuperTypesAndParams(ast)
.map(ast => declareNamedInterfaces(ast, options, rootASTName, processed))
.map(ast => declareNamedInterfaces(ast, options, rootASTName, processed, usedNames))
.filter(Boolean)
.join('\n')
]
Expand All @@ -87,11 +112,11 @@ function declareNamedInterfaces(ast: AST, options: Options, rootASTName: string,
case 'TUPLE':
case 'UNION':
type = ast.params
.map(_ => declareNamedInterfaces(_, options, rootASTName, processed))
.map(_ => declareNamedInterfaces(_, options, rootASTName, processed, usedNames))
.filter(Boolean)
.join('\n')
if (ast.type === 'TUPLE' && ast.spreadParam) {
type += declareNamedInterfaces(ast.spreadParam, options, rootASTName, processed)
type += declareNamedInterfaces(ast.spreadParam, options, rootASTName, processed, usedNames)
}
break
default:
Expand All @@ -101,17 +126,31 @@ function declareNamedInterfaces(ast: AST, options: Options, rootASTName: string,
return type
}

function declareNamedTypes(ast: AST, options: Options, rootASTName: string, processed = new Set<AST>()): string {
function declareNamedTypes(
ast: AST,
options: Options,
rootASTName: string,
processed = new Set<AST>(),
usedNames = new Set<string>()
): string {
if (processed.has(ast)) {
return ''
}

if (options.sameExplicitTitle && typeof ast.standaloneName !== 'undefined') {
if (usedNames.has(ast.standaloneName)) {
return ''
}

usedNames.add(ast.standaloneName)
}

processed.add(ast)

switch (ast.type) {
case 'ARRAY':
return [
declareNamedTypes(ast.params, options, rootASTName, processed),
declareNamedTypes(ast.params, options, rootASTName, processed, usedNames),
hasStandaloneName(ast) ? generateStandaloneType(ast, options) : undefined
]
.filter(Boolean)
Expand All @@ -123,7 +162,7 @@ function declareNamedTypes(ast: AST, options: Options, rootASTName: string, proc
.map(
ast =>
(ast.standaloneName === rootASTName || options.declareExternallyReferenced) &&
declareNamedTypes(ast, options, rootASTName, processed)
declareNamedTypes(ast, options, rootASTName, processed, usedNames)
)
.filter(Boolean)
.join('\n')
Expand All @@ -133,11 +172,11 @@ function declareNamedTypes(ast: AST, options: Options, rootASTName: string, proc
return [
hasStandaloneName(ast) ? generateStandaloneType(ast, options) : undefined,
ast.params
.map(ast => declareNamedTypes(ast, options, rootASTName, processed))
.map(ast => declareNamedTypes(ast, options, rootASTName, processed, usedNames))
.filter(Boolean)
.join('\n'),
'spreadParam' in ast && ast.spreadParam
? declareNamedTypes(ast.spreadParam, options, rootASTName, processed)
? declareNamedTypes(ast.spreadParam, options, rootASTName, processed, usedNames)
: undefined
]
.filter(Boolean)
Expand Down
17 changes: 12 additions & 5 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {readFileSync} from 'fs'
import {JSONSchema4} from 'json-schema'
import {Options as $RefOptions} from '@bcherny/json-schema-ref-parser'
import {Options as $RefOptions} from '@apidevtools/json-schema-ref-parser'
import {cloneDeep, endsWith, merge} from 'lodash'
import {dirname} from 'path'
import {Options as PrettierOptions} from 'prettier'
Expand Down Expand Up @@ -76,6 +76,12 @@ export interface Options {
* Generate unknown type instead of any
*/
unknownAny: boolean
/**
* Generate same title one types
*
* If two schemas are (a) identical and (b) have the same explicit title, then emit just one type to represent the schema
*/
sameExplicitTitle: boolean
}

export const DEFAULT_OPTIONS: Options = {
Expand Down Expand Up @@ -104,10 +110,11 @@ export const DEFAULT_OPTIONS: Options = {
useTabs: false
},
unreachableDefinitions: false,
unknownAny: true
unknownAny: true,
sameExplicitTitle: false
}

export function compileFromFile (filename: string, options: Partial<Options> = DEFAULT_OPTIONS): Promise<string> {
export function compileFromFile(filename: string, options: Partial<Options> = DEFAULT_OPTIONS): Promise<string> {
const contents = Try(
() => readFileSync(filename),
() => {
Expand All @@ -123,13 +130,13 @@ export function compileFromFile (filename: string, options: Partial<Options> = D
return compile(schema, stripExtension(filename), {cwd: dirname(filename), ...options})
}

export async function compile (schema: JSONSchema4, name: string, options: Partial<Options> = {}): Promise<string> {
export async function compile(schema: JSONSchema4, name: string, options: Partial<Options> = {}): Promise<string> {
validateOptions(options)

const _options = merge({}, DEFAULT_OPTIONS, options)

const start = Date.now()
function time () {
function time() {
return `(${Date.now() - start}ms)`
}

Expand Down
45 changes: 23 additions & 22 deletions src/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ function parseNonLiteral(
return {
comment: schema.description,
keyName,
standaloneName: standaloneName(schema, keyNameFromDefinition, usedNames),
standaloneName: standaloneName(schema, keyNameFromDefinition, usedNames, options.sameExplicitTitle),
params: schema.allOf!.map(_ => parse(_, options, undefined, processed, usedNames)),
type: 'INTERSECTION'
}
Expand All @@ -136,38 +136,38 @@ function parseNonLiteral(
...(options.unknownAny ? T_UNKNOWN : T_ANY),
comment: schema.description,
keyName,
standaloneName: standaloneName(schema, keyNameFromDefinition, usedNames)
standaloneName: standaloneName(schema, keyNameFromDefinition, usedNames, options.sameExplicitTitle)
}
case 'ANY_OF':
return {
comment: schema.description,
keyName,
standaloneName: standaloneName(schema, keyNameFromDefinition, usedNames),
standaloneName: standaloneName(schema, keyNameFromDefinition, usedNames, options.sameExplicitTitle),
params: schema.anyOf!.map(_ => parse(_, options, undefined, processed, usedNames)),
type: 'UNION'
}
case 'BOOLEAN':
return {
comment: schema.description,
keyName,
standaloneName: standaloneName(schema, keyNameFromDefinition, usedNames),
standaloneName: standaloneName(schema, keyNameFromDefinition, usedNames, options.sameExplicitTitle),
type: 'BOOLEAN'
}
case 'CUSTOM_TYPE':
return {
comment: schema.description,
keyName,
params: schema.tsType!,
standaloneName: standaloneName(schema, keyNameFromDefinition, usedNames),
standaloneName: standaloneName(schema, keyNameFromDefinition, usedNames, options.sameExplicitTitle),
type: 'CUSTOM_TYPE'
}
case 'NAMED_ENUM':
return {
comment: schema.description,
keyName,
standaloneName: standaloneName(schema, keyNameFromDefinition ?? keyName, usedNames)!,
standaloneName: standaloneName(schema, keyNameFromDefinition ?? keyName, usedNames, options.sameExplicitTitle)!,
params: schema.enum!.map((_, n) => ({
ast: parseLiteral(_, undefined),
ast: parse(_, options, undefined, processed, usedNames),
keyName: schema.tsEnumNames![n]
})),
type: 'ENUM'
Expand All @@ -178,28 +178,28 @@ function parseNonLiteral(
return {
comment: schema.description,
keyName,
standaloneName: standaloneName(schema, keyNameFromDefinition, usedNames),
standaloneName: standaloneName(schema, keyNameFromDefinition, usedNames, options.sameExplicitTitle),
type: 'NULL'
}
case 'NUMBER':
return {
comment: schema.description,
keyName,
standaloneName: standaloneName(schema, keyNameFromDefinition, usedNames),
standaloneName: standaloneName(schema, keyNameFromDefinition, usedNames, options.sameExplicitTitle),
type: 'NUMBER'
}
case 'OBJECT':
return {
comment: schema.description,
keyName,
standaloneName: standaloneName(schema, keyNameFromDefinition, usedNames),
standaloneName: standaloneName(schema, keyNameFromDefinition, usedNames, options.sameExplicitTitle),
type: 'OBJECT'
}
case 'ONE_OF':
return {
comment: schema.description,
keyName,
standaloneName: standaloneName(schema, keyNameFromDefinition, usedNames),
standaloneName: standaloneName(schema, keyNameFromDefinition, usedNames, options.sameExplicitTitle),
params: schema.oneOf!.map(_ => parse(_, options, undefined, processed, usedNames)),
type: 'UNION'
}
Expand All @@ -209,7 +209,7 @@ function parseNonLiteral(
return {
comment: schema.description,
keyName,
standaloneName: standaloneName(schema, keyNameFromDefinition, usedNames),
standaloneName: standaloneName(schema, keyNameFromDefinition, usedNames, options.sameExplicitTitle),
type: 'STRING'
}
case 'TYPED_ARRAY':
Expand All @@ -222,7 +222,7 @@ function parseNonLiteral(
keyName,
maxItems,
minItems,
standaloneName: standaloneName(schema, keyNameFromDefinition, usedNames),
standaloneName: standaloneName(schema, keyNameFromDefinition, usedNames, options.sameExplicitTitle),
params: schema.items.map(_ => parse(_, options, undefined, processed, usedNames)),
type: 'TUPLE'
}
Expand All @@ -236,7 +236,7 @@ function parseNonLiteral(
return {
comment: schema.description,
keyName,
standaloneName: standaloneName(schema, keyNameFromDefinition, usedNames),
standaloneName: standaloneName(schema, keyNameFromDefinition, usedNames, options.sameExplicitTitle),
params: parse(schema.items!, options, undefined, processed, usedNames),
type: 'ARRAY'
}
Expand All @@ -245,7 +245,7 @@ function parseNonLiteral(
return {
comment: schema.description,
keyName,
standaloneName: standaloneName(schema, keyNameFromDefinition, usedNames),
standaloneName: standaloneName(schema, keyNameFromDefinition, usedNames, options.sameExplicitTitle),
params: (schema.type as JSONSchema4TypeName[]).map(type => {
const member: LinkedJSONSchema = {...omit(schema, '$id', 'description', 'title'), type}
return parse(maybeStripDefault(member as any), options, undefined, processed, usedNames)
Expand All @@ -256,8 +256,8 @@ function parseNonLiteral(
return {
comment: schema.description,
keyName,
standaloneName: standaloneName(schema, keyNameFromDefinition, usedNames),
params: schema.enum!.map(_ => parseLiteral(_, undefined)),
standaloneName: standaloneName(schema, keyNameFromDefinition, usedNames, options.sameExplicitTitle),
params: schema.enum!.map(_ => parse(_, options, undefined, processed, usedNames)),
type: 'UNION'
}
case 'UNNAMED_SCHEMA':
Expand All @@ -277,7 +277,7 @@ function parseNonLiteral(
params: Array(Math.max(maxItems, minItems) || 0).fill(params),
// if there is no maximum, then add a spread item to collect the rest
spreadParam: maxItems >= 0 ? undefined : params,
standaloneName: standaloneName(schema, keyNameFromDefinition, usedNames),
standaloneName: standaloneName(schema, keyNameFromDefinition, usedNames, options.sameExplicitTitle),
type: 'TUPLE'
}
}
Expand All @@ -286,7 +286,7 @@ function parseNonLiteral(
comment: schema.description,
keyName,
params,
standaloneName: standaloneName(schema, keyNameFromDefinition, usedNames),
standaloneName: standaloneName(schema, keyNameFromDefinition, usedNames, options.sameExplicitTitle),
type: 'ARRAY'
}
}
Expand All @@ -298,11 +298,12 @@ function parseNonLiteral(
function standaloneName(
schema: LinkedJSONSchema,
keyNameFromDefinition: string | undefined,
usedNames: UsedNames
usedNames: UsedNames,
sameExplicitTitle: boolean
): string | undefined {
const name = schema.title || schema.$id || keyNameFromDefinition
if (name) {
return generateName(name, usedNames)
return generateName(name, usedNames, sameExplicitTitle)
}
}

Expand All @@ -314,7 +315,7 @@ function newInterface(
keyName?: string,
keyNameFromDefinition?: string
): TInterface {
const name = standaloneName(schema, keyNameFromDefinition, usedNames)!
const name = standaloneName(schema, keyNameFromDefinition, usedNames, options.sameExplicitTitle)!
return {
comment: schema.description,
keyName,
Expand Down
Loading

0 comments on commit fe0951a

Please sign in to comment.