diff --git a/package.json b/package.json index ec6063a4..b3fd052f 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,8 @@ "run": "ts-node typescript-json-schema-cli.ts", "build": "tsc -p .", "lint": "tslint --project tsconfig.json -c tslint.json --exclude '**/*.d.ts'", - "style": "yarn prettier --write *.js *.ts test/*.ts" + "style": "yarn prettier --write *.js *.ts test/*.ts", + "dev": "tsc -w -p .", + "test:dev": "mocha -t 5000 --watch --require source-map-support/register dist/test" } } diff --git a/test/programs/namespace-deep-1/schema.json b/test/programs/namespace-deep-1/schema.json index 80ea995c..c1b93a92 100644 --- a/test/programs/namespace-deep-1/schema.json +++ b/test/programs/namespace-deep-1/schema.json @@ -1,5 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", + "$ref": "#/definitions/RootNamespace.Def", "definitions": { "RootNamespace.Def": { "properties": { @@ -55,26 +56,6 @@ "type": "object" } }, - "properties": { - "nest": { - "$ref": "#/definitions/RootNamespace.Def" - }, - "prev": { - "$ref": "#/definitions/RootNamespace.Def" - }, - "propA": { - "$ref": "#/definitions/RootNamespace.SubNamespace.HelperA" - }, - "propB": { - "$ref": "#/definitions/RootNamespace.SubNamespace.HelperB" - } - }, - "required": [ - "nest", - "prev", - "propA", - "propB" - ], "type": "object" } diff --git a/test/programs/namespace-deep-2/schema.json b/test/programs/namespace-deep-2/schema.json index f7b4bb88..e4e96d8e 100644 --- a/test/programs/namespace-deep-2/schema.json +++ b/test/programs/namespace-deep-2/schema.json @@ -1,5 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", + "$ref": "#/definitions/RootNamespace.SubNamespace.HelperA", "definitions": { "RootNamespace.Def": { "properties": { @@ -55,18 +56,6 @@ "type": "object" } }, - "properties": { - "propA": { - "type": "number" - }, - "propB": { - "$ref": "#/definitions/RootNamespace.SubNamespace.HelperB" - } - }, - "required": [ - "propA", - "propB" - ], "type": "object" } diff --git a/test/programs/type-globalThis/main.ts b/test/programs/type-globalThis/main.ts new file mode 100644 index 00000000..fb100b2b --- /dev/null +++ b/test/programs/type-globalThis/main.ts @@ -0,0 +1 @@ +export type Test = typeof globalThis; diff --git a/test/programs/type-globalThis/schema.json b/test/programs/type-globalThis/schema.json new file mode 100644 index 00000000..001ce34d --- /dev/null +++ b/test/programs/type-globalThis/schema.json @@ -0,0 +1,4 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object" +} diff --git a/test/programs/type-recursive/main.ts b/test/programs/type-recursive/main.ts new file mode 100644 index 00000000..f272be90 --- /dev/null +++ b/test/programs/type-recursive/main.ts @@ -0,0 +1,8 @@ +/** + * A recursive type + */ +export type TestChildren = TestChild | Array; + +interface TestChild { + type: string; +} diff --git a/test/programs/type-recursive/schema.json b/test/programs/type-recursive/schema.json new file mode 100644 index 00000000..dd0ee49f --- /dev/null +++ b/test/programs/type-recursive/schema.json @@ -0,0 +1,28 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$ref": "#/definitions/TestChildren", + "definitions": { + "TestChild": { + "properties": { + "type": { + "type": "string" + } + }, + "required": ["type"], + "type": "object" + }, + "TestChildren": { + "anyOf": [ + { + "$ref": "#/definitions/TestChild" + }, + { + "items": { + "$ref": "#/definitions/TestChildren" + }, + "type": "array" + } + ] + } + } +} diff --git a/test/schema.test.ts b/test/schema.test.ts index 6e3483aa..cb8dd840 100644 --- a/test/schema.test.ts +++ b/test/schema.test.ts @@ -388,6 +388,14 @@ describe("schema", () => { assertSchema("object-numeric-index", "IndexInterface"); assertSchema("object-numeric-index-as-property", "Target", { required: false }); }); + + describe("recursive type", () => { + assertSchema("type-recursive", "TestChildren"); + }); + + describe("typeof globalThis", () => { + assertSchema("type-globalThis", "Test"); + }); }); describe("tsconfig.json", () => { diff --git a/typescript-json-schema.ts b/typescript-json-schema.ts index 652139b8..0e740076 100644 --- a/typescript-json-schema.ts +++ b/typescript-json-schema.ts @@ -888,6 +888,13 @@ export class JsonSchemaGenerator { private getClassDefinition(clazzType: ts.Type, definition: Definition): Definition { const node = clazzType.getSymbol()!.getDeclarations()![0]; + + // Example: typeof globalThis may not have any declaration + if (!node) { + definition.type = "object"; + return definition; + } + if (this.args.typeOfKeyword && node.kind === ts.SyntaxKind.FunctionType) { definition.typeof = "function"; return definition; @@ -1048,6 +1055,8 @@ export class JsonSchemaGenerator { return name; } + private recursiveTypeRef = new Map(); + private getTypeDefinition( typ: ts.Type, asRef = this.args.ref, @@ -1084,9 +1093,11 @@ export class JsonSchemaGenerator { // FIXME: We can't just compare the name of the symbol - it ignores the namespace const isRawType = !symbol || - this.tc.getFullyQualifiedName(symbol) === "Date" || - symbol.name === "integer" || - this.tc.getIndexInfoOfType(typ, ts.IndexKind.Number) !== undefined; + // Window is incorrectly marked as rawType here for some reason + (this.tc.getFullyQualifiedName(symbol) !== "Window" && + (this.tc.getFullyQualifiedName(symbol) === "Date" || + symbol.name === "integer" || + this.tc.getIndexInfoOfType(typ, ts.IndexKind.Number) !== undefined)); // special case: an union where all child are string literals -> make an enum instead let isStringEnum = false; @@ -1106,6 +1117,7 @@ export class JsonSchemaGenerator { ) { asRef = false; // raw types and inline types cannot be reffed, // unless we are handling a type alias + // or it is recursive type - see below } } @@ -1116,15 +1128,16 @@ export class JsonSchemaGenerator { reffedType!.getFlags() & ts.SymbolFlags.Alias ? this.tc.getAliasedSymbol(reffedType!) : reffedType! ) .replace(REGEX_FILE_NAME_OR_SPACE, ""); - if (this.args.uniqueNames) { - const sourceFile = getSourceFile(reffedType!); + if (this.args.uniqueNames && reffedType) { + const sourceFile = getSourceFile(reffedType); const relativePath = path.relative(process.cwd(), sourceFile.fileName); fullTypeName = `${typeName}.${generateHashOfNode(getCanonicalDeclaration(reffedType!), relativePath)}`; } else { fullTypeName = this.makeTypeNameUnique(typ, typeName); } - } else if (asRef) { - if (this.args.uniqueNames) { + } else { + // typ.symbol can be undefined + if (this.args.uniqueNames && typ.symbol) { const sym = typ.symbol; const sourceFile = getSourceFile(sym); const relativePath = path.relative(process.cwd(), sourceFile.fileName); @@ -1139,6 +1152,15 @@ export class JsonSchemaGenerator { } } + // Handle recursive types + if (!isRawType || !!typ.aliasSymbol) { + if (this.recursiveTypeRef.has(fullTypeName)) { + asRef = true; + } else { + this.recursiveTypeRef.set(fullTypeName, definition); + } + } + if (asRef) { // We don't return the full definition, but we put it into // reffedDefinitions below. @@ -1227,6 +1249,26 @@ export class JsonSchemaGenerator { } } + if (this.recursiveTypeRef.get(fullTypeName) === definition) { + this.recursiveTypeRef.delete(fullTypeName); + // If the type was recursive (there is reffedDefinitions) - lets replace it to reference + if (this.reffedDefinitions[fullTypeName]) { + // Here we may want to filter out all type specific fields + // and include fields like description etc + const annotations = Object.entries(returnedDefinition).reduce((acc, [key, value]) => { + if (validationKeywords[key] && typeof value !== undefined) { + acc[key] = value; + } + return acc; + }, {}); + + returnedDefinition = { + $ref: `${this.args.id}#/definitions/` + fullTypeName, + ...annotations, + }; + } + } + if (otherAnnotations["nullable"]) { makeNullable(returnedDefinition); }