diff --git a/README.md b/README.md index bc388555..ad83506d 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ Options: --include Further limit tsconfig to include only matching files [array] [default: []] --ignoreErrors Generate even if the program has errors. [boolean] [default: false] --excludePrivate Exclude private members from the schema [boolean] [default: false] + --uniqueNames Use unique names for type symbols. [boolean] [default: false] ``` ### Programmatic use @@ -84,6 +85,38 @@ generator.getSchemaForSymbol("MyType"); generator.getSchemaForSymbol("AnotherType"); ``` +```ts +// In larger projects type names may not be unique, +// while unique names may be enabled. +const settings: TJS.PartialArgs = { + uniqueNames: true +}; + +const generator = TJS.buildGenerator(program, settings); + +// A list of all types of a given name can then be retrieved. +const symbolList = generator.getSymbols("MyType"); + +// Choose the appropriate type, and continue with the symbol's unique name. +generator.getSchemaForSymbol(symbolList[1].name); + +// Also it is possible to get a list of all symbols. +const fullSymbolList = generator.getSymbols(); +``` + +`getSymbols('')` and `getSymbols()` return an array of `SymbolRef`, which is of the following format: + +```ts +type SymbolRef = { + name: string; + typeName: string; + fullyQualifiedName: string; + symbol: ts.Symbol; +}; +``` + +`getUserSymbols` and `getMainFileSymbols` return an array of `string`. + ### Annotations The schema generator converts annotations to JSON schema properties. diff --git a/test/programs/unique-names/main.ts b/test/programs/unique-names/main.ts new file mode 100644 index 00000000..f3f013bd --- /dev/null +++ b/test/programs/unique-names/main.ts @@ -0,0 +1,5 @@ +import "./other"; + +class MyObject { + is: "MyObject_1"; +} diff --git a/test/programs/unique-names/other.ts b/test/programs/unique-names/other.ts new file mode 100644 index 00000000..f92df128 --- /dev/null +++ b/test/programs/unique-names/other.ts @@ -0,0 +1,3 @@ +class MyObject { + is: "MyObject_2"; +} diff --git a/test/programs/unique-names/schema.MyObject.2139669.json b/test/programs/unique-names/schema.MyObject.2139669.json new file mode 100644 index 00000000..38b78afe --- /dev/null +++ b/test/programs/unique-names/schema.MyObject.2139669.json @@ -0,0 +1,16 @@ +{ + "type": "object", + "properties": { + "is": { + "type": "string", + "enum": [ + "MyObject_2" + ] + } + }, + "required": [ + "is" + ], + "$schema": "http://json-schema.org/draft-06/schema#" +} + diff --git a/test/programs/unique-names/schema.MyObject.2139671.json b/test/programs/unique-names/schema.MyObject.2139671.json new file mode 100644 index 00000000..e8872c9a --- /dev/null +++ b/test/programs/unique-names/schema.MyObject.2139671.json @@ -0,0 +1,16 @@ +{ + "type": "object", + "properties": { + "is": { + "type": "string", + "enum": [ + "MyObject_1" + ] + } + }, + "required": [ + "is" + ], + "$schema": "http://json-schema.org/draft-06/schema#" +} + diff --git a/test/schema.test.ts b/test/schema.test.ts index aed75838..82042e3b 100644 --- a/test/schema.test.ts +++ b/test/schema.test.ts @@ -38,6 +38,35 @@ export function assertSchema(group: string, type: string, settings: TJS.PartialA }); } +export function assertSchemas(group: string, type: string, settings: TJS.PartialArgs = {}, compilerOptions?: TJS.CompilerOptions) { + it(group + " should create correct schema", () => { + if (!("required" in settings)) { + settings.required = true; + } + + const generator = TJS.buildGenerator(TJS.getProgramFromFiles([resolve(BASE + group + "/main.ts")], compilerOptions), settings); + const symbols = generator!.getSymbols(type); + + for (let symbol of symbols) { + const actual = generator!.getSchemaForSymbol(symbol.name); + + // writeFileSync(BASE + group + `/schema.${symbol.name}.json`, JSON.stringify(actual, null, 4) + "\n\n"); + + const file = readFileSync(BASE + group + `/schema.${symbol.name}.json`, "utf8"); + const expected = JSON.parse(file); + + assert.isObject(actual); + assert.deepEqual(actual, expected, "The schema is not as expected"); + + // test against the meta schema + if (actual !== null) { + ajv.validateSchema(actual); + assert.equal(ajv.errors, null, "The schema is not valid"); + } + } + }); +} + describe("interfaces", () => { it("should return an instance of JsonSchemaGenerator", () => { const program = TJS.getProgramFromFiles([resolve(BASE + "comments/main.ts")]); @@ -240,6 +269,11 @@ describe("schema", () => { assertSchema("private-members", "MyObject", { excludePrivate: true }); + + assertSchemas("unique-names", "MyObject", { + uniqueNames: true + }); + assertSchema("builtin-names", "Ext.Foo"); }); }); diff --git a/typescript-json-schema-cli.ts b/typescript-json-schema-cli.ts index 9c97f0fe..f5b6fdc9 100644 --- a/typescript-json-schema-cli.ts +++ b/typescript-json-schema-cli.ts @@ -32,6 +32,10 @@ export function run() { .describe("out", "The output file, defaults to using stdout") .array("validationKeywords").default("validationKeywords", defaultArgs.validationKeywords) .describe("validationKeywords", "Provide additional validation keywords to include.") + .boolean("excludePrivate").default("excludePrivate", defaultArgs.excludePrivate) + .describe("excludePrivate", "Exclude private members from the schema.") + .boolean("uniqueNames").default("uniqueNames", defaultArgs.uniqueNames) + .describe("uniqueNames", "Use unique names for type symbols.") .array("include").default("*", defaultArgs.include) .describe("include", "Further limit tsconfig to include only matching files.") .argv; @@ -52,6 +56,7 @@ export function run() { validationKeywords: args.validationKeywords, include: args.include, excludePrivate: args.excludePrivate, + uniqueNames: args.uniqueNames, }); } diff --git a/typescript-json-schema.ts b/typescript-json-schema.ts index c833eb0d..217bc98a 100644 --- a/typescript-json-schema.ts +++ b/typescript-json-schema.ts @@ -2,7 +2,7 @@ import * as glob from "glob"; import * as stringify from "json-stable-stringify"; import * as path from "path"; import * as ts from "typescript"; -export { Program, CompilerOptions } from "typescript"; +export { Program, CompilerOptions, Symbol } from "typescript"; const vm = require("vm"); @@ -28,6 +28,7 @@ export function getDefaultArgs(): Args { validationKeywords: [], include: [], excludePrivate: false, + uniqueNames: false, }; } @@ -51,6 +52,7 @@ export type Args = { validationKeywords: string[]; include: string[]; excludePrivate: boolean; + uniqueNames: boolean; }; export type PartialArgs = Partial; @@ -84,6 +86,13 @@ export type Definition = { typeof?: "function" }; +export type SymbolRef = { + name: string; + typeName: string; + fullyQualifiedName: string; + symbol: ts.Symbol; +}; + function extend(target: any, ..._: any[]) { if (target == null) { // TypeError if undefined or null throw new TypeError("Cannot convert undefined or null to object"); @@ -264,6 +273,11 @@ const validationKeywords = { export class JsonSchemaGenerator { private tc: ts.TypeChecker; + /** + * Holds all symbols within a custom SymbolRef object, containing useful + * information. + */ + private symbols: SymbolRef[]; /** * All types for declarations of classes, interfaces, enums, and type aliases * defined in all TS files. @@ -299,12 +313,14 @@ export class JsonSchemaGenerator { private typeNamesUsed: { [name: string]: boolean } = {}; constructor( + symbols: SymbolRef[], allSymbols: { [name: string]: ts.Type }, userSymbols: { [name: string]: ts.Symbol }, inheritingTypes: { [baseName: string]: string[] }, tc: ts.TypeChecker, private args = getDefaultArgs(), ) { + this.symbols = symbols; this.allSymbols = allSymbols; this.userSymbols = userSymbols; this.inheritingTypes = inheritingTypes; @@ -952,6 +968,14 @@ export class JsonSchemaGenerator { return root; } + public getSymbols(name?: string): SymbolRef[] { + if (name === void 0) { + return this.symbols; + } + + return this.symbols.filter(symbol => symbol.typeName === name); + } + public getUserSymbols(): string[] { return Object.keys(this.userSymbols); } @@ -1011,6 +1035,7 @@ export function buildGenerator(program: ts.Program, args: PartialArgs = {}): Jso if (diagnostics.length === 0 || args.ignoreErrors) { + const symbols: SymbolRef[] = []; const allSymbols: { [name: string]: ts.Type } = {}; const userSymbols: { [name: string]: ts.Symbol } = {}; const inheritingTypes: { [baseName: string]: string[] } = {}; @@ -1024,20 +1049,17 @@ export function buildGenerator(program: ts.Program, args: PartialArgs = {}): Jso || node.kind === ts.SyntaxKind.TypeAliasDeclaration ) { const symbol: ts.Symbol = (node).symbol; - const nodeType = tc.getTypeAtLocation(node); + const fullyQualifiedName = tc.getFullyQualifiedName(symbol); + const typeName = fullyQualifiedName.replace(/".*"\./, ""); + const name = !args.uniqueNames ? typeName : `${typeName}.${(symbol).id}`; - // remove file name - // TODO: we probably don't want this eventually, - // as same types can occur in different files and will override eachother in allSymbols - // This means atm we can't generate all types in large programs. - const fullName = tc.getFullyQualifiedName(symbol).replace(/".*"\./, ""); - - allSymbols[fullName] = nodeType; + symbols.push({ name, typeName, fullyQualifiedName, symbol }); + allSymbols[name] = nodeType; // if (sourceFileIdx === 1) { if (!sourceFile.hasNoDefaultLib) { - userSymbols[fullName] = symbol; + userSymbols[name] = symbol; } const baseTypes = nodeType.getBaseTypes() || []; @@ -1047,7 +1069,7 @@ export function buildGenerator(program: ts.Program, args: PartialArgs = {}): Jso if (!inheritingTypes[baseName]) { inheritingTypes[baseName] = []; } - inheritingTypes[baseName].push(fullName); + inheritingTypes[baseName].push(name); }); } else { ts.forEachChild(node, n => inspect(n, tc)); @@ -1056,7 +1078,7 @@ export function buildGenerator(program: ts.Program, args: PartialArgs = {}): Jso inspect(sourceFile, typeChecker); }); - return new JsonSchemaGenerator(allSymbols, userSymbols, inheritingTypes, typeChecker, settings); + return new JsonSchemaGenerator(symbols, allSymbols, userSymbols, inheritingTypes, typeChecker, settings); } else { diagnostics.forEach((diagnostic) => { const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n");