typescript-json-schema
Version:
typescript-json-schema generates JSON Schema files from your Typescript sources
1,314 lines (1,189 loc) • 73.1 kB
text/typescript
import * as glob from "glob";
import { stringify } from "safe-stable-stringify";
import * as path from "path";
import { createHash } from "crypto";
import * as ts from "typescript";
import { JSONSchema7, JSONSchema7TypeName } from "json-schema";
import { pathEqual } from "path-equal";
export { Program, CompilerOptions, Symbol } from "typescript";
const vm = require("vm");
const REGEX_FILE_NAME_OR_SPACE = /(\bimport\(".*?"\)|".*?")\.| /g;
const REGEX_TSCONFIG_NAME = /^.*\.json$/;
const REGEX_TJS_JSDOC = /^-([\w]+)\s+(\S|\S[\s\S]*\S)\s*$/g;
const REGEX_GROUP_JSDOC = /^[.]?([\w]+)\s+(\S|\S[\s\S]*\S)\s*$/g;
/**
* Resolve required file, his path and a property name,
* pattern: require([file_path]).[property_name]
*
* the part ".[property_name]" is optional in the regex
*
* will match:
*
* require('./path.ts')
* require('./path.ts').objectName
* require("./path.ts")
* require("./path.ts").objectName
* require('@module-name')
*
* match[2] = file_path (a path to the file with quotes)
* match[3] = (optional) property_name (a property name, exported in the file)
*
* for more details, see tests/require.test.ts
*/
const REGEX_REQUIRE = /^(\s+)?require\((\'@?[a-zA-Z0-9.\/_-]+\'|\"@?[a-zA-Z0-9.\/_-]+\")\)(\.([a-zA-Z0-9_$]+))?(\s+|$)/;
const NUMERIC_INDEX_PATTERN = "^[0-9]+$";
export function getDefaultArgs(): Args {
return {
ref: true,
aliasRef: false,
topRef: false,
titles: false,
defaultProps: false,
noExtraProps: false,
propOrder: false,
typeOfKeyword: false,
required: false,
strictNullChecks: false,
esModuleInterop: false,
skipLibCheck: false,
experimentalDecorators: true,
ignoreErrors: false,
out: "",
validationKeywords: [],
include: [],
excludePrivate: false,
uniqueNames: false,
rejectDateType: false,
id: "",
defaultNumberType: "number",
tsNodeRegister: false,
constAsEnum: false,
};
}
export type ValidationKeywords = {
[prop: string]: boolean;
};
export type Args = {
ref: boolean;
aliasRef: boolean;
topRef: boolean;
titles: boolean;
defaultProps: boolean;
noExtraProps: boolean;
propOrder: boolean;
typeOfKeyword: boolean;
required: boolean;
strictNullChecks: boolean;
esModuleInterop: boolean;
skipLibCheck: boolean;
ignoreErrors: boolean;
experimentalDecorators: boolean;
out: string;
validationKeywords: string[];
include: string[];
excludePrivate: boolean;
uniqueNames: boolean;
rejectDateType: boolean;
id: string;
defaultNumberType: "number" | "integer";
tsNodeRegister: boolean;
constAsEnum: boolean;
};
export type PartialArgs = Partial<Args>;
export type PrimitiveType = number | boolean | string | null;
type MetaDefinitionFields = "ignore";
type RedefinedFields =
| "items"
| "additionalItems"
| "contains"
| "properties"
| "patternProperties"
| "additionalProperties"
| "dependencies"
| "propertyNames"
| "if"
| "then"
| "else"
| "allOf"
| "anyOf"
| "oneOf"
| "not"
| "definitions";
export type DefinitionOrBoolean = Definition | boolean;
export interface Definition extends Omit<JSONSchema7, RedefinedFields> {
// Non-standard fields
propertyOrder?: string[];
defaultProperties?: string[];
typeof?: "function";
// Fields that must be redefined because they make use of this definition itself
items?: DefinitionOrBoolean | DefinitionOrBoolean[];
additionalItems?: DefinitionOrBoolean;
contains?: JSONSchema7;
properties?: {
[key: string]: DefinitionOrBoolean;
};
patternProperties?: {
[key: string]: DefinitionOrBoolean;
};
additionalProperties?: DefinitionOrBoolean;
dependencies?: {
[key: string]: DefinitionOrBoolean | string[];
};
propertyNames?: DefinitionOrBoolean;
if?: DefinitionOrBoolean;
then?: DefinitionOrBoolean;
else?: DefinitionOrBoolean;
allOf?: DefinitionOrBoolean[];
anyOf?: DefinitionOrBoolean[];
oneOf?: DefinitionOrBoolean[];
not?: DefinitionOrBoolean;
definitions?: {
[key: string]: DefinitionOrBoolean;
};
}
/** A looser Definition type that allows for indexing with arbitrary strings. */
type DefinitionIndex = { [key: string]: Definition[keyof Definition] };
export type SymbolRef = {
name: string;
typeName: string;
fullyQualifiedName: string;
symbol: ts.Symbol;
};
function extend(target: any, ..._: any[]): any {
if (target == null) {
// TypeError if undefined or null
throw new TypeError("Cannot convert undefined or null to object");
}
const to = Object(target);
for (var index = 1; index < arguments.length; index++) {
const nextSource = arguments[index];
if (nextSource != null) {
// Skip over if undefined or null
for (const nextKey in nextSource) {
// Avoid bugs when hasOwnProperty is shadowed
if (Object.prototype.hasOwnProperty.call(nextSource, nextKey)) {
to[nextKey] = nextSource[nextKey];
}
}
}
}
return to;
}
function unique(arr: string[]): string[] {
const temp: Record<string, true> = {};
for (const e of arr) {
temp[e] = true;
}
const r: string[] = [];
for (const k in temp) {
// Avoid bugs when hasOwnProperty is shadowed
if (Object.prototype.hasOwnProperty.call(temp, k)) {
r.push(k);
}
}
return r;
}
/**
* Resolve required file
*/
function resolveRequiredFile(symbol: ts.Symbol, key: string, fileName: string, objectName: string): any {
const sourceFile = getSourceFile(symbol);
const requiredFilePath = /^[.\/]+/.test(fileName)
? fileName === "."
? path.resolve(sourceFile.fileName)
: path.resolve(path.dirname(sourceFile.fileName), fileName)
: fileName;
const requiredFile = require(requiredFilePath);
if (!requiredFile) {
throw Error("Required: File couldn't be loaded");
}
const requiredObject = objectName ? requiredFile[objectName] : requiredFile.default;
if (requiredObject === undefined) {
throw Error("Required: Variable is undefined");
}
if (typeof requiredObject === "function") {
throw Error("Required: Can't use function as a variable");
}
if (key === "examples" && !Array.isArray(requiredObject)) {
throw Error("Required: Variable isn't an array");
}
return requiredObject;
}
export function regexRequire(value: string) {
return REGEX_REQUIRE.exec(value);
}
/**
* Try to parse a value and returns the string if it fails.
*/
function parseValue(symbol: ts.Symbol, key: string, value: string): any {
const match = regexRequire(value);
if (match) {
const fileName = match[2].substr(1, match[2].length - 2).trim();
const objectName = match[4];
return resolveRequiredFile(symbol, key, fileName, objectName);
}
try {
return JSON.parse(value);
} catch (error) {
return value;
}
}
function extractLiteralValue(typ: ts.Type): PrimitiveType | undefined {
let str = (<ts.LiteralType>typ).value;
if (str === undefined) {
str = (typ as any).text;
}
if (typ.flags & ts.TypeFlags.StringLiteral) {
return str as string;
} else if (typ.flags & ts.TypeFlags.BooleanLiteral) {
return (typ as any).intrinsicName === "true";
} else if (typ.flags & ts.TypeFlags.EnumLiteral) {
// or .text for old TS
const num = parseFloat(str as string);
return isNaN(num) ? (str as string) : num;
} else if (typ.flags & ts.TypeFlags.NumberLiteral) {
return parseFloat(str as string);
}
return undefined;
}
/**
* Checks whether a type is a tuple type.
*/
function resolveTupleType(propertyType: ts.Type): ts.TupleTypeNode | null {
if (
!propertyType.getSymbol() &&
propertyType.getFlags() & ts.TypeFlags.Object &&
(<ts.ObjectType>propertyType).objectFlags & ts.ObjectFlags.Reference
) {
return (propertyType as ts.TypeReference).target as any;
}
if (
!(
propertyType.getFlags() & ts.TypeFlags.Object &&
(<ts.ObjectType>propertyType).objectFlags & ts.ObjectFlags.Tuple
)
) {
return null;
}
return propertyType as any;
}
const simpleTypesAllowedProperties: Record<string, true> = {
type: true,
description: true,
};
function addSimpleType(def: Definition, type: JSONSchema7TypeName): boolean {
for (const k in def) {
if (!simpleTypesAllowedProperties[k]) {
return false;
}
}
if (!def.type) {
def.type = type;
} else if (typeof def.type !== "string") {
if (
!(<Object[]>def.type).every((val) => {
return typeof val === "string";
})
) {
return false;
}
if (def.type.indexOf("null") === -1) {
def.type.push("null");
}
} else {
if (typeof def.type !== "string") {
return false;
}
if (def.type !== "null") {
def.type = [def.type, "null"];
}
}
return true;
}
function makeNullable(def: Definition): Definition {
if (!addSimpleType(def, "null")) {
const union = def.oneOf || def.anyOf;
if (union) {
union.push({ type: "null" });
} else {
const subdef: DefinitionIndex = {};
for (var k in def as any) {
if (def.hasOwnProperty(k)) {
subdef[k] = def[k as keyof Definition];
delete def[k as keyof typeof def];
}
}
def.anyOf = [subdef, { type: "null" }];
}
}
return def;
}
/**
* Given a Symbol, returns a canonical Definition. That can be either:
* 1) The Symbol's valueDeclaration parameter if defined, or
* 2) The sole entry in the Symbol's declarations array, provided that array has a length of 1.
*
* valueDeclaration is listed as a required parameter in the definition of a Symbol, but I've
* experienced crashes when it's undefined at runtime, which is the reason for this function's
* existence. Not sure if that's a compiler API bug or what.
*/
function getCanonicalDeclaration(sym: ts.Symbol): ts.Declaration {
if (sym.valueDeclaration !== undefined) {
return sym.valueDeclaration;
} else if (sym.declarations?.length === 1) {
return sym.declarations[0];
}
const declarationCount = sym.declarations?.length ?? 0;
throw new Error(`Symbol "${sym.name}" has no valueDeclaration and ${declarationCount} declarations.`);
}
/**
* Given a Symbol, finds the place it was declared and chases parent pointers until we find a
* node where SyntaxKind === SourceFile.
*/
function getSourceFile(sym: ts.Symbol): ts.SourceFile {
let currentDecl: ts.Node = getCanonicalDeclaration(sym);
while (currentDecl.kind !== ts.SyntaxKind.SourceFile) {
if (currentDecl.parent === undefined) {
throw new Error(`Unable to locate source file for declaration "${sym.name}".`);
}
currentDecl = currentDecl.parent;
}
return currentDecl as ts.SourceFile;
}
/**
* JSDoc keywords that should be used to annotate the JSON schema.
*
* Many of these validation keywords are defined here: http://json-schema.org/latest/json-schema-validation.html
*/
// prettier-ignore
const validationKeywords = {
multipleOf: true, // 6.1.
maximum: true, // 6.2.
exclusiveMaximum: true, // 6.3.
minimum: true, // 6.4.
exclusiveMinimum: true, // 6.5.
maxLength: true, // 6.6.
minLength: true, // 6.7.
pattern: true, // 6.8.
items: true, // 6.9.
// additionalItems: true, // 6.10.
maxItems: true, // 6.11.
minItems: true, // 6.12.
uniqueItems: true, // 6.13.
contains: true, // 6.14.
maxProperties: true, // 6.15.
minProperties: true, // 6.16.
// required: true, // 6.17. This is not required. It is auto-generated.
// properties: true, // 6.18. This is not required. It is auto-generated.
// patternProperties: true, // 6.19.
additionalProperties: true, // 6.20.
// dependencies: true, // 6.21.
// propertyNames: true, // 6.22.
enum: true, // 6.23.
// const: true, // 6.24.
type: true, // 6.25.
// allOf: true, // 6.26.
// anyOf: true, // 6.27.
// oneOf: true, // 6.28.
// not: true, // 6.29.
examples: true, // Draft 6 (draft-handrews-json-schema-validation-01)
ignore: true,
description: true,
format: true,
default: true,
$ref: true,
id: true,
$id: true,
$comment: true,
title: true
};
/**
* Subset of descriptive, non-type keywords that are permitted alongside a $ref.
* Prior to JSON Schema draft 2019-09, $ref is a special keyword that doesn't
* permit keywords alongside it, and so AJV may raise warnings if it encounters
* any type-related keywords; see https://github.com/ajv-validator/ajv/issues/1121
*/
const annotationKeywords: { [k in keyof typeof validationKeywords]?: true } = {
description: true,
default: true,
examples: true,
title: true,
// A JSDoc $ref annotation can appear as a $ref.
$ref: true,
};
const subDefinitions: Record<string, true> = {
items: true,
additionalProperties: true,
contains: true,
};
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.
*/
private allSymbols: { [name: string]: ts.Type };
/**
* All symbols for declarations of classes, interfaces, enums, and type aliases
* defined in non-default-lib TS files.
*/
private userSymbols: { [name: string]: ts.Symbol };
/**
* Maps from the names of base types to the names of the types that inherit from
* them.
*/
private inheritingTypes: { [baseName: string]: string[] };
/**
* This map holds references to all reffed definitions, including schema
* overrides and generated definitions.
*/
private reffedDefinitions: { [key: string]: Definition } = {};
/**
* This map only holds explicit schema overrides. This helps differentiate between
* user defined schema overrides and generated definitions.
*/
private schemaOverrides = new Map<string, Definition>();
/**
* This is a set of all the user-defined validation keywords.
*/
private userValidationKeywords: ValidationKeywords;
/**
* If true, this makes constants be defined as enums with a single value. This is useful
* for cases where constant values are not supported, such as OpenAPI.
*/
private constAsEnum: boolean;
/**
* Types are assigned names which are looked up by their IDs. This is the
* map from type IDs to type names.
*/
private typeNamesById: { [id: number]: string } = {};
/**
* Whenever a type is assigned its name, its entry in this dictionary is set,
* so that we don't give the same name to two separate types.
*/
private typeIdsByName: { [name: string]: number } = {};
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;
this.tc = tc;
this.userValidationKeywords = args.validationKeywords.reduce((acc, word) => ({ ...acc, [word]: true }), {});
this.constAsEnum = args.constAsEnum;
}
public get ReffedDefinitions(): { [key: string]: Definition } {
return this.reffedDefinitions;
}
private isFromDefaultLib(symbol: ts.Symbol) {
const declarations = symbol.getDeclarations();
if (declarations && declarations.length > 0 && declarations[0].parent) {
return declarations[0].parent.getSourceFile().hasNoDefaultLib;
}
return false;
}
private resetSchemaSpecificProperties(includeAllOverrides: boolean = false) {
this.reffedDefinitions = {};
this.typeIdsByName = {};
this.typeNamesById = {};
// restore schema overrides
if (includeAllOverrides) {
this.schemaOverrides.forEach((value, key) => {
this.reffedDefinitions[key] = value;
});
}
}
/**
* Parse the comments of a symbol into the definition and other annotations.
*/
private parseCommentsIntoDefinition(symbol: ts.Symbol, definition: Definition, otherAnnotations: Record<string, true>): void {
if (!symbol) {
return;
}
if (!this.isFromDefaultLib(symbol)) {
// the comments for a symbol
const comments = symbol.getDocumentationComment(this.tc);
if (comments.length) {
definition.description = comments
.map((comment) => {
const newlineNormalizedComment = comment.text.replace(/\r\n/g, "\n");
// If a comment contains a "{@link XYZ}" inline tag that could not be
// resolved by the TS checker, then this comment will contain a trailing
// whitespace that we need to remove.
if (comment.kind === "linkText") {
return newlineNormalizedComment.trim();
}
return newlineNormalizedComment;
})
.join("").trim();
}
}
// jsdocs are separate from comments
const jsdocs = symbol.getJsDocTags();
jsdocs.forEach((doc) => {
// if we have @TJS-... annotations, we have to parse them
let name = doc.name;
const originalText = doc.text ? doc.text.map((t) => t.text).join("") : "";
let text = originalText;
// In TypeScript versions prior to 3.7, it stops parsing the annotation
// at the first non-alphanumeric character and puts the rest of the line as the
// "text" of the annotation, so we have a little hack to check for the name
// "TJS" and then we sort of re-parse the annotation to support prior versions
// of TypeScript.
if (name.startsWith("TJS-")) {
name = name.slice(4);
if (!text) {
text = "true";
}
} else if (name === "TJS" && text.startsWith("-")) {
let match: string[] | RegExpExecArray | null = new RegExp(REGEX_TJS_JSDOC).exec(originalText);
if (match) {
name = match[1];
text = match[2];
} else {
// Treat empty text as boolean true
name = (text as string).replace(/^[\s\-]+/, "");
text = "true";
}
}
// In TypeScript ~3.5, the annotation name splits at the dot character so we have
// to process the "." and beyond from the value
if (subDefinitions[name]) {
const match: string[] | RegExpExecArray | null = new RegExp(REGEX_GROUP_JSDOC).exec(text);
if (match) {
const k = match[1];
const v = match[2];
(definition as DefinitionIndex)[name] = { ...(definition as Record<string, Record<string, unknown>>)[name], [k]: v ? parseValue(symbol, k, v) : true };
return;
}
}
// In TypeScript 3.7+, the "." is kept as part of the annotation name
if (name.includes(".")) {
const parts = name.split(".");
const key = parts[0] as keyof Definition;
if (parts.length === 2 && subDefinitions[key]) {
(definition as DefinitionIndex)[key] = {
...definition[key] as Record<string, unknown>,
[parts[1]]: text ? parseValue(symbol, name, text) : true,
};
}
}
if (validationKeywords[name as keyof typeof validationKeywords] || this.userValidationKeywords[name]) {
(definition as DefinitionIndex)[name] = text === undefined ? "" : parseValue(symbol, name, text);
} else {
// special annotations
otherAnnotations[doc.name] = true;
}
});
}
private getDefinitionForRootType(
propertyType: ts.Type,
reffedType: ts.Symbol,
definition: Definition,
defaultNumberType = this.args.defaultNumberType,
ignoreUndefined = false,
): Definition {
const tupleType = resolveTupleType(propertyType);
if (tupleType) {
// tuple
const elemTypes: ts.NodeArray<ts.TypeNode> = (propertyType as any).typeArguments;
const fixedTypes = elemTypes.map((elType) => this.getTypeDefinition(elType as any));
definition.type = "array";
if (fixedTypes.length > 0) {
definition.items = fixedTypes;
}
const targetTupleType = (propertyType as ts.TupleTypeReference).target;
definition.minItems = targetTupleType.minLength;
if (targetTupleType.hasRestElement) {
definition.additionalItems = fixedTypes[fixedTypes.length - 1];
fixedTypes.splice(fixedTypes.length - 1, 1);
} else {
definition.maxItems = targetTupleType.fixedLength;
}
} else {
const propertyTypeString = this.tc.typeToString(
propertyType,
undefined,
ts.TypeFormatFlags.UseFullyQualifiedType
);
const flags = propertyType.flags;
const arrayType = this.tc.getIndexTypeOfType(propertyType, ts.IndexKind.Number);
if (flags & ts.TypeFlags.String) {
definition.type = "string";
} else if (flags & ts.TypeFlags.Number) {
const isInteger =
definition.type === "integer" ||
reffedType?.getName() === "integer" ||
defaultNumberType === "integer";
definition.type = isInteger ? "integer" : "number";
} else if (flags & ts.TypeFlags.Boolean) {
definition.type = "boolean";
} else if (flags & ts.TypeFlags.ESSymbol) {
definition.type = "object";
} else if (flags & ts.TypeFlags.Null) {
definition.type = "null";
} else if (flags & ts.TypeFlags.Undefined || propertyTypeString === "void") {
if (!ignoreUndefined) {
throw new Error("Not supported: root type undefined");
}
// will be deleted
definition.type = "undefined" as any;
} else if (flags & ts.TypeFlags.Any || flags & ts.TypeFlags.Unknown) {
// no type restriction, so that anything will match
} else if (propertyTypeString === "Date" && !this.args.rejectDateType) {
definition.type = "string";
definition.format = definition.format || "date-time";
} else if (propertyTypeString === "object") {
definition.type = "object";
definition.properties = {};
definition.additionalProperties = true;
} else if (propertyTypeString === "bigint") {
definition.type = "number";
definition.properties = {};
definition.additionalProperties = false;
} else {
const value = extractLiteralValue(propertyType);
if (value !== undefined) {
// typeof value can be: "string", "boolean", "number", or "object" if value is null
const typeofValue = typeof value;
switch (typeofValue) {
case "string":
case "boolean":
definition.type = typeofValue;
break;
case "number":
definition.type = this.args.defaultNumberType;
break;
case "object":
definition.type = "null";
break;
default:
throw new Error(`Not supported: ${value} as a enum value`);
}
if (this.constAsEnum) {
definition.enum = [value];
} else {
definition.const = value;
}
} else if (arrayType !== undefined) {
if (
propertyType.flags & ts.TypeFlags.Object &&
(propertyType as ts.ObjectType).objectFlags &
(ts.ObjectFlags.Anonymous | ts.ObjectFlags.Interface | ts.ObjectFlags.Mapped)
) {
definition.type = "object";
definition.additionalProperties = false;
definition.patternProperties = {
[NUMERIC_INDEX_PATTERN]: this.getTypeDefinition(arrayType),
};
if (!!Array.from((<any>propertyType).members)?.find((member: [string]) => member[0] !== "__index")) {
this.getClassDefinition(propertyType, definition);
}
} else if (propertyType.flags & ts.TypeFlags.TemplateLiteral) {
definition.type = "string";
// @ts-ignore
const {texts, types} = propertyType;
const pattern = [];
for (let i = 0; i < texts.length; i++) {
const text = texts[i].replace(/[\\^$.*+?()[\]{}|]/g, "\\$&");
const type = types[i];
if (i === 0) {
pattern.push(`^`);
}
if (type) {
if (type.flags & ts.TypeFlags.String) {
pattern.push(`${text}.*`);
}
if (type.flags & ts.TypeFlags.Number
|| type.flags & ts.TypeFlags.BigInt) {
pattern.push(`${text}[0-9]*`);
}
if (type.flags & ts.TypeFlags.Undefined) {
pattern.push(`${text}undefined`);
}
if (type.flags & ts.TypeFlags.Null) {
pattern.push(`${text}null`);
}
}
if (i === texts.length - 1) {
pattern.push(`${text}$`);
}
}
definition.pattern = pattern.join("");
} else {
definition.type = "array";
if (!definition.items) {
definition.items = this.getTypeDefinition(arrayType);
}
}
} else {
// Report that type could not be processed
const error = new TypeError("Unsupported type: " + propertyTypeString);
(error as any).type = propertyType;
throw error;
// definition = this.getTypeDefinition(propertyType, tc);
}
}
}
return definition;
}
private getReferencedTypeSymbol(prop: ts.Symbol): ts.Symbol | undefined {
const decl = prop.getDeclarations();
if (decl?.length) {
const type = <ts.TypeReferenceNode>(<any>decl[0]).type;
if (type && type.kind & ts.SyntaxKind.TypeReference && type.typeName) {
const symbol = this.tc.getSymbolAtLocation(type.typeName);
if (symbol && symbol.flags & ts.SymbolFlags.Alias) {
return this.tc.getAliasedSymbol(symbol);
}
return symbol;
}
}
return undefined;
}
private getDefinitionForProperty(prop: ts.Symbol, node: ts.Node): Definition | null {
if (prop.flags & ts.SymbolFlags.Method) {
return null;
}
const propertyName = prop.getName();
const propertyType = this.tc.getTypeOfSymbolAtLocation(prop, node);
const reffedType = this.getReferencedTypeSymbol(prop);
const definition = this.getTypeDefinition(propertyType, undefined, undefined, prop, reffedType);
if (this.args.titles) {
definition.title = propertyName;
}
if (definition.hasOwnProperty("ignore")) {
return null;
}
// try to get default value
const valDecl = prop.valueDeclaration as ts.VariableDeclaration;
if (valDecl?.initializer) {
let initial = valDecl.initializer;
while (ts.isTypeAssertionExpression(initial)) {
initial = initial.expression;
}
if ((<any>initial).expression) {
// node
console.warn("initializer is expression for property " + propertyName);
} else if ((<any>initial).kind && (<any>initial).kind === ts.SyntaxKind.NoSubstitutionTemplateLiteral) {
definition.default = initial.getText();
} else {
try {
const sandbox = { sandboxvar: null as any };
vm.runInNewContext("sandboxvar=" + initial.getText(), sandbox);
const val = sandbox.sandboxvar;
if (
val === null ||
typeof val === "string" ||
typeof val === "number" ||
typeof val === "boolean" ||
Object.prototype.toString.call(val) === "[object Array]"
) {
definition.default = val;
} else if (val) {
console.warn("unknown initializer for property " + propertyName + ": " + val);
}
} catch (e) {
console.warn("exception evaluating initializer for property " + propertyName);
}
}
}
return definition;
}
private getEnumDefinition(clazzType: ts.Type, definition: Definition): Definition {
const node = clazzType.getSymbol()!.getDeclarations()![0];
const fullName = this.tc.typeToString(clazzType, undefined, ts.TypeFormatFlags.UseFullyQualifiedType);
const members: ts.NodeArray<ts.EnumMember> =
node.kind === ts.SyntaxKind.EnumDeclaration
? (node as ts.EnumDeclaration).members
: ts.factory.createNodeArray([node as ts.EnumMember]);
var enumValues: (number | boolean | string | null)[] = [];
const enumTypes: JSONSchema7TypeName[] = [];
const addType = (type: JSONSchema7TypeName) => {
if (enumTypes.indexOf(type) === -1) {
enumTypes.push(type);
}
};
members.forEach((member) => {
const caseLabel = (<ts.Identifier>member.name).text;
const constantValue = this.tc.getConstantValue(member);
if (constantValue !== undefined) {
enumValues.push(constantValue);
addType(typeof constantValue as JSONSchema7TypeName); // can be only string or number;
} else {
// try to extract the enums value; it will probably by a cast expression
const initial: ts.Expression | undefined = member.initializer;
if (initial) {
if ((<any>initial).expression) {
// node
const exp = (<any>initial).expression;
const text = (<any>exp).text;
// if it is an expression with a text literal, chances are it is the enum convention:
// CASELABEL = 'literal' as any
if (text) {
enumValues.push(text);
addType("string");
} else if (exp.kind === ts.SyntaxKind.TrueKeyword || exp.kind === ts.SyntaxKind.FalseKeyword) {
enumValues.push(exp.kind === ts.SyntaxKind.TrueKeyword);
addType("boolean");
} else {
console.warn("initializer is expression for enum: " + fullName + "." + caseLabel);
}
} else if (initial.kind === ts.SyntaxKind.NoSubstitutionTemplateLiteral) {
enumValues.push(initial.getText());
addType("string");
} else if (initial.kind === ts.SyntaxKind.NullKeyword) {
enumValues.push(null);
addType("null");
}
}
}
});
if (enumTypes.length) {
definition.type = enumTypes.length === 1 ? enumTypes[0] : enumTypes;
}
if (enumValues.length > 0) {
if (enumValues.length > 1) {
definition.enum = enumValues;
} else {
definition.const = enumValues[0];
}
}
return definition;
}
private getUnionDefinition(
unionType: ts.UnionType,
unionModifier: keyof Definition,
definition: Definition
): Definition {
const enumValues: PrimitiveType[] = [];
const simpleTypes: JSONSchema7TypeName[] = [];
const schemas: Definition[] = [];
const pushSimpleType = (type: JSONSchema7TypeName) => {
if (simpleTypes.indexOf(type) === -1) {
simpleTypes.push(type);
}
};
const pushEnumValue = (val: PrimitiveType) => {
if (enumValues.indexOf(val) === -1) {
enumValues.push(val);
}
};
for (const valueType of unionType.types) {
const value = extractLiteralValue(valueType);
if (value !== undefined) {
pushEnumValue(value);
} else {
const symbol = valueType.aliasSymbol;
const def = this.getTypeDefinition(valueType, undefined, undefined, symbol, symbol, undefined, undefined, true);
if (def.type === "undefined" as any) {
continue;
}
const keys = Object.keys(def);
if (keys.length === 1 && keys[0] === "type") {
if (typeof def.type !== "string") {
console.error("Expected only a simple type.");
} else {
pushSimpleType(def.type);
}
} else {
schemas.push(def);
}
}
}
if (enumValues.length > 0) {
// If the values are true and false, just add "boolean" as simple type
const isOnlyBooleans =
enumValues.length === 2 &&
typeof enumValues[0] === "boolean" &&
typeof enumValues[1] === "boolean" &&
enumValues[0] !== enumValues[1];
if (isOnlyBooleans) {
pushSimpleType("boolean");
} else {
const enumSchema: Definition = enumValues.length > 1 ? { enum: enumValues.sort() } : { const: enumValues[0] };
// If all values are of the same primitive type, add a "type" field to the schema
if (
enumValues.every((x) => {
return typeof x === "string";
})
) {
enumSchema.type = "string";
} else if (
enumValues.every((x) => {
return typeof x === "number";
})
) {
enumSchema.type = "number";
} else if (
enumValues.every((x) => {
return typeof x === "boolean";
})
) {
enumSchema.type = "boolean";
}
schemas.push(enumSchema);
}
}
if (simpleTypes.length > 0) {
schemas.push({ type: simpleTypes.length === 1 ? simpleTypes[0] : simpleTypes });
}
if (schemas.length === 1) {
for (const k in schemas[0]) {
if (schemas[0].hasOwnProperty(k)) {
if (k === "description" && definition.hasOwnProperty(k)) {
// If we already have a more specific description, don't overwrite it.
continue;
}
(definition as DefinitionIndex)[k] = schemas[0][k as keyof Definition];
}
}
} else {
(definition as DefinitionIndex)[unionModifier] = schemas;
}
return definition;
}
private getIntersectionDefinition(intersectionType: ts.IntersectionType, definition: Definition): Definition {
const simpleTypes: JSONSchema7TypeName[] = [];
const schemas: Definition[] = [];
const pushSimpleType = (type: JSONSchema7TypeName) => {
if (simpleTypes.indexOf(type) === -1) {
simpleTypes.push(type);
}
};
for (const intersectionMember of intersectionType.types) {
const def = this.getTypeDefinition(intersectionMember);
const keys = Object.keys(def);
if (keys.length === 1 && keys[0] === "type") {
if (typeof def.type !== "string") {
console.error("Expected only a simple type.");
} else {
pushSimpleType(def.type);
}
} else {
schemas.push(def);
}
}
if (simpleTypes.length > 0) {
schemas.push({ type: simpleTypes.length === 1 ? simpleTypes[0] : simpleTypes });
}
if (schemas.length === 1) {
for (const k in schemas[0]) {
if (schemas[0].hasOwnProperty(k)) {
(definition as DefinitionIndex)[k] = schemas[0][k as keyof Definition];
}
}
} else {
definition.allOf = schemas;
}
return definition;
}
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;
}
const clazz = <ts.ClassDeclaration>node;
const props = this.tc.getPropertiesOfType(clazzType).filter((prop) => {
// filter never and undefined
const propertyFlagType = this.tc.getTypeOfSymbolAtLocation(prop, node).getFlags();
if (ts.TypeFlags.Never === propertyFlagType || ts.TypeFlags.Undefined === propertyFlagType) {
return false;
}
if (!this.args.excludePrivate) {
return true;
}
const decls = prop.declarations;
return !(
decls &&
decls.filter((decl) => {
const mods = (decl as any).modifiers;
return mods && mods.filter((mod: any) => mod.kind === ts.SyntaxKind.PrivateKeyword).length > 0;
}).length > 0
);
});
const fullName = this.tc.typeToString(clazzType, undefined, ts.TypeFormatFlags.UseFullyQualifiedType);
const modifierFlags = ts.getCombinedModifierFlags(node);
if (modifierFlags & ts.ModifierFlags.Abstract && this.inheritingTypes[fullName]) {
const oneOf = this.inheritingTypes[fullName].map((typename) => {
return this.getTypeDefinition(this.allSymbols[typename]);
});
definition.oneOf = oneOf;
} else {
if (clazz.members) {
const indexSignatures =
clazz.members == null ? [] : clazz.members.filter((x) => x.kind === ts.SyntaxKind.IndexSignature);
if (indexSignatures.length === 1) {
// for case "array-types"
const indexSignature = indexSignatures[0] as ts.IndexSignatureDeclaration;
if (indexSignature.parameters.length !== 1) {
throw new Error("Not supported: IndexSignatureDeclaration parameters.length != 1");
}
const indexSymbol: ts.Symbol = (<any>indexSignature.parameters[0]).symbol;
const indexType = this.tc.getTypeOfSymbolAtLocation(indexSymbol, node);
const isIndexedObject = indexType.flags === ts.TypeFlags.String || indexType.flags === ts.TypeFlags.Number;
if (indexType.flags !== ts.TypeFlags.Number && !isIndexedObject) {
throw new Error(
"Not supported: IndexSignatureDeclaration with index symbol other than a number or a string"
);
}
const typ = this.tc.getTypeAtLocation(indexSignature.type!);
let def: Definition | undefined;
if (typ.flags & ts.TypeFlags.IndexedAccess) {
const targetName = ts.escapeLeadingUnderscores((<any>clazzType).mapper?.target?.value);
const indexedAccessType = <ts.IndexedAccessType>typ;
const symbols: Map<ts.__String, ts.Symbol> = (<any>indexedAccessType.objectType).members;
const targetSymbol = symbols?.get(targetName);
if (targetSymbol) {
const targetNode = targetSymbol.getDeclarations()![0];
const targetDef = this.getDefinitionForProperty(targetSymbol, targetNode);
if (targetDef) {
def = targetDef;
}
}
}
if (!def) {
def = this.getTypeDefinition(typ, undefined, "anyOf");
}
if (isIndexedObject) {
definition.type = "object";
if (!Object.keys(definition.patternProperties || {}).length) {
definition.additionalProperties = def;
}
} else {
definition.type = "array";
if (!definition.items) {
definition.items = def;
}
}
}
}
const propertyDefinitions = props.reduce<Record<string, Definition>>((all, prop) => {
const propertyName = prop.getName();
const propDef = this.getDefinitionForProperty(prop, node);
if (propDef != null) {
all[propertyName] = propDef;
}
return all;
}, {});
if (definition.type === undefined) {
definition.type = "object";
}
if (definition.type === "object" && Object.keys(propertyDefinitions).length > 0) {
definition.properties = propertyDefinitions;
}
if (this.args.defaultProps) {
definition.defaultProperties = [];
}
if (this.args.noExtraProps && definition.additionalProperties === undefined) {
definition.additionalProperties = false;
}
if (this.args.propOrder) {
// propertyOrder is non-standard, but useful:
// https://github.com/json-schema/json-schema/issues/87
const propertyOrder = props.reduce((order: string[], prop: ts.Symbol) => {
order.push(prop.getName());
return order;
}, []);
definition.propertyOrder = propertyOrder;
}
if (this.args.required) {
const requiredProps = props.reduce((required: string[], prop: ts.Symbol) => {
const def = {};
this.parseCommentsIntoDefinition(prop, def, {});
const allUnionTypesFlags: number[] = (<any>prop).links?.type?.types?.map?.((t: any) => t.flags) || [];
if (
!(prop.flags & ts.SymbolFlags.Optional) &&
!(prop.flags & ts.SymbolFlags.Method) &&
!allUnionTypesFlags.includes(ts.TypeFlags.Undefined) &&
!allUnionTypesFlags.includes(ts.TypeFlags.Void) &&
!def.hasOwnProperty("ignore")
) {
required.push(prop.getName());
}
return required;
}, []);
if (requiredProps.length > 0) {
definition.required = unique(requiredProps).sort();
}
}
}
return definition;
}
/**
* Gets/generates a globally unique type name for the given type
*/
private getTypeName(typ: ts.Type): string {
const id = (typ as any).id as number;
if (this.typeNamesById[id]) {
// Name already assigned?
return this.typeNamesById[id];
}
return this.makeTypeNameUnique(
typ,
this.tc
.typeToString(
typ,
undefined,
ts.TypeFormatFlags.NoTruncation | ts.TypeFormatFlags.UseFullyQualifiedType
)
.replace(REGEX_FILE_NAME_OR_SPACE, "")
);
}
private makeTypeNameUnique(typ: ts.Type, baseName: string): string {
const id = (typ as any).id as number;
let name = baseName;
// If a type with same name exists
// Try appending "_1", "_2", etc.
for (let i = 1; this.typeIdsByName[name] !== undefined && this.typeIdsByName[name] !== id; ++i) {
name = baseName + "_" + i;
}
this.typeNamesById[id] = name;
this.typeIdsByName[name] = id;
return name;
}
private recursiveTypeRef = new Map();
private getTypeDefinition(
typ: ts.Type,
asRef = this.args.ref,
unionModifier: keyof Definition = "anyOf",
prop?: ts.Symbol,
reffedType?: ts.Symbol,
pairedSymbol?: ts.Symbol,
forceNotRef: boolean = false,
ignoreUndefined = false,
): Definition {
const definition: Definition = {}; // real definition
// 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
while (
typ.aliasSymbol &&
(typ.aliasSymbol.escapedName === "Readonly" || typ.aliasSymbol.escapedName === "Mutable") &&
typ.aliasTypeArguments &&
typ.aliasTypeArguments[0]
) {
typ = typ.aliasTypeArguments[0];
reffedType = undefined;
}
if (
this.args.typeOfKeyword &&
typ.flags & ts.TypeFlags.Object &&
(<ts.ObjectType>typ).objectFlags & ts.ObjectFlags.Anonymous
) {
definition.typeof = "function";