UNPKG

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