diff --git a/factory/parser.ts b/factory/parser.ts index f682b2f24..04ef98f12 100644 --- a/factory/parser.ts +++ b/factory/parser.ts @@ -62,6 +62,7 @@ import { SatisfiesNodeParser } from "../src/NodeParser/SatisfiesNodeParser.js"; import { PromiseNodeParser } from "../src/NodeParser/PromiseNodeParser.js"; import { SpreadElementNodeParser } from "../src/NodeParser/SpreadElementNodeParser.js"; import { IdentifierNodeParser } from "../src/NodeParser/IdentifierNodeParser.js"; +import { castArray } from "../src/Utils/castArray.js"; export type ParserAugmentor = (parser: MutableParser) => void; @@ -73,7 +74,10 @@ export function createParser(program: ts.Program, config: CompletedConfig, augme return new ExposeNodeParser(typeChecker, nodeParser, config.expose, config.jsDoc); } function withTopRef(nodeParser: NodeParser): NodeParser { - return new TopRefNodeParser(chainNodeParser, config.type, config.topRef); + const typeArr = castArray(config.type); + // If we have multiple types, don't set a top-level $ref. + const topRefFullName = typeArr && typeArr.length === 1 ? typeArr[0] : undefined; + return new TopRefNodeParser(chainNodeParser, topRefFullName, config.topRef); } function withJsDoc(nodeParser: SubNodeParser): SubNodeParser { const extraTags = new Set(config.extraTags); diff --git a/src/Config.ts b/src/Config.ts index 3a16458b1..281a04c7b 100644 --- a/src/Config.ts +++ b/src/Config.ts @@ -8,10 +8,10 @@ export interface Config { path?: string; /** - * Name of the type/interface to generate schema for. + * Name of the type(s)/interface(s) to generate schema for. * Use "*" to generate schemas for all exported types. */ - type?: string; + type?: string | string[]; /** * Minify the output JSON schema (no whitespace). diff --git a/src/SchemaGenerator.ts b/src/SchemaGenerator.ts index 4d9665f1d..c9ee27dfb 100644 --- a/src/SchemaGenerator.ts +++ b/src/SchemaGenerator.ts @@ -10,6 +10,7 @@ import type { TypeFormatter } from "./TypeFormatter.js"; import type { StringMap } from "./Utils/StringMap.js"; import { hasJsDocTag } from "./Utils/hasJsDocTag.js"; import { removeUnreachable } from "./Utils/removeUnreachable.js"; +import { castArray } from "./Utils/castArray.js"; import { symbolAtNode } from "./Utils/symbolAtNode.js"; export class SchemaGenerator { @@ -20,8 +21,8 @@ export class SchemaGenerator { protected readonly config?: Config, ) {} - public createSchema(fullName?: string): Schema { - const rootNodes = this.getRootNodes(fullName); + public createSchema(fullNames?: string | string[]): Schema { + const rootNodes = this.getRootNodes(castArray(fullNames)); return this.createSchemaFromNodes(rootNodes); } @@ -60,9 +61,15 @@ export class SchemaGenerator { }; } - protected getRootNodes(fullName: string | undefined): ts.Node[] { - if (fullName && fullName !== "*") { - return [this.findNamedNode(fullName)]; + protected getRootNodes(fullNames: string[] | undefined): ts.Node[] { + // ["*"] means generate everything. + if (fullNames && fullNames.includes("*") && fullNames.length > 1) { + throw new Error("Cannot mix '*' with specific type names"); + } + + const generateAll = !fullNames || fullNames.length === 0 || (fullNames.length === 1 && fullNames[0] === "*"); + if (!generateAll) { + return fullNames.map((name) => this.findNamedNode(name)); } const rootFileNames = this.program.getRootFileNames(); diff --git a/src/Utils/castArray.ts b/src/Utils/castArray.ts new file mode 100644 index 000000000..74afe465a --- /dev/null +++ b/src/Utils/castArray.ts @@ -0,0 +1,7 @@ +export function castArray(input: undefined | T | T[]): undefined | T[] { + if (input === undefined) { + return undefined; + } + + return Array.isArray(input) ? input : [input]; +} diff --git a/test/config.test.ts b/test/config.test.ts index 39aba6853..9b609bc6a 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -26,7 +26,7 @@ const basePath = "test/config"; function assertSchema( name: string, - userConfig: Config & { type: string }, + userConfig: Config & { type: string | string[] }, tsconfig?: boolean, formatterAugmentor?: FormatterAugmentor, parserAugmentor?: ParserAugmentor, @@ -390,6 +390,20 @@ describe("config", () => { }), ); + it( + "multiple-types", + assertSchema("multiple-types", { + type: ["MyObject1", "MyObject2"], + }), + ); + + it( + "multiple-types-all", + assertSchema("multiple-types-all", { + type: ["MyObject1", "MyObject2", "Object1Prop", "Object2Prop"], + }), + ); + it( "mapped-intersection", assertSchema("mapped-intersection", { diff --git a/test/config/multiple-types-all/main.ts b/test/config/multiple-types-all/main.ts new file mode 100644 index 000000000..4808c676b --- /dev/null +++ b/test/config/multiple-types-all/main.ts @@ -0,0 +1,30 @@ +type NonExportedType = { + misc: number; +}; + +export type ExportedType = { + val: string; + val2: NonExportedType; +}; + +export interface ExportedInterface { + val: string; +} + +export type Object1Prop = { + name: string; +}; + +export type Object2Prop = { + description: string; +}; + +export type MyObject1 = { + id: number; + bar: Object1Prop; +}; + +export type MyObject2 = { + idStr: string; + baz: Object2Prop; +}; diff --git a/test/config/multiple-types-all/schema.json b/test/config/multiple-types-all/schema.json new file mode 100644 index 000000000..db7a87aed --- /dev/null +++ b/test/config/multiple-types-all/schema.json @@ -0,0 +1,61 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "MyObject1": { + "properties": { + "id": { + "type": "number" + }, + "bar": { + "$ref": "#/definitions/Object1Prop" + } + }, + "required": [ + "id", + "bar" + ], + "type": "object", + "additionalProperties": false + }, + "MyObject2": { + "properties": { + "idStr": { + "type": "string" + }, + "baz": { + "$ref": "#/definitions/Object2Prop" + } + }, + "required": [ + "idStr", + "baz" + ], + "type": "object", + "additionalProperties": false + }, + "Object1Prop": { + "properties": { + "name": { + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object", + "additionalProperties": false + }, + "Object2Prop": { + "properties": { + "description": { + "type": "string" + } + }, + "required": [ + "description" + ], + "type": "object", + "additionalProperties": false + } + } +} diff --git a/test/config/multiple-types/main.ts b/test/config/multiple-types/main.ts new file mode 100644 index 000000000..4b470a5d0 --- /dev/null +++ b/test/config/multiple-types/main.ts @@ -0,0 +1,31 @@ +type NonExportedType = { + misc: number; +}; + +export type ExportedType = { + val: string; + val2: NonExportedType; +}; + +export interface ExportedInterface { + val: string; +} + +// Exported, so we include it as a root node +export type Object1Prop = { + name: string; +}; + +type Object2Prop = { + description: string; +}; + +export type MyObject1 = { + id: number; + bar: Object1Prop; +}; + +export type MyObject2 = { + idStr: string; + baz: Object2Prop; +}; diff --git a/test/config/multiple-types/schema.json b/test/config/multiple-types/schema.json new file mode 100644 index 000000000..758cb0740 --- /dev/null +++ b/test/config/multiple-types/schema.json @@ -0,0 +1,58 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "MyObject1": { + "properties": { + "id": { + "type": "number" + }, + "bar": { + "$ref": "#/definitions/Object1Prop" + } + }, + "required": [ + "id", + "bar" + ], + "type": "object", + "additionalProperties": false + }, + "MyObject2": { + "properties": { + "idStr": { + "type": "string" + }, + "baz": { + "properties": { + "description": { + "type": "string" + } + }, + "required": [ + "description" + ], + "type": "object", + "additionalProperties": false + } + }, + "required": [ + "idStr", + "baz" + ], + "type": "object", + "additionalProperties": false + }, + "Object1Prop": { + "properties": { + "name": { + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object", + "additionalProperties": false + } + } +} diff --git a/test/invalid-data.test.ts b/test/invalid-data.test.ts index 64f52062d..3e668d77d 100644 --- a/test/invalid-data.test.ts +++ b/test/invalid-data.test.ts @@ -7,7 +7,7 @@ import type { CompletedConfig } from "../src/Config.js"; import { DEFAULT_CONFIG } from "../src/Config.js"; import { SchemaGenerator } from "../src/SchemaGenerator.js"; -function assertSchema(name: string, type: string, message: string) { +function assertSchema(name: string, type: string | string[], message: string) { return () => { const config: CompletedConfig = { ...DEFAULT_CONFIG, @@ -35,6 +35,7 @@ describe("invalid-data", () => { it("script-empty", assertSchema("script-empty", "MyType", `No root type "MyType" found`)); it("duplicates", assertSchema("duplicates", "MyType", `Type "A" has multiple definitions.`)); + it("mixing * and types", assertSchema("duplicates", ["*", "MyType"], `Cannot mix '*' with specific type names`)); it( "missing-discriminator", assertSchema("missing-discriminator", "MyType", 'Cannot find discriminator keyword "type" in type B.'), diff --git a/ts-json-schema-generator.ts b/ts-json-schema-generator.ts index 0ed6a2a02..c1baa8ba8 100644 --- a/ts-json-schema-generator.ts +++ b/ts-json-schema-generator.ts @@ -11,7 +11,16 @@ import pkg from "./package.json"; const args = new Command() .option("-p, --path ", "Source file path") - .option("-t, --type ", "Type name") + .option( + "-t, --type ", + "Type name (can be passed multiple times)", + (value: string, previous: string[] | undefined) => { + if (previous) { + return previous.concat(value); + } + return [value]; + }, + ) .option("-i, --id ", "$id for generated schema") .option("-f, --tsconfig ", "Custom tsconfig.json path") .addOption( @@ -84,7 +93,7 @@ const config: Config = { }; try { - const schema = createGenerator(config).createSchema(args.type); + const schema = createGenerator(config).createSchema(config.type); const stringify = config.sortProps ? stableStringify : JSON.stringify; // need as string since TS can't figure out that the string | undefined case doesn't happen