UNPKG

48.6 kBPlain TextView Raw
1import * as glob from "glob";
2import * as stringify from "json-stable-stringify";
3import * as path from "path";
4import { createHash } from "crypto";
5import * as ts from "typescript";
6export { Program, CompilerOptions, Symbol } from "typescript";
7
8
9const vm = require("vm");
10
11const REGEX_FILE_NAME_OR_SPACE = /(\bimport\(".*?"\)|".*?")\.| /g;
12const REGEX_TSCONFIG_NAME = /^.*\.json$/;
13const REGEX_TJS_JSDOC = /^-([\w]+)\s+(\S|\S[\s\S]*\S)\s*$/g;
14const NUMERIC_INDEX_PATTERN = "^[0-9]+$";
15
16export function getDefaultArgs(): Args {
17 return {
18 ref: true,
19 aliasRef: false,
20 topRef: false,
21 titles: false,
22 defaultProps: false,
23 noExtraProps: false,
24 propOrder: false,
25 typeOfKeyword: false,
26 required: false,
27 strictNullChecks: false,
28 ignoreErrors: false,
29 out: "",
30 validationKeywords: [],
31 include: [],
32 excludePrivate: false,
33 uniqueNames: false,
34 rejectDateType: false,
35 id: "",
36 };
37}
38
39export type ValidationKeywords = {
40 [prop: string]: boolean
41};
42
43export type Args = {
44 ref: boolean;
45 aliasRef: boolean;
46 topRef: boolean;
47 titles: boolean;
48 defaultProps: boolean;
49 noExtraProps: boolean;
50 propOrder: boolean;
51 typeOfKeyword: boolean;
52 required: boolean;
53 strictNullChecks: boolean;
54 ignoreErrors: boolean;
55 out: string;
56 validationKeywords: string[];
57 include: string[];
58 excludePrivate: boolean;
59 uniqueNames: boolean;
60 rejectDateType: boolean;
61 id: string;
62};
63
64export type PartialArgs = Partial<Args>;
65
66export type PrimitiveType = number | boolean | string | null;
67
68export type Definition = {
69 $ref?: string,
70 $schema?: string,
71 $id?: string,
72 description?: string,
73 allOf?: Definition[],
74 oneOf?: Definition[],
75 anyOf?: Definition[],
76 title?: string,
77 type?: string | string[],
78 definitions?: {[key: string]: any},
79 format?: string,
80 items?: Definition | Definition[],
81 minItems?: number,
82 additionalItems?: {
83 anyOf: Definition[]
84 } | Definition,
85 enum?: PrimitiveType[] | Definition[],
86 default?: PrimitiveType | Object,
87 additionalProperties?: Definition | boolean,
88 required?: string[],
89 propertyOrder?: string[],
90 properties?: {[key: string]: any},
91 defaultProperties?: string[],
92 patternProperties?: {[pattern: string]: Definition},
93 typeof?: "function"
94};
95
96export type SymbolRef = {
97 name: string;
98 typeName: string;
99 fullyQualifiedName: string;
100 symbol: ts.Symbol;
101};
102
103function extend(target: any, ..._: any[]) {
104 if (target == null) { // TypeError if undefined or null
105 throw new TypeError("Cannot convert undefined or null to object");
106 }
107
108 const to = Object(target);
109
110 for (var index = 1; index < arguments.length; index++) {
111 const nextSource = arguments[index];
112
113 if (nextSource != null) { // Skip over if undefined or null
114 for (const nextKey in nextSource) {
115 // Avoid bugs when hasOwnProperty is shadowed
116 if (Object.prototype.hasOwnProperty.call(nextSource, nextKey)) {
117 to[nextKey] = nextSource[nextKey];
118 }
119 }
120 }
121 }
122 return to;
123}
124
125function unique(arr: string[]): string[] {
126 const temp = {};
127 for (const e of arr) {
128 temp[e] = true;
129 }
130 const r: string[] = [];
131 for (const k in temp) {
132 // Avoid bugs when hasOwnProperty is shadowed
133 if (Object.prototype.hasOwnProperty.call(temp, k)) {
134 r.push(k);
135 }
136 }
137 return r;
138}
139
140/**
141 * Try to parse a value and returns the string if it fails.
142 */
143function parseValue(value: string) {
144 try {
145 return JSON.parse(value);
146 } catch (error) {
147 return value;
148 }
149}
150
151function extractLiteralValue(typ: ts.Type): PrimitiveType | undefined {
152 let str = (<ts.LiteralType>typ).value;
153 if (str === undefined) {
154 str = (typ as any).text;
155 }
156 if (typ.flags & ts.TypeFlags.StringLiteral) {
157 return str as string;
158 } else if (typ.flags & ts.TypeFlags.BooleanLiteral) {
159 return (typ as any).intrinsicName === "true";
160 } else if (typ.flags & ts.TypeFlags.EnumLiteral) {
161 // or .text for old TS
162 const num = parseFloat(str as string);
163 return isNaN(num) ? str as string : num;
164 } else if (typ.flags & ts.TypeFlags.NumberLiteral) {
165 return parseFloat(str as string);
166 }
167 return undefined;
168}
169
170/**
171 * Checks whether a type is a tuple type.
172 */
173function resolveTupleType(propertyType: ts.Type): ts.TupleTypeNode|null {
174 if (!propertyType.getSymbol() && (propertyType.getFlags() & ts.TypeFlags.Object && (<ts.ObjectType>propertyType).objectFlags & ts.ObjectFlags.Reference)) {
175 return (propertyType as ts.TypeReference).target as any;
176 }
177 if (!(propertyType.getFlags() & ts.TypeFlags.Object && (<ts.ObjectType>propertyType).objectFlags & ts.ObjectFlags.Tuple)) {
178 return null;
179 }
180 return propertyType as any;
181}
182
183const simpleTypesAllowedProperties = {
184 type: true,
185 description: true
186};
187
188function addSimpleType(def: Definition, type: string) {
189 for (const k in def) {
190 if (!simpleTypesAllowedProperties[k]) {
191 return false;
192 }
193 }
194
195 if (!def.type) {
196 def.type = type;
197 } else if (typeof def.type !== "string") {
198 if (!(<Object[]>def.type).every((val) => { return typeof val === "string"; })) {
199 return false;
200 }
201
202 if (def.type.indexOf("null") === -1) {
203 def.type.push("null");
204 }
205 } else {
206 if (typeof def.type !== "string") {
207 return false;
208 }
209
210 if (def.type !== "null") {
211 def.type = [ def.type, "null" ];
212 }
213 }
214 return true;
215}
216
217function makeNullable(def: Definition) {
218 if (!addSimpleType(def, "null")) {
219 const union = def.oneOf || def.anyOf;
220 if (union) {
221 union.push({ type: "null" });
222 } else {
223 const subdef = {};
224 for (var k in def) {
225 if (def.hasOwnProperty(k)) {
226 subdef[k] = def[k];
227 delete def[k];
228 }
229 }
230 def.anyOf = [ subdef, { type: "null" } ];
231 }
232 }
233 return def;
234}
235
236/**
237 * JSDoc keywords that should be used to annotate the JSON schema.
238 *
239 * Many of these validation keywords are defined here: http://json-schema.org/latest/json-schema-validation.html
240 */
241const validationKeywords = {
242 multipleOf: true, // 6.1.
243 maximum: true, // 6.2.
244 exclusiveMaximum: true, // 6.3.
245 minimum: true, // 6.4.
246 exclusiveMinimum: true, // 6.5.
247 maxLength: true, // 6.6.
248 minLength: true, // 6.7.
249 pattern: true, // 6.8.
250 // items: true, // 6.9.
251 // additionalItems: true, // 6.10.
252 maxItems: true, // 6.11.
253 minItems: true, // 6.12.
254 uniqueItems: true, // 6.13.
255 // contains: true, // 6.14.
256 maxProperties: true, // 6.15.
257 minProperties: true, // 6.16.
258 // required: true, // 6.17. This is not required. It is auto-generated.
259 // properties: true, // 6.18. This is not required. It is auto-generated.
260 // patternProperties: true, // 6.19.
261 additionalProperties: true, // 6.20.
262 // dependencies: true, // 6.21.
263 // propertyNames: true, // 6.22.
264 enum: true, // 6.23.
265 // const: true, // 6.24.
266 type: true, // 6.25.
267 // allOf: true, // 6.26.
268 // anyOf: true, // 6.27.
269 // oneOf: true, // 6.28.
270 // not: true, // 6.29.
271
272 ignore: true,
273 description: true,
274 format: true,
275 default: true,
276 $ref: true,
277 id: true
278};
279
280export class JsonSchemaGenerator {
281 private tc: ts.TypeChecker;
282
283 /**
284 * Holds all symbols within a custom SymbolRef object, containing useful
285 * information.
286 */
287 private symbols: SymbolRef[];
288 /**
289 * All types for declarations of classes, interfaces, enums, and type aliases
290 * defined in all TS files.
291 */
292 private allSymbols: { [name: string]: ts.Type };
293 /**
294 * All symbols for declarations of classes, interfaces, enums, and type aliases
295 * defined in non-default-lib TS files.
296 */
297 private userSymbols: { [name: string]: ts.Symbol };
298 /**
299 * Maps from the names of base types to the names of the types that inherit from
300 * them.
301 */
302 private inheritingTypes: { [baseName: string]: string[] };
303
304 private reffedDefinitions: { [key: string]: Definition } = {};
305
306 /**
307 * This is a set of all the user-defined validation keywords.
308 */
309 private userValidationKeywords: ValidationKeywords;
310
311 /**
312 * Types are assigned names which are looked up by their IDs. This is the
313 * map from type IDs to type names.
314 */
315 private typeNamesById: { [id: number]: string } = {};
316 /**
317 * Whenever a type is assigned its name, its entry in this dictionary is set,
318 * so that we don't give the same name to two separate types.
319 */
320 private typeNamesUsed: { [name: string]: boolean } = {};
321
322 constructor(
323 symbols: SymbolRef[],
324 allSymbols: { [name: string]: ts.Type },
325 userSymbols: { [name: string]: ts.Symbol },
326 inheritingTypes: { [baseName: string]: string[] },
327 tc: ts.TypeChecker,
328 private args = getDefaultArgs(),
329 ) {
330 this.symbols = symbols;
331 this.allSymbols = allSymbols;
332 this.userSymbols = userSymbols;
333 this.inheritingTypes = inheritingTypes;
334 this.tc = tc;
335 this.userValidationKeywords = args.validationKeywords.reduce(
336 (acc, word) => ({ ...acc, [word]: true }),
337 {}
338 );
339 }
340
341 public get ReffedDefinitions(): { [key: string]: Definition } {
342 return this.reffedDefinitions;
343 }
344
345 /**
346 * Parse the comments of a symbol into the definition and other annotations.
347 */
348 private parseCommentsIntoDefinition(symbol: ts.Symbol, definition: {description?: string}, otherAnnotations: {}): void {
349 if (!symbol) {
350 return;
351 }
352
353 // the comments for a symbol
354 const comments = symbol.getDocumentationComment(this.tc);
355
356 if (comments.length) {
357 definition.description = comments.map(comment => comment.kind === "lineBreak" ? comment.text : comment.text.trim().replace(/\r\n/g, "\n")).join("");
358 }
359
360 // jsdocs are separate from comments
361 const jsdocs = symbol.getJsDocTags();
362 jsdocs.forEach(doc => {
363 // if we have @TJS-... annotations, we have to parse them
364 const [name, text] = (doc.name === "TJS" ? new RegExp(REGEX_TJS_JSDOC).exec(doc.text!)!.slice(1,3) : [doc.name, doc.text]) as string[];
365 if (validationKeywords[name] || this.userValidationKeywords[name]) {
366 definition[name] = text === undefined ? "" : parseValue(text);
367 } else {
368 // special annotations
369 otherAnnotations[doc.name] = true;
370 }
371 });
372 }
373
374 private getDefinitionForRootType(propertyType: ts.Type, reffedType: ts.Symbol, definition: Definition) {
375 const tupleType = resolveTupleType(propertyType);
376
377 if (tupleType) { // tuple
378 const elemTypes: ts.NodeArray<ts.TypeNode> = tupleType.elementTypes || (propertyType as any).typeArguments;
379 const fixedTypes = elemTypes.map(elType => this.getTypeDefinition(elType as any));
380 definition.type = "array";
381 definition.items = fixedTypes;
382 const targetTupleType = (propertyType as ts.TupleTypeReference).target;
383 definition.minItems = targetTupleType.minLength;
384 if (targetTupleType.hasRestElement) {
385 definition.additionalItems = fixedTypes[fixedTypes.length - 1];
386 fixedTypes.splice(fixedTypes.length - 1, 1);
387 } else {
388 definition.additionalItems = {
389 anyOf: fixedTypes
390 };
391 }
392 } else {
393 const propertyTypeString = this.tc.typeToString(propertyType, undefined, ts.TypeFormatFlags.UseFullyQualifiedType);
394 const flags = propertyType.flags;
395 const arrayType = this.tc.getIndexTypeOfType(propertyType, ts.IndexKind.Number);
396
397 if (flags & ts.TypeFlags.String) {
398 definition.type = "string";
399 } else if (flags & ts.TypeFlags.Number) {
400 const isInteger = (definition.type === "integer" || (reffedType && reffedType.getName() === "integer"));
401 definition.type = isInteger ? "integer" : "number";
402 } else if (flags & ts.TypeFlags.Boolean) {
403 definition.type = "boolean";
404 } else if (flags & ts.TypeFlags.Null) {
405 definition.type = "null";
406 } else if (flags & ts.TypeFlags.Undefined) {
407 definition.type = "undefined";
408 } else if (flags & ts.TypeFlags.Any) {
409 // no type restriction, so that anything will match
410 } else if (propertyTypeString === "Date" && !this.args.rejectDateType) {
411 definition.type = "string";
412 definition.format = "date-time";
413 } else if (propertyTypeString === "object") {
414 definition.type = "object";
415 definition.properties = {};
416 definition.additionalProperties = true;
417 } else {
418 const value = extractLiteralValue(propertyType);
419 if (value !== undefined) {
420 definition.type = typeof value;
421 definition.enum = [ value ];
422 } else if (arrayType !== undefined) {
423 if ((propertyType.flags & ts.TypeFlags.Object) &&
424 ((propertyType as ts.ObjectType).objectFlags & (ts.ObjectFlags.Anonymous | ts.ObjectFlags.Interface))) {
425 definition.type = "object";
426 definition.additionalProperties = false;
427 definition.patternProperties = {
428 [NUMERIC_INDEX_PATTERN]: this.getTypeDefinition(arrayType)
429 };
430 } else {
431 definition.type = "array";
432 definition.items = this.getTypeDefinition(arrayType);
433 }
434 } else {
435 // Report that type could not be processed
436 const error = new TypeError("Unsupported type: " + propertyTypeString);
437 (error as any).type = propertyType;
438 throw error;
439 // definition = this.getTypeDefinition(propertyType, tc);
440 }
441 }
442 }
443
444 return definition;
445 }
446
447 private getReferencedTypeSymbol(prop: ts.Symbol): ts.Symbol|undefined {
448 const decl = prop.getDeclarations();
449 if (decl && decl.length) {
450 const type = (<ts.TypeReferenceNode> (<any> decl[0]).type);
451 if (type && (type.kind & ts.SyntaxKind.TypeReference) && type.typeName) {
452 const symbol = this.tc.getSymbolAtLocation(type.typeName);
453 if (symbol && (symbol.flags & ts.SymbolFlags.Alias)) {
454 return this.tc.getAliasedSymbol(symbol);
455 }
456 return symbol;
457 }
458 }
459 return undefined;
460 }
461
462 private getDefinitionForProperty(prop: ts.Symbol, node: ts.Node) {
463 if (prop.flags & ts.SymbolFlags.Method) {
464 return null;
465 }
466 const propertyName = prop.getName();
467 const propertyType = this.tc.getTypeOfSymbolAtLocation(prop, node);
468
469 const reffedType = this.getReferencedTypeSymbol(prop);
470
471 const definition = this.getTypeDefinition(propertyType, undefined, undefined, prop, reffedType);
472
473 if (this.args.titles) {
474 definition.title = propertyName;
475 }
476
477 if (definition.hasOwnProperty("ignore")) {
478 return null;
479 }
480
481 // try to get default value
482 const valDecl = prop.valueDeclaration as ts.VariableDeclaration;
483 if (valDecl && valDecl.initializer) {
484 let initial = valDecl.initializer;
485
486 while (ts.isTypeAssertion(initial)) {
487 initial = initial.expression;
488 }
489
490 if ((<any>initial).expression) { // node
491 console.warn("initializer is expression for property " + propertyName);
492 } else if ((<any>initial).kind && (<any>initial).kind === ts.SyntaxKind.NoSubstitutionTemplateLiteral) {
493 definition.default = initial.getText();
494 } else {
495 try {
496 const sandbox = { sandboxvar: null as any };
497 vm.runInNewContext("sandboxvar=" + initial.getText(), sandbox);
498
499 const val = sandbox.sandboxvar;
500 if (val === null || typeof val === "string" || typeof val === "number" || typeof val === "boolean" || Object.prototype.toString.call(val) === "[object Array]") {
501 definition.default = val;
502 } else if (val) {
503 console.warn("unknown initializer for property " + propertyName + ": " + val);
504 }
505 } catch (e) {
506 console.warn("exception evaluating initializer for property " + propertyName);
507 }
508 }
509 }
510
511 return definition;
512 }
513
514 private getEnumDefinition(clazzType: ts.Type, definition: Definition): Definition {
515 const node = clazzType.getSymbol()!.getDeclarations()![0];
516 const fullName = this.tc.typeToString(clazzType, undefined, ts.TypeFormatFlags.UseFullyQualifiedType);
517 const members: ts.NodeArray<ts.EnumMember> = node.kind === ts.SyntaxKind.EnumDeclaration ?
518 (node as ts.EnumDeclaration).members :
519 ts.createNodeArray([node as ts.EnumMember]);
520 var enumValues: (number|boolean|string|null)[] = [];
521 const enumTypes: string[] = [];
522
523 const addType = (type: string) => {
524 if (enumTypes.indexOf(type) === -1) {
525 enumTypes.push(type);
526 }
527 };
528
529 members.forEach(member => {
530 const caseLabel = (<ts.Identifier>member.name).text;
531 const constantValue = this.tc.getConstantValue(member);
532 if (constantValue !== undefined) {
533 enumValues.push(constantValue);
534 addType(typeof constantValue);
535 } else {
536 // try to extract the enums value; it will probably by a cast expression
537 const initial: ts.Expression|undefined = member.initializer;
538 if (initial) {
539 if ((<any>initial).expression) { // node
540 const exp = (<any>initial).expression;
541 const text = (<any>exp).text;
542 // if it is an expression with a text literal, chances are it is the enum convension:
543 // CASELABEL = 'literal' as any
544 if (text) {
545 enumValues.push(text);
546 addType("string");
547 } else if (exp.kind === ts.SyntaxKind.TrueKeyword || exp.kind === ts.SyntaxKind.FalseKeyword) {
548 enumValues.push((exp.kind === ts.SyntaxKind.TrueKeyword));
549 addType("boolean");
550 } else {
551 console.warn("initializer is expression for enum: " + fullName + "." + caseLabel);
552 }
553 } else if (initial.kind === ts.SyntaxKind.NoSubstitutionTemplateLiteral) {
554 enumValues.push(initial.getText());
555 addType("string");
556 } else if (initial.kind === ts.SyntaxKind.NullKeyword) {
557 enumValues.push(null);
558 addType("null");
559 }
560 }
561 }
562 });
563
564 if (enumTypes.length) {
565 definition.type = (enumTypes.length === 1) ? enumTypes[0] : enumTypes;
566 }
567
568 if (enumValues.length > 0) {
569 definition.enum = enumValues.sort();
570 }
571
572 return definition;
573 }
574
575 private getUnionDefinition(unionType: ts.UnionType, prop: ts.Symbol, unionModifier: string, definition: Definition) {
576 const enumValues: PrimitiveType[] = [];
577 const simpleTypes: string[] = [];
578 const schemas: Definition[] = [];
579
580 const pushSimpleType = (type: string) => {
581 if (simpleTypes.indexOf(type) === -1) {
582 simpleTypes.push(type);
583 }
584 };
585
586 const pushEnumValue = (val: PrimitiveType) => {
587 if (enumValues.indexOf(val) === -1) {
588 enumValues.push(val);
589 }
590 };
591
592 for (const valueType of unionType.types) {
593 const value = extractLiteralValue(valueType);
594 if (value !== undefined) {
595 pushEnumValue(value);
596 } else {
597 const def = this.getTypeDefinition(valueType);
598 if (def.type === "undefined") {
599 if (prop) {
600 (<any>prop).mayBeUndefined = true;
601 }
602 } else {
603 const keys = Object.keys(def);
604 if (keys.length === 1 && keys[0] === "type") {
605 if (typeof def.type !== "string") {
606 console.error("Expected only a simple type.");
607 } else {
608 pushSimpleType(def.type);
609 }
610 } else {
611 schemas.push(def);
612 }
613 }
614 }
615 }
616
617 if (enumValues.length > 0) {
618 // If the values are true and false, just add "boolean" as simple type
619 const isOnlyBooleans = enumValues.length === 2 &&
620 typeof enumValues[0] === "boolean" &&
621 typeof enumValues[1] === "boolean" &&
622 enumValues[0] !== enumValues[1];
623
624 if (isOnlyBooleans) {
625 pushSimpleType("boolean");
626 } else {
627 const enumSchema: Definition = { enum: enumValues.sort() };
628
629 // If all values are of the same primitive type, add a "type" field to the schema
630 if (enumValues.every((x) => { return typeof x === "string"; })) {
631 enumSchema.type = "string";
632 } else if (enumValues.every((x) => { return typeof x === "number"; })) {
633 enumSchema.type = "number";
634 } else if (enumValues.every((x) => { return typeof x === "boolean"; })) {
635 enumSchema.type = "boolean";
636 }
637
638 schemas.push(enumSchema);
639 }
640 }
641
642 if (simpleTypes.length > 0) {
643 schemas.push({ type: simpleTypes.length === 1 ? simpleTypes[0] : simpleTypes });
644 }
645
646 if (schemas.length === 1) {
647 for (const k in schemas[0]) {
648 if (schemas[0].hasOwnProperty(k)) {
649 definition[k] = schemas[0][k];
650 }
651 }
652 } else {
653 definition[unionModifier] = schemas;
654 }
655 return definition;
656 }
657
658 private getIntersectionDefinition(intersectionType: ts.IntersectionType, definition: Definition) {
659 const simpleTypes: string[] = [];
660 const schemas: Definition[] = [];
661
662 const pushSimpleType = (type: string) => {
663 if (simpleTypes.indexOf(type) === -1) {
664 simpleTypes.push(type);
665 }
666 };
667
668 for (const intersectionMember of intersectionType.types) {
669 const def = this.getTypeDefinition(intersectionMember);
670 if (def.type === "undefined") {
671 console.error("Undefined in intersection makes no sense.");
672 } else {
673 const keys = Object.keys(def);
674 if (keys.length === 1 && keys[0] === "type") {
675 if (typeof def.type !== "string") {
676 console.error("Expected only a simple type.");
677 } else {
678 pushSimpleType(def.type);
679 }
680 } else {
681 schemas.push(def);
682 }
683 }
684 }
685
686 if (simpleTypes.length > 0) {
687 schemas.push({ type: simpleTypes.length === 1 ? simpleTypes[0] : simpleTypes });
688 }
689
690 if (schemas.length === 1) {
691 for (const k in schemas[0]) {
692 if (schemas[0].hasOwnProperty(k)) {
693 definition[k] = schemas[0][k];
694 }
695 }
696 } else {
697 definition.allOf = schemas;
698 }
699 return definition;
700 }
701
702
703 private getClassDefinition(clazzType: ts.Type, definition: Definition): Definition {
704 const node = clazzType.getSymbol()!.getDeclarations()![0];
705 if (this.args.typeOfKeyword && node.kind === ts.SyntaxKind.FunctionType) {
706 definition.typeof = "function";
707 return definition;
708 }
709
710 const clazz = <ts.ClassDeclaration>node;
711 const props = this.tc.getPropertiesOfType(clazzType).filter(prop => {
712 if (!this.args.excludePrivate) {
713 return true;
714 }
715
716 const decls = prop.declarations;
717 return !(decls && decls.filter(decl => {
718 const mods = decl.modifiers;
719 return mods && mods.filter(mod => mod.kind === ts.SyntaxKind.PrivateKeyword).length > 0;
720 }).length > 0);
721 });
722 const fullName = this.tc.typeToString(clazzType, undefined, ts.TypeFormatFlags.UseFullyQualifiedType);
723
724 const modifierFlags = ts.getCombinedModifierFlags(node);
725
726 if (modifierFlags & ts.ModifierFlags.Abstract && this.inheritingTypes[fullName]) {
727 const oneOf = this.inheritingTypes[fullName].map((typename) => {
728 return this.getTypeDefinition(this.allSymbols[typename]);
729 });
730
731 definition.oneOf = oneOf;
732 } else {
733 if (clazz.members) {
734 const indexSignatures = clazz.members == null ? [] : clazz.members.filter(x => x.kind === ts.SyntaxKind.IndexSignature);
735 if (indexSignatures.length === 1) {
736 // for case "array-types"
737 const indexSignature = indexSignatures[0] as ts.IndexSignatureDeclaration;
738 if (indexSignature.parameters.length !== 1) {
739 throw new Error("Not supported: IndexSignatureDeclaration parameters.length != 1");
740 }
741 const indexSymbol: ts.Symbol = (<any>indexSignature.parameters[0]).symbol;
742 const indexType = this.tc.getTypeOfSymbolAtLocation(indexSymbol, node);
743 const isStringIndexed = (indexType.flags === ts.TypeFlags.String);
744 if (indexType.flags !== ts.TypeFlags.Number && !isStringIndexed) {
745 throw new Error("Not supported: IndexSignatureDeclaration with index symbol other than a number or a string");
746 }
747
748 const typ = this.tc.getTypeAtLocation(indexSignature.type!);
749 const def = this.getTypeDefinition(typ, undefined, "anyOf");
750
751 if (isStringIndexed) {
752 definition.type = "object";
753 definition.additionalProperties = def;
754 } else {
755 definition.type = "array";
756 definition.items = def;
757 }
758 }
759 }
760
761 const propertyDefinitions = props.reduce((all, prop) => {
762 const propertyName = prop.getName();
763 const propDef = this.getDefinitionForProperty(prop, node);
764 if (propDef != null) {
765 all[propertyName] = propDef;
766 }
767 return all;
768 }, {});
769
770 if (definition.type === undefined) {
771 definition.type = "object";
772 }
773
774 if (definition.type === "object" && Object.keys(propertyDefinitions).length > 0) {
775 definition.properties = propertyDefinitions;
776 }
777
778 if (this.args.defaultProps) {
779 definition.defaultProperties = [];
780 }
781 if (this.args.noExtraProps && definition.additionalProperties === undefined) {
782 definition.additionalProperties = false;
783 }
784 if (this.args.propOrder) {
785 // propertyOrder is non-standard, but useful:
786 // https://github.com/json-schema/json-schema/issues/87
787 const propertyOrder = props.reduce((order: string[], prop: ts.Symbol) => {
788 order.push(prop.getName());
789 return order;
790 }, []);
791
792 definition.propertyOrder = propertyOrder;
793 }
794 if (this.args.required) {
795 const requiredProps = props.reduce((required: string[], prop: ts.Symbol) => {
796 const def = {};
797 this.parseCommentsIntoDefinition(prop, def, {});
798 if (!(prop.flags & ts.SymbolFlags.Optional) && !(prop.flags & ts.SymbolFlags.Method) && !(<any>prop).mayBeUndefined && !def.hasOwnProperty("ignore")) {
799 required.push(prop.getName());
800 }
801 return required;
802 }, []);
803
804 if (requiredProps.length > 0) {
805 definition.required = unique(requiredProps).sort();
806 }
807 }
808 }
809 return definition;
810 }
811
812 /**
813 * Gets/generates a globally unique type name for the given type
814 */
815 private getTypeName(typ: ts.Type) {
816 const id = (typ as any).id as number;
817 if (this.typeNamesById[id]) { // Name already assigned?
818 return this.typeNamesById[id];
819 }
820
821 const baseName = this.tc.typeToString(typ, undefined, ts.TypeFormatFlags.NoTruncation | ts.TypeFormatFlags.UseFullyQualifiedType).replace(REGEX_FILE_NAME_OR_SPACE, "");
822 let name = baseName;
823 if (this.typeNamesUsed[name]) { // If a type with same name exists
824 for (let i = 1; true; ++i) { // Try appending "_1", "_2", etc.
825 name = baseName + "_" + i;
826 if (!this.typeNamesUsed[name]) {
827 break;
828 }
829 }
830 }
831
832 this.typeNamesById[id] = name;
833 this.typeNamesUsed[name] = true;
834 return name;
835 }
836
837 private getTypeDefinition(typ: ts.Type, asRef = this.args.ref, unionModifier: string = "anyOf", prop?: ts.Symbol, reffedType?: ts.Symbol, pairedSymbol?: ts.Symbol): Definition {
838 const definition: Definition = {}; // real definition
839
840 // Ignore any number of Readonly and Mutable type wrappings, since they only add and remove readonly modifiers on fields and JSON Schema is not concerned with mutability
841 while (typ.aliasSymbol && (typ.aliasSymbol.escapedName === "Readonly" || typ.aliasSymbol.escapedName === "Mutable") && typ.aliasTypeArguments && typ.aliasTypeArguments[0]) {
842 typ = typ.aliasTypeArguments[0];
843 reffedType = undefined;
844 }
845
846 if (this.args.typeOfKeyword && (typ.flags & ts.TypeFlags.Object) && ((<ts.ObjectType>typ).objectFlags & ts.ObjectFlags.Anonymous)) {
847 definition.typeof = "function";
848 return definition;
849 }
850
851 let returnedDefinition = definition; // returned definition, may be a $ref
852
853 const symbol = typ.getSymbol();
854 // FIXME: We can't just compare the name of the symbol - it ignores the namespace
855 const isRawType = (!symbol || symbol.name === "Date" || symbol.name === "integer" || this.tc.getIndexInfoOfType(typ, ts.IndexKind.Number) !== undefined);
856
857 // special case: an union where all child are string literals -> make an enum instead
858 let isStringEnum = false;
859 if (typ.flags & ts.TypeFlags.Union) {
860 const unionType = <ts.UnionType>typ;
861 isStringEnum = (unionType.types.every(propType => {
862 return (propType.getFlags() & ts.TypeFlags.StringLiteral) !== 0;
863 }));
864 }
865
866 // aliased types must be handled slightly different
867 const asTypeAliasRef = asRef && reffedType && (this.args.aliasRef || isStringEnum);
868 if (!asTypeAliasRef) {
869 if (isRawType || typ.getFlags() & ts.TypeFlags.Object && (<ts.ObjectType>typ).objectFlags & ts.ObjectFlags.Anonymous) {
870 asRef = false; // raw types and inline types cannot be reffed,
871 // unless we are handling a type alias
872 }
873 }
874
875 let fullTypeName = "";
876 if (asTypeAliasRef) {
877 fullTypeName = this.tc.getFullyQualifiedName(
878 reffedType!.getFlags() & ts.SymbolFlags.Alias ?
879 this.tc.getAliasedSymbol(reffedType!) :
880 reffedType!
881 ).replace(REGEX_FILE_NAME_OR_SPACE, "");
882 } else if (asRef) {
883 fullTypeName = this.getTypeName(typ);
884 }
885
886 if (asRef) {
887 // We don't return the full definition, but we put it into
888 // reffedDefinitions below.
889 returnedDefinition = {
890 $ref: `${this.args.id}#/definitions/` + fullTypeName
891 };
892 }
893
894 // Parse comments
895 const otherAnnotations = {};
896 this.parseCommentsIntoDefinition(reffedType!, definition, otherAnnotations); // handle comments in the type alias declaration
897 if (prop) {
898 this.parseCommentsIntoDefinition(prop, returnedDefinition, otherAnnotations);
899 }
900 this.parseCommentsIntoDefinition(symbol!, definition, otherAnnotations);
901
902 // Create the actual definition only if is an inline definition, or
903 // if it will be a $ref and it is not yet created
904 if (!asRef || !this.reffedDefinitions[fullTypeName]) {
905 if (asRef) { // must be here to prevent recursivity problems
906 let reffedDefinition: Definition;
907 if (asTypeAliasRef && reffedType!.getFlags() & (ts.TypeFlags.IndexedAccess | ts.TypeFlags.Index | ts.TypeFlags.Intersection) && symbol) {
908 reffedDefinition = this.getTypeDefinition(typ, true, undefined, symbol, symbol);
909 } else {
910 reffedDefinition = definition;
911 }
912 this.reffedDefinitions[fullTypeName] = reffedDefinition;
913 if (this.args.titles && fullTypeName) {
914 definition.title = fullTypeName;
915 }
916 }
917 const node = symbol && symbol.getDeclarations() !== undefined ? symbol.getDeclarations()![0] : null;
918
919 if (definition.type === undefined) { // if users override the type, do not try to infer it
920 if (typ.flags & ts.TypeFlags.Union) {
921 this.getUnionDefinition(typ as ts.UnionType, prop!, unionModifier, definition);
922 } else if (typ.flags & ts.TypeFlags.Intersection) {
923 if (this.args.noExtraProps) {
924 // extend object instead of using allOf because allOf does not work well with additional properties. See #107
925 if (this.args.noExtraProps) {
926 definition.additionalProperties = false;
927 }
928
929 const types = (<ts.IntersectionType> typ).types;
930 for (const member of types) {
931 const other = this.getTypeDefinition(member, false);
932 definition.type = other.type; // should always be object
933 definition.properties = extend(definition.properties || {}, other.properties);
934 if (Object.keys(other.default || {}).length > 0) {
935 definition.default = extend(definition.default || {}, other.default);
936 }
937 if (other.required) {
938 definition.required = unique((definition.required || []).concat(other.required)).sort();
939 }
940 }
941 } else {
942 this.getIntersectionDefinition(typ as ts.IntersectionType, definition);
943 }
944 } else if (isRawType) {
945 if (pairedSymbol) {
946 this.parseCommentsIntoDefinition(pairedSymbol, definition, {});
947 }
948 this.getDefinitionForRootType(typ, reffedType!, definition);
949 } else if (node && (node.kind === ts.SyntaxKind.EnumDeclaration || node.kind === ts.SyntaxKind.EnumMember)) {
950 this.getEnumDefinition(typ, definition);
951 } else if (symbol && symbol.flags & ts.SymbolFlags.TypeLiteral && symbol.members!.size === 0 && !(node && (node.kind === ts.SyntaxKind.MappedType))) {
952 // {} is TypeLiteral with no members. Need special case because it doesn't have declarations.
953 definition.type = "object";
954 definition.properties = {};
955 } else {
956 this.getClassDefinition(typ, definition);
957 }
958 }
959 }
960
961 if (otherAnnotations["nullable"]) {
962 makeNullable(returnedDefinition);
963 }
964
965 return returnedDefinition;
966 }
967
968 public setSchemaOverride(symbolName: string, schema: Definition) {
969 this.reffedDefinitions[symbolName] = schema;
970 }
971
972 public getSchemaForSymbol(symbolName: string, includeReffedDefinitions: boolean = true): Definition {
973 if(!this.allSymbols[symbolName]) {
974 throw new Error(`type ${symbolName} not found`);
975 }
976 const def = this.getTypeDefinition(this.allSymbols[symbolName], this.args.topRef, undefined, undefined, undefined, this.userSymbols[symbolName] || undefined);
977
978 if (this.args.ref && includeReffedDefinitions && Object.keys(this.reffedDefinitions).length > 0) {
979 def.definitions = this.reffedDefinitions;
980 }
981 def["$schema"] = "http://json-schema.org/draft-07/schema#";
982 const id = this.args.id;
983 if(id) {
984 def["$id"] = this.args.id;
985 }
986 return def;
987 }
988
989 public getSchemaForSymbols(symbolNames: string[], includeReffedDefinitions: boolean = true): Definition {
990 const root = {
991 $schema: "http://json-schema.org/draft-07/schema#",
992 definitions: {}
993 };
994 const id = this.args.id;
995
996 if(id) {
997 root["$id"] = id;
998 }
999
1000 for (const symbolName of symbolNames) {
1001 root.definitions[symbolName] = this.getTypeDefinition(this.allSymbols[symbolName], this.args.topRef, undefined, undefined, undefined, this.userSymbols[symbolName]);
1002 }
1003 if (this.args.ref && includeReffedDefinitions && Object.keys(this.reffedDefinitions).length > 0) {
1004 root.definitions = {...root.definitions, ... this.reffedDefinitions};
1005 }
1006 return root;
1007 }
1008
1009 public getSymbols(name?: string): SymbolRef[] {
1010 if (name === void 0) {
1011 return this.symbols;
1012 }
1013
1014 return this.symbols.filter(symbol => symbol.typeName === name);
1015 }
1016
1017 public getUserSymbols(): string[] {
1018 return Object.keys(this.userSymbols);
1019 }
1020
1021 public getMainFileSymbols(program: ts.Program, onlyIncludeFiles?: string[]): string[] {
1022 function includeFile(file: ts.SourceFile): boolean {
1023 if (onlyIncludeFiles === undefined) {
1024 return !file.isDeclarationFile;
1025 }
1026 return onlyIncludeFiles.indexOf(file.fileName) >= 0;
1027 }
1028 const files = program.getSourceFiles().filter(includeFile);
1029 if (files.length) {
1030 return Object.keys(this.userSymbols).filter((key) => {
1031 const symbol = this.userSymbols[key];
1032 if (!symbol || !symbol.declarations || !symbol.declarations.length) {
1033 return false;
1034 }
1035 let node: ts.Node = symbol.declarations[0];
1036 while (node && node.parent) {
1037 node = node.parent;
1038 }
1039 return files.indexOf(node.getSourceFile()) > -1;
1040 });
1041 }
1042 return [];
1043 }
1044}
1045
1046export function getProgramFromFiles(files: string[], jsonCompilerOptions: any = {}, basePath: string = "./"): ts.Program {
1047 // use built-in default options
1048 const compilerOptions = ts.convertCompilerOptionsFromJson(jsonCompilerOptions, basePath).options;
1049 const options: ts.CompilerOptions = {
1050 noEmit: true, emitDecoratorMetadata: true, experimentalDecorators: true, target: ts.ScriptTarget.ES5, module: ts.ModuleKind.CommonJS, allowUnusedLabels: true,
1051 };
1052 for (const k in compilerOptions) {
1053 if (compilerOptions.hasOwnProperty(k)) {
1054 options[k] = compilerOptions[k];
1055 }
1056 }
1057 return ts.createProgram(files, options);
1058}
1059
1060function generateHashOfNode(node: ts.Node, relativePath: string) {
1061 return createHash("md5").update(relativePath).update(node.pos.toString()).digest("hex").substring(0, 8);
1062}
1063
1064export function buildGenerator(program: ts.Program, args: PartialArgs = {}, onlyIncludeFiles?: string[]): JsonSchemaGenerator|null {
1065 function isUserFile(file: ts.SourceFile): boolean {
1066 if (onlyIncludeFiles === undefined) {
1067 return !file.hasNoDefaultLib;
1068 }
1069 return onlyIncludeFiles.indexOf(file.fileName) >= 0;
1070 }
1071 // Use defaults unles otherwise specified
1072 const settings = getDefaultArgs();
1073
1074 for (const pref in args) {
1075 if (args.hasOwnProperty(pref)) {
1076 settings[pref] = args[pref];
1077 }
1078 }
1079
1080 let diagnostics: ReadonlyArray<ts.Diagnostic> = [];
1081
1082 if (!args.ignoreErrors) {
1083 diagnostics = ts.getPreEmitDiagnostics(program);
1084 }
1085
1086 if (diagnostics.length === 0) {
1087 const typeChecker = program.getTypeChecker();
1088
1089 const symbols: SymbolRef[] = [];
1090 const allSymbols: { [name: string]: ts.Type } = {};
1091 const userSymbols: { [name: string]: ts.Symbol } = {};
1092 const inheritingTypes: { [baseName: string]: string[] } = {};
1093 const workingDir = program.getCurrentDirectory();
1094
1095 program.getSourceFiles().forEach((sourceFile, _sourceFileIdx) => {
1096 const relativePath = path.relative(workingDir, sourceFile.fileName);
1097
1098 function inspect(node: ts.Node, tc: ts.TypeChecker) {
1099
1100 if (node.kind === ts.SyntaxKind.ClassDeclaration
1101 || node.kind === ts.SyntaxKind.InterfaceDeclaration
1102 || node.kind === ts.SyntaxKind.EnumDeclaration
1103 || node.kind === ts.SyntaxKind.TypeAliasDeclaration
1104 ) {
1105 const symbol: ts.Symbol = (<any>node).symbol;
1106 const nodeType = tc.getTypeAtLocation(node);
1107 const fullyQualifiedName = tc.getFullyQualifiedName(symbol);
1108 const typeName = fullyQualifiedName.replace(/".*"\./, "");
1109 const name = !args.uniqueNames ? typeName : `${typeName}.${generateHashOfNode(node, relativePath)}`;
1110
1111 symbols.push({ name, typeName, fullyQualifiedName, symbol });
1112 if (!userSymbols[name]) {
1113 allSymbols[name] = nodeType;
1114 }
1115
1116 if (isUserFile(sourceFile)) {
1117 userSymbols[name] = symbol;
1118 }
1119
1120 const baseTypes = nodeType.getBaseTypes() || [];
1121
1122 baseTypes.forEach(baseType => {
1123 var baseName = tc.typeToString(baseType, undefined, ts.TypeFormatFlags.UseFullyQualifiedType);
1124 if (!inheritingTypes[baseName]) {
1125 inheritingTypes[baseName] = [];
1126 }
1127 inheritingTypes[baseName].push(name);
1128 });
1129 } else {
1130 ts.forEachChild(node, n => inspect(n, tc));
1131 }
1132 }
1133 inspect(sourceFile, typeChecker);
1134 });
1135
1136 return new JsonSchemaGenerator(symbols, allSymbols, userSymbols, inheritingTypes, typeChecker, settings);
1137 } else {
1138 diagnostics.forEach((diagnostic) => {
1139 const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n");
1140 if(diagnostic.file) {
1141 const { line, character } = diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start!);
1142 console.error(`${diagnostic.file.fileName} (${line + 1},${character + 1}): ${message}`);
1143 } else {
1144 console.error(message);
1145 }
1146 });
1147 return null;
1148 }
1149}
1150
1151export function generateSchema(program: ts.Program, fullTypeName: string, args: PartialArgs = {}, onlyIncludeFiles?: string[]): Definition|null {
1152 const generator = buildGenerator(program, args, onlyIncludeFiles);
1153
1154 if (generator === null) {
1155 return null;
1156 }
1157
1158 if (fullTypeName === "*") { // All types in file(s)
1159 return generator.getSchemaForSymbols(generator.getMainFileSymbols(program, onlyIncludeFiles));
1160 } else { // Use specific type as root object
1161 return generator.getSchemaForSymbol(fullTypeName);
1162 }
1163}
1164
1165export function programFromConfig(configFileName: string, onlyIncludeFiles?: string[]): ts.Program {
1166 // basically a copy of https://github.com/Microsoft/TypeScript/blob/3663d400270ccae8b69cbeeded8ffdc8fa12d7ad/src/compiler/tsc.ts -> parseConfigFile
1167 const result = ts.parseConfigFileTextToJson(configFileName, ts.sys.readFile(configFileName)!);
1168 const configObject = result.config;
1169
1170 const configParseResult = ts.parseJsonConfigFileContent(configObject, ts.sys, path.dirname(configFileName), {}, path.basename(configFileName));
1171 const options = configParseResult.options;
1172 options.noEmit = true;
1173 delete options.out;
1174 delete options.outDir;
1175 delete options.outFile;
1176 delete options.declaration;
1177
1178 const program = ts.createProgram(onlyIncludeFiles || configParseResult.fileNames, options);
1179 return program;
1180}
1181
1182function normalizeFileName(fn: string): string {
1183 while (fn.substr(0, 2) === "./") {
1184 fn = fn.substr(2);
1185 }
1186 return fn;
1187}
1188
1189export function exec(filePattern: string, fullTypeName: string, args = getDefaultArgs()) {
1190 let program: ts.Program;
1191 let onlyIncludeFiles: string[] | undefined = undefined;
1192 if (REGEX_TSCONFIG_NAME.test(path.basename(filePattern))) {
1193 if (args.include && args.include.length > 0) {
1194 const globs: string[][] = args.include.map(f => glob.sync(f));
1195 onlyIncludeFiles = ([] as string[]).concat(...globs).map(normalizeFileName);
1196 }
1197 program = programFromConfig(filePattern, onlyIncludeFiles);
1198 } else {
1199 onlyIncludeFiles = glob.sync(filePattern);
1200 program = getProgramFromFiles(onlyIncludeFiles, {
1201 strictNullChecks: args.strictNullChecks
1202 });
1203 onlyIncludeFiles = onlyIncludeFiles.map(normalizeFileName);
1204 }
1205
1206 const definition = generateSchema(program, fullTypeName, args, onlyIncludeFiles);
1207 if (definition === null) {
1208 throw new Error("No output definition. Probably caused by errors prior to this?");
1209 }
1210
1211 const json = stringify(definition, {space: 4}) + "\n\n";
1212 if (args.out) {
1213 require("fs").writeFile(args.out, json, function(err: Error) {
1214 if (err) {
1215 throw new Error("Unable to write output file: " + err.message);
1216 }
1217 });
1218 } else {
1219 process.stdout.write(json);
1220 }
1221}