diff --git a/index.ts b/index.ts index 08bdbf65e..a72a3084c 100644 --- a/index.ts +++ b/index.ts @@ -21,6 +21,7 @@ export * from "./src/Utils/isAssignableTo"; export * from "./src/Utils/isHidden"; export * from "./src/Utils/modifiers"; export * from "./src/Utils/narrowType"; +export * from "./src/Utils/nodeFilename"; export * from "./src/Utils/nodeKey"; export * from "./src/Utils/notNever"; export * from "./src/Utils/preserveAnnotation"; diff --git a/src/NodeParser/EnumNodeParser.ts b/src/NodeParser/EnumNodeParser.ts index 0064f6fd0..7d809a6e2 100644 --- a/src/NodeParser/EnumNodeParser.ts +++ b/src/NodeParser/EnumNodeParser.ts @@ -5,6 +5,7 @@ import { BaseType } from "../Type/BaseType"; import { EnumType, EnumValue } from "../Type/EnumType"; import { isNodeHidden } from "../Utils/isHidden"; import { getKey } from "../Utils/nodeKey"; +import { nodeFilename } from "../Utils/nodeFilename"; export class EnumNodeParser implements SubNodeParser { public constructor(protected typeChecker: ts.TypeChecker) {} @@ -19,7 +20,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)), + nodeFilename(node) ); } diff --git a/src/NodeParser/FunctionParser.ts b/src/NodeParser/FunctionParser.ts index 1959e591d..eee55f92b 100644 --- a/src/NodeParser/FunctionParser.ts +++ b/src/NodeParser/FunctionParser.ts @@ -5,6 +5,7 @@ import { SubNodeParser } from "../SubNodeParser"; import { ObjectProperty, ObjectType } from "../Type/ObjectType"; import { getKey } from "../Utils/nodeKey"; import { DefinitionType } from "../Type/DefinitionType"; +import { nodeFilename } from "../Utils/nodeFilename"; /** * This function parser supports both `FunctionDeclaration` & `ArrowFunction` nodes. @@ -39,7 +40,9 @@ export class FunctionParser implements SubNodeParser { return new ObjectProperty(node.parameters[index].name.getText(), parameterType, required); }), - false + false, + false, + nodeFilename(node) ); return new DefinitionType(this.getTypeName(node, context), namedArguments); } diff --git a/src/NodeParser/InterfaceAndClassNodeParser.ts b/src/NodeParser/InterfaceAndClassNodeParser.ts index e2e598e60..b18811f44 100644 --- a/src/NodeParser/InterfaceAndClassNodeParser.ts +++ b/src/NodeParser/InterfaceAndClassNodeParser.ts @@ -9,6 +9,7 @@ import { ReferenceType } from "../Type/ReferenceType"; import { isNodeHidden } from "../Utils/isHidden"; import { isPublic, isStatic } from "../Utils/modifiers"; import { getKey } from "../Utils/nodeKey"; +import { nodeFilename } from "../Utils/nodeFilename"; export class InterfaceAndClassNodeParser implements SubNodeParser { public constructor( @@ -60,7 +61,14 @@ 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, + false, + nodeFilename(node) + ); } /** diff --git a/src/NodeParser/MappedTypeNodeParser.ts b/src/NodeParser/MappedTypeNodeParser.ts index e17543a2a..f9f4ddcf8 100644 --- a/src/NodeParser/MappedTypeNodeParser.ts +++ b/src/NodeParser/MappedTypeNodeParser.ts @@ -18,6 +18,7 @@ import { derefAnnotatedType, derefType } from "../Utils/derefType"; import { getKey } from "../Utils/nodeKey"; import { preserveAnnotation } from "../Utils/preserveAnnotation"; import { removeUndefined } from "../Utils/removeUndefined"; +import { nodeFilename } from "../Utils/nodeFilename"; export class MappedTypeNodeParser implements SubNodeParser { public constructor( @@ -33,6 +34,7 @@ export class MappedTypeNodeParser implements SubNodeParser { const constraintType = this.childNodeParser.createType(node.typeParameter.constraint!, context); const keyListType = derefType(constraintType); const id = `indexed-type-${getKey(node, context)}`; + const srcFileName = nodeFilename(node); if (keyListType instanceof UnionType) { // Key type resolves to a set of known properties @@ -40,11 +42,20 @@ export class MappedTypeNodeParser implements SubNodeParser { id, [], this.getProperties(node, keyListType, context), - this.getAdditionalProperties(node, keyListType, context) + this.getAdditionalProperties(node, keyListType, context), + false, + srcFileName ); } 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, + false, + srcFileName + ); } else if ( keyListType instanceof StringType || keyListType instanceof NumberType || @@ -60,7 +71,7 @@ export class MappedTypeNodeParser implements SubNodeParser { // Key type widens to `string` const type = this.childNodeParser.createType(node.type!, context); // const resultType = type instanceof NeverType ? new NeverType() : new ObjectType(id, [], [], type); - const resultType = new ObjectType(id, [], [], type); + const resultType = new ObjectType(id, [], [], type, false, srcFileName); if (resultType) { let annotations; @@ -78,9 +89,9 @@ export class MappedTypeNodeParser implements SubNodeParser { } return resultType; } 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, false, srcFileName); } else if (keyListType instanceof NeverType) { - return new ObjectType(id, [], [], false); + return new ObjectType(id, [], [], false, false, srcFileName); } else { throw new LogicError( // eslint-disable-next-line max-len diff --git a/src/NodeParser/ObjectLiteralExpressionNodeParser.ts b/src/NodeParser/ObjectLiteralExpressionNodeParser.ts index e741e43ac..75a766af5 100644 --- a/src/NodeParser/ObjectLiteralExpressionNodeParser.ts +++ b/src/NodeParser/ObjectLiteralExpressionNodeParser.ts @@ -5,6 +5,7 @@ import { SubNodeParser } from "../SubNodeParser"; import { BaseType } from "../Type/BaseType"; import { getKey } from "../Utils/nodeKey"; import { ObjectProperty, ObjectType } from "../Type/ObjectType"; +import { nodeFilename } from "../Utils/nodeFilename"; export class ObjectLiteralExpressionNodeParser implements SubNodeParser { public constructor(protected childNodeParser: NodeParser) {} @@ -23,6 +24,6 @@ export class ObjectLiteralExpressionNodeParser implements SubNodeParser { ) ); - return new ObjectType(`object-${getKey(node, context)}`, [], properties, false); + return new ObjectType(`object-${getKey(node, context)}`, [], properties, false, false, nodeFilename(node)); } } diff --git a/src/NodeParser/ObjectTypeNodeParser.ts b/src/NodeParser/ObjectTypeNodeParser.ts index fe6e63023..5efb9545a 100644 --- a/src/NodeParser/ObjectTypeNodeParser.ts +++ b/src/NodeParser/ObjectTypeNodeParser.ts @@ -4,6 +4,7 @@ import { SubNodeParser } from "../SubNodeParser"; import { BaseType } from "../Type/BaseType"; import { ObjectType } from "../Type/ObjectType"; import { getKey } from "../Utils/nodeKey"; +import { nodeFilename } from "../Utils/nodeFilename"; export class ObjectTypeNodeParser implements SubNodeParser { public supportsNode(node: ts.KeywordTypeNode): boolean { @@ -11,6 +12,6 @@ export class ObjectTypeNodeParser implements SubNodeParser { } public createType(node: ts.KeywordTypeNode, context: Context): BaseType { - return new ObjectType(`object-${getKey(node, context)}`, [], [], true, true); + return new ObjectType(`object-${getKey(node, context)}`, [], [], true, true, nodeFilename(node)); } } diff --git a/src/NodeParser/TypeAliasNodeParser.ts b/src/NodeParser/TypeAliasNodeParser.ts index 2425b19e5..ebefc87ae 100644 --- a/src/NodeParser/TypeAliasNodeParser.ts +++ b/src/NodeParser/TypeAliasNodeParser.ts @@ -6,6 +6,7 @@ import { BaseType } from "../Type/BaseType"; import { NeverType } from "../Type/NeverType"; import { ReferenceType } from "../Type/ReferenceType"; import { getKey } from "../Utils/nodeKey"; +import { nodeFilename } from "../Utils/nodeFilename"; export class TypeAliasNodeParser implements SubNodeParser { public constructor( @@ -41,7 +42,7 @@ export class TypeAliasNodeParser implements SubNodeParser { if (type instanceof NeverType) { return new NeverType(); } - return new AliasType(id, type); + return new AliasType(id, type, nodeFilename(node)); } protected getTypeId(node: ts.TypeAliasDeclaration, context: Context): string { diff --git a/src/NodeParser/TypeLiteralNodeParser.ts b/src/NodeParser/TypeLiteralNodeParser.ts index ea49cc0c9..73d3eaae9 100644 --- a/src/NodeParser/TypeLiteralNodeParser.ts +++ b/src/NodeParser/TypeLiteralNodeParser.ts @@ -8,6 +8,7 @@ import { ObjectProperty, ObjectType } from "../Type/ObjectType"; import { ReferenceType } from "../Type/ReferenceType"; import { isNodeHidden } from "../Utils/isHidden"; import { getKey } from "../Utils/nodeKey"; +import { nodeFilename } from "../Utils/nodeFilename"; export class TypeLiteralNodeParser implements SubNodeParser { public constructor( @@ -32,7 +33,14 @@ export class TypeLiteralNodeParser implements SubNodeParser { return new NeverType(); } - return new ObjectType(id, [], properties, this.getAdditionalProperties(node, context)); + return new ObjectType( + id, + [], + properties, + this.getAdditionalProperties(node, context), + false, + nodeFilename(node) + ); } protected getProperties(node: ts.TypeLiteralNode, context: Context): ObjectProperty[] | undefined { diff --git a/src/NodeParser/TypeofNodeParser.ts b/src/NodeParser/TypeofNodeParser.ts index 178910045..e45fa713b 100644 --- a/src/NodeParser/TypeofNodeParser.ts +++ b/src/NodeParser/TypeofNodeParser.ts @@ -8,6 +8,7 @@ import { ReferenceType } from "../Type/ReferenceType"; import { getKey } from "../Utils/nodeKey"; import { LiteralType } from "../Type/LiteralType"; import { UnknownType } from "../Type/UnknownType"; +import { nodeFilename } from "../Utils/nodeFilename"; export class TypeofNodeParser implements SubNodeParser { public constructor( @@ -78,6 +79,6 @@ export class TypeofNodeParser implements SubNodeParser { return new ObjectProperty(name, type, true); }); - return new ObjectType(id, [], properties, false); + return new ObjectType(id, [], properties, false, false, nodeFilename(node)); } } diff --git a/src/SchemaGenerator.ts b/src/SchemaGenerator.ts index 00f4d5802..c811c614f 100644 --- a/src/SchemaGenerator.ts +++ b/src/SchemaGenerator.ts @@ -11,6 +11,9 @@ import { localSymbolAtNode, symbolAtNode } from "./Utils/symbolAtNode"; import { removeUnreachable } from "./Utils/removeUnreachable"; import { Config } from "./Config"; import { hasJsDocTag } from "./Utils/hasJsDocTag"; +import { unambiguousName } from "./Utils/unambiguousName"; +import { resolveIdRefs } from "./Utils/resolveIdRefs"; +import { JSONSchema7 } from "json-schema"; export class SchemaGenerator { public constructor( @@ -31,17 +34,26 @@ export class SchemaGenerator { }); const rootTypeDefinition = rootTypes.length === 1 ? this.getRootTypeDefinition(rootTypes[0]) : undefined; + const definitions: StringMap = {}; - rootTypes.forEach((rootType) => this.appendRootChildDefinitions(rootType, definitions)); + // Definitions will be referred to by their ID. + // This "ID → name" map will be used to resolve object + // names before delivering the final schema to caller. + const idToNameMap = new Map(); + + rootTypes.forEach((rootType) => this.appendRootChildDefinitions(rootType, definitions, idToNameMap)); const reachableDefinitions = removeUnreachable(rootTypeDefinition, definitions); - return { + const schema = { ...(this.config?.schemaId ? { $id: this.config.schemaId } : {}), $schema: "http://json-schema.org/draft-07/schema#", ...(rootTypeDefinition ?? {}), definitions: reachableDefinitions, }; + + // Finally, replace all IDs by their actual names. + return resolveIdRefs(schema, idToNameMap, this.config?.encodeRefs ?? true) as JSONSchema7; } protected getRootNodes(fullName: string | undefined): ts.Node[] { @@ -79,7 +91,11 @@ export class SchemaGenerator { protected getRootTypeDefinition(rootType: BaseType): Definition { return this.typeFormatter.getDefinition(rootType); } - protected appendRootChildDefinitions(rootType: BaseType, childDefinitions: StringMap): void { + protected appendRootChildDefinitions( + rootType: BaseType, + childDefinitions: StringMap, + idToNameMap: Map + ): void { const seen = new Set(); const children = this.typeFormatter @@ -93,25 +109,29 @@ export class SchemaGenerator { return false; }); - const ids = new Map(); + // identify duplicated definitions. ie: definitions with distinct + // origins but sharing the same name + const duplicates: StringMap> = {}; 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] ??= new Set(); + duplicates[name].add(child); } + // for duplicated definitions, lift the name ambiguity by renaming them. children.reduce((definitions, child) => { - const name = child.getName(); - if (!(name in definitions)) { - definitions[name] = this.typeFormatter.getDefinition(child.getType()); + const id = child.getId(); + if (!(id in definitions)) { + const name = unambiguousName(child, child === rootType, [...duplicates[child.getName()]]); + + // associate the schema to its ID, allowing steps like removeUnreachable to work + definitions[id] = this.typeFormatter.getDefinition(child.getType()); + + // this ID → name map will be used in the final step, to resolve object + // names before delivering the final schema to caller + idToNameMap.set(id, name); } + return definitions; }, childDefinitions); } diff --git a/src/Type/AliasType.ts b/src/Type/AliasType.ts index e8d2e6887..92134e0a2 100644 --- a/src/Type/AliasType.ts +++ b/src/Type/AliasType.ts @@ -3,9 +3,10 @@ import { BaseType } from "./BaseType"; export class AliasType extends BaseType { public constructor( private id: string, - private type: BaseType + private type: BaseType, + srcFileName?: string ) { - super(); + super(srcFileName); } public getId(): string { diff --git a/src/Type/AnnotatedType.ts b/src/Type/AnnotatedType.ts index da55f5558..a7f1032ee 100644 --- a/src/Type/AnnotatedType.ts +++ b/src/Type/AnnotatedType.ts @@ -27,4 +27,8 @@ export class AnnotatedType extends BaseType { public isNullable(): boolean { return this.nullable; } + + public getSrcFileName(): string | null { + return this.type.getSrcFileName(); + } } diff --git a/src/Type/BaseType.ts b/src/Type/BaseType.ts index 7b2fd8cbe..a8017ebd4 100644 --- a/src/Type/BaseType.ts +++ b/src/Type/BaseType.ts @@ -1,10 +1,24 @@ export abstract class BaseType { + private srcFileName: string | null; + public abstract getId(): string; + public constructor(srcFileName?: string) { + this.srcFileName = srcFileName || null; + } + /** * Get the definition name of the type. Override for non-basic types. */ public getName(): string { return this.getId(); } + + /** + * Name of the file in which the type is defined. + * Only expected to be valued for the following types: Alias, Enum, Class, Interface. + */ + public getSrcFileName(): string | null { + return this.srcFileName; + } } diff --git a/src/Type/DefinitionType.ts b/src/Type/DefinitionType.ts index e7f8f71d8..3ee341fcd 100644 --- a/src/Type/DefinitionType.ts +++ b/src/Type/DefinitionType.ts @@ -9,7 +9,7 @@ export class DefinitionType extends BaseType { } public getId(): string { - return `def-${this.type.getId()}`; + return this.type.getId(); } public getName(): string { @@ -19,4 +19,8 @@ export class DefinitionType extends BaseType { public getType(): BaseType { return this.type; } + + public getSrcFileName(): string | null { + return this.type.getSrcFileName(); + } } diff --git a/src/Type/EnumType.ts b/src/Type/EnumType.ts index 0a252ae45..bebed4291 100644 --- a/src/Type/EnumType.ts +++ b/src/Type/EnumType.ts @@ -9,9 +9,10 @@ export class EnumType extends BaseType { public constructor( private id: string, - private values: readonly EnumValue[] + private values: readonly EnumValue[], + srcFileName?: string ) { - super(); + super(srcFileName); this.types = values.map((value) => (value == null ? new NullType() : new LiteralType(value))); } diff --git a/src/Type/ObjectType.ts b/src/Type/ObjectType.ts index 6828df694..38f1e49d5 100644 --- a/src/Type/ObjectType.ts +++ b/src/Type/ObjectType.ts @@ -26,9 +26,10 @@ export class ObjectType extends BaseType { private properties: readonly ObjectProperty[], private additionalProperties: BaseType | boolean, // whether the object is `object` - private nonPrimitive: boolean = false + private nonPrimitive: boolean = false, + srcFileName?: string ) { - super(); + super(srcFileName); } public getId(): string { diff --git a/src/TypeFormatter/DefinitionTypeFormatter.ts b/src/TypeFormatter/DefinitionTypeFormatter.ts index 04dba922c..0946af077 100644 --- a/src/TypeFormatter/DefinitionTypeFormatter.ts +++ b/src/TypeFormatter/DefinitionTypeFormatter.ts @@ -15,7 +15,7 @@ export class DefinitionTypeFormatter implements SubTypeFormatter { return type instanceof DefinitionType; } public getDefinition(type: DefinitionType): Definition { - const ref = type.getName(); + const ref = type.getId(); return { $ref: `#/definitions/${this.encodeRefs ? encodeURIComponent(ref) : ref}` }; } public getChildren(type: DefinitionType): BaseType[] { diff --git a/src/TypeFormatter/ReferenceTypeFormatter.ts b/src/TypeFormatter/ReferenceTypeFormatter.ts index 633fb074d..31554d9e1 100644 --- a/src/TypeFormatter/ReferenceTypeFormatter.ts +++ b/src/TypeFormatter/ReferenceTypeFormatter.ts @@ -15,7 +15,7 @@ export class ReferenceTypeFormatter implements SubTypeFormatter { return type instanceof ReferenceType; } public getDefinition(type: ReferenceType): Definition { - const ref = type.getName(); + const ref = type.getId(); return { $ref: `#/definitions/${this.encodeRefs ? encodeURIComponent(ref) : ref}` }; } public getChildren(type: ReferenceType): BaseType[] { diff --git a/src/Utils/constants.ts b/src/Utils/constants.ts new file mode 100644 index 000000000..ab83f4d4c --- /dev/null +++ b/src/Utils/constants.ts @@ -0,0 +1 @@ +export const DEFINITION_OFFSET = "#/definitions/".length; diff --git a/src/Utils/isLocalRef.ts b/src/Utils/isLocalRef.ts new file mode 100644 index 000000000..1bb06620a --- /dev/null +++ b/src/Utils/isLocalRef.ts @@ -0,0 +1,3 @@ +export function isLocalRef(ref: string) { + return ref.charAt(0) === "#"; +} diff --git a/src/Utils/nodeFilename.ts b/src/Utils/nodeFilename.ts new file mode 100644 index 000000000..8aa03d031 --- /dev/null +++ b/src/Utils/nodeFilename.ts @@ -0,0 +1,9 @@ +import { Node } from "typescript"; + +export function nodeFilename(node: Node): string | undefined { + if (!node.getSourceFile()) { + return undefined; + } + + return node.getSourceFile().fileName.substring(process.cwd().length + 1); +} diff --git a/src/Utils/removeUnreachable.ts b/src/Utils/removeUnreachable.ts index 479c99d89..6f6bac797 100644 --- a/src/Utils/removeUnreachable.ts +++ b/src/Utils/removeUnreachable.ts @@ -1,6 +1,7 @@ import { JSONSchema7Definition } from "json-schema"; import { Definition } from "../Schema/Definition"; import { StringMap } from "./StringMap"; +import { isLocalRef } from "./isLocalRef"; const DEFINITION_OFFSET = "#/definitions/".length; @@ -14,13 +15,13 @@ function addReachable( } if (definition.$ref) { - const typeName = decodeURIComponent(definition.$ref.slice(DEFINITION_OFFSET)); - if (reachable.has(typeName) || !isLocalRef(definition.$ref)) { + const typeId = decodeURIComponent(definition.$ref.slice(DEFINITION_OFFSET)); + if (reachable.has(typeId) || !isLocalRef(definition.$ref)) { // we've already processed this definition, or this definition refers to an external schema return; } - reachable.add(typeName); - const refDefinition = definitions[typeName]; + reachable.add(typeId); + const refDefinition = definitions[typeId]; if (!refDefinition) { throw new Error(`Encountered a reference to a missing definition: "${definition.$ref}". This is a bug.`); } @@ -83,7 +84,3 @@ export function removeUnreachable( return out; } - -function isLocalRef(ref: string) { - return ref.charAt(0) === "#"; -} diff --git a/src/Utils/resolveIdRefs.ts b/src/Utils/resolveIdRefs.ts new file mode 100644 index 000000000..57f27ccb0 --- /dev/null +++ b/src/Utils/resolveIdRefs.ts @@ -0,0 +1,72 @@ +import { JSONSchema7Definition } from "json-schema"; +import { StringMap } from "./StringMap"; +import { isLocalRef } from "./isLocalRef"; +import { DEFINITION_OFFSET } from "./constants"; + +/** + * Replace all `#/definitions/{definition-id}` and `$ref` in the schema with actual definition names. + */ +export function resolveIdRefs( + schema: JSONSchema7Definition, + idNameMap: Map, + encodeRefs: boolean +): JSONSchema7Definition { + if (!schema || typeof schema === "boolean") { + return schema; + } + const { $ref, allOf, oneOf, anyOf, not, properties, items, definitions, additionalProperties, ...rest } = schema; + const result: JSONSchema7Definition = { ...rest }; + if ($ref) { + if (!isLocalRef($ref)) { + result.$ref = $ref; + } else { + // THE Money Shot. + const id = encodeRefs ? decodeURIComponent($ref.slice(DEFINITION_OFFSET)) : $ref.slice(DEFINITION_OFFSET); + const name = idNameMap.get(id); + result.$ref = `#/definitions/${encodeRefs ? encodeURIComponent(name!) : name}`; + } + } + if (definitions) { + result.definitions = Object.entries(definitions).reduce((acc, [prop, value]) => { + const name = idNameMap.get(prop)!; + acc[name] = resolveIdRefs(value, idNameMap, encodeRefs); + return acc; + }, {} as StringMap); + } + if (properties) { + result.properties = Object.entries(properties).reduce((acc, [prop, value]) => { + acc[prop] = resolveIdRefs(value, idNameMap, encodeRefs); + return acc; + }, {} as StringMap); + } + if (additionalProperties || additionalProperties === false) { + result.additionalProperties = resolveIdRefs(additionalProperties, idNameMap, encodeRefs); + } + if (items) { + result.items = Array.isArray(items) + ? items.map((el) => resolveIdRefs(el, idNameMap, encodeRefs)) + : resolveIdRefs(items, idNameMap, encodeRefs); + } + if (allOf) { + result.allOf = allOf.map((el) => resolveIdRefs(el, idNameMap, encodeRefs)); + } + if (anyOf) { + result.anyOf = anyOf.map((el) => resolveIdRefs(el, idNameMap, encodeRefs)); + } + if (oneOf) { + result.oneOf = oneOf.map((el) => resolveIdRefs(el, idNameMap, encodeRefs)); + } + if (schema.if) { + result.if = resolveIdRefs(schema.if, idNameMap, encodeRefs); + } + if (schema.then) { + result.then = resolveIdRefs(schema.then, idNameMap, encodeRefs); + } + if (schema.else) { + result.else = resolveIdRefs(schema.else, idNameMap, encodeRefs); + } + if (not) { + result.not = resolveIdRefs(not, idNameMap, encodeRefs); + } + return result; +} diff --git a/src/Utils/unambiguousName.ts b/src/Utils/unambiguousName.ts new file mode 100644 index 000000000..3a1f60cf1 --- /dev/null +++ b/src/Utils/unambiguousName.ts @@ -0,0 +1,61 @@ +import { DefinitionType } from "../Type/DefinitionType"; + +/** + * Identifies the longest prefix common to all inputs and returns it. + */ +function longestCommonPrefix(inputs: string[]): string { + let prefix = inputs.reduce((acc, str) => (str.length < acc.length ? str : acc)); + + for (const str of inputs) { + while (str.slice(0, prefix.length) != prefix) { + prefix = prefix.slice(0, -1); + } + } + + return prefix; +} + +/** + * Returns an unambiguous name for the given definition. + * + * If the definition's name doesn't cause conflicts, its behavior is identical to getName(). + * Otherwise, it uses the definition's file name to generate an unambiguous name. + */ +export function unambiguousName(child: DefinitionType, isRoot: boolean, peers: DefinitionType[]): string { + // Root definitions or unambiguous ones get to keep their name. + if (peers.length === 1 || isRoot) { + return child.getName(); + } + + // "intermediate" type + if (!child.getType().getSrcFileName()) { + return child.getName(); + } + + // filter peers to keep only those who have file names. + // Intermediate Types - AnnotationTypes, UnionTypes, do not have file names + const sourcedPeers = peers.filter((peer) => peer.getType().getSrcFileName()); + if (sourcedPeers.length === 1) { + return sourcedPeers[0].getName(); + } + + let pathIndex = -1; + const srcPaths = sourcedPeers.map((peer, count) => { + pathIndex = child === peer ? count : pathIndex; + return peer.getType().getSrcFileName()!; + }); + + const commonPrefixLength = longestCommonPrefix(srcPaths).length; + + // the definition and its peers actually seem to refer to the same thing + if (commonPrefixLength == srcPaths[pathIndex].length) { + return child.getName(); + } + + const uniquePath = srcPaths[pathIndex] + .substring(commonPrefixLength) // remove the common prefix + .replace(/\//g, "__") // replace "/" by double underscores + .replace(/\.[^.]+$/, ""); // strip the extension + + return `${uniquePath}-${child.getName()}`; +} diff --git a/test/config.test.ts b/test/config.test.ts index 3f9ffa71c..460ecaefc 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -118,7 +118,7 @@ export class ExampleDefinitionOverrideFormatter implements SubTypeFormatter { return type instanceof DefinitionType; } public getDefinition(type: DefinitionType): Definition { - const ref = type.getName(); + const ref = type.getId(); return { $ref: `#/definitions/${ref}`, $comment: "overriden" }; } public getChildren(type: DefinitionType): BaseType[] { diff --git a/test/invalid-data.test.ts b/test/invalid-data.test.ts index 0c7f26185..fa0d936c3 100644 --- a/test/invalid-data.test.ts +++ b/test/invalid-data.test.ts @@ -32,14 +32,15 @@ describe("invalid-data", () => { // TODO: template recursive it("script-empty", assertSchema("script-empty", "MyType", `No root type "MyType" found`)); - it("duplicates", assertSchema("duplicates", "MyType", `Type "A" has multiple definitions.`)); it( "missing-discriminator", assertSchema( "missing-discriminator", "MyType", 'Cannot find discriminator keyword "type" in type ' + - '{"name":"B","type":{"id":"interface-1119825560-40-63-1119825560-0-124",' + + '{"srcFileName":null,"name":"B","type":{"srcFileName":' + + '"test/invalid-data/missing-discriminator/main.ts",' + + '"id":"interface-1119825560-40-63-1119825560-0-124",' + '"baseTypes":[],"properties":[],"additionalProperties":false,"nonPrimitive":false}}.' ) ); @@ -49,8 +50,9 @@ describe("invalid-data", () => { "non-union-discriminator", "MyType", "Cannot assign discriminator tag to type: " + - '{"id":"interface-2103469249-0-76-2103469249-0-77","baseTypes":[],' + - '"properties":[{"name":"name","type":{},"required":true}],' + + '{"srcFileName":"test/invalid-data/non-union-discriminator/main.ts",' + + '"id":"interface-2103469249-0-76-2103469249-0-77","baseTypes":[],' + + '"properties":[{"name":"name","type":{"srcFileName":null},"required":true}],' + '"additionalProperties":false,"nonPrimitive":false}. ' + "This tag can only be assigned to union types." ) diff --git a/test/valid-data-type.test.ts b/test/valid-data-type.test.ts index a55a0d8ba..0a4c1edb3 100644 --- a/test/valid-data-type.test.ts +++ b/test/valid-data-type.test.ts @@ -140,4 +140,6 @@ describe("valid-data-type", () => { it("type-satisfies", assertValidSchema("type-satisfies", "MyType")); it("ignore-export", assertValidSchema("ignore-export", "*")); + + it("type-duplicated-name", assertValidSchema("type-duplicated-name", "MyType")); }); diff --git a/test/invalid-data/duplicates/import1.ts b/test/valid-data/type-duplicated-name/import1.ts similarity index 100% rename from test/invalid-data/duplicates/import1.ts rename to test/valid-data/type-duplicated-name/import1.ts diff --git a/test/invalid-data/duplicates/import2.ts b/test/valid-data/type-duplicated-name/import2.ts similarity index 100% rename from test/invalid-data/duplicates/import2.ts rename to test/valid-data/type-duplicated-name/import2.ts diff --git a/test/invalid-data/duplicates/main.ts b/test/valid-data/type-duplicated-name/main.ts similarity index 100% rename from test/invalid-data/duplicates/main.ts rename to test/valid-data/type-duplicated-name/main.ts diff --git a/test/valid-data/type-duplicated-name/schema.json b/test/valid-data/type-duplicated-name/schema.json new file mode 100644 index 000000000..71165da87 --- /dev/null +++ b/test/valid-data/type-duplicated-name/schema.json @@ -0,0 +1,22 @@ +{ + "$ref": "#/definitions/MyType", + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "1-A": { + "type": "number" + }, + "2-A": { + "type": "string" + }, + "MyType": { + "anyOf": [ + { + "$ref": "#/definitions/1-A" + }, + { + "$ref": "#/definitions/2-A" + } + ] + } + } +}