Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Tackle multiple instances of a TypeName within a schema #667

Draft
wants to merge 15 commits into
base: next
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/NodeParser/EnumNodeParser.ts
Expand Up @@ -19,7 +19,8 @@ export class EnumNodeParser implements SubNodeParser {
`enum-${getKey(node, context)}`,
members
.filter((member: ts.EnumMember) => !isNodeHidden(member))
.map((member, index) => this.getMemberValue(member, index))
.map((member, index) => this.getMemberValue(member, index)),
node.getSourceFile().fileName
);
}

Expand Down
20 changes: 18 additions & 2 deletions src/NodeParser/InterfaceAndClassNodeParser.ts
Expand Up @@ -5,10 +5,12 @@ import { ArrayType } from "../Type/ArrayType";
import { BaseType } from "../Type/BaseType";
import { ObjectProperty, ObjectType } from "../Type/ObjectType";
import { ReferenceType } from "../Type/ReferenceType";
import { hasJsDocTag } from "../Utils/hasJsDocTag";
import { isNodeHidden } from "../Utils/isHidden";
import { isPublic, isStatic } from "../Utils/modifiers";
import { getKey } from "../Utils/nodeKey";
import { notUndefined } from "../Utils/notUndefined";
import { localSymbolAtNode } from "../Utils/symbolAtNode";

export class InterfaceAndClassNodeParser implements SubNodeParser {
public constructor(
Expand Down Expand Up @@ -64,7 +66,13 @@ export class InterfaceAndClassNodeParser implements SubNodeParser {
}
}

return new ObjectType(id, this.getBaseTypes(node, context), properties, additionalProperties);
return new ObjectType(
id,
this.getBaseTypes(node, context),
properties,
additionalProperties,
node.getSourceFile().fileName
);
}

/**
Expand Down Expand Up @@ -162,6 +170,14 @@ export class InterfaceAndClassNodeParser implements SubNodeParser {

private getTypeId(node: ts.Node, context: Context): string {
const nodeType = ts.isInterfaceDeclaration(node) ? "interface" : "class";
return `${nodeType}-${getKey(node, context)}`;
return `${this.isExportType(node) ? "def-" : ""}${nodeType}-${getKey(node, context)}`;
}

private isExportType(node: ts.Node): boolean {
if (hasJsDocTag(node, "internal")) {
return false;
}
const localSymbol = localSymbolAtNode(node);
return localSymbol ? "exportSymbol" in localSymbol : false;
}
}
21 changes: 17 additions & 4 deletions src/NodeParser/MappedTypeNodeParser.ts
Expand Up @@ -35,20 +35,33 @@ export class MappedTypeNodeParser implements SubNodeParser {
id,
[],
this.getProperties(node, keyListType, context),
this.getAdditionalProperties(node, keyListType, context)
this.getAdditionalProperties(node, keyListType, context),
node.getSourceFile().fileName
);
} else if (keyListType instanceof LiteralType) {
// Key type resolves to single known property
return new ObjectType(id, [], this.getProperties(node, new UnionType([keyListType]), context), false);
return new ObjectType(
id,
[],
this.getProperties(node, new UnionType([keyListType]), context),
false,
node.getSourceFile().fileName
);
} else if (keyListType instanceof StringType || keyListType instanceof SymbolType) {
// Key type widens to `string`
const type = this.childNodeParser.createType(node.type!, context);
return type === undefined ? undefined : new ObjectType(id, [], [], type);
return type === undefined ? undefined : new ObjectType(id, [], [], type, node.getSourceFile().fileName);
} else if (keyListType instanceof NumberType) {
const type = this.childNodeParser.createType(node.type!, this.createSubContext(node, keyListType, context));
return type === undefined ? undefined : new ArrayType(type);
} else if (keyListType instanceof EnumType) {
return new ObjectType(id, [], this.getValues(node, keyListType, context), false);
return new ObjectType(
id,
[],
this.getValues(node, keyListType, context),
false,
node.getSourceFile().fileName
);
} else {
throw new LogicError(
// eslint-disable-next-line max-len
Expand Down
8 changes: 7 additions & 1 deletion src/NodeParser/ObjectLiteralExpressionNodeParser.ts
Expand Up @@ -24,7 +24,13 @@ export class ObjectLiteralExpressionNodeParser implements SubNodeParser {
)
);

return new ObjectType(`object-${getKey(node, context)}`, [], properties, false);
return new ObjectType(
`object-${getKey(node, context)}`,
[],
properties,
false,
node.getSourceFile().fileName
);
}

// TODO: implement this?
Expand Down
2 changes: 1 addition & 1 deletion src/NodeParser/ObjectTypeNodeParser.ts
Expand Up @@ -11,6 +11,6 @@ export class ObjectTypeNodeParser implements SubNodeParser {
}

public createType(node: ts.KeywordTypeNode, context: Context): BaseType {
return new ObjectType(`object-${getKey(node, context)}`, [], [], true);
return new ObjectType(`object-${getKey(node, context)}`, [], [], true, node.getSourceFile().fileName);
}
}
2 changes: 1 addition & 1 deletion src/NodeParser/TypeAliasNodeParser.ts
Expand Up @@ -41,7 +41,7 @@ export class TypeAliasNodeParser implements SubNodeParser {
if (type === undefined) {
return undefined;
}
return new AliasType(id, type);
return new AliasType(id, type, node.getSourceFile().fileName);
}

private getTypeId(node: ts.TypeAliasDeclaration, context: Context): string {
Expand Down
8 changes: 7 additions & 1 deletion src/NodeParser/TypeLiteralNodeParser.ts
Expand Up @@ -26,7 +26,13 @@ export class TypeLiteralNodeParser implements SubNodeParser {
return undefined;
}

return new ObjectType(id, [], properties, this.getAdditionalProperties(node, context));
return new ObjectType(
id,
[],
properties,
this.getAdditionalProperties(node, context),
node.getSourceFile().fileName
);
}

private getProperties(node: ts.TypeLiteralNode, context: Context): ObjectProperty[] | undefined {
Expand Down
2 changes: 1 addition & 1 deletion src/NodeParser/TypeofNodeParser.ts
Expand Up @@ -61,6 +61,6 @@ export class TypeofNodeParser implements SubNodeParser {
return new ObjectProperty(name, type, true);
});

return new ObjectType(id, [], properties, false);
return new ObjectType(id, [], properties, false, node.getSourceFile().fileName);
}
}
48 changes: 28 additions & 20 deletions src/SchemaGenerator.ts
Expand Up @@ -12,6 +12,9 @@ import { notUndefined } from "./Utils/notUndefined";
import { removeUnreachable } from "./Utils/removeUnreachable";
import { Config } from "./Config";
import { hasJsDocTag } from "./Utils/hasJsDocTag";
import { JSONSchema7, JSONSchema7Definition } from "json-schema";
import { unambiguousName } from "./Utils/unambiguousName";
import { resolveIdRefs } from "./Utils/resolveIdRefs";

export class SchemaGenerator {
public constructor(
Expand All @@ -34,19 +37,23 @@ export class SchemaGenerator {
.filter(notUndefined);
const rootTypeDefinition = rootTypes.length === 1 ? this.getRootTypeDefinition(rootTypes[0]) : undefined;
const definitions: StringMap<Definition> = {};
rootTypes.forEach((rootType) => this.appendRootChildDefinitions(rootType, definitions));
const idNameMap = new Map<string, string>();

rootTypes.forEach((rootType) => this.appendRootChildDefinitions(rootType, definitions, idNameMap));
const reachableDefinitions = removeUnreachable(rootTypeDefinition, definitions);

return {
// create schema - all $ref's use getId().
const schema: JSONSchema7Definition = {
...(this.config?.schemaId ? { $id: this.config.schemaId } : {}),
$schema: "http://json-schema.org/draft-07/schema#",
...(rootTypeDefinition ?? {}),
definitions: reachableDefinitions,
};
// Finally, replace all getId() by their equivalent names.
return resolveIdRefs(schema, idNameMap, this.config?.encodeRefs ?? true) as JSONSchema7;
}

protected getRootNodes(fullName: string | undefined) {
protected getRootNodes(fullName: string | undefined): ts.Node[] {
if (fullName && fullName !== "*") {
return [this.findNamedNode(fullName)];
} else {
Expand Down Expand Up @@ -81,9 +88,12 @@ export class SchemaGenerator {
protected getRootTypeDefinition(rootType: BaseType): Definition {
return this.typeFormatter.getDefinition(rootType);
}
protected appendRootChildDefinitions(rootType: BaseType, childDefinitions: StringMap<Definition>): void {
protected appendRootChildDefinitions(
rootType: BaseType,
childDefinitions: StringMap<Definition>,
idNameMap: Map<string, string>
): void {
const seen = new Set<string>();

const children = this.typeFormatter
.getChildren(rootType)
.filter((child): child is DefinitionType => child instanceof DefinitionType)
Expand All @@ -95,29 +105,27 @@ export class SchemaGenerator {
return false;
});

const ids = new Map<string, string>();
const duplicates: StringMap<Set<DefinitionType>> = {};
for (const child of children) {
const name = child.getName();
const previousId = ids.get(name);
// remove def prefix from ids to avoid false alarms
// FIXME: we probably shouldn't be doing this as there is probably something wrong with the deduplication
const childId = child.getId().replace(/def-/g, "");

if (previousId && childId !== previousId) {
throw new Error(`Type "${name}" has multiple definitions.`);
}
ids.set(name, childId);
duplicates[name] = duplicates[name] ?? new Set<DefinitionType>();
duplicates[name].add(child);
}

children.reduce((definitions, child) => {
const name = child.getName();
if (!(name in definitions)) {
definitions[name] = this.typeFormatter.getDefinition(child.getType());
const id = child.getId().replace(/^def-/, "");
if (!(id in definitions)) {
const name = unambiguousName(child, child === rootType, [...duplicates[child.getName()]]);
// Record the schema against the ID, allowing steps like removeUnreachable to work
definitions[id] = this.typeFormatter.getDefinition(child.getType());
// Create a record of id->name mapping. This is used in the final step
// to resolve id -> name before delivering the schema to caller.
idNameMap.set(id, name);
}
return definitions;
}, childDefinitions);
}
protected partitionFiles() {
protected partitionFiles(): { projectFiles: ts.SourceFile[]; externalFiles: ts.SourceFile[] } {
const projectFiles = new Array<ts.SourceFile>();
const externalFiles = new Array<ts.SourceFile>();

Expand All @@ -132,7 +140,7 @@ export class SchemaGenerator {
sourceFiles: readonly ts.SourceFile[],
typeChecker: ts.TypeChecker,
types: Map<string, ts.Node>
) {
): void {
for (const sourceFile of sourceFiles) {
this.inspectNode(sourceFile, typeChecker, types);
}
Expand Down
6 changes: 5 additions & 1 deletion src/Type/AliasType.ts
@@ -1,7 +1,7 @@
import { BaseType } from "./BaseType";

export class AliasType extends BaseType {
public constructor(private id: string, private type: BaseType) {
public constructor(private id: string, private type: BaseType, private srcFileName: string) {
super();
}

Expand All @@ -12,4 +12,8 @@ export class AliasType extends BaseType {
public getType(): BaseType {
return this.type;
}

public getSrcFileName(): string {
return this.srcFileName;
}
}
8 changes: 8 additions & 0 deletions src/Type/BaseType.ts
Expand Up @@ -7,4 +7,12 @@ export abstract class BaseType {
public getName(): string {
return this.getId();
}

/**
* Provide a base class implementation. Will only be exported for entities
* exposed in a schema - Alias|Enum|Class|Interface.
*/
public getSrcFileName(): string | null {
return null;
}
}
4 changes: 4 additions & 0 deletions src/Type/DefinitionType.ts
Expand Up @@ -16,4 +16,8 @@ export class DefinitionType extends BaseType {
public getType(): BaseType {
return this.type;
}

public getSrcFileName(): string | null {
return this.type.getSrcFileName();
}
}
6 changes: 5 additions & 1 deletion src/Type/EnumType.ts
Expand Up @@ -7,7 +7,7 @@ export type EnumValue = string | boolean | number | null;
export class EnumType extends BaseType {
private types: BaseType[];

public constructor(private id: string, private values: readonly EnumValue[]) {
public constructor(private id: string, private values: readonly EnumValue[], private srcFileName: string) {
super();
this.types = values.map((value) => (value == null ? new NullType() : new LiteralType(value)));
}
Expand All @@ -23,4 +23,8 @@ export class EnumType extends BaseType {
public getTypes(): BaseType[] {
return this.types;
}

public getSrcFileName(): string {
return this.srcFileName;
}
}
6 changes: 5 additions & 1 deletion src/Type/ObjectType.ts
Expand Up @@ -20,7 +20,8 @@ export class ObjectType extends BaseType {
private id: string,
private baseTypes: readonly BaseType[],
private properties: readonly ObjectProperty[],
private additionalProperties: BaseType | boolean
private additionalProperties: BaseType | boolean,
private srcFileName: string
) {
super();
}
Expand All @@ -38,4 +39,7 @@ export class ObjectType extends BaseType {
public getAdditionalProperties(): BaseType | boolean {
return this.additionalProperties;
}
public getSrcFileName(): string {
return this.srcFileName;
}
}
9 changes: 9 additions & 0 deletions src/Type/ReferenceType.ts
Expand Up @@ -7,6 +7,8 @@ export class ReferenceType extends BaseType {

private name: string | null = null;

private srcFileName: string | null = null;

public getId(): string {
if (this.id == null) {
throw new Error("Reference type ID not set yet");
Expand Down Expand Up @@ -41,4 +43,11 @@ export class ReferenceType extends BaseType {
this.setId(type.getId());
this.setName(type.getName());
}

public getSrcFileName(): string {
if (!this.srcFileName) {
throw new Error("Reference srcFileName not set yet");
}
return this.srcFileName;
}
}
2 changes: 1 addition & 1 deletion src/TypeFormatter/DefinitionTypeFormatter.ts
Expand Up @@ -12,7 +12,7 @@ export class DefinitionTypeFormatter implements SubTypeFormatter {
return type instanceof DefinitionType;
}
public getDefinition(type: DefinitionType): Definition {
const ref = type.getName();
const ref = type.getId().replace(/^def-/, "");
return { $ref: `#/definitions/${this.encodeRefs ? encodeURIComponent(ref) : ref}` };
}
public getChildren(type: DefinitionType): BaseType[] {
Expand Down
2 changes: 1 addition & 1 deletion src/TypeFormatter/ReferenceTypeFormatter.ts
Expand Up @@ -12,7 +12,7 @@ export class ReferenceTypeFormatter implements SubTypeFormatter {
return type instanceof ReferenceType;
}
public getDefinition(type: ReferenceType): Definition {
const ref = type.getName();
const ref = type.getId().replace(/^def-/, "");
return { $ref: `#/definitions/${this.encodeRefs ? encodeURIComponent(ref) : ref}` };
}
public getChildren(type: ReferenceType): BaseType[] {
Expand Down
10 changes: 9 additions & 1 deletion src/Utils/isAssignableTo.ts
Expand Up @@ -37,7 +37,15 @@ function combineIntersectingTypes(intersection: IntersectionType): BaseType[] {
if (objectTypes.length === 1) {
combined.push(objectTypes[0]);
} else if (objectTypes.length > 1) {
combined.push(new ObjectType(`combined-objects-${intersection.getId()}`, objectTypes, [], false));
combined.push(
new ObjectType(
`combined-objects-${intersection.getId()}`,
objectTypes,
[],
false,
`non-terminal-intersection`
)
);
}
return combined;
}
Expand Down
2 changes: 1 addition & 1 deletion src/Utils/removeUnreachable.ts
Expand Up @@ -8,7 +8,7 @@ function addReachable(
definitions: StringMap<Definition>,
reachable: Set<string>
) {
if (isBoolean(definition)) {
if (!definition || isBoolean(definition)) {
return;
}

Expand Down