diff --git a/src/cli.ts b/src/cli.ts index a2ed0f9e..f8cb91f7 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,189 +1,189 @@ -#!/usr/bin/env node - -import minimist = require('minimist') -import getStdin from 'get-stdin' -import {readFile, writeFile, existsSync, lstatSync, readdirSync} from 'mz/fs' -import * as mkdirp from 'mkdirp' -import glob from 'glob-promise' -import isGlob = require('is-glob') -import {join, resolve, dirname, basename} from 'path' -import {compile, DEFAULT_OPTIONS, Options} from './index' -import {pathTransform, error} from './utils' - -main( - minimist(process.argv.slice(2), { - alias: { - help: ['h'], - input: ['i'], - output: ['o'], - }, - boolean: [ - 'additionalProperties', - 'declareExternallyReferenced', - 'enableConstEnums', - 'format', - 'ignoreMinAndMaxItems', - 'strictIndexSignatures', - 'unknownAny', - 'unreachableDefinitions', - ], - default: DEFAULT_OPTIONS, - string: ['bannerComment', 'cwd'], - }), -) - -async function main(argv: minimist.ParsedArgs) { - if (argv.help) { - printHelp() - process.exit(0) - } - - const argIn: string = argv._[0] || argv.input - const argOut: string | undefined = argv._[1] || argv.output // the output can be omitted so this can be undefined - - const ISGLOB = isGlob(argIn) - const ISDIR = isDir(argIn) - - if ((ISGLOB || ISDIR) && argOut && argOut.includes('.d.ts')) { - throw new ReferenceError( - `You have specified a single file ${argOut} output for a multi file input ${argIn}. This feature is not yet supported, refer to issue #272 (https://github.com/bcherny/json-schema-to-typescript/issues/272)`, - ) - } - - try { - // Process input as either glob, directory, or single file - if (ISGLOB) { - await processGlob(argIn, argOut, argv as Partial) - } else if (ISDIR) { - await processDir(argIn, argOut, argv as Partial) - } else { - const result = await processFile(argIn, argv as Partial) - outputResult(result, argOut) - } - } catch (e) { - error(e) - process.exit(1) - } -} - -// check if path is an existing directory -function isDir(path: string): boolean { - return existsSync(path) && lstatSync(path).isDirectory() -} - -async function processGlob(argIn: string, argOut: string | undefined, argv: Partial) { - const files = await glob(argIn) // execute glob pattern match - - if (files.length === 0) { - throw ReferenceError( - `You passed a glob pattern "${argIn}", but there are no files that match that pattern in ${process.cwd()}`, - ) - } - - // we can do this concurrently for perf - const results = await Promise.all( - files.map(async file => { - return [file, await processFile(file, argv)] as const - }), - ) - - // careful to do this serially - results.forEach(([file, result]) => { - const outputPath = argOut && `${argOut}/${basename(file, '.json')}.d.ts` - outputResult(result, outputPath) - }) -} - -async function processDir(argIn: string, argOut: string | undefined, argv: Partial) { - const files = getPaths(argIn) - - // we can do this concurrently for perf - const results = await Promise.all( - files.map(async file => { - if (!argOut) { - return [file, await processFile(file, argv)] as const - } else { - const outputPath = pathTransform(argOut, argIn, file) - return [file, await processFile(file, argv), outputPath] as const - } - }), - ) - - // careful to do this serially - results.forEach(([file, result, outputPath]) => - outputResult(result, outputPath ? `${outputPath}/${basename(file, '.json')}.d.ts` : undefined), - ) -} - -async function outputResult(result: string, outputPath: string | undefined): Promise { - if (!outputPath) { - process.stdout.write(result) - } else { - if (!isDir(dirname(outputPath))) { - mkdirp.sync(dirname(outputPath)) - } - return await writeFile(outputPath, result) - } -} - -async function processFile(argIn: string, argv: Partial): Promise { - const schema = JSON.parse(await readInput(argIn)) - return compile(schema, argIn, argv) -} - -function getPaths(path: string, paths: string[] = []) { - if (existsSync(path) && lstatSync(path).isDirectory()) { - readdirSync(resolve(path)).forEach(item => getPaths(join(path, item), paths)) - } else { - paths.push(path) - } - - return paths -} - -function readInput(argIn?: string) { - if (!argIn) { - return getStdin() - } - return readFile(resolve(process.cwd(), argIn), 'utf-8') -} - -function printHelp() { - const pkg = require('../../package.json') - - process.stdout.write( - ` -${pkg.name} ${pkg.version} -Usage: json2ts [--input, -i] [IN_FILE] [--output, -o] [OUT_FILE] [OPTIONS] - -With no IN_FILE, or when IN_FILE is -, read standard input. -With no OUT_FILE and when IN_FILE is specified, create .d.ts file in the same directory. -With no OUT_FILE nor IN_FILE, write to standard output. - -You can use any of the following options by adding them at the end. -Boolean values can be set to false using the 'no-' prefix. - - --additionalProperties - Default value for additionalProperties, when it is not explicitly set - --cwd=XXX - Root directory for resolving $ref - --declareExternallyReferenced - Declare external schemas referenced via '$ref'? - --enableConstEnums - Prepend enums with 'const'? - --format - Format code? Set this to false to improve performance. - --maxItems - Maximum number of unioned tuples to emit when representing bounded-size - array types, before falling back to emitting unbounded arrays. Increase - this to improve precision of emitted types, decrease it to improve - performance, or set it to -1 to ignore minItems and maxItems. - --style.XXX=YYY - Prettier configuration - --unknownAny - Output unknown type instead of any type - --unreachableDefinitions - Generates code for definitions that aren't referenced by the schema -`, - ) -} +#!/usr/bin/env node +import minimist = require('minimist') +import getStdin from 'get-stdin' +import {readFile, writeFile, existsSync, lstatSync, readdirSync} from 'mz/fs' +import * as mkdirp from 'mkdirp' +import glob from 'glob-promise' +import isGlob = require('is-glob') +import {join, resolve, dirname, basename} from 'path' +import {compile, DEFAULT_OPTIONS, Options} from './index' +import {pathTransform, error} from './utils' + +main( + minimist(process.argv.slice(2), { + alias: { + help: ['h'], + input: ['i'], + output: ['o'], + }, + boolean: [ + 'additionalProperties', + 'declareExternallyReferenced', + 'enableConstEnums', + 'format', + 'ignoreMinAndMaxItems', + 'strictIndexSignatures', + 'unknownAny', + 'unreachableDefinitions', + 'usePrefixItems', + ], + default: DEFAULT_OPTIONS, + string: ['bannerComment', 'cwd'], + }), +) + +async function main(argv: minimist.ParsedArgs) { + if (argv.help) { + printHelp() + process.exit(0) + } + + const argIn: string = argv._[0] || argv.input + const argOut: string | undefined = argv._[1] || argv.output // the output can be omitted so this can be undefined + + const ISGLOB = isGlob(argIn) + const ISDIR = isDir(argIn) + + if ((ISGLOB || ISDIR) && argOut && argOut.includes('.d.ts')) { + throw new ReferenceError( + `You have specified a single file ${argOut} output for a multi file input ${argIn}. This feature is not yet supported, refer to issue #272 (https://github.com/bcherny/json-schema-to-typescript/issues/272)`, + ) + } + + try { + // Process input as either glob, directory, or single file + if (ISGLOB) { + await processGlob(argIn, argOut, argv as Partial) + } else if (ISDIR) { + await processDir(argIn, argOut, argv as Partial) + } else { + const result = await processFile(argIn, argv as Partial) + outputResult(result, argOut) + } + } catch (e) { + error(e) + process.exit(1) + } +} + +// check if path is an existing directory +function isDir(path: string): boolean { + return existsSync(path) && lstatSync(path).isDirectory() +} + +async function processGlob(argIn: string, argOut: string | undefined, argv: Partial) { + const files = await glob(argIn) // execute glob pattern match + + if (files.length === 0) { + throw ReferenceError( + `You passed a glob pattern "${argIn}", but there are no files that match that pattern in ${process.cwd()}`, + ) + } + + // we can do this concurrently for perf + const results = await Promise.all( + files.map(async file => { + return [file, await processFile(file, argv)] as const + }), + ) + + // careful to do this serially + results.forEach(([file, result]) => { + const outputPath = argOut && `${argOut}/${basename(file, '.json')}.d.ts` + outputResult(result, outputPath) + }) +} + +async function processDir(argIn: string, argOut: string | undefined, argv: Partial) { + const files = getPaths(argIn) + + // we can do this concurrently for perf + const results = await Promise.all( + files.map(async file => { + if (!argOut) { + return [file, await processFile(file, argv)] as const + } else { + const outputPath = pathTransform(argOut, argIn, file) + return [file, await processFile(file, argv), outputPath] as const + } + }), + ) + + // careful to do this serially + results.forEach(([file, result, outputPath]) => + outputResult(result, outputPath ? `${outputPath}/${basename(file, '.json')}.d.ts` : undefined), + ) +} + +async function outputResult(result: string, outputPath: string | undefined): Promise { + if (!outputPath) { + process.stdout.write(result) + } else { + if (!isDir(dirname(outputPath))) { + mkdirp.sync(dirname(outputPath)) + } + return await writeFile(outputPath, result) + } +} + +async function processFile(argIn: string, argv: Partial): Promise { + const schema = JSON.parse(await readInput(argIn)) + return compile(schema, argIn, argv) +} + +function getPaths(path: string, paths: string[] = []) { + if (existsSync(path) && lstatSync(path).isDirectory()) { + readdirSync(resolve(path)).forEach(item => getPaths(join(path, item), paths)) + } else { + paths.push(path) + } + + return paths +} + +function readInput(argIn?: string) { + if (!argIn) { + return getStdin() + } + return readFile(resolve(process.cwd(), argIn), 'utf-8') +} + +function printHelp() { + const pkg = require('../../package.json') + + process.stdout.write( + ` +${pkg.name} ${pkg.version} +Usage: json2ts [--input, -i] [IN_FILE] [--output, -o] [OUT_FILE] [OPTIONS] + +With no IN_FILE, or when IN_FILE is -, read standard input. +With no OUT_FILE and when IN_FILE is specified, create .d.ts file in the same directory. +With no OUT_FILE nor IN_FILE, write to standard output. + +You can use any of the following options by adding them at the end. +Boolean values can be set to false using the 'no-' prefix. + + --additionalProperties + Default value for additionalProperties, when it is not explicitly set + --cwd=XXX + Root directory for resolving $ref + --declareExternallyReferenced + Declare external schemas referenced via '$ref'? + --enableConstEnums + Prepend enums with 'const'? + --format + Format code? Set this to false to improve performance. + --maxItems + Maximum number of unioned tuples to emit when representing bounded-size + array types, before falling back to emitting unbounded arrays. Increase + this to improve precision of emitted types, decrease it to improve + performance, or set it to -1 to ignore minItems and maxItems. + --style.XXX=YYY + Prettier configuration + --unknownAny + Output unknown type instead of any type + --unreachableDefinitions + Generates code for definitions that aren't referenced by the schema +`, + ) +} diff --git a/src/index.ts b/src/index.ts index 20a078de..78e4d727 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,185 +1,191 @@ -import {readFileSync} from 'fs' -import {JSONSchema4} from 'json-schema' -import {Options as $RefOptions} from '@bcherny/json-schema-ref-parser' -import {cloneDeep, endsWith, merge} from 'lodash' -import {dirname} from 'path' -import {Options as PrettierOptions} from 'prettier' -import {format} from './formatter' -import {generate} from './generator' -import {normalize} from './normalizer' -import {optimize} from './optimizer' -import {parse} from './parser' -import {dereference} from './resolver' -import {error, stripExtension, Try, log} from './utils' -import {validate} from './validator' -import {isDeepStrictEqual} from 'util' -import {link} from './linker' -import {validateOptions} from './optionValidator' - -export {EnumJSONSchema, JSONSchema, NamedEnumJSONSchema, CustomTypeJSONSchema} from './types/JSONSchema' - -export interface Options { - /** - * [$RefParser](https://github.com/BigstickCarpet/json-schema-ref-parser) Options, used when resolving `$ref`s - */ - $refOptions: $RefOptions - /** - * Default value for additionalProperties, when it is not explicitly set. - */ - additionalProperties: boolean - /** - * Disclaimer comment prepended to the top of each generated file. - */ - bannerComment: string - /** - * Root directory for resolving [`$ref`](https://tools.ietf.org/id/draft-pbryan-zyp-json-ref-03.html)s. - */ - cwd: string - /** - * Declare external schemas referenced via `$ref`? - */ - declareExternallyReferenced: boolean - /** - * Prepend enums with [`const`](https://www.typescriptlang.org/docs/handbook/enums.html#computed-and-constant-members)? - */ - enableConstEnums: boolean - /** - * Format code? Set this to `false` to improve performance. - */ - format: boolean - /** - * Ignore maxItems and minItems for `array` types, preventing tuples being generated. - */ - ignoreMinAndMaxItems: boolean - /** - * Maximum number of unioned tuples to emit when representing bounded-size array types, - * before falling back to emitting unbounded arrays. Increase this to improve precision - * of emitted types, decrease it to improve performance, or set it to `-1` to ignore - * `minItems` and `maxItems`. - */ - maxItems: number - /** - * Append all index signatures with `| undefined` so that they are strictly typed. - * - * This is required to be compatible with `strictNullChecks`. - */ - strictIndexSignatures: boolean - /** - * A [Prettier](https://prettier.io/docs/en/options.html) configuration. - */ - style: PrettierOptions - /** - * Generate code for `definitions` that aren't referenced by the schema? - */ - unreachableDefinitions: boolean - /** - * Generate unknown type instead of any - */ - unknownAny: boolean -} - -export const DEFAULT_OPTIONS: Options = { - $refOptions: {}, - additionalProperties: true, // TODO: default to empty schema (as per spec) instead - bannerComment: `/* eslint-disable */ -/** -* This file was automatically generated by json-schema-to-typescript. -* DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, -* and run json-schema-to-typescript to regenerate this file. -*/`, - cwd: process.cwd(), - declareExternallyReferenced: true, - enableConstEnums: true, - format: true, - ignoreMinAndMaxItems: false, - maxItems: 20, - strictIndexSignatures: false, - style: { - bracketSpacing: false, - printWidth: 120, - semi: true, - singleQuote: false, - tabWidth: 2, - trailingComma: 'none', - useTabs: false, - }, - unreachableDefinitions: false, - unknownAny: true, -} - -export function compileFromFile(filename: string, options: Partial = DEFAULT_OPTIONS): Promise { - const contents = Try( - () => readFileSync(filename), - () => { - throw new ReferenceError(`Unable to read file "${filename}"`) - }, - ) - const schema = Try( - () => JSON.parse(contents.toString()), - () => { - throw new TypeError(`Error parsing JSON in file "${filename}"`) - }, - ) - return compile(schema, stripExtension(filename), {cwd: dirname(filename), ...options}) -} - -export async function compile(schema: JSONSchema4, name: string, options: Partial = {}): Promise { - validateOptions(options) - - const _options = merge({}, DEFAULT_OPTIONS, options) - - const start = Date.now() - function time() { - return `(${Date.now() - start}ms)` - } - - // normalize options - if (!endsWith(_options.cwd, '/')) { - _options.cwd += '/' - } - - // Initial clone to avoid mutating the input - const _schema = cloneDeep(schema) - - const {dereferencedPaths, dereferencedSchema} = await dereference(_schema, _options) - if (process.env.VERBOSE) { - if (isDeepStrictEqual(_schema, dereferencedSchema)) { - log('green', 'dereferencer', time(), '✅ No change') - } else { - log('green', 'dereferencer', time(), '✅ Result:', dereferencedSchema) - } - } - - const linked = link(dereferencedSchema) - if (process.env.VERBOSE) { - log('green', 'linker', time(), '✅ No change') - } - - const errors = validate(linked, name) - if (errors.length) { - errors.forEach(_ => error(_)) - throw new ValidationError() - } - if (process.env.VERBOSE) { - log('green', 'validator', time(), '✅ No change') - } - - const normalized = normalize(linked, dereferencedPaths, name, _options) - log('yellow', 'normalizer', time(), '✅ Result:', normalized) - - const parsed = parse(normalized, _options) - log('blue', 'parser', time(), '✅ Result:', parsed) - - const optimized = optimize(parsed, _options) - log('cyan', 'optimizer', time(), '✅ Result:', optimized) - - const generated = generate(optimized, _options) - log('magenta', 'generator', time(), '✅ Result:', generated) - - const formatted = format(generated, _options) - log('white', 'formatter', time(), '✅ Result:', formatted) - - return formatted -} - -export class ValidationError extends Error {} +import {readFileSync} from 'fs' +import {JSONSchema4} from 'json-schema' +import {Options as $RefOptions} from '@bcherny/json-schema-ref-parser' +import {cloneDeep, endsWith, merge} from 'lodash' +import {dirname} from 'path' +import {Options as PrettierOptions} from 'prettier' +import {format} from './formatter' +import {generate} from './generator' +import {normalize} from './normalizer' +import {optimize} from './optimizer' +import {parse} from './parser' +import {dereference} from './resolver' +import {error, stripExtension, Try, log} from './utils' +import {validate} from './validator' +import {isDeepStrictEqual} from 'util' +import {link} from './linker' +import {validateOptions} from './optionValidator' + +export {EnumJSONSchema, JSONSchema, NamedEnumJSONSchema, CustomTypeJSONSchema} from './types/JSONSchema' + +export interface Options { + /** + * [$RefParser](https://github.com/BigstickCarpet/json-schema-ref-parser) Options, used when resolving `$ref`s + */ + $refOptions: $RefOptions + /** + * Default value for additionalProperties, when it is not explicitly set. + */ + additionalProperties: boolean + /** + * Disclaimer comment prepended to the top of each generated file. + */ + bannerComment: string + /** + * Root directory for resolving [`$ref`](https://tools.ietf.org/id/draft-pbryan-zyp-json-ref-03.html)s. + */ + cwd: string + /** + * Declare external schemas referenced via `$ref`? + */ + declareExternallyReferenced: boolean + /** + * Prepend enums with [`const`](https://www.typescriptlang.org/docs/handbook/enums.html#computed-and-constant-members)? + */ + enableConstEnums: boolean + /** + * Format code? Set this to `false` to improve performance. + */ + format: boolean + /** + * Ignore maxItems and minItems for `array` types, preventing tuples being generated. + */ + ignoreMinAndMaxItems: boolean + /** + * Maximum number of unioned tuples to emit when representing bounded-size array types, + * before falling back to emitting unbounded arrays. Increase this to improve precision + * of emitted types, decrease it to improve performance, or set it to `-1` to ignore + * `minItems` and `maxItems`. + */ + maxItems: number + /** + * Use `prefixItems` and `items` instead of `items` and `additionalItems` for tuple validation. This change was introduced in the 2020-12 Release Notes of the + * JSON Schema specification. [Read more about this](https://json-schema.org/draft/2020-12/release-notes#2020-12-release-notes) + */ + usePrefixItems: boolean + /** + * Append all index signatures with `| undefined` so that they are strictly typed. + * + * This is required to be compatible with `strictNullChecks`. + */ + strictIndexSignatures: boolean + /** + * A [Prettier](https://prettier.io/docs/en/options.html) configuration. + */ + style: PrettierOptions + /** + * Generate code for `definitions` that aren't referenced by the schema? + */ + unreachableDefinitions: boolean + /** + * Generate unknown type instead of any + */ + unknownAny: boolean +} + +export const DEFAULT_OPTIONS: Options = { + $refOptions: {}, + additionalProperties: true, // TODO: default to empty schema (as per spec) instead + bannerComment: `/* eslint-disable */ +/** +* This file was automatically generated by json-schema-to-typescript. +* DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, +* and run json-schema-to-typescript to regenerate this file. +*/`, + cwd: process.cwd(), + declareExternallyReferenced: true, + enableConstEnums: true, + format: true, + ignoreMinAndMaxItems: false, + maxItems: 20, + usePrefixItems: false, + strictIndexSignatures: false, + style: { + bracketSpacing: false, + printWidth: 120, + semi: true, + singleQuote: false, + tabWidth: 2, + trailingComma: 'none', + useTabs: false, + }, + unreachableDefinitions: false, + unknownAny: true, +} + +export function compileFromFile(filename: string, options: Partial = DEFAULT_OPTIONS): Promise { + const contents = Try( + () => readFileSync(filename), + () => { + throw new ReferenceError(`Unable to read file "${filename}"`) + }, + ) + const schema = Try( + () => JSON.parse(contents.toString()), + () => { + throw new TypeError(`Error parsing JSON in file "${filename}"`) + }, + ) + return compile(schema, stripExtension(filename), {cwd: dirname(filename), ...options}) +} + +export async function compile(schema: JSONSchema4, name: string, options: Partial = {}): Promise { + validateOptions(options) + + const _options = merge({}, DEFAULT_OPTIONS, options) + + const start = Date.now() + function time() { + return `(${Date.now() - start}ms)` + } + + // normalize options + if (!endsWith(_options.cwd, '/')) { + _options.cwd += '/' + } + + // Initial clone to avoid mutating the input + const _schema = cloneDeep(schema) + + const {dereferencedPaths, dereferencedSchema} = await dereference(_schema, _options) + if (process.env.VERBOSE) { + if (isDeepStrictEqual(_schema, dereferencedSchema)) { + log('green', 'dereferencer', time(), '✅ No change') + } else { + log('green', 'dereferencer', time(), '✅ Result:', dereferencedSchema) + } + } + + const linked = link(dereferencedSchema) + if (process.env.VERBOSE) { + log('green', 'linker', time(), '✅ No change') + } + + const errors = validate(linked, name) + if (errors.length) { + errors.forEach(_ => error(_)) + throw new ValidationError() + } + if (process.env.VERBOSE) { + log('green', 'validator', time(), '✅ No change') + } + + const normalized = normalize(linked, dereferencedPaths, name, _options) + log('yellow', 'normalizer', time(), '✅ Result:', normalized) + + const parsed = parse(normalized, _options) + log('blue', 'parser', time(), '✅ Result:', parsed) + + const optimized = optimize(parsed, _options) + log('cyan', 'optimizer', time(), '✅ Result:', optimized) + + const generated = generate(optimized, _options) + log('magenta', 'generator', time(), '✅ Result:', generated) + + const formatted = format(generated, _options) + log('white', 'formatter', time(), '✅ Result:', formatted) + + return formatted +} + +export class ValidationError extends Error {} diff --git a/src/normalizer.ts b/src/normalizer.ts index fa3ec6ba..012d70e4 100644 --- a/src/normalizer.ts +++ b/src/normalizer.ts @@ -1,233 +1,246 @@ -import {JSONSchemaTypeName, LinkedJSONSchema, NormalizedJSONSchema, 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, - fileName: string, - options: Options, - key: string | null, - dereferencedPaths: DereferencedPaths, -) => void -const rules = new Map() - -function hasType(schema: LinkedJSONSchema, type: JSONSchemaTypeName) { - return schema.type === type || (Array.isArray(schema.type) && schema.type.includes(type)) -} -function isObjectType(schema: LinkedJSONSchema) { - return schema.properties !== undefined || hasType(schema, 'object') || hasType(schema, 'any') -} -function isArrayType(schema: LinkedJSONSchema) { - return schema.items !== undefined || hasType(schema, 'array') || hasType(schema, 'any') -} - -rules.set('Remove `type=["null"]` if `enum=[null]`', schema => { - if ( - Array.isArray(schema.enum) && - schema.enum.some(e => e === null) && - Array.isArray(schema.type) && - schema.type.includes('null') - ) { - schema.type = schema.type.filter(type => type !== 'null') - } -}) - -rules.set('Destructure unary types', schema => { - if (schema.type && Array.isArray(schema.type) && schema.type.length === 1) { - schema.type = schema.type[0] - } -}) - -rules.set('Add empty `required` property if none is defined', schema => { - if (isObjectType(schema) && !('required' in schema)) { - schema.required = [] - } -}) - -rules.set('Transform `required`=false to `required`=[]', schema => { - if (schema.required === false) { - schema.required = [] - } -}) - -rules.set('Default additionalProperties', (schema, _, options) => { - if (isObjectType(schema) && !('additionalProperties' in schema) && schema.patternProperties === undefined) { - schema.additionalProperties = options.additionalProperties - } -}) - -rules.set('Transform id to $id', (schema, fileName) => { - if (!isSchemaLike(schema)) { - 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}`, - ) - } - if (schema.id) { - schema.$id = schema.id - delete schema.id - } -}) - -rules.set('Add an $id to anything that needs it', (schema, fileName, _options, _key, dereferencedPaths) => { - if (!isSchemaLike(schema)) { - return - } - - // Top-level schema - if (!schema.$id && !schema[Parent]) { - schema.$id = toSafeString(justName(fileName)) - return - } - - // Sub-schemas with references - if (!isArrayType(schema) && !isObjectType(schema)) { - 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 (dereferencedName) { - dereferencedPaths.delete(schema) - } -}) - -rules.set('Escape closing JSDoc comment', schema => { - escapeBlockComment(schema) -}) - -rules.set('Add JSDoc comments for minItems and maxItems', schema => { - if (!isArrayType(schema)) { - return - } - const commentsToAppend = [ - 'minItems' in schema ? `@minItems ${schema.minItems}` : '', - 'maxItems' in schema ? `@maxItems ${schema.maxItems}` : '', - ].filter(Boolean) - if (commentsToAppend.length) { - schema.description = appendToDescription(schema.description, ...commentsToAppend) - } -}) - -rules.set('Optionally remove maxItems and minItems', (schema, _fileName, options) => { - if (!isArrayType(schema)) { - return - } - if ('minItems' in schema && options.ignoreMinAndMaxItems) { - delete schema.minItems - } - if ('maxItems' in schema && (options.ignoreMinAndMaxItems || options.maxItems === -1)) { - delete schema.maxItems - } -}) - -rules.set('Normalize schema.minItems', (schema, _fileName, options) => { - if (options.ignoreMinAndMaxItems) { - return - } - // make sure we only add the props onto array types - if (!isArrayType(schema)) { - return - } - const {minItems} = schema - schema.minItems = typeof minItems === 'number' ? minItems : 0 - // cannot normalize maxItems because maxItems = 0 has an actual meaning -}) - -rules.set('Remove maxItems if it is big enough to likely cause OOMs', (schema, _fileName, options) => { - if (options.ignoreMinAndMaxItems || options.maxItems === -1) { - return - } - if (!isArrayType(schema)) { - return - } - const {maxItems, minItems} = schema - // minItems is guaranteed to be a number after the previous rule runs - if (maxItems !== undefined && maxItems - (minItems as number) > options.maxItems) { - delete schema.maxItems - } -}) - -rules.set('Normalize schema.items', (schema, _fileName, options) => { - if (options.ignoreMinAndMaxItems) { - return - } - const {maxItems, minItems} = schema - const hasMaxItems = typeof maxItems === 'number' && maxItems >= 0 - const hasMinItems = typeof minItems === 'number' && minItems > 0 - - if (schema.items && !Array.isArray(schema.items) && (hasMaxItems || hasMinItems)) { - const items = schema.items - // create a tuple of length N - const newItems = Array(maxItems || minItems || 0).fill(items) - if (!hasMaxItems) { - // if there is no maximum, then add a spread item to collect the rest - schema.additionalItems = items - } - schema.items = newItems - } - - if (Array.isArray(schema.items) && hasMaxItems && maxItems! < schema.items.length) { - // it's perfectly valid to provide 5 item defs but require maxItems 1 - // obviously we shouldn't emit a type for items that aren't expected - schema.items = schema.items.slice(0, maxItems) - } - - return schema -}) - -rules.set('Remove extends, if it is empty', schema => { - if (!schema.hasOwnProperty('extends')) { - return - } - if (schema.extends == null || (Array.isArray(schema.extends) && schema.extends.length === 0)) { - delete schema.extends - } -}) - -rules.set('Make extends always an array, if it is defined', schema => { - if (schema.extends == null) { - return - } - if (!Array.isArray(schema.extends)) { - schema.extends = [schema.extends] - } -}) - -rules.set('Transform definitions to $defs', (schema, fileName) => { - if (schema.definitions && schema.$defs && !isDeepStrictEqual(schema.definitions, schema.$defs)) { - throw ReferenceError( - `Schema must define either definitions or $defs, not both. Given id=${schema.id} in ${fileName}`, - ) - } - if (schema.definitions) { - schema.$defs = schema.definitions - delete schema.definitions - } -}) - -rules.set('Transform const to singleton enum', schema => { - if (schema.const !== undefined) { - schema.enum = [schema.const] - delete schema.const - } -}) - -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))) - return rootSchema as NormalizedJSONSchema -} +/* eslint-disable prettier/prettier */ +import {JSONSchemaTypeName, LinkedJSONSchema, NormalizedJSONSchema, 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, + fileName: string, + options: Options, + key: string | null, + dereferencedPaths: DereferencedPaths, +) => void +const rules = new Map() + +function hasType(schema: LinkedJSONSchema, type: JSONSchemaTypeName) { + return schema.type === type || (Array.isArray(schema.type) && schema.type.includes(type)) +} +function isObjectType(schema: LinkedJSONSchema) { + return schema.properties !== undefined || hasType(schema, 'object') || hasType(schema, 'any') +} +function isArrayType(schema: LinkedJSONSchema) { + return ( + schema.items !== undefined || schema.prefixItems !== undefined || hasType(schema, 'array') || hasType(schema, 'any') + ) +} + +rules.set('Remove `type=["null"]` if `enum=[null]`', schema => { + if ( + Array.isArray(schema.enum) && + schema.enum.some(e => e === null) && + Array.isArray(schema.type) && + schema.type.includes('null') + ) { + schema.type = schema.type.filter(type => type !== 'null') + } +}) + +rules.set('Destructure unary types', schema => { + if (schema.type && Array.isArray(schema.type) && schema.type.length === 1) { + schema.type = schema.type[0] + } +}) + +rules.set('Add empty `required` property if none is defined', schema => { + if (isObjectType(schema) && !('required' in schema)) { + schema.required = [] + } +}) + +rules.set('Transform `required`=false to `required`=[]', schema => { + if (schema.required === false) { + schema.required = [] + } +}) + +rules.set('Default additionalProperties', (schema, _, options) => { + if (isObjectType(schema) && !('additionalProperties' in schema) && schema.patternProperties === undefined) { + schema.additionalProperties = options.additionalProperties + } +}) + +rules.set('Transform id to $id', (schema, fileName) => { + if (!isSchemaLike(schema)) { + 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}`, + ) + } + if (schema.id) { + schema.$id = schema.id + delete schema.id + } +}) + +rules.set('Add an $id to anything that needs it', (schema, fileName, _options, _key, dereferencedPaths) => { + if (!isSchemaLike(schema)) { + return + } + + // Top-level schema + if (!schema.$id && !schema[Parent]) { + schema.$id = toSafeString(justName(fileName)) + return + } + + // Sub-schemas with references + if (!isArrayType(schema) && !isObjectType(schema)) { + 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 (dereferencedName) { + dereferencedPaths.delete(schema) + } +}) + +rules.set('Escape closing JSDoc comment', schema => { + escapeBlockComment(schema) +}) + +rules.set('Rename `prefixItems` to `items` and `items` to `additionallItems`', (schema, _, options) => { + if (isArrayType(schema) && options.usePrefixItems) { + // @ts-expect-error this is a simple renaming procedure, there is in no need to change the `schema.items` type to allow for boolean values + schema.additionalItems = schema.items + schema.items = schema.prefixItems + delete schema.prefixItems + } + return +}) + +rules.set('Add JSDoc comments for minItems and maxItems', schema => { + if (!isArrayType(schema)) { + return + } + const commentsToAppend = [ + 'minItems' in schema ? `@minItems ${schema.minItems}` : '', + 'maxItems' in schema ? `@maxItems ${schema.maxItems}` : '', + ].filter(Boolean) + if (commentsToAppend.length) { + schema.description = appendToDescription(schema.description, ...commentsToAppend) + } +}) + +rules.set('Optionally remove maxItems and minItems', (schema, _fileName, options) => { + if (!isArrayType(schema)) { + return + } + if ('minItems' in schema && options.ignoreMinAndMaxItems) { + delete schema.minItems + } + if ('maxItems' in schema && (options.ignoreMinAndMaxItems || options.maxItems === -1)) { + delete schema.maxItems + } +}) + +rules.set('Normalize schema.minItems', (schema, _fileName, options) => { + if (options.ignoreMinAndMaxItems) { + return + } + // make sure we only add the props onto array types + if (!isArrayType(schema)) { + return + } + const {minItems} = schema + schema.minItems = typeof minItems === 'number' ? minItems : 0 + // cannot normalize maxItems because maxItems = 0 has an actual meaning +}) + +rules.set('Remove maxItems if it is big enough to likely cause OOMs', (schema, _fileName, options) => { + if (options.ignoreMinAndMaxItems || options.maxItems === -1) { + return + } + if (!isArrayType(schema)) { + return + } + const {maxItems, minItems} = schema + // minItems is guaranteed to be a number after the previous rule runs + if (maxItems !== undefined && maxItems - (minItems as number) > options.maxItems) { + delete schema.maxItems + } +}) + +rules.set('Normalize schema.items', (schema, _fileName, options) => { + if (options.ignoreMinAndMaxItems) { + return + } + const {maxItems, minItems} = schema + const hasMaxItems = typeof maxItems === 'number' && maxItems >= 0 + const hasMinItems = typeof minItems === 'number' && minItems > 0 + + if (schema.items && !Array.isArray(schema.items) && (hasMaxItems || hasMinItems)) { + const items = schema.items + // create a tuple of length N + const newItems = Array(maxItems || minItems || 0).fill(items) + if (!hasMaxItems) { + // if there is no maximum, then add a spread item to collect the rest + schema.additionalItems = items + } + schema.items = newItems + } + + if (Array.isArray(schema.items) && hasMaxItems && maxItems! < schema.items.length) { + // it's perfectly valid to provide 5 item defs but require maxItems 1 + // obviously we shouldn't emit a type for items that aren't expected + schema.items = schema.items.slice(0, maxItems) + } + + return schema +}) + +rules.set('Remove extends, if it is empty', schema => { + if (!schema.hasOwnProperty('extends')) { + return + } + if (schema.extends == null || (Array.isArray(schema.extends) && schema.extends.length === 0)) { + delete schema.extends + } +}) + +rules.set('Make extends always an array, if it is defined', schema => { + if (schema.extends == null) { + return + } + if (!Array.isArray(schema.extends)) { + schema.extends = [schema.extends] + } +}) + +rules.set('Transform definitions to $defs', (schema, fileName) => { + if (schema.definitions && schema.$defs && !isDeepStrictEqual(schema.definitions, schema.$defs)) { + throw ReferenceError( + `Schema must define either definitions or $defs, not both. Given id=${schema.id} in ${fileName}`, + ) + } + if (schema.definitions) { + schema.$defs = schema.definitions + delete schema.definitions + } +}) + +rules.set('Transform const to singleton enum', schema => { + if (schema.const !== undefined) { + schema.enum = [schema.const] + delete schema.const + } +}) + +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))) + return rootSchema as NormalizedJSONSchema +} diff --git a/test/normalizer/renamePrefixItemsToItems.json b/test/normalizer/renamePrefixItemsToItems.json new file mode 100644 index 00000000..6fa2bcd5 --- /dev/null +++ b/test/normalizer/renamePrefixItemsToItems.json @@ -0,0 +1,31 @@ +{ + "name": "Rename prefixItems to items and items to additionalItems", + "in": { + "additionalProperties": false, + "$id": "RenamePrefixItems", + "properties":{ + "a": { + "description": "Test prefixItems", + "prefixItems": [true, false], + "items":false + } + }, + "required": [] + }, + "out": { + "additionalProperties": false, + "$id": "RenamePrefixItems", + "properties": { + "a": { + "description": "Test prefixItems", + "items": [true, false], + "additionalItems": false, + "minItems": 0 + } + }, + "required": [] + }, + "options": { + "usePrefixItems": true + } +}