@twin.org/tools-core 0.0.3-next.2 → 0.0.3-next.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/es/index.js +20 -11
- package/dist/es/index.js.map +1 -1
- package/dist/es/models/ITypeScriptToSchemaContext.js +2 -0
- package/dist/es/models/ITypeScriptToSchemaContext.js.map +1 -0
- package/dist/es/models/ITypeScriptToSchemaDiagnostics.js +4 -0
- package/dist/es/models/ITypeScriptToSchemaDiagnostics.js.map +1 -0
- package/dist/es/models/ITypeScriptToSchemaOptions.js +4 -0
- package/dist/es/models/ITypeScriptToSchemaOptions.js.map +1 -0
- package/dist/es/utils/constants.js +43 -0
- package/dist/es/utils/constants.js.map +1 -0
- package/dist/es/utils/diagnosticReporter.js +32 -0
- package/dist/es/utils/diagnosticReporter.js.map +1 -0
- package/dist/es/utils/disallowedTypeGuard.js +151 -0
- package/dist/es/utils/disallowedTypeGuard.js.map +1 -0
- package/dist/es/utils/enum.js +152 -0
- package/dist/es/utils/enum.js.map +1 -0
- package/dist/es/utils/fileUtils.js +130 -0
- package/dist/es/utils/fileUtils.js.map +1 -0
- package/dist/es/utils/importTypeQuerySchemaResolver.js +328 -0
- package/dist/es/utils/importTypeQuerySchemaResolver.js.map +1 -0
- package/dist/es/utils/indexSignaturePatternResolver.js +94 -0
- package/dist/es/utils/indexSignaturePatternResolver.js.map +1 -0
- package/dist/es/utils/intersectionSchemaMerger.js +85 -0
- package/dist/es/utils/intersectionSchemaMerger.js.map +1 -0
- package/dist/es/utils/jsDoc.js +120 -0
- package/dist/es/utils/jsDoc.js.map +1 -0
- package/dist/es/utils/jsonSchemaBuilder.js +3373 -0
- package/dist/es/utils/jsonSchemaBuilder.js.map +1 -0
- package/dist/es/utils/mappedTypeSchemaResolver.js +231 -0
- package/dist/es/utils/mappedTypeSchemaResolver.js.map +1 -0
- package/dist/es/utils/objectTransformer.js +162 -0
- package/dist/es/utils/objectTransformer.js.map +1 -0
- package/dist/es/utils/regEx.js +128 -0
- package/dist/es/utils/regEx.js.map +1 -0
- package/dist/es/utils/resolver.js +164 -0
- package/dist/es/utils/resolver.js.map +1 -0
- package/dist/es/utils/templateLiteralPatternBuilder.js +94 -0
- package/dist/es/utils/templateLiteralPatternBuilder.js.map +1 -0
- package/dist/es/utils/typeScriptToSchema.js +112 -0
- package/dist/es/utils/typeScriptToSchema.js.map +1 -0
- package/dist/es/utils/utilityTypeSchemaMapper.js +412 -0
- package/dist/es/utils/utilityTypeSchemaMapper.js.map +1 -0
- package/dist/types/index.d.ts +20 -11
- package/dist/types/models/ITypeScriptToSchemaContext.d.ts +64 -0
- package/dist/types/models/ITypeScriptToSchemaDiagnostics.d.ts +31 -0
- package/dist/types/models/ITypeScriptToSchemaOptions.d.ts +22 -0
- package/dist/types/utils/constants.d.ts +13 -0
- package/dist/types/utils/diagnosticReporter.d.ts +17 -0
- package/dist/types/utils/disallowedTypeGuard.d.ts +16 -0
- package/dist/types/utils/enum.d.ts +42 -0
- package/dist/types/utils/fileUtils.d.ts +66 -0
- package/dist/types/utils/importTypeQuerySchemaResolver.d.ts +52 -0
- package/dist/types/utils/indexSignaturePatternResolver.d.ts +21 -0
- package/dist/types/utils/intersectionSchemaMerger.d.ts +16 -0
- package/dist/types/utils/jsDoc.d.ts +46 -0
- package/dist/types/utils/jsonSchemaBuilder.d.ts +747 -0
- package/dist/types/utils/mappedTypeSchemaResolver.d.ts +46 -0
- package/dist/types/utils/objectTransformer.d.ts +33 -0
- package/dist/types/utils/regEx.d.ts +24 -0
- package/dist/types/utils/resolver.d.ts +16 -0
- package/dist/types/utils/templateLiteralPatternBuilder.d.ts +12 -0
- package/dist/types/utils/typeScriptToSchema.d.ts +31 -0
- package/dist/types/utils/utilityTypeSchemaMapper.d.ts +92 -0
- package/docs/changelog.md +176 -1
- package/docs/examples.md +87 -1
- package/docs/reference/classes/Constants.md +29 -0
- package/docs/reference/classes/DiagnosticReporter.md +49 -0
- package/docs/reference/classes/DisallowedTypeGuard.md +35 -0
- package/docs/reference/classes/Enum.md +93 -0
- package/docs/reference/classes/FileUtils.md +237 -0
- package/docs/reference/classes/ImportTypeQuerySchemaResolver.md +87 -0
- package/docs/reference/classes/IndexSignaturePatternResolver.md +69 -0
- package/docs/reference/classes/IntersectionSchemaMerger.md +48 -0
- package/docs/reference/classes/JsDoc.md +141 -0
- package/docs/reference/classes/JsonSchemaBuilder.md +2870 -0
- package/docs/reference/classes/MappedTypeSchemaResolver.md +211 -0
- package/docs/reference/classes/ObjectTransformer.md +119 -0
- package/docs/reference/classes/RegEx.md +99 -0
- package/docs/reference/classes/Resolver.md +41 -0
- package/docs/reference/classes/TemplateLiteralPatternBuilder.md +35 -0
- package/docs/reference/classes/TypeScriptToSchema.md +91 -0
- package/docs/reference/classes/UtilityTypeSchemaMapper.md +341 -0
- package/docs/reference/index.md +20 -14
- package/docs/reference/interfaces/ITypeScriptToSchemaContext.md +113 -0
- package/docs/reference/interfaces/ITypeScriptToSchemaDiagnostics.md +55 -0
- package/docs/reference/interfaces/ITypeScriptToSchemaOptions.md +44 -0
- package/locales/en.json +32 -1
- package/package.json +4 -3
- package/dist/es/models/IJsonSchema.js +0 -2
- package/dist/es/models/IJsonSchema.js.map +0 -1
- package/dist/es/models/IOpenApi.js +0 -2
- package/dist/es/models/IOpenApi.js.map +0 -1
- package/dist/es/models/IOpenApiExample.js +0 -4
- package/dist/es/models/IOpenApiExample.js.map +0 -1
- package/dist/es/models/IOpenApiHeader.js +0 -4
- package/dist/es/models/IOpenApiHeader.js.map +0 -1
- package/dist/es/models/IOpenApiPathMethod.js +0 -2
- package/dist/es/models/IOpenApiPathMethod.js.map +0 -1
- package/dist/es/models/IOpenApiResponse.js +0 -2
- package/dist/es/models/IOpenApiResponse.js.map +0 -1
- package/dist/es/models/IOpenApiSecurityScheme.js +0 -4
- package/dist/es/models/IOpenApiSecurityScheme.js.map +0 -1
- package/dist/es/models/IPackageJson.js +0 -4
- package/dist/es/models/IPackageJson.js.map +0 -1
- package/dist/es/models/jsonTypeName.js +0 -2
- package/dist/es/models/jsonTypeName.js.map +0 -1
- package/dist/es/utils/jsonSchemaHelper.js +0 -258
- package/dist/es/utils/jsonSchemaHelper.js.map +0 -1
- package/dist/es/utils/openApiHelper.js +0 -12
- package/dist/es/utils/openApiHelper.js.map +0 -1
- package/dist/types/models/IJsonSchema.d.ts +0 -5
- package/dist/types/models/IOpenApi.d.ts +0 -54
- package/dist/types/models/IOpenApiExample.d.ts +0 -13
- package/dist/types/models/IOpenApiHeader.d.ts +0 -19
- package/dist/types/models/IOpenApiPathMethod.d.ts +0 -65
- package/dist/types/models/IOpenApiResponse.d.ts +0 -32
- package/dist/types/models/IOpenApiSecurityScheme.d.ts +0 -25
- package/dist/types/models/IPackageJson.d.ts +0 -15
- package/dist/types/models/jsonTypeName.d.ts +0 -5
- package/dist/types/utils/jsonSchemaHelper.d.ts +0 -78
- package/dist/types/utils/openApiHelper.d.ts +0 -9
- package/docs/reference/classes/JsonSchemaHelper.md +0 -233
- package/docs/reference/classes/OpenApiHelper.md +0 -21
- package/docs/reference/interfaces/IOpenApi.md +0 -103
- package/docs/reference/interfaces/IOpenApiExample.md +0 -19
- package/docs/reference/interfaces/IOpenApiHeader.md +0 -31
- package/docs/reference/interfaces/IOpenApiPathMethod.md +0 -119
- package/docs/reference/interfaces/IOpenApiResponse.md +0 -35
- package/docs/reference/interfaces/IOpenApiSecurityScheme.md +0 -43
- package/docs/reference/interfaces/IPackageJson.md +0 -23
- package/docs/reference/type-aliases/IJsonSchema.md +0 -5
- package/docs/reference/type-aliases/JsonTypeName.md +0 -5
|
@@ -0,0 +1,3373 @@
|
|
|
1
|
+
// Copyright 2026 IOTA Stiftung.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0.
|
|
3
|
+
import { ArrayHelper, GeneralError, Is, JsonHelper, ObjectHelper, StringHelper } from "@twin.org/core";
|
|
4
|
+
import { JsonSchemaTagNames } from "@twin.org/tools-models";
|
|
5
|
+
import * as ts from "typescript";
|
|
6
|
+
import { Constants } from "./constants.js";
|
|
7
|
+
import { DiagnosticReporter } from "./diagnosticReporter.js";
|
|
8
|
+
import { DisallowedTypeGuard } from "./disallowedTypeGuard.js";
|
|
9
|
+
import { Enum } from "./enum.js";
|
|
10
|
+
import { FileUtils } from "./fileUtils.js";
|
|
11
|
+
import { ImportTypeQuerySchemaResolver } from "./importTypeQuerySchemaResolver.js";
|
|
12
|
+
import { IndexSignaturePatternResolver } from "./indexSignaturePatternResolver.js";
|
|
13
|
+
import { IntersectionSchemaMerger } from "./intersectionSchemaMerger.js";
|
|
14
|
+
import { JsDoc } from "./jsDoc.js";
|
|
15
|
+
import { MappedTypeSchemaResolver } from "./mappedTypeSchemaResolver.js";
|
|
16
|
+
import { ObjectTransformer } from "./objectTransformer.js";
|
|
17
|
+
import { RegEx } from "./regEx.js";
|
|
18
|
+
import { Resolver } from "./resolver.js";
|
|
19
|
+
import { TemplateLiteralPatternBuilder } from "./templateLiteralPatternBuilder.js";
|
|
20
|
+
import { UtilityTypeSchemaMapper } from "./utilityTypeSchemaMapper.js";
|
|
21
|
+
/**
|
|
22
|
+
* Builder for composing JSON schema fragments from TypeScript AST nodes.
|
|
23
|
+
*/
|
|
24
|
+
export class JsonSchemaBuilder {
|
|
25
|
+
/**
|
|
26
|
+
* The JSON Schema version used.
|
|
27
|
+
*/
|
|
28
|
+
static SCHEMA_VERSION = "https://json-schema.org/draft/2020-12/schema";
|
|
29
|
+
/**
|
|
30
|
+
* Runtime name for the class.
|
|
31
|
+
*/
|
|
32
|
+
static CLASS_NAME = "JsonSchemaBuilder";
|
|
33
|
+
/**
|
|
34
|
+
* Dictionary of TypeScript utility type names to their schema mapping handlers.
|
|
35
|
+
*/
|
|
36
|
+
static _utilityTypeHandlers = {
|
|
37
|
+
Partial: (context, typeNode) => JsonSchemaBuilder.mapPartialUtilityType(context, typeNode),
|
|
38
|
+
Required: (context, typeNode) => JsonSchemaBuilder.mapRequiredUtilityType(context, typeNode),
|
|
39
|
+
Pick: (context, typeNode) => JsonSchemaBuilder.mapPickUtilityType(context, typeNode),
|
|
40
|
+
Omit: (context, typeNode) => JsonSchemaBuilder.mapOmitUtilityType(context, typeNode),
|
|
41
|
+
Exclude: (context, typeNode) => JsonSchemaBuilder.mapExcludeUtilityType(context, typeNode),
|
|
42
|
+
Extract: (context, typeNode) => JsonSchemaBuilder.mapExtractUtilityType(context, typeNode),
|
|
43
|
+
NonNullable: (context, typeNode) => JsonSchemaBuilder.mapNonNullableUtilityType(context, typeNode),
|
|
44
|
+
Record: (context, typeNode) => JsonSchemaBuilder.mapRecordUtilityType(context, typeNode),
|
|
45
|
+
JsonLdObjectWithId: (context, typeNode) => JsonSchemaBuilder.mapJsonLdObjectUtilityType(context, typeNode, {
|
|
46
|
+
keysToRemove: ["id", "@id"],
|
|
47
|
+
keyToAdd: "id",
|
|
48
|
+
isAddedKeyRequired: true
|
|
49
|
+
}),
|
|
50
|
+
JsonLdObjectWithAtId: (context, typeNode) => JsonSchemaBuilder.mapJsonLdObjectUtilityType(context, typeNode, {
|
|
51
|
+
keysToRemove: ["id", "@id"],
|
|
52
|
+
keyToAdd: "@id",
|
|
53
|
+
isAddedKeyRequired: true
|
|
54
|
+
}),
|
|
55
|
+
JsonLdObjectWithOptionalId: (context, typeNode) => JsonSchemaBuilder.mapJsonLdObjectUtilityType(context, typeNode, {
|
|
56
|
+
keysToRemove: ["id", "@id"],
|
|
57
|
+
keyToAdd: "id",
|
|
58
|
+
isAddedKeyRequired: false
|
|
59
|
+
}),
|
|
60
|
+
JsonLdObjectWithOptionalAtId: (context, typeNode) => JsonSchemaBuilder.mapJsonLdObjectUtilityType(context, typeNode, {
|
|
61
|
+
keysToRemove: ["id", "@id"],
|
|
62
|
+
keyToAdd: "@id",
|
|
63
|
+
isAddedKeyRequired: false
|
|
64
|
+
}),
|
|
65
|
+
JsonLdObjectWithNoId: (context, typeNode) => JsonSchemaBuilder.mapJsonLdObjectUtilityType(context, typeNode, {
|
|
66
|
+
keysToRemove: ["id"]
|
|
67
|
+
}),
|
|
68
|
+
JsonLdObjectWithNoAtId: (context, typeNode) => JsonSchemaBuilder.mapJsonLdObjectUtilityType(context, typeNode, {
|
|
69
|
+
keysToRemove: ["@id"]
|
|
70
|
+
}),
|
|
71
|
+
JsonLdObjectWithType: (context, typeNode) => JsonSchemaBuilder.mapJsonLdObjectUtilityType(context, typeNode, {
|
|
72
|
+
keysToRemove: ["type", "@type"],
|
|
73
|
+
keyToAdd: "type",
|
|
74
|
+
isAddedKeyRequired: true
|
|
75
|
+
}),
|
|
76
|
+
JsonLdObjectWithAtType: (context, typeNode) => JsonSchemaBuilder.mapJsonLdObjectUtilityType(context, typeNode, {
|
|
77
|
+
keysToRemove: ["type", "@type"],
|
|
78
|
+
keyToAdd: "@type",
|
|
79
|
+
isAddedKeyRequired: true
|
|
80
|
+
}),
|
|
81
|
+
JsonLdObjectWithOptionalType: (context, typeNode) => JsonSchemaBuilder.mapJsonLdObjectUtilityType(context, typeNode, {
|
|
82
|
+
keysToRemove: ["type", "@type"],
|
|
83
|
+
keyToAdd: "type",
|
|
84
|
+
isAddedKeyRequired: false
|
|
85
|
+
}),
|
|
86
|
+
JsonLdObjectWithOptionalAtType: (context, typeNode) => JsonSchemaBuilder.mapJsonLdObjectUtilityType(context, typeNode, {
|
|
87
|
+
keysToRemove: ["type", "@type"],
|
|
88
|
+
keyToAdd: "@type",
|
|
89
|
+
isAddedKeyRequired: false
|
|
90
|
+
}),
|
|
91
|
+
JsonLdObjectWithNoType: (context, typeNode) => JsonSchemaBuilder.mapJsonLdObjectUtilityType(context, typeNode, {
|
|
92
|
+
keysToRemove: ["type"]
|
|
93
|
+
}),
|
|
94
|
+
JsonLdObjectWithNoAtType: (context, typeNode) => JsonSchemaBuilder.mapJsonLdObjectUtilityType(context, typeNode, {
|
|
95
|
+
keysToRemove: ["@type"]
|
|
96
|
+
}),
|
|
97
|
+
JsonLdObjectWithContext: (context, typeNode) => JsonSchemaBuilder.mapJsonLdObjectUtilityType(context, typeNode, {
|
|
98
|
+
keysToRemove: ["@context"],
|
|
99
|
+
keyToAdd: "@context",
|
|
100
|
+
isAddedKeyRequired: true
|
|
101
|
+
}),
|
|
102
|
+
JsonLdObjectWithOptionalContext: (context, typeNode) => JsonSchemaBuilder.mapJsonLdObjectUtilityType(context, typeNode, {
|
|
103
|
+
keysToRemove: ["@context"],
|
|
104
|
+
keyToAdd: "@context",
|
|
105
|
+
isAddedKeyRequired: false
|
|
106
|
+
}),
|
|
107
|
+
JsonLdObjectWithNoContext: (context, typeNode) => JsonSchemaBuilder.mapJsonLdObjectUtilityType(context, typeNode, {
|
|
108
|
+
keysToRemove: ["@context"]
|
|
109
|
+
}),
|
|
110
|
+
SingleOccurrenceArray: (context, typeNode) => JsonSchemaBuilder.mapSingleOccurrenceArrayUtilityType(context, typeNode),
|
|
111
|
+
ObjectOrArray: (context, typeNode) => JsonSchemaBuilder.mapObjectOrArrayUtilityType(context, typeNode)
|
|
112
|
+
};
|
|
113
|
+
/**
|
|
114
|
+
* Parse all object declarations from a source file.
|
|
115
|
+
* @param context The generation context.
|
|
116
|
+
* @param sourceFilePath The source file path.
|
|
117
|
+
* @param source The TypeScript source.
|
|
118
|
+
* @param visitedFiles The list of visited source files.
|
|
119
|
+
* @returns The generated schema titles.
|
|
120
|
+
*/
|
|
121
|
+
static parseAllObjectSchemas(context, sourceFilePath, source, visitedFiles) {
|
|
122
|
+
const absoluteSourcePath = FileUtils.normalizeFilePath(sourceFilePath);
|
|
123
|
+
if (visitedFiles.includes(absoluteSourcePath)) {
|
|
124
|
+
return [];
|
|
125
|
+
}
|
|
126
|
+
visitedFiles.push(absoluteSourcePath);
|
|
127
|
+
const sourceFile = ts.createSourceFile(sourceFilePath, source, ts.ScriptTarget.Latest, true);
|
|
128
|
+
const previousSourceFile = context.activeSourceFile;
|
|
129
|
+
context.activeSourceFile = sourceFile;
|
|
130
|
+
context.schemas[context.packageName] ??= {};
|
|
131
|
+
const packageSchemaEntries = context.schemas[context.packageName];
|
|
132
|
+
const parsedTitles = [];
|
|
133
|
+
// Preload local imports so utility type mapping can resolve referenced
|
|
134
|
+
// object schemas while processing declarations in this file.
|
|
135
|
+
for (const statement of sourceFile.statements) {
|
|
136
|
+
// import { Foo } from "./module.js" (local relative import)
|
|
137
|
+
if (ts.isImportDeclaration(statement) && ts.isStringLiteral(statement.moduleSpecifier)) {
|
|
138
|
+
const importPath = statement.moduleSpecifier.text;
|
|
139
|
+
if (importPath.startsWith(".")) {
|
|
140
|
+
const resolvedImportPath = FileUtils.resolveImportSourceFilePath(sourceFilePath, importPath);
|
|
141
|
+
if (resolvedImportPath) {
|
|
142
|
+
const importedSource = FileUtils.readFile(resolvedImportPath);
|
|
143
|
+
if (importedSource) {
|
|
144
|
+
const importedTitles = JsonSchemaBuilder.parseAllObjectSchemas(context, resolvedImportPath, importedSource, visitedFiles);
|
|
145
|
+
for (const importedTitle of importedTitles) {
|
|
146
|
+
if (!parsedTitles.includes(importedTitle)) {
|
|
147
|
+
parsedTitles.push(importedTitle);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
for (const statement of sourceFile.statements) {
|
|
156
|
+
let title;
|
|
157
|
+
let schema;
|
|
158
|
+
context.activeDisallowedType = undefined;
|
|
159
|
+
// interface IFoo { prop: string; } (interface declaration)
|
|
160
|
+
if (ts.isInterfaceDeclaration(statement)) {
|
|
161
|
+
const boundContext = JsonSchemaBuilder.withTypeParameterBindings(context, statement.typeParameters);
|
|
162
|
+
title = StringHelper.stripPrefix(statement.name.text);
|
|
163
|
+
boundContext.activeEnclosingObjectName = title;
|
|
164
|
+
schema = JsonSchemaBuilder.buildBaseSchema(context.namespace, title, statement);
|
|
165
|
+
JsonSchemaBuilder.buildObjectSchema(boundContext, schema, statement.members);
|
|
166
|
+
JsonSchemaBuilder.applyInterfaceExtendsSchema(boundContext, schema, statement);
|
|
167
|
+
// type Foo = string | number or type Foo = { prop: string } (type alias declaration)
|
|
168
|
+
}
|
|
169
|
+
else if (ts.isTypeAliasDeclaration(statement)) {
|
|
170
|
+
const boundContext = JsonSchemaBuilder.withTypeParameterBindings(context, statement.typeParameters);
|
|
171
|
+
title = StringHelper.stripPrefix(statement.name.text);
|
|
172
|
+
boundContext.activeEnclosingObjectName = title;
|
|
173
|
+
// { prop: string } (type alias whose RHS is an inline object type)
|
|
174
|
+
if (ts.isTypeLiteralNode(statement.type)) {
|
|
175
|
+
schema = JsonSchemaBuilder.buildBaseSchema(context.namespace, title, statement);
|
|
176
|
+
JsonSchemaBuilder.buildObjectSchema(boundContext, schema, statement.type.members);
|
|
177
|
+
}
|
|
178
|
+
else {
|
|
179
|
+
// Const-and-type enum patterns always take priority so that JsDoc descriptions
|
|
180
|
+
// on the const object members are preserved in the generated oneOf schema.
|
|
181
|
+
const constValues = Enum.extractEnumValuesFromConstAndType(statement.name.text, sourceFile);
|
|
182
|
+
if (constValues) {
|
|
183
|
+
schema = JsonSchemaBuilder.buildBaseSchema(context.namespace, title, statement);
|
|
184
|
+
JsonSchemaBuilder.buildEnumSchema(schema, constValues);
|
|
185
|
+
}
|
|
186
|
+
else {
|
|
187
|
+
const mappedType = JsonSchemaBuilder.mapTypeNodeToSchema(boundContext, statement.type);
|
|
188
|
+
if (mappedType) {
|
|
189
|
+
schema = JsonSchemaBuilder.buildBaseSchema(context.namespace, title, statement);
|
|
190
|
+
Object.assign(schema, mappedType);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
// enum Color { Red = "red", Blue = "blue" } (native enum declaration)
|
|
195
|
+
}
|
|
196
|
+
else if (ts.isEnumDeclaration(statement)) {
|
|
197
|
+
title = StringHelper.stripPrefix(statement.name.text);
|
|
198
|
+
const enumValues = Enum.extractEnumValuesFromEnumDeclaration(statement);
|
|
199
|
+
if (enumValues && enumValues.length > 0) {
|
|
200
|
+
schema = JsonSchemaBuilder.buildBaseSchema(context.namespace, title, statement);
|
|
201
|
+
JsonSchemaBuilder.buildEnumSchema(schema, enumValues);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
const activeDisallowedType = context.activeDisallowedType;
|
|
205
|
+
if (activeDisallowedType) {
|
|
206
|
+
DiagnosticReporter.report(context, statement, "jsonSchemaBuilder.diagnostic.excludedEnclosingObjectDisallowedType", {
|
|
207
|
+
disallowedTypeName: activeDisallowedType.disallowedTypeName,
|
|
208
|
+
enclosingObjectName: activeDisallowedType.enclosingObjectName,
|
|
209
|
+
propertyName: activeDisallowedType.propertyName
|
|
210
|
+
});
|
|
211
|
+
context.activeDisallowedType = undefined;
|
|
212
|
+
}
|
|
213
|
+
else if (title && schema) {
|
|
214
|
+
const mappedSchema = ObjectHelper.removeEmptyProperties(ObjectTransformer.normalizeSchemaDescriptions(schema));
|
|
215
|
+
packageSchemaEntries[title] = mappedSchema;
|
|
216
|
+
if (!parsedTitles.includes(title)) {
|
|
217
|
+
parsedTitles.push(title);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
for (const statement of sourceFile.statements) {
|
|
222
|
+
// import { Foo } from "./module.js" (re-scan imports to process any newly visible schemas)
|
|
223
|
+
if (ts.isImportDeclaration(statement) && ts.isStringLiteral(statement.moduleSpecifier)) {
|
|
224
|
+
const importPath = statement.moduleSpecifier.text;
|
|
225
|
+
if (importPath.startsWith(".")) {
|
|
226
|
+
const resolvedImportPath = FileUtils.resolveImportSourceFilePath(sourceFilePath, importPath);
|
|
227
|
+
if (resolvedImportPath) {
|
|
228
|
+
const importedSource = FileUtils.readFile(resolvedImportPath);
|
|
229
|
+
if (importedSource) {
|
|
230
|
+
const importedTitles = JsonSchemaBuilder.parseAllObjectSchemas(context, resolvedImportPath, importedSource, visitedFiles);
|
|
231
|
+
for (const importedTitle of importedTitles) {
|
|
232
|
+
if (!parsedTitles.includes(importedTitle)) {
|
|
233
|
+
parsedTitles.push(importedTitle);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
context.activeSourceFile = previousSourceFile;
|
|
242
|
+
return parsedTitles;
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Apply @json-schema tags from JSDoc to a schema object.
|
|
246
|
+
* @param schema The schema to expand.
|
|
247
|
+
* @param node The node to inspect for tags.
|
|
248
|
+
* @throws GeneralError Thrown when a tag key is not supported by IJsonSchema.
|
|
249
|
+
*/
|
|
250
|
+
static applyJsonSchemaTags(schema, node) {
|
|
251
|
+
const defaultTagComment = JsDoc.getNodeTagComment(node, "default");
|
|
252
|
+
if (defaultTagComment !== undefined && schema.default === undefined) {
|
|
253
|
+
schema.default = JsDoc.parseTagValue(defaultTagComment);
|
|
254
|
+
}
|
|
255
|
+
const tags = JsDoc.getNodeTags(node, "json-schema");
|
|
256
|
+
for (const [rawKey, rawValue] of Object.entries(tags)) {
|
|
257
|
+
const schemaKey = JsonSchemaBuilder.mapJsonSchemaTagKey(rawKey);
|
|
258
|
+
if (!JsonSchemaBuilder.isAllowedJsonSchemaTagKey(schemaKey)) {
|
|
259
|
+
throw new GeneralError(JsonSchemaBuilder.CLASS_NAME, "invalidJsonSchemaTagKey", {
|
|
260
|
+
rawKey,
|
|
261
|
+
schemaKey
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
JsonSchemaBuilder.validateJsonSchemaTagConstraint(schemaKey, schema.type, rawValue);
|
|
265
|
+
const parsedValue = JsDoc.parseTagValue(rawValue);
|
|
266
|
+
ObjectHelper.propertySet(schema, schemaKey, parsedValue);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Validate that a @json-schema constraint key is compatible with the given schema type,
|
|
271
|
+
* and that the raw value is valid for the constraint.
|
|
272
|
+
* @param schemaKey The mapped schema key being applied.
|
|
273
|
+
* @param schemaType The type already set on the schema, if any.
|
|
274
|
+
* @param rawValue The raw string value from the JSDoc tag.
|
|
275
|
+
* @throws GeneralError Thrown when the constraint is not valid for the schema type.
|
|
276
|
+
* @throws GeneralError Thrown when the format value is not a recognised JSON Schema format.
|
|
277
|
+
*/
|
|
278
|
+
static validateJsonSchemaTagConstraint(schemaKey, schemaType, rawValue) {
|
|
279
|
+
if (schemaType !== undefined) {
|
|
280
|
+
const types = ArrayHelper.fromObjectOrArray(schemaType);
|
|
281
|
+
const numericConstraints = [
|
|
282
|
+
"minimum",
|
|
283
|
+
"maximum",
|
|
284
|
+
"multipleOf",
|
|
285
|
+
"exclusiveMinimum",
|
|
286
|
+
"exclusiveMaximum"
|
|
287
|
+
];
|
|
288
|
+
const stringConstraints = ["minLength", "maxLength", "pattern", "format"];
|
|
289
|
+
const objectConstraints = ["minProperties", "maxProperties"];
|
|
290
|
+
const arrayConstraints = ["minItems", "maxItems", "uniqueItems"];
|
|
291
|
+
let requiredType;
|
|
292
|
+
if (numericConstraints.includes(schemaKey)) {
|
|
293
|
+
if (!types.includes("number") && !types.includes("integer")) {
|
|
294
|
+
requiredType = "number|integer";
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
else if (stringConstraints.includes(schemaKey)) {
|
|
298
|
+
if (!types.includes("string")) {
|
|
299
|
+
requiredType = "string";
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
else if (objectConstraints.includes(schemaKey)) {
|
|
303
|
+
if (!types.includes("object")) {
|
|
304
|
+
requiredType = "object";
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
else if (arrayConstraints.includes(schemaKey)) {
|
|
308
|
+
if (!types.includes("array")) {
|
|
309
|
+
requiredType = "array";
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
if (requiredType !== undefined) {
|
|
313
|
+
throw new GeneralError(JsonSchemaBuilder.CLASS_NAME, "constraintOnIncompatibleType", {
|
|
314
|
+
schemaKey,
|
|
315
|
+
requiredType,
|
|
316
|
+
schemaType: Is.array(schemaType) ? schemaType.join("|") : schemaType
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
if (schemaKey === "format") {
|
|
321
|
+
const validFormats = [
|
|
322
|
+
"date-time",
|
|
323
|
+
"date",
|
|
324
|
+
"time",
|
|
325
|
+
"duration",
|
|
326
|
+
"email",
|
|
327
|
+
"idn-email",
|
|
328
|
+
"hostname",
|
|
329
|
+
"idn-hostname",
|
|
330
|
+
"ipv4",
|
|
331
|
+
"ipv6",
|
|
332
|
+
"uri",
|
|
333
|
+
"uri-reference",
|
|
334
|
+
"iri",
|
|
335
|
+
"iri-reference",
|
|
336
|
+
"uuid",
|
|
337
|
+
"uri-template",
|
|
338
|
+
"json-pointer",
|
|
339
|
+
"relative-json-pointer",
|
|
340
|
+
"regex"
|
|
341
|
+
];
|
|
342
|
+
if (!validFormats.includes(rawValue)) {
|
|
343
|
+
throw new GeneralError(JsonSchemaBuilder.CLASS_NAME, "invalidFormatValue", {
|
|
344
|
+
formatValue: rawValue,
|
|
345
|
+
validFormats: validFormats.join(", ")
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Determine whether a mapped @json-schema tag key is supported.
|
|
352
|
+
* @param key The mapped schema key.
|
|
353
|
+
* @returns True if the key is supported.
|
|
354
|
+
*/
|
|
355
|
+
static isAllowedJsonSchemaTagKey(key) {
|
|
356
|
+
return JsonSchemaTagNames.includes(key);
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* Build the base schema with common properties.
|
|
360
|
+
* @param namespace The namespace for generated schema id.
|
|
361
|
+
* @param title The schema title.
|
|
362
|
+
* @param statement The type statement node.
|
|
363
|
+
* @returns The base schema to be expanded.
|
|
364
|
+
*/
|
|
365
|
+
static buildBaseSchema(namespace, title, statement) {
|
|
366
|
+
const schema = {
|
|
367
|
+
$schema: JsonSchemaBuilder.SCHEMA_VERSION,
|
|
368
|
+
$id: `${namespace}${title}`,
|
|
369
|
+
title
|
|
370
|
+
};
|
|
371
|
+
const description = JsDoc.getNodeJsDocDescription(statement);
|
|
372
|
+
if (description) {
|
|
373
|
+
schema.description = description;
|
|
374
|
+
}
|
|
375
|
+
JsonSchemaBuilder.applyJsonSchemaTags(schema, statement);
|
|
376
|
+
return schema;
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* Add enum information to a schema.
|
|
380
|
+
* @param schema The schema to expand.
|
|
381
|
+
* @param entries The enum entries.
|
|
382
|
+
*/
|
|
383
|
+
static buildEnumSchema(schema, entries) {
|
|
384
|
+
schema.oneOf = entries.map(entry => ({
|
|
385
|
+
const: entry.value,
|
|
386
|
+
description: entry.description
|
|
387
|
+
}));
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* Build an object schema from interface or type literal members.
|
|
391
|
+
* @param context The generation context.
|
|
392
|
+
* @param schema The schema to expand.
|
|
393
|
+
* @param members The members to process.
|
|
394
|
+
*/
|
|
395
|
+
static buildObjectSchema(context, schema, members) {
|
|
396
|
+
const { properties, required, patternProperties, additionalProperties, propertyNames } = JsonSchemaBuilder.buildObjectMembersSchema(context, members);
|
|
397
|
+
schema.type = "object";
|
|
398
|
+
if (Object.keys(properties).length > 0) {
|
|
399
|
+
schema.properties = properties;
|
|
400
|
+
}
|
|
401
|
+
if (patternProperties) {
|
|
402
|
+
schema.patternProperties = patternProperties;
|
|
403
|
+
}
|
|
404
|
+
if (propertyNames) {
|
|
405
|
+
schema.propertyNames = propertyNames;
|
|
406
|
+
}
|
|
407
|
+
if (additionalProperties) {
|
|
408
|
+
schema.additionalProperties = additionalProperties;
|
|
409
|
+
}
|
|
410
|
+
if (required.length > 0) {
|
|
411
|
+
schema.required = required;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* Build property schemas and required list from type members.
|
|
416
|
+
* @param context The generation context.
|
|
417
|
+
* @param members The members to process.
|
|
418
|
+
* @returns The object property schema map and required list.
|
|
419
|
+
*/
|
|
420
|
+
static buildObjectMembersSchema(context, members) {
|
|
421
|
+
const properties = {};
|
|
422
|
+
const required = [];
|
|
423
|
+
const indexSignatureSchemas = [];
|
|
424
|
+
const patternPropertySchemas = [];
|
|
425
|
+
for (const member of members) {
|
|
426
|
+
if (context.activeDisallowedType) {
|
|
427
|
+
break;
|
|
428
|
+
}
|
|
429
|
+
// prop: string or prop?: string (property signature member)
|
|
430
|
+
if (ts.isPropertySignature(member) && member.type) {
|
|
431
|
+
if (JsonSchemaBuilder.isSymbolTypeNode(member.type)) {
|
|
432
|
+
DiagnosticReporter.report(context, member, "jsonSchemaBuilder.diagnostic.symbolValuedProperty", {
|
|
433
|
+
propertyName: member.name.getText()
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
else {
|
|
437
|
+
const isMemberTypeAllowed = JsonSchemaBuilder.checkTypeNodeAllowed(context, member.type, member.name.getText(), context.activeEnclosingObjectName);
|
|
438
|
+
if (isMemberTypeAllowed && JsonSchemaBuilder.isFunctionPropertyType(member.type)) {
|
|
439
|
+
DiagnosticReporter.report(context, member, "jsonSchemaBuilder.diagnostic.functionTypedProperty", {
|
|
440
|
+
propertyName: member.name.getText()
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
else if (isMemberTypeAllowed) {
|
|
444
|
+
const memberName = member.name
|
|
445
|
+
? JsonSchemaBuilder.extractPropertyName(context, member.name)
|
|
446
|
+
: undefined;
|
|
447
|
+
const memberTypeSchema = memberName
|
|
448
|
+
? JsonSchemaBuilder.mapMemberTypeToSchema(context, member.type)
|
|
449
|
+
: undefined;
|
|
450
|
+
if (memberName && memberTypeSchema) {
|
|
451
|
+
const memberDescription = JsDoc.getNodeJsDocDescription(member);
|
|
452
|
+
if (memberDescription) {
|
|
453
|
+
memberTypeSchema.description = memberDescription;
|
|
454
|
+
}
|
|
455
|
+
JsonSchemaBuilder.applyJsonSchemaTags(memberTypeSchema, member);
|
|
456
|
+
properties[memberName] = ObjectHelper.removeEmptyProperties(memberTypeSchema);
|
|
457
|
+
if (!member.questionToken) {
|
|
458
|
+
required.push(memberName);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
// [key: string]: Value (index signature member)
|
|
465
|
+
if (ts.isIndexSignatureDeclaration(member)) {
|
|
466
|
+
const valueTypeSchema = member.type
|
|
467
|
+
? (JsonSchemaBuilder.mapTypeNodeToSchema(context, member.type) ?? {})
|
|
468
|
+
: {};
|
|
469
|
+
if (IndexSignaturePatternResolver.isSupportedIndexSignature(member)) {
|
|
470
|
+
indexSignatureSchemas.push(valueTypeSchema);
|
|
471
|
+
}
|
|
472
|
+
else {
|
|
473
|
+
const pattern = IndexSignaturePatternResolver.extractIndexSignaturePattern(context, member, (boundContext, typeName) => JsonSchemaBuilder.getTypeParameterBinding(boundContext, typeName));
|
|
474
|
+
if (pattern) {
|
|
475
|
+
patternPropertySchemas.push({ pattern, schema: valueTypeSchema });
|
|
476
|
+
}
|
|
477
|
+
else {
|
|
478
|
+
DiagnosticReporter.report(context, member, "jsonSchemaBuilder.diagnostic.unsupportedIndexSignature", { keyType: member.parameters[0]?.type?.getText() ?? "unknown" });
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
if (!ts.isPropertySignature(member) && !ts.isIndexSignatureDeclaration(member)) {
|
|
483
|
+
// any other member kind (method signatures, call signatures, etc.) is reported and skipped
|
|
484
|
+
DiagnosticReporter.report(context, member, "jsonSchemaBuilder.diagnostic.unsupportedTypeElementMember", { kind: ts.SyntaxKind[member.kind] });
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
const uniqueIndexSignatureSchemas = indexSignatureSchemas.filter((schema, index, schemas) => {
|
|
488
|
+
const schemaKey = JsonHelper.canonicalize(schema);
|
|
489
|
+
return schemas.findIndex(s => JsonHelper.canonicalize(s) === schemaKey) === index;
|
|
490
|
+
});
|
|
491
|
+
let additionalProperties;
|
|
492
|
+
if (uniqueIndexSignatureSchemas.length === 1) {
|
|
493
|
+
additionalProperties = uniqueIndexSignatureSchemas[0];
|
|
494
|
+
}
|
|
495
|
+
else if (uniqueIndexSignatureSchemas.length > 1) {
|
|
496
|
+
additionalProperties = { anyOf: uniqueIndexSignatureSchemas };
|
|
497
|
+
}
|
|
498
|
+
const patternProperties = JsonSchemaBuilder.mergePatternPropertySchemas(patternPropertySchemas);
|
|
499
|
+
let propertyNames;
|
|
500
|
+
if (patternPropertySchemas.length > 0 && uniqueIndexSignatureSchemas.length === 0) {
|
|
501
|
+
const staticKeys = Object.keys(properties);
|
|
502
|
+
const patternSchemas = Object.keys(patternProperties ?? {}).map(p => ({
|
|
503
|
+
pattern: p
|
|
504
|
+
}));
|
|
505
|
+
if (staticKeys.length === 0) {
|
|
506
|
+
propertyNames = patternSchemas.length === 1 ? patternSchemas[0] : { anyOf: patternSchemas };
|
|
507
|
+
}
|
|
508
|
+
else {
|
|
509
|
+
propertyNames = { anyOf: [{ enum: staticKeys }, ...patternSchemas] };
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
return { properties, required, patternProperties, additionalProperties, propertyNames };
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* Build an object schema from a type literal node.
|
|
516
|
+
* @param context The generation context.
|
|
517
|
+
* @param typeNode The type literal node.
|
|
518
|
+
* @returns The object schema.
|
|
519
|
+
*/
|
|
520
|
+
static buildTypeLiteralSchema(context, typeNode) {
|
|
521
|
+
const { properties, required, patternProperties, additionalProperties, propertyNames } = JsonSchemaBuilder.buildObjectMembersSchema(context, typeNode.members);
|
|
522
|
+
return {
|
|
523
|
+
type: "object",
|
|
524
|
+
properties: Object.keys(properties).length > 0 ? properties : undefined,
|
|
525
|
+
required: required.length > 0 ? required : undefined,
|
|
526
|
+
patternProperties,
|
|
527
|
+
propertyNames,
|
|
528
|
+
additionalProperties
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
/**
|
|
532
|
+
* Merge pattern property schemas by pattern, deduplicating equivalent branches.
|
|
533
|
+
* @param patternPropertySchemas Pattern and schema entries to merge.
|
|
534
|
+
* @returns Merged patternProperties map.
|
|
535
|
+
*/
|
|
536
|
+
static mergePatternPropertySchemas(patternPropertySchemas) {
|
|
537
|
+
if (patternPropertySchemas.length === 0) {
|
|
538
|
+
return undefined;
|
|
539
|
+
}
|
|
540
|
+
const groupedSchemas = {};
|
|
541
|
+
for (const patternPropertySchema of patternPropertySchemas) {
|
|
542
|
+
groupedSchemas[patternPropertySchema.pattern] ??= [];
|
|
543
|
+
groupedSchemas[patternPropertySchema.pattern].push(patternPropertySchema.schema);
|
|
544
|
+
}
|
|
545
|
+
const mergedPatternProperties = {};
|
|
546
|
+
for (const [pattern, schemas] of Object.entries(groupedSchemas)) {
|
|
547
|
+
const uniqueSchemas = schemas.filter((schema, index, allSchemas) => {
|
|
548
|
+
const schemaKey = JsonHelper.canonicalize(schema);
|
|
549
|
+
return allSchemas.findIndex(s => JsonHelper.canonicalize(s) === schemaKey) === index;
|
|
550
|
+
});
|
|
551
|
+
mergedPatternProperties[pattern] =
|
|
552
|
+
uniqueSchemas.length === 1 ? uniqueSchemas[0] : { anyOf: uniqueSchemas };
|
|
553
|
+
}
|
|
554
|
+
return mergedPatternProperties;
|
|
555
|
+
}
|
|
556
|
+
/**
|
|
557
|
+
* Map a property type node to schema.
|
|
558
|
+
* @param context The generation context.
|
|
559
|
+
* @param typeNode The member type node.
|
|
560
|
+
* @returns The mapped member schema.
|
|
561
|
+
*/
|
|
562
|
+
static mapMemberTypeToSchema(context, typeNode) {
|
|
563
|
+
// [string, number] or [label: string, ...rest: string[]] (tuple type)
|
|
564
|
+
if (ts.isTupleTypeNode(typeNode)) {
|
|
565
|
+
return JsonSchemaBuilder.mapTupleTypeToSchema(context, typeNode);
|
|
566
|
+
}
|
|
567
|
+
// typeof variable (type query, resolves the type of a declared value)
|
|
568
|
+
if (ts.isTypeQueryNode(typeNode)) {
|
|
569
|
+
return ImportTypeQuerySchemaResolver.mapTypeQueryNodeToSchema(context, typeNode);
|
|
570
|
+
}
|
|
571
|
+
// { prop: string } (inline object type literal as a member type)
|
|
572
|
+
if (ts.isTypeLiteralNode(typeNode)) {
|
|
573
|
+
return JsonSchemaBuilder.buildTypeLiteralSchema(context, typeNode);
|
|
574
|
+
}
|
|
575
|
+
return JsonSchemaBuilder.mapTypeNodeToSchema(context, typeNode);
|
|
576
|
+
}
|
|
577
|
+
/**
|
|
578
|
+
* Map TypeScript type nodes to JSON schema.
|
|
579
|
+
* @param context The generation context.
|
|
580
|
+
* @param typeNode The node to process.
|
|
581
|
+
* @returns The mapped schema.
|
|
582
|
+
*/
|
|
583
|
+
static mapTypeNodeToSchema(context, typeNode) {
|
|
584
|
+
if (!JsonSchemaBuilder.checkTypeNodeAllowed(context, typeNode, undefined, context.activeEnclosingObjectName)) {
|
|
585
|
+
return undefined;
|
|
586
|
+
}
|
|
587
|
+
// readonly T[] or keyof T (type operator node)
|
|
588
|
+
if (ts.isTypeOperatorNode(typeNode)) {
|
|
589
|
+
if (typeNode.operator === ts.SyntaxKind.ReadonlyKeyword) {
|
|
590
|
+
return JsonSchemaBuilder.mapTypeNodeToSchema(context, typeNode.type);
|
|
591
|
+
}
|
|
592
|
+
if (typeNode.operator === ts.SyntaxKind.KeyOfKeyword) {
|
|
593
|
+
const keyofKeys = JsonSchemaBuilder.extractKeyofTypeKeys(context, typeNode.type);
|
|
594
|
+
if (keyofKeys.length > 0) {
|
|
595
|
+
return { enum: keyofKeys };
|
|
596
|
+
}
|
|
597
|
+
if (JsonSchemaBuilder.isGenericKeyofOperand(context, typeNode.type)) {
|
|
598
|
+
return {};
|
|
599
|
+
}
|
|
600
|
+
DiagnosticReporter.report(context, typeNode, "jsonSchemaBuilder.diagnostic.unresolvedKeyofOperand", {
|
|
601
|
+
operand: typeNode.type.getText()
|
|
602
|
+
});
|
|
603
|
+
return {};
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
// T["key"] (indexed access type)
|
|
607
|
+
if (ts.isIndexedAccessTypeNode(typeNode)) {
|
|
608
|
+
return JsonSchemaBuilder.mapIndexedAccessTypeToSchema(context, typeNode);
|
|
609
|
+
}
|
|
610
|
+
// `prefix-${T}` (template literal type)
|
|
611
|
+
if (ts.isTemplateLiteralTypeNode(typeNode)) {
|
|
612
|
+
return JsonSchemaBuilder.mapTemplateLiteralTypeToSchema(context, typeNode);
|
|
613
|
+
}
|
|
614
|
+
// { [K in keyof T]: T[K] } (mapped type)
|
|
615
|
+
if (ts.isMappedTypeNode(typeNode)) {
|
|
616
|
+
return JsonSchemaBuilder.mapMappedTypeToSchema(context, typeNode);
|
|
617
|
+
}
|
|
618
|
+
// T extends U ? X : Y (conditional type)
|
|
619
|
+
if (ts.isConditionalTypeNode(typeNode)) {
|
|
620
|
+
return JsonSchemaBuilder.mapConditionalTypeToSchema(context, typeNode);
|
|
621
|
+
}
|
|
622
|
+
// (string | number) (parenthesised type, unwrap and recurse)
|
|
623
|
+
if (ts.isParenthesizedTypeNode(typeNode)) {
|
|
624
|
+
return JsonSchemaBuilder.mapTypeNodeToSchema(context, typeNode.type);
|
|
625
|
+
}
|
|
626
|
+
// [string, number] or [label: string, ...rest: string[]] (tuple type)
|
|
627
|
+
if (ts.isTupleTypeNode(typeNode)) {
|
|
628
|
+
return JsonSchemaBuilder.mapTupleTypeToSchema(context, typeNode);
|
|
629
|
+
}
|
|
630
|
+
// unknown (open schema, accepts any value)
|
|
631
|
+
if (typeNode.kind === ts.SyntaxKind.UnknownKeyword) {
|
|
632
|
+
return {};
|
|
633
|
+
}
|
|
634
|
+
// any (open schema, accepts any value)
|
|
635
|
+
if (typeNode.kind === ts.SyntaxKind.AnyKeyword) {
|
|
636
|
+
return {};
|
|
637
|
+
}
|
|
638
|
+
// never (empty schema that rejects all values)
|
|
639
|
+
if (typeNode.kind === ts.SyntaxKind.NeverKeyword) {
|
|
640
|
+
return { not: {} };
|
|
641
|
+
}
|
|
642
|
+
if (typeNode.kind === ts.SyntaxKind.UndefinedKeyword) {
|
|
643
|
+
return undefined;
|
|
644
|
+
}
|
|
645
|
+
if (typeNode.kind === ts.SyntaxKind.VoidKeyword) {
|
|
646
|
+
return undefined;
|
|
647
|
+
}
|
|
648
|
+
// string (string keyword type)
|
|
649
|
+
if (typeNode.kind === ts.SyntaxKind.StringKeyword) {
|
|
650
|
+
return { type: "string" };
|
|
651
|
+
}
|
|
652
|
+
// number (number keyword type)
|
|
653
|
+
if (typeNode.kind === ts.SyntaxKind.NumberKeyword) {
|
|
654
|
+
return { type: "number" };
|
|
655
|
+
}
|
|
656
|
+
// boolean (boolean keyword type)
|
|
657
|
+
if (typeNode.kind === ts.SyntaxKind.BooleanKeyword) {
|
|
658
|
+
return { type: "boolean" };
|
|
659
|
+
}
|
|
660
|
+
// object (object keyword type)
|
|
661
|
+
if (typeNode.kind === ts.SyntaxKind.ObjectKeyword) {
|
|
662
|
+
return { type: "object" };
|
|
663
|
+
}
|
|
664
|
+
// null (null keyword type)
|
|
665
|
+
if (typeNode.kind === ts.SyntaxKind.NullKeyword) {
|
|
666
|
+
return { type: "null" };
|
|
667
|
+
}
|
|
668
|
+
// string[] or Foo[] (array type)
|
|
669
|
+
if (ts.isArrayTypeNode(typeNode)) {
|
|
670
|
+
const elementType = JsonSchemaBuilder.mapTypeNodeToSchema(context, typeNode.elementType);
|
|
671
|
+
if (elementType) {
|
|
672
|
+
return {
|
|
673
|
+
type: "array",
|
|
674
|
+
items: elementType
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
// MyType<T> or Namespace.MyType (named type reference, possibly with type arguments)
|
|
679
|
+
if (ts.isTypeReferenceNode(typeNode)) {
|
|
680
|
+
const typeName = ts.isIdentifier(typeNode.typeName)
|
|
681
|
+
? typeNode.typeName.text
|
|
682
|
+
: typeNode.typeName.right.text;
|
|
683
|
+
const typeParameterBinding = JsonSchemaBuilder.getTypeParameterBinding(context, typeName);
|
|
684
|
+
if (typeParameterBinding !== undefined) {
|
|
685
|
+
return typeParameterBinding
|
|
686
|
+
? JsonSchemaBuilder.mapTypeNodeToSchema(context, typeParameterBinding)
|
|
687
|
+
: {};
|
|
688
|
+
}
|
|
689
|
+
if (typeName === "undefined") {
|
|
690
|
+
return undefined;
|
|
691
|
+
}
|
|
692
|
+
if (typeName === "Boolean") {
|
|
693
|
+
return { type: "boolean" };
|
|
694
|
+
}
|
|
695
|
+
if (typeName === "Object") {
|
|
696
|
+
return { type: "object" };
|
|
697
|
+
}
|
|
698
|
+
if (typeName === "Map") {
|
|
699
|
+
const mapValueType = typeNode.typeArguments?.[1];
|
|
700
|
+
const additionalProperties = mapValueType
|
|
701
|
+
? (JsonSchemaBuilder.mapTypeNodeToSchema(context, mapValueType) ?? {})
|
|
702
|
+
: {};
|
|
703
|
+
return {
|
|
704
|
+
type: "object",
|
|
705
|
+
additionalProperties
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
if (typeName === "Set") {
|
|
709
|
+
const setItemType = typeNode.typeArguments?.[0];
|
|
710
|
+
const items = setItemType
|
|
711
|
+
? (JsonSchemaBuilder.mapTypeNodeToSchema(context, setItemType) ?? {})
|
|
712
|
+
: {};
|
|
713
|
+
return {
|
|
714
|
+
type: "array",
|
|
715
|
+
items,
|
|
716
|
+
uniqueItems: true
|
|
717
|
+
};
|
|
718
|
+
}
|
|
719
|
+
if (typeName === "ReadonlyArray") {
|
|
720
|
+
const arrayItemType = typeNode.typeArguments?.[0];
|
|
721
|
+
const items = arrayItemType
|
|
722
|
+
? (JsonSchemaBuilder.mapTypeNodeToSchema(context, arrayItemType) ?? {})
|
|
723
|
+
: {};
|
|
724
|
+
return {
|
|
725
|
+
type: "array",
|
|
726
|
+
items
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
if (Constants.ARRAY_NUMBER_TYPE_NAMES.includes(typeName)) {
|
|
730
|
+
return {
|
|
731
|
+
type: "array",
|
|
732
|
+
items: { type: "number" }
|
|
733
|
+
};
|
|
734
|
+
}
|
|
735
|
+
const utilityHandler = JsonSchemaBuilder._utilityTypeHandlers[typeName];
|
|
736
|
+
if (utilityHandler) {
|
|
737
|
+
return utilityHandler(context, typeNode);
|
|
738
|
+
}
|
|
739
|
+
if (Constants.UNSUPPORTED_UTILITY_TYPE_NAMES.includes(typeName)) {
|
|
740
|
+
DiagnosticReporter.report(context, typeNode, "jsonSchemaBuilder.diagnostic.unsupportedUtilityType", { utilityType: typeName });
|
|
741
|
+
return {};
|
|
742
|
+
}
|
|
743
|
+
const title = StringHelper.stripPrefix(typeName);
|
|
744
|
+
const importedModuleSpecifier = context.activeSourceFile
|
|
745
|
+
? JsonSchemaBuilder.findImportedModuleSpecifier(context.activeSourceFile, typeNode, typeName)
|
|
746
|
+
: undefined;
|
|
747
|
+
if (!importedModuleSpecifier || importedModuleSpecifier.startsWith(".")) {
|
|
748
|
+
const mappedReference = JsonSchemaBuilder.resolveReferenceMappingTarget(context, "", typeName);
|
|
749
|
+
if (mappedReference?.schemaId) {
|
|
750
|
+
return {
|
|
751
|
+
$ref: mappedReference.schemaId
|
|
752
|
+
};
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
const resolvedImportedSchemaId = JsonSchemaBuilder.resolveExternalTypeReferenceSchemaId(context, typeNode, typeName);
|
|
756
|
+
const existingSchemaId = resolvedImportedSchemaId
|
|
757
|
+
? undefined
|
|
758
|
+
: JsonSchemaBuilder.findExistingSchemaIdByTitle(context, title);
|
|
759
|
+
return {
|
|
760
|
+
$ref: resolvedImportedSchemaId ?? existingSchemaId ?? `${context.namespace}${title}`
|
|
761
|
+
};
|
|
762
|
+
}
|
|
763
|
+
// import("./module.js").TypeName (import type node)
|
|
764
|
+
if (ts.isImportTypeNode(typeNode)) {
|
|
765
|
+
return ImportTypeQuerySchemaResolver.mapImportTypeNodeToSchema(context, typeNode);
|
|
766
|
+
}
|
|
767
|
+
// { prop: string } (inline object type literal)
|
|
768
|
+
if (ts.isTypeLiteralNode(typeNode)) {
|
|
769
|
+
return JsonSchemaBuilder.buildTypeLiteralSchema(context, typeNode);
|
|
770
|
+
}
|
|
771
|
+
// "hello" or 42 or true or false (literal type node wrapping a literal keyword or value)
|
|
772
|
+
if (ts.isLiteralTypeNode(typeNode)) {
|
|
773
|
+
// "hello" (string literal type)
|
|
774
|
+
if (ts.isStringLiteral(typeNode.literal)) {
|
|
775
|
+
return { const: typeNode.literal.text };
|
|
776
|
+
}
|
|
777
|
+
// 42 (numeric literal type)
|
|
778
|
+
if (ts.isNumericLiteral(typeNode.literal)) {
|
|
779
|
+
return { const: Number(typeNode.literal.text) };
|
|
780
|
+
}
|
|
781
|
+
if (typeNode.literal.kind === ts.SyntaxKind.NullKeyword) {
|
|
782
|
+
// null (null keyword inside a literal type node)
|
|
783
|
+
return { type: "null" };
|
|
784
|
+
}
|
|
785
|
+
if (typeNode.literal.kind === ts.SyntaxKind.TrueKeyword) {
|
|
786
|
+
// true (boolean true literal type — distinct from the boolean keyword type)
|
|
787
|
+
return { const: true };
|
|
788
|
+
}
|
|
789
|
+
if (typeNode.literal.kind === ts.SyntaxKind.FalseKeyword) {
|
|
790
|
+
// false (boolean false literal type — distinct from the boolean keyword type)
|
|
791
|
+
return { const: false };
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
// string | number | null (union type)
|
|
795
|
+
if (ts.isUnionTypeNode(typeNode)) {
|
|
796
|
+
const mappedUnionTypes = typeNode.types
|
|
797
|
+
.map(unionType => JsonSchemaBuilder.mapTypeNodeToSchema(context, unionType))
|
|
798
|
+
.filter((mappedType) => mappedType !== undefined);
|
|
799
|
+
if (mappedUnionTypes.length > 0) {
|
|
800
|
+
if (mappedUnionTypes.length === 1) {
|
|
801
|
+
return mappedUnionTypes[0];
|
|
802
|
+
}
|
|
803
|
+
if (JsonSchemaBuilder.isNeverDiscriminatedObjectUnion(context, typeNode.types, mappedUnionTypes) ||
|
|
804
|
+
JsonSchemaBuilder.isLiteralTagDiscriminatedObjectUnion(context, mappedUnionTypes) ||
|
|
805
|
+
JsonSchemaBuilder.isDisjointPrimitiveKeywordUnion(typeNode, typeNode.types, mappedUnionTypes)) {
|
|
806
|
+
return {
|
|
807
|
+
oneOf: mappedUnionTypes
|
|
808
|
+
};
|
|
809
|
+
}
|
|
810
|
+
return {
|
|
811
|
+
anyOf: mappedUnionTypes
|
|
812
|
+
};
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
// TypeA & TypeB (intersection type, try to merge into a single object schema)
|
|
816
|
+
if (ts.isIntersectionTypeNode(typeNode)) {
|
|
817
|
+
const mappedIntersectionTypes = typeNode.types
|
|
818
|
+
.map(intersectionType => JsonSchemaBuilder.mapTypeNodeToSchema(context, intersectionType))
|
|
819
|
+
.filter((mappedType) => mappedType !== undefined);
|
|
820
|
+
if (mappedIntersectionTypes.length > 0) {
|
|
821
|
+
const mergedIntersectionObjectSchema = IntersectionSchemaMerger.mergeIntersectionObjectSchemas(context, mappedIntersectionTypes, schema => ObjectTransformer.toInlineUtilityObjectSchema(schema));
|
|
822
|
+
if (mergedIntersectionObjectSchema) {
|
|
823
|
+
return mergedIntersectionObjectSchema;
|
|
824
|
+
}
|
|
825
|
+
return {
|
|
826
|
+
allOf: mappedIntersectionTypes
|
|
827
|
+
};
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
// typeof variable (type query, treated as an open schema)
|
|
831
|
+
if (ts.isTypeQueryNode(typeNode)) {
|
|
832
|
+
return ImportTypeQuerySchemaResolver.mapTypeQueryNodeToSchema(context, typeNode);
|
|
833
|
+
}
|
|
834
|
+
DiagnosticReporter.report(context, typeNode, "jsonSchemaBuilder.diagnostic.unmappedTypeNode", {
|
|
835
|
+
kind: ts.SyntaxKind[typeNode.kind]
|
|
836
|
+
});
|
|
837
|
+
return undefined;
|
|
838
|
+
}
|
|
839
|
+
/**
|
|
840
|
+
* Determine whether a union of primitive keyword branches is pairwise disjoint.
|
|
841
|
+
* @param unionTypeNode The union node being mapped.
|
|
842
|
+
* @param unionTypeNodes The original union branch type nodes.
|
|
843
|
+
* @param unionSchemas The mapped union branch schemas.
|
|
844
|
+
* @returns True if every branch is a primitive keyword schema and no branches overlap.
|
|
845
|
+
*/
|
|
846
|
+
static isDisjointPrimitiveKeywordUnion(unionTypeNode, unionTypeNodes, unionSchemas) {
|
|
847
|
+
if (unionSchemas.length < 2 || unionSchemas.length !== unionTypeNodes.length) {
|
|
848
|
+
return false;
|
|
849
|
+
}
|
|
850
|
+
let unionContainer = unionTypeNode;
|
|
851
|
+
while (ts.isParenthesizedTypeNode(unionContainer.parent)) {
|
|
852
|
+
unionContainer = unionContainer.parent;
|
|
853
|
+
}
|
|
854
|
+
if (!ts.isTypeAliasDeclaration(unionContainer.parent) ||
|
|
855
|
+
unionContainer.parent.type !== unionContainer) {
|
|
856
|
+
// Keep nested/property unions as anyOf; only top-level alias unions are promoted.
|
|
857
|
+
return false;
|
|
858
|
+
}
|
|
859
|
+
const primitiveKinds = new Set([
|
|
860
|
+
ts.SyntaxKind.StringKeyword,
|
|
861
|
+
ts.SyntaxKind.NumberKeyword,
|
|
862
|
+
ts.SyntaxKind.BooleanKeyword,
|
|
863
|
+
ts.SyntaxKind.NullKeyword
|
|
864
|
+
]);
|
|
865
|
+
if (!unionTypeNodes.every(unionBranchType => primitiveKinds.has(unionBranchType.kind))) {
|
|
866
|
+
// Mixed or non-primitive unions can overlap in value space.
|
|
867
|
+
return false;
|
|
868
|
+
}
|
|
869
|
+
const schemaTypeDomains = unionSchemas.map(schema => {
|
|
870
|
+
if (schema.type === "integer") {
|
|
871
|
+
return "number";
|
|
872
|
+
}
|
|
873
|
+
return Is.stringValue(schema.type) ? schema.type : undefined;
|
|
874
|
+
});
|
|
875
|
+
if (schemaTypeDomains.some(schemaType => schemaType !== "string" &&
|
|
876
|
+
schemaType !== "number" &&
|
|
877
|
+
schemaType !== "boolean" &&
|
|
878
|
+
schemaType !== "null")) {
|
|
879
|
+
// Only primitive keyword domains are considered disjoint enough for oneOf.
|
|
880
|
+
return false;
|
|
881
|
+
}
|
|
882
|
+
// oneOf is safe when every branch maps to a unique primitive domain.
|
|
883
|
+
return new Set(schemaTypeDomains).size === unionSchemas.length;
|
|
884
|
+
}
|
|
885
|
+
/**
|
|
886
|
+
* Determine whether a union of schemas represents mutually exclusive object branches.
|
|
887
|
+
* @param context The generation context.
|
|
888
|
+
* @param unionTypeNodes The union branch type nodes for cross-checking with the mapped schemas.
|
|
889
|
+
* @param unionSchemas The mapped union branch schemas.
|
|
890
|
+
* @returns True if each branch is an object schema with a unique required key set.
|
|
891
|
+
*/
|
|
892
|
+
static isNeverDiscriminatedObjectUnion(context, unionTypeNodes, unionSchemas) {
|
|
893
|
+
if (unionSchemas.length < 2 || unionSchemas.length !== unionTypeNodes.length) {
|
|
894
|
+
return false;
|
|
895
|
+
}
|
|
896
|
+
const branchInfos = [];
|
|
897
|
+
let hasNeverDiscriminator = false;
|
|
898
|
+
for (const unionTypeNode of unionTypeNodes) {
|
|
899
|
+
const branchMembers = JsonSchemaBuilder.resolveNeverDiscriminatorMembers(context, unionTypeNode);
|
|
900
|
+
if (!branchMembers) {
|
|
901
|
+
return false;
|
|
902
|
+
}
|
|
903
|
+
const branchInfo = {
|
|
904
|
+
requiredKeys: new Set(),
|
|
905
|
+
forbiddenKeys: new Set()
|
|
906
|
+
};
|
|
907
|
+
for (const member of branchMembers) {
|
|
908
|
+
if (ts.isPropertySignature(member) && member.type && member.name) {
|
|
909
|
+
const propertyName = JsonSchemaBuilder.extractPropertyName(context, member.name);
|
|
910
|
+
if (propertyName) {
|
|
911
|
+
if (member.type.kind === ts.SyntaxKind.NeverKeyword) {
|
|
912
|
+
// never marks a property as impossible in this branch.
|
|
913
|
+
branchInfo.forbiddenKeys.add(propertyName);
|
|
914
|
+
hasNeverDiscriminator = true;
|
|
915
|
+
}
|
|
916
|
+
else if (!member.questionToken) {
|
|
917
|
+
// Required non-never keys are the positive branch signals.
|
|
918
|
+
branchInfo.requiredKeys.add(propertyName);
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
branchInfos.push(branchInfo);
|
|
924
|
+
}
|
|
925
|
+
if (!hasNeverDiscriminator) {
|
|
926
|
+
return false;
|
|
927
|
+
}
|
|
928
|
+
for (const branchInfo of branchInfos) {
|
|
929
|
+
if (branchInfo.requiredKeys.size === 0 && branchInfo.forbiddenKeys.size === 0) {
|
|
930
|
+
// Require each branch to contribute at least one discriminating signal.
|
|
931
|
+
return false;
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
for (let i = 0; i < branchInfos.length; i++) {
|
|
935
|
+
for (let j = i + 1; j < branchInfos.length; j++) {
|
|
936
|
+
const firstBranch = branchInfos[i];
|
|
937
|
+
const secondBranch = branchInfos[j];
|
|
938
|
+
const firstExcludesSecond = [...firstBranch.requiredKeys].some(requiredKey => secondBranch.forbiddenKeys.has(requiredKey));
|
|
939
|
+
const secondExcludesFirst = [...secondBranch.requiredKeys].some(requiredKey => firstBranch.forbiddenKeys.has(requiredKey));
|
|
940
|
+
if (!firstExcludesSecond && !secondExcludesFirst) {
|
|
941
|
+
// Branch pairs must be mutually exclusive in at least one direction.
|
|
942
|
+
return false;
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
return true;
|
|
947
|
+
}
|
|
948
|
+
/**
|
|
949
|
+
* Determine whether object union branches are discriminated by a shared required literal tag.
|
|
950
|
+
* @param context The generation context.
|
|
951
|
+
* @param unionSchemas The mapped union branch schemas.
|
|
952
|
+
* @returns True if the union can be safely represented as oneOf.
|
|
953
|
+
*/
|
|
954
|
+
static isLiteralTagDiscriminatedObjectUnion(context, unionSchemas) {
|
|
955
|
+
if (unionSchemas.length < 2) {
|
|
956
|
+
return false;
|
|
957
|
+
}
|
|
958
|
+
const resolvedObjectSchemas = unionSchemas
|
|
959
|
+
.map(schema => JsonSchemaBuilder.resolveLocalSchemaReference(context, schema) ?? schema)
|
|
960
|
+
.filter((schema) => schema.type === "object" &&
|
|
961
|
+
Is.object(schema.properties) &&
|
|
962
|
+
Is.array(schema.required));
|
|
963
|
+
if (resolvedObjectSchemas.length !== unionSchemas.length) {
|
|
964
|
+
return false;
|
|
965
|
+
}
|
|
966
|
+
const requiredDiscriminatorKeys = resolvedObjectSchemas.map(objectSchema => {
|
|
967
|
+
const discriminators = new Map();
|
|
968
|
+
for (const requiredKey of objectSchema.required) {
|
|
969
|
+
const propertySchema = objectSchema.properties[requiredKey];
|
|
970
|
+
if (Is.object(propertySchema) &&
|
|
971
|
+
Object.prototype.hasOwnProperty.call(propertySchema, "const")) {
|
|
972
|
+
discriminators.set(requiredKey, propertySchema.const);
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
return discriminators;
|
|
976
|
+
});
|
|
977
|
+
const candidateKeys = new Set(requiredDiscriminatorKeys[0].keys());
|
|
978
|
+
for (const discriminatorMap of requiredDiscriminatorKeys.slice(1)) {
|
|
979
|
+
for (const candidateKey of [...candidateKeys]) {
|
|
980
|
+
if (!discriminatorMap.has(candidateKey)) {
|
|
981
|
+
candidateKeys.delete(candidateKey);
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
for (const candidateKey of candidateKeys) {
|
|
986
|
+
const discriminatorValues = requiredDiscriminatorKeys.map(discriminatorMap => JsonHelper.canonicalize(discriminatorMap.get(candidateKey)));
|
|
987
|
+
if (new Set(discriminatorValues).size === unionSchemas.length) {
|
|
988
|
+
return true;
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
return false;
|
|
992
|
+
}
|
|
993
|
+
/**
|
|
994
|
+
* Resolve branch members that can encode never-based discriminators.
|
|
995
|
+
* @param context The generation context.
|
|
996
|
+
* @param unionTypeNode The union branch type node.
|
|
997
|
+
* @returns Type members for the branch when resolvable.
|
|
998
|
+
*/
|
|
999
|
+
static resolveNeverDiscriminatorMembers(context, unionTypeNode) {
|
|
1000
|
+
const findTypeMembersInSourceFile = (sourceFile, typeName) => {
|
|
1001
|
+
for (const statement of sourceFile.statements) {
|
|
1002
|
+
if (ts.isInterfaceDeclaration(statement) && statement.name.text === typeName) {
|
|
1003
|
+
return statement.members;
|
|
1004
|
+
}
|
|
1005
|
+
if (ts.isTypeAliasDeclaration(statement) &&
|
|
1006
|
+
statement.name.text === typeName &&
|
|
1007
|
+
ts.isTypeLiteralNode(statement.type)) {
|
|
1008
|
+
return statement.type.members;
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
return undefined;
|
|
1012
|
+
};
|
|
1013
|
+
// { discriminator: "a"; other: string } (inline type literal as union branch)
|
|
1014
|
+
if (ts.isTypeLiteralNode(unionTypeNode)) {
|
|
1015
|
+
return unionTypeNode.members;
|
|
1016
|
+
}
|
|
1017
|
+
// BranchType (named type reference resolves to its declaration)
|
|
1018
|
+
if (!ts.isTypeReferenceNode(unionTypeNode)) {
|
|
1019
|
+
return undefined;
|
|
1020
|
+
}
|
|
1021
|
+
const sourceFile = context.activeSourceFile;
|
|
1022
|
+
if (!sourceFile) {
|
|
1023
|
+
return undefined;
|
|
1024
|
+
}
|
|
1025
|
+
// BranchType (plain identifier type name)
|
|
1026
|
+
const typeName = ts.isIdentifier(unionTypeNode.typeName)
|
|
1027
|
+
? unionTypeNode.typeName.text
|
|
1028
|
+
: unionTypeNode.typeName.right.text;
|
|
1029
|
+
const localMembers = findTypeMembersInSourceFile(sourceFile, typeName);
|
|
1030
|
+
if (localMembers) {
|
|
1031
|
+
return localMembers;
|
|
1032
|
+
}
|
|
1033
|
+
const importedTypeReference = JsonSchemaBuilder.findImportedTypeReference(context, typeName);
|
|
1034
|
+
if (!importedTypeReference?.moduleSpecifier.startsWith(".")) {
|
|
1035
|
+
return undefined;
|
|
1036
|
+
}
|
|
1037
|
+
const resolvedImportPath = JsonSchemaBuilder.resolveImportDeclarationSourceFile(sourceFile.fileName, importedTypeReference.moduleSpecifier);
|
|
1038
|
+
if (!resolvedImportPath) {
|
|
1039
|
+
return undefined;
|
|
1040
|
+
}
|
|
1041
|
+
const importedSource = FileUtils.readFile(resolvedImportPath);
|
|
1042
|
+
if (!importedSource) {
|
|
1043
|
+
return undefined;
|
|
1044
|
+
}
|
|
1045
|
+
const importedSourceFile = ts.createSourceFile(resolvedImportPath, importedSource, ts.ScriptTarget.Latest, true);
|
|
1046
|
+
return findTypeMembersInSourceFile(importedSourceFile, importedTypeReference.candidateTypeName);
|
|
1047
|
+
}
|
|
1048
|
+
/**
|
|
1049
|
+
* Resolve a local $ref schema to its stored schema definition.
|
|
1050
|
+
* @param context The generation context.
|
|
1051
|
+
* @param schema The schema that may contain a local reference.
|
|
1052
|
+
* @returns The resolved schema when available.
|
|
1053
|
+
*/
|
|
1054
|
+
static resolveLocalSchemaReference(context, schema) {
|
|
1055
|
+
if (!schema.$ref?.startsWith(context.namespace)) {
|
|
1056
|
+
return undefined;
|
|
1057
|
+
}
|
|
1058
|
+
const schemaTitle = schema.$ref.slice(context.namespace.length);
|
|
1059
|
+
return context.schemas[context.packageName]?.[schemaTitle];
|
|
1060
|
+
}
|
|
1061
|
+
/**
|
|
1062
|
+
* Resolve a property value from a const object declaration in an imported source file.
|
|
1063
|
+
* @param context The generation context.
|
|
1064
|
+
* @param objectName The name of the const object.
|
|
1065
|
+
* @param propertyName The name of the property to resolve.
|
|
1066
|
+
* @returns The resolved string or number value, or undefined if unresolvable.
|
|
1067
|
+
*/
|
|
1068
|
+
static resolveConstObjectProperty(context, objectName, propertyName) {
|
|
1069
|
+
if (!context.activeSourceFile) {
|
|
1070
|
+
return undefined;
|
|
1071
|
+
}
|
|
1072
|
+
const importReference = JsonSchemaBuilder.findImportedValueReference(context.activeSourceFile, objectName);
|
|
1073
|
+
if (!importReference) {
|
|
1074
|
+
return undefined;
|
|
1075
|
+
}
|
|
1076
|
+
const resolvedPath = JsonSchemaBuilder.resolveImportDeclarationSourceFile(context.activeSourceFile.fileName, importReference.moduleSpecifier);
|
|
1077
|
+
if (!resolvedPath) {
|
|
1078
|
+
return undefined;
|
|
1079
|
+
}
|
|
1080
|
+
const importedSource = FileUtils.readFile(resolvedPath);
|
|
1081
|
+
if (!importedSource) {
|
|
1082
|
+
return undefined;
|
|
1083
|
+
}
|
|
1084
|
+
const importedSourceFile = ts.createSourceFile(resolvedPath, importedSource, ts.ScriptTarget.Latest, true);
|
|
1085
|
+
let objDecl = importedSourceFile.statements
|
|
1086
|
+
// export const FOO = { ... } as const (variable statement)
|
|
1087
|
+
.filter((stmt) => ts.isVariableStatement(stmt))
|
|
1088
|
+
.flatMap(stmt => [...stmt.declarationList.declarations])
|
|
1089
|
+
// FOO (plain identifier binding name)
|
|
1090
|
+
.find(decl => ts.isIdentifier(decl.name) && decl.name.text === importReference.importedName);
|
|
1091
|
+
objDecl ??= JsonSchemaBuilder.findVariableDeclarationInModuleGraph(resolvedPath, importReference.importedName, new Set());
|
|
1092
|
+
if (!objDecl) {
|
|
1093
|
+
return undefined;
|
|
1094
|
+
}
|
|
1095
|
+
const valueFromInitializer = JsonSchemaBuilder.extractConstObjectPropertyFromDeclarationInitializer(objDecl, propertyName);
|
|
1096
|
+
if (valueFromInitializer !== undefined) {
|
|
1097
|
+
return valueFromInitializer;
|
|
1098
|
+
}
|
|
1099
|
+
if (objDecl.type) {
|
|
1100
|
+
return JsonSchemaBuilder.extractConstObjectPropertyFromDeclarationType(objDecl.type, propertyName);
|
|
1101
|
+
}
|
|
1102
|
+
return undefined;
|
|
1103
|
+
}
|
|
1104
|
+
/**
|
|
1105
|
+
* Find a variable declaration by traversing import and export chains.
|
|
1106
|
+
* @param sourceFilePath The source file path to start from.
|
|
1107
|
+
* @param variableName The variable declaration name to find.
|
|
1108
|
+
* @param visitedFiles The file set already visited.
|
|
1109
|
+
* @returns The variable declaration when found.
|
|
1110
|
+
*/
|
|
1111
|
+
static findVariableDeclarationInModuleGraph(sourceFilePath, variableName, visitedFiles) {
|
|
1112
|
+
const normalizedSourceFilePath = FileUtils.normalizeFilePath(sourceFilePath);
|
|
1113
|
+
if (visitedFiles.has(normalizedSourceFilePath)) {
|
|
1114
|
+
return undefined;
|
|
1115
|
+
}
|
|
1116
|
+
visitedFiles.add(normalizedSourceFilePath);
|
|
1117
|
+
const source = FileUtils.readFile(sourceFilePath);
|
|
1118
|
+
if (!source) {
|
|
1119
|
+
return undefined;
|
|
1120
|
+
}
|
|
1121
|
+
const sourceFile = ts.createSourceFile(sourceFilePath, source, ts.ScriptTarget.Latest, true);
|
|
1122
|
+
const declaration = sourceFile.statements
|
|
1123
|
+
.filter((stmt) => ts.isVariableStatement(stmt))
|
|
1124
|
+
.flatMap(stmt => [...stmt.declarationList.declarations])
|
|
1125
|
+
.find(decl => ts.isIdentifier(decl.name) && decl.name.text === variableName);
|
|
1126
|
+
if (declaration) {
|
|
1127
|
+
return declaration;
|
|
1128
|
+
}
|
|
1129
|
+
const compilerOptions = {
|
|
1130
|
+
module: ts.ModuleKind.NodeNext,
|
|
1131
|
+
moduleResolution: ts.ModuleResolutionKind.NodeNext,
|
|
1132
|
+
target: ts.ScriptTarget.ESNext,
|
|
1133
|
+
skipLibCheck: true
|
|
1134
|
+
};
|
|
1135
|
+
const moduleSpecifiers = sourceFile.statements
|
|
1136
|
+
.filter(statement => ts.isExportDeclaration(statement) || ts.isImportDeclaration(statement))
|
|
1137
|
+
.flatMap(statement => {
|
|
1138
|
+
const moduleSpecifier = statement.moduleSpecifier;
|
|
1139
|
+
return moduleSpecifier && ts.isStringLiteral(moduleSpecifier) ? [moduleSpecifier.text] : [];
|
|
1140
|
+
});
|
|
1141
|
+
for (const moduleSpecifier of moduleSpecifiers) {
|
|
1142
|
+
const resolvedModulePath = ts.resolveModuleName(moduleSpecifier, sourceFilePath, compilerOptions, ts.sys).resolvedModule?.resolvedFileName;
|
|
1143
|
+
if (resolvedModulePath) {
|
|
1144
|
+
const declarationInModule = JsonSchemaBuilder.findVariableDeclarationInModuleGraph(resolvedModulePath, variableName, visitedFiles);
|
|
1145
|
+
if (declarationInModule) {
|
|
1146
|
+
return declarationInModule;
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
return undefined;
|
|
1151
|
+
}
|
|
1152
|
+
/**
|
|
1153
|
+
* Find an imported symbol reference by local identifier name.
|
|
1154
|
+
* @param sourceFile The source file to inspect.
|
|
1155
|
+
* @param localName The local identifier name.
|
|
1156
|
+
* @returns The module specifier and imported symbol name.
|
|
1157
|
+
*/
|
|
1158
|
+
static findImportedValueReference(sourceFile, localName) {
|
|
1159
|
+
for (const statement of sourceFile.statements) {
|
|
1160
|
+
if (ts.isImportDeclaration(statement) && ts.isStringLiteral(statement.moduleSpecifier)) {
|
|
1161
|
+
const bindings = statement.importClause?.namedBindings;
|
|
1162
|
+
if (bindings && ts.isNamedImports(bindings)) {
|
|
1163
|
+
const importElement = bindings.elements.find(el => el.name.text === localName);
|
|
1164
|
+
if (importElement) {
|
|
1165
|
+
return {
|
|
1166
|
+
moduleSpecifier: statement.moduleSpecifier.text,
|
|
1167
|
+
importedName: importElement.propertyName?.text ?? importElement.name.text
|
|
1168
|
+
};
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
return undefined;
|
|
1174
|
+
}
|
|
1175
|
+
/**
|
|
1176
|
+
* Resolve an import declaration module specifier to a source file.
|
|
1177
|
+
* @param containingSourceFilePath The containing source file path.
|
|
1178
|
+
* @param moduleSpecifier The module specifier text.
|
|
1179
|
+
* @returns The resolved source file path.
|
|
1180
|
+
*/
|
|
1181
|
+
static resolveImportDeclarationSourceFile(containingSourceFilePath, moduleSpecifier) {
|
|
1182
|
+
const resolvedContainingSourceFilePath = FileUtils.resolvePath(containingSourceFilePath);
|
|
1183
|
+
if (moduleSpecifier.startsWith(".")) {
|
|
1184
|
+
return (FileUtils.resolveImportSourceFilePath(resolvedContainingSourceFilePath, moduleSpecifier) ??
|
|
1185
|
+
(moduleSpecifier.endsWith(".js")
|
|
1186
|
+
? FileUtils.resolveImportSourceFilePath(resolvedContainingSourceFilePath, `${moduleSpecifier.slice(0, -3)}.ts`)
|
|
1187
|
+
: undefined));
|
|
1188
|
+
}
|
|
1189
|
+
const compilerOptions = {
|
|
1190
|
+
module: ts.ModuleKind.NodeNext,
|
|
1191
|
+
moduleResolution: ts.ModuleResolutionKind.NodeNext,
|
|
1192
|
+
target: ts.ScriptTarget.ESNext,
|
|
1193
|
+
skipLibCheck: true
|
|
1194
|
+
};
|
|
1195
|
+
return ts.resolveModuleName(moduleSpecifier, resolvedContainingSourceFilePath, compilerOptions, ts.sys).resolvedModule?.resolvedFileName;
|
|
1196
|
+
}
|
|
1197
|
+
/**
|
|
1198
|
+
* Extract a const-object property value from a declaration initializer.
|
|
1199
|
+
* @param objectDeclaration The variable declaration.
|
|
1200
|
+
* @param propertyName The property to resolve.
|
|
1201
|
+
* @returns The resolved value.
|
|
1202
|
+
*/
|
|
1203
|
+
static extractConstObjectPropertyFromDeclarationInitializer(objectDeclaration, propertyName) {
|
|
1204
|
+
if (!objectDeclaration.initializer) {
|
|
1205
|
+
return undefined;
|
|
1206
|
+
}
|
|
1207
|
+
let objLiteral;
|
|
1208
|
+
// { key: "value" } (plain object literal initializer)
|
|
1209
|
+
if (ts.isObjectLiteralExpression(objectDeclaration.initializer)) {
|
|
1210
|
+
objLiteral = objectDeclaration.initializer;
|
|
1211
|
+
}
|
|
1212
|
+
else if (
|
|
1213
|
+
// { key: "value" } as const (object literal wrapped in as-expression)
|
|
1214
|
+
ts.isAsExpression(objectDeclaration.initializer) &&
|
|
1215
|
+
// { key: "value" } (unwrap the as-expression to get the literal)
|
|
1216
|
+
ts.isObjectLiteralExpression(objectDeclaration.initializer.expression)) {
|
|
1217
|
+
objLiteral = objectDeclaration.initializer.expression;
|
|
1218
|
+
}
|
|
1219
|
+
if (!objLiteral) {
|
|
1220
|
+
return undefined;
|
|
1221
|
+
}
|
|
1222
|
+
const prop = objLiteral.properties
|
|
1223
|
+
// key: "value" (named property assignment)
|
|
1224
|
+
.filter((p) => ts.isPropertyAssignment(p))
|
|
1225
|
+
// myProp (plain identifier property name)
|
|
1226
|
+
.find(p => ts.isIdentifier(p.name) && p.name.text === propertyName);
|
|
1227
|
+
if (!prop) {
|
|
1228
|
+
return undefined;
|
|
1229
|
+
}
|
|
1230
|
+
// "value" (string literal property value)
|
|
1231
|
+
if (ts.isStringLiteral(prop.initializer)) {
|
|
1232
|
+
return prop.initializer.text;
|
|
1233
|
+
}
|
|
1234
|
+
// 42 (numeric literal property value)
|
|
1235
|
+
if (ts.isNumericLiteral(prop.initializer)) {
|
|
1236
|
+
return Number(prop.initializer.text);
|
|
1237
|
+
}
|
|
1238
|
+
return undefined;
|
|
1239
|
+
}
|
|
1240
|
+
/**
|
|
1241
|
+
* Extract a const-object property value from a declaration type annotation.
|
|
1242
|
+
* @param declarationTypeNode The declaration type annotation.
|
|
1243
|
+
* @param propertyName The property to resolve.
|
|
1244
|
+
* @returns The resolved value.
|
|
1245
|
+
*/
|
|
1246
|
+
static extractConstObjectPropertyFromDeclarationType(declarationTypeNode, propertyName) {
|
|
1247
|
+
if (ts.isParenthesizedTypeNode(declarationTypeNode)) {
|
|
1248
|
+
return JsonSchemaBuilder.extractConstObjectPropertyFromDeclarationType(declarationTypeNode.type, propertyName);
|
|
1249
|
+
}
|
|
1250
|
+
if (ts.isTypeOperatorNode(declarationTypeNode) &&
|
|
1251
|
+
declarationTypeNode.operator === ts.SyntaxKind.ReadonlyKeyword) {
|
|
1252
|
+
return JsonSchemaBuilder.extractConstObjectPropertyFromDeclarationType(declarationTypeNode.type, propertyName);
|
|
1253
|
+
}
|
|
1254
|
+
if (ts.isTypeReferenceNode(declarationTypeNode) &&
|
|
1255
|
+
ts.isIdentifier(declarationTypeNode.typeName) &&
|
|
1256
|
+
declarationTypeNode.typeName.text === "Readonly" &&
|
|
1257
|
+
Is.arrayValue(declarationTypeNode.typeArguments)) {
|
|
1258
|
+
return JsonSchemaBuilder.extractConstObjectPropertyFromDeclarationType(declarationTypeNode.typeArguments[0], propertyName);
|
|
1259
|
+
}
|
|
1260
|
+
if (ts.isTypeLiteralNode(declarationTypeNode)) {
|
|
1261
|
+
const propertySignature = declarationTypeNode.members.find((member) => {
|
|
1262
|
+
if (!ts.isPropertySignature(member) || !member.name) {
|
|
1263
|
+
return false;
|
|
1264
|
+
}
|
|
1265
|
+
return ((ts.isIdentifier(member.name) && member.name.text === propertyName) ||
|
|
1266
|
+
(ts.isStringLiteral(member.name) && member.name.text === propertyName) ||
|
|
1267
|
+
(ts.isNumericLiteral(member.name) && member.name.text === propertyName));
|
|
1268
|
+
});
|
|
1269
|
+
if (propertySignature?.type) {
|
|
1270
|
+
return JsonSchemaBuilder.extractLiteralValueFromTypeNode(propertySignature.type);
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
return undefined;
|
|
1274
|
+
}
|
|
1275
|
+
/**
|
|
1276
|
+
* Extract a literal value from a type node when possible.
|
|
1277
|
+
* @param typeNode The type node.
|
|
1278
|
+
* @returns The literal value.
|
|
1279
|
+
*/
|
|
1280
|
+
static extractLiteralValueFromTypeNode(typeNode) {
|
|
1281
|
+
if (ts.isLiteralTypeNode(typeNode)) {
|
|
1282
|
+
if (ts.isStringLiteral(typeNode.literal)) {
|
|
1283
|
+
return typeNode.literal.text;
|
|
1284
|
+
}
|
|
1285
|
+
if (ts.isNumericLiteral(typeNode.literal)) {
|
|
1286
|
+
return Number(typeNode.literal.text);
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
return undefined;
|
|
1290
|
+
}
|
|
1291
|
+
/**
|
|
1292
|
+
* Extract a referenced type name from an import type qualifier.
|
|
1293
|
+
* @param qualifier The import type qualifier.
|
|
1294
|
+
* @returns The resolved type name.
|
|
1295
|
+
*/
|
|
1296
|
+
static extractImportTypeName(qualifier) {
|
|
1297
|
+
if (!qualifier) {
|
|
1298
|
+
return undefined;
|
|
1299
|
+
}
|
|
1300
|
+
// TypeName (plain identifier, e.g. import("pkg").TypeName)
|
|
1301
|
+
if (ts.isIdentifier(qualifier)) {
|
|
1302
|
+
return qualifier.text;
|
|
1303
|
+
}
|
|
1304
|
+
// Namespace.TypeName (qualified name, e.g. import("pkg").Namespace.TypeName)
|
|
1305
|
+
return qualifier.right.text;
|
|
1306
|
+
}
|
|
1307
|
+
/**
|
|
1308
|
+
* Resolve import-type references to local or external schema ids.
|
|
1309
|
+
* @param context The generation context.
|
|
1310
|
+
* @param moduleSpecifier The import module specifier.
|
|
1311
|
+
* @param typeName The imported type name.
|
|
1312
|
+
* @param title The derived schema title.
|
|
1313
|
+
* @returns The resolved schema id.
|
|
1314
|
+
*/
|
|
1315
|
+
static resolveImportTypeReferenceSchemaId(context, moduleSpecifier, typeName, title) {
|
|
1316
|
+
const mappedReference = JsonSchemaBuilder.resolveReferenceMappingTarget(context, moduleSpecifier, typeName);
|
|
1317
|
+
if (moduleSpecifier.startsWith(".")) {
|
|
1318
|
+
if (mappedReference?.schemaId) {
|
|
1319
|
+
return mappedReference.schemaId;
|
|
1320
|
+
}
|
|
1321
|
+
const activeFilePath = context.activeSourceFile?.fileName;
|
|
1322
|
+
if (!activeFilePath) {
|
|
1323
|
+
return undefined;
|
|
1324
|
+
}
|
|
1325
|
+
const resolvedImportPath = FileUtils.resolveImportSourceFilePath(activeFilePath, moduleSpecifier);
|
|
1326
|
+
if (!resolvedImportPath) {
|
|
1327
|
+
return undefined;
|
|
1328
|
+
}
|
|
1329
|
+
const importedSource = FileUtils.readFile(resolvedImportPath);
|
|
1330
|
+
if (!importedSource) {
|
|
1331
|
+
return undefined;
|
|
1332
|
+
}
|
|
1333
|
+
JsonSchemaBuilder.parseAllObjectSchemas(context, resolvedImportPath, importedSource, []);
|
|
1334
|
+
return context.schemas[context.packageName]?.[title]?.$id;
|
|
1335
|
+
}
|
|
1336
|
+
const cachedSchemaId = context.schemas[moduleSpecifier]?.[title]?.$id;
|
|
1337
|
+
if (cachedSchemaId) {
|
|
1338
|
+
return cachedSchemaId;
|
|
1339
|
+
}
|
|
1340
|
+
const declarationResult = Resolver.resolveTypeDeclarationAst(moduleSpecifier, typeName);
|
|
1341
|
+
if (!declarationResult) {
|
|
1342
|
+
return mappedReference?.schemaId;
|
|
1343
|
+
}
|
|
1344
|
+
const externalContext = {
|
|
1345
|
+
namespace: mappedReference?.namespace ?? context.namespace,
|
|
1346
|
+
packageName: moduleSpecifier,
|
|
1347
|
+
schemas: context.schemas,
|
|
1348
|
+
activeSourceFile: context.activeSourceFile,
|
|
1349
|
+
options: context.options
|
|
1350
|
+
};
|
|
1351
|
+
JsonSchemaBuilder.parseAllObjectSchemas(externalContext, declarationResult.sourceFile.fileName, declarationResult.sourceFile.getFullText(), []);
|
|
1352
|
+
return context.schemas[moduleSpecifier]?.[title]?.$id ?? mappedReference?.schemaId;
|
|
1353
|
+
}
|
|
1354
|
+
/**
|
|
1355
|
+
* Infer a primitive JSON schema from a literal expression.
|
|
1356
|
+
* @param context The generation context.
|
|
1357
|
+
* @param expr The expression to inspect.
|
|
1358
|
+
* @param asConst Whether to produce exact const schemas matching an as-const assertion.
|
|
1359
|
+
* @returns The inferred schema.
|
|
1360
|
+
*/
|
|
1361
|
+
static inferSchemaFromExpression(context, expr, asConst = false) {
|
|
1362
|
+
// expr satisfies Type (satisfies operator, unwrap and recurse)
|
|
1363
|
+
if (ts.isSatisfiesExpression(expr)) {
|
|
1364
|
+
return JsonSchemaBuilder.inferSchemaFromExpression(context, expr.expression, asConst);
|
|
1365
|
+
}
|
|
1366
|
+
// expr as const or expr as Type (as-expression, detect const assertion)
|
|
1367
|
+
// TypeScript does not have a dedicated SyntaxKind for `as const`. Instead the
|
|
1368
|
+
// compiler represents it as an AsExpression whose type child is a TypeReferenceNode
|
|
1369
|
+
// whose typeName identifier text is literally "const". Checking for that string is
|
|
1370
|
+
// the only reliable way to distinguish `as const` from `as SomeNamedType`.
|
|
1371
|
+
if (ts.isAsExpression(expr)) {
|
|
1372
|
+
const isConstAssertion =
|
|
1373
|
+
// expr as Type (type reference in the as clause)
|
|
1374
|
+
ts.isTypeReferenceNode(expr.type) &&
|
|
1375
|
+
// const (the identifier must be "const")
|
|
1376
|
+
ts.isIdentifier(expr.type.typeName) &&
|
|
1377
|
+
expr.type.typeName.text === "const";
|
|
1378
|
+
return JsonSchemaBuilder.inferSchemaFromExpression(context, expr.expression, isConstAssertion);
|
|
1379
|
+
}
|
|
1380
|
+
if (
|
|
1381
|
+
// -42 (prefix unary minus applied to a numeric literal)
|
|
1382
|
+
ts.isPrefixUnaryExpression(expr) &&
|
|
1383
|
+
expr.operator === ts.SyntaxKind.MinusToken &&
|
|
1384
|
+
// 42 (the operand must be a bare numeric literal)
|
|
1385
|
+
ts.isNumericLiteral(expr.operand)) {
|
|
1386
|
+
return asConst ? { const: -Number(expr.operand.text) } : { type: "number" };
|
|
1387
|
+
}
|
|
1388
|
+
// "hello" or `hello` (plain string or no-substitution template literal)
|
|
1389
|
+
if (ts.isStringLiteral(expr) || ts.isNoSubstitutionTemplateLiteral(expr)) {
|
|
1390
|
+
return asConst ? { const: expr.text } : { type: "string" };
|
|
1391
|
+
}
|
|
1392
|
+
// 42 (numeric literal)
|
|
1393
|
+
if (ts.isNumericLiteral(expr)) {
|
|
1394
|
+
return asConst ? { const: Number(expr.text) } : { type: "number" };
|
|
1395
|
+
}
|
|
1396
|
+
if (expr.kind === ts.SyntaxKind.TrueKeyword) {
|
|
1397
|
+
return asConst ? { const: true } : { type: "boolean" };
|
|
1398
|
+
}
|
|
1399
|
+
if (expr.kind === ts.SyntaxKind.FalseKeyword) {
|
|
1400
|
+
return asConst ? { const: false } : { type: "boolean" };
|
|
1401
|
+
}
|
|
1402
|
+
if (expr.kind === ts.SyntaxKind.NullKeyword) {
|
|
1403
|
+
return asConst ? { const: null } : { type: "null" };
|
|
1404
|
+
}
|
|
1405
|
+
// { key: "value" } (object literal expression)
|
|
1406
|
+
if (ts.isObjectLiteralExpression(expr)) {
|
|
1407
|
+
return JsonSchemaBuilder.inferObjectLiteralSchema(context, expr, asConst);
|
|
1408
|
+
}
|
|
1409
|
+
// ["a", "b"] (array literal expression)
|
|
1410
|
+
if (ts.isArrayLiteralExpression(expr)) {
|
|
1411
|
+
return JsonSchemaBuilder.inferArrayLiteralSchema(context, expr, asConst);
|
|
1412
|
+
}
|
|
1413
|
+
DiagnosticReporter.report(context, expr, "jsonSchemaBuilder.diagnostic.unsupportedInferredExpressionKind", { kind: ts.SyntaxKind[expr.kind] });
|
|
1414
|
+
return {};
|
|
1415
|
+
}
|
|
1416
|
+
/**
|
|
1417
|
+
* Infer an object schema from an object literal expression, using const or widened types.
|
|
1418
|
+
* @param context The generation context.
|
|
1419
|
+
* @param expr The object literal expression.
|
|
1420
|
+
* @param asConst Whether property value schemas should be exact const types.
|
|
1421
|
+
* @returns The inferred object schema.
|
|
1422
|
+
*/
|
|
1423
|
+
static inferObjectLiteralSchema(context, expr, asConst) {
|
|
1424
|
+
const properties = {};
|
|
1425
|
+
const required = [];
|
|
1426
|
+
for (const property of expr.properties) {
|
|
1427
|
+
// key: "value" (named property assignment)
|
|
1428
|
+
if (ts.isPropertyAssignment(property)) {
|
|
1429
|
+
if (ts.isComputedPropertyName(property.name)) {
|
|
1430
|
+
DiagnosticReporter.report(context, property.name, "jsonSchemaBuilder.diagnostic.unsupportedInferredObjectComputedKey", { propertyName: property.name.getText() });
|
|
1431
|
+
}
|
|
1432
|
+
else {
|
|
1433
|
+
let key;
|
|
1434
|
+
// myProp or "my-prop" (identifier or quoted string key)
|
|
1435
|
+
if (ts.isIdentifier(property.name) || ts.isStringLiteral(property.name)) {
|
|
1436
|
+
key = property.name.text;
|
|
1437
|
+
}
|
|
1438
|
+
if (key !== undefined) {
|
|
1439
|
+
properties[key] = JsonSchemaBuilder.inferSchemaFromExpression(context, property.initializer, asConst);
|
|
1440
|
+
required.push(key);
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
// { shorthand } (shorthand property, resolves the variable's type/value)
|
|
1444
|
+
}
|
|
1445
|
+
else if (ts.isShorthandPropertyAssignment(property)) {
|
|
1446
|
+
const shorthandDeclaration = JsonSchemaBuilder.findVariableDeclaration(context.activeSourceFile, property.name.text);
|
|
1447
|
+
if (shorthandDeclaration?.type) {
|
|
1448
|
+
properties[property.name.text] =
|
|
1449
|
+
JsonSchemaBuilder.mapTypeNodeToSchema(context, shorthandDeclaration.type) ?? {};
|
|
1450
|
+
required.push(property.name.text);
|
|
1451
|
+
}
|
|
1452
|
+
else if (shorthandDeclaration?.initializer) {
|
|
1453
|
+
properties[property.name.text] = JsonSchemaBuilder.inferSchemaFromExpression(context, shorthandDeclaration.initializer, asConst);
|
|
1454
|
+
required.push(property.name.text);
|
|
1455
|
+
}
|
|
1456
|
+
else {
|
|
1457
|
+
DiagnosticReporter.report(context, property, "jsonSchemaBuilder.diagnostic.unsupportedInferredObjectMemberKind", { kind: "ShorthandPropertyAssignment", memberName: property.name.text });
|
|
1458
|
+
}
|
|
1459
|
+
// { ...spread } (spread assignment, not supported for inference)
|
|
1460
|
+
}
|
|
1461
|
+
else if (ts.isSpreadAssignment(property)) {
|
|
1462
|
+
DiagnosticReporter.report(context, property, "jsonSchemaBuilder.diagnostic.unsupportedInferredObjectSpread", { expression: property.expression.getText() });
|
|
1463
|
+
}
|
|
1464
|
+
else {
|
|
1465
|
+
DiagnosticReporter.report(context, property, "jsonSchemaBuilder.diagnostic.unsupportedInferredObjectMemberKind", { kind: ts.SyntaxKind[property.kind] });
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
if (Object.keys(properties).length === 0) {
|
|
1469
|
+
return { type: "object" };
|
|
1470
|
+
}
|
|
1471
|
+
return { type: "object", properties, required };
|
|
1472
|
+
}
|
|
1473
|
+
/**
|
|
1474
|
+
* Infer an array schema from an array literal expression.
|
|
1475
|
+
* For as-const arrays a fixed-length tuple schema is produced; otherwise a plain array schema.
|
|
1476
|
+
* @param context The generation context.
|
|
1477
|
+
* @param expr The array literal expression.
|
|
1478
|
+
* @param asConst Whether to produce a const-exact tuple schema.
|
|
1479
|
+
* @returns The inferred array schema.
|
|
1480
|
+
*/
|
|
1481
|
+
static inferArrayLiteralSchema(context, expr, asConst) {
|
|
1482
|
+
if (!asConst || expr.elements.length === 0) {
|
|
1483
|
+
return { type: "array" };
|
|
1484
|
+
}
|
|
1485
|
+
const prefixItems = [];
|
|
1486
|
+
for (const element of expr.elements) {
|
|
1487
|
+
// [...items] or a hole in [1,, 3] (spread or omitted element, not supported)
|
|
1488
|
+
if (ts.isSpreadElement(element) || ts.isOmittedExpression(element)) {
|
|
1489
|
+
DiagnosticReporter.report(context, element, "jsonSchemaBuilder.diagnostic.unsupportedInferredTupleElement", { kind: ts.SyntaxKind[element.kind] });
|
|
1490
|
+
return { type: "array" };
|
|
1491
|
+
}
|
|
1492
|
+
prefixItems.push(JsonSchemaBuilder.inferSchemaFromExpression(context, element, true));
|
|
1493
|
+
}
|
|
1494
|
+
return {
|
|
1495
|
+
type: "array",
|
|
1496
|
+
prefixItems,
|
|
1497
|
+
items: false,
|
|
1498
|
+
minItems: prefixItems.length,
|
|
1499
|
+
maxItems: prefixItems.length
|
|
1500
|
+
};
|
|
1501
|
+
}
|
|
1502
|
+
/**
|
|
1503
|
+
* Map conditional type nodes (e.g. T extends U ? X : Y) to schema.
|
|
1504
|
+
* @param context The generation context.
|
|
1505
|
+
* @param typeNode The conditional type node.
|
|
1506
|
+
* @returns The mapped schema.
|
|
1507
|
+
*/
|
|
1508
|
+
static mapConditionalTypeToSchema(context, typeNode) {
|
|
1509
|
+
const trueSchema = JsonSchemaBuilder.mapTypeNodeToSchema(context, typeNode.trueType);
|
|
1510
|
+
const falseSchema = JsonSchemaBuilder.mapTypeNodeToSchema(context, typeNode.falseType);
|
|
1511
|
+
if (!trueSchema && !falseSchema) {
|
|
1512
|
+
return undefined;
|
|
1513
|
+
}
|
|
1514
|
+
if (trueSchema && falseSchema) {
|
|
1515
|
+
if (JsonHelper.canonicalize(trueSchema) === JsonHelper.canonicalize(falseSchema)) {
|
|
1516
|
+
return trueSchema;
|
|
1517
|
+
}
|
|
1518
|
+
return {
|
|
1519
|
+
anyOf: [trueSchema, falseSchema]
|
|
1520
|
+
};
|
|
1521
|
+
}
|
|
1522
|
+
return trueSchema ?? falseSchema;
|
|
1523
|
+
}
|
|
1524
|
+
/**
|
|
1525
|
+
* Map indexed access type nodes (e.g. T["id"]) to schema.
|
|
1526
|
+
* @param context The generation context.
|
|
1527
|
+
* @param typeNode The indexed access type node.
|
|
1528
|
+
* @returns The mapped schema.
|
|
1529
|
+
*/
|
|
1530
|
+
static mapIndexedAccessTypeToSchema(context, typeNode) {
|
|
1531
|
+
const baseSchema = JsonSchemaBuilder.resolveUtilityBaseObjectSchema(context, typeNode.objectType) ??
|
|
1532
|
+
JsonSchemaBuilder.mapTypeNodeToSchema(context, typeNode.objectType);
|
|
1533
|
+
if (!baseSchema) {
|
|
1534
|
+
return undefined;
|
|
1535
|
+
}
|
|
1536
|
+
const indexKeys = JsonSchemaBuilder.extractIndexedAccessKeys(context, typeNode.indexType);
|
|
1537
|
+
if (indexKeys.length === 0) {
|
|
1538
|
+
if (typeNode.indexType.kind === ts.SyntaxKind.NumberKeyword &&
|
|
1539
|
+
baseSchema.type === "array" &&
|
|
1540
|
+
Is.object(baseSchema.items)) {
|
|
1541
|
+
return ObjectHelper.clone(baseSchema.items);
|
|
1542
|
+
}
|
|
1543
|
+
if (typeNode.indexType.kind === ts.SyntaxKind.NumberKeyword &&
|
|
1544
|
+
baseSchema.type === "array" &&
|
|
1545
|
+
Is.array(baseSchema.prefixItems)) {
|
|
1546
|
+
const uniquePrefixSchemas = baseSchema.prefixItems.filter((schema, index, allSchemas) => {
|
|
1547
|
+
const schemaKey = JsonHelper.canonicalize(schema);
|
|
1548
|
+
return allSchemas.findIndex(s => JsonHelper.canonicalize(s) === schemaKey) === index;
|
|
1549
|
+
});
|
|
1550
|
+
if (uniquePrefixSchemas.length === 1) {
|
|
1551
|
+
return ObjectHelper.clone(uniquePrefixSchemas[0]);
|
|
1552
|
+
}
|
|
1553
|
+
if (uniquePrefixSchemas.length > 1) {
|
|
1554
|
+
return {
|
|
1555
|
+
anyOf: uniquePrefixSchemas.map(schema => ObjectHelper.clone(schema))
|
|
1556
|
+
};
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
if ((typeNode.indexType.kind === ts.SyntaxKind.StringKeyword ||
|
|
1560
|
+
typeNode.indexType.kind === ts.SyntaxKind.NumberKeyword) &&
|
|
1561
|
+
Is.object(baseSchema.additionalProperties)) {
|
|
1562
|
+
return ObjectHelper.clone(baseSchema.additionalProperties);
|
|
1563
|
+
}
|
|
1564
|
+
return undefined;
|
|
1565
|
+
}
|
|
1566
|
+
const resolvedPropertySchemas = indexKeys
|
|
1567
|
+
.map(indexKey => ObjectTransformer.resolvePropertySchemaFromObjectSchema(baseSchema, indexKey))
|
|
1568
|
+
.filter((schema) => schema !== undefined);
|
|
1569
|
+
if (resolvedPropertySchemas.length === 0) {
|
|
1570
|
+
return undefined;
|
|
1571
|
+
}
|
|
1572
|
+
const uniqueSchemas = resolvedPropertySchemas.filter((schema, index, allSchemas) => {
|
|
1573
|
+
const schemaKey = JsonHelper.canonicalize(schema);
|
|
1574
|
+
return allSchemas.findIndex(s => JsonHelper.canonicalize(s) === schemaKey) === index;
|
|
1575
|
+
});
|
|
1576
|
+
if (uniqueSchemas.length === 1) {
|
|
1577
|
+
return uniqueSchemas[0];
|
|
1578
|
+
}
|
|
1579
|
+
return {
|
|
1580
|
+
anyOf: uniqueSchemas
|
|
1581
|
+
};
|
|
1582
|
+
}
|
|
1583
|
+
/**
|
|
1584
|
+
* Map template literal type nodes to string schemas.
|
|
1585
|
+
* @param context The generation context.
|
|
1586
|
+
* @param typeNode The template literal type node.
|
|
1587
|
+
* @returns The mapped schema.
|
|
1588
|
+
*/
|
|
1589
|
+
static mapTemplateLiteralTypeToSchema(context, typeNode) {
|
|
1590
|
+
const templatePattern = TemplateLiteralPatternBuilder.buildTemplateLiteralPattern(typeNode);
|
|
1591
|
+
if (!templatePattern) {
|
|
1592
|
+
DiagnosticReporter.report(context, typeNode, "jsonSchemaBuilder.diagnostic.unsupportedTemplateLiteralSpan", { type: typeNode.getText() });
|
|
1593
|
+
}
|
|
1594
|
+
return templatePattern
|
|
1595
|
+
? {
|
|
1596
|
+
type: "string",
|
|
1597
|
+
pattern: templatePattern
|
|
1598
|
+
}
|
|
1599
|
+
: {
|
|
1600
|
+
type: "string"
|
|
1601
|
+
};
|
|
1602
|
+
}
|
|
1603
|
+
/**
|
|
1604
|
+
* Map mapped type nodes to object schemas.
|
|
1605
|
+
* @param context The generation context.
|
|
1606
|
+
* @param typeNode The mapped type node.
|
|
1607
|
+
* @returns The mapped schema.
|
|
1608
|
+
*/
|
|
1609
|
+
static mapMappedTypeToSchema(context, typeNode) {
|
|
1610
|
+
const mappedTypeParameterName = typeNode.typeParameter.name.text;
|
|
1611
|
+
const mappedKeys = JsonSchemaBuilder.extractMappedTypeKeys(context, typeNode.typeParameter.constraint);
|
|
1612
|
+
const sourceObjectSchema = JsonSchemaBuilder.resolveMappedTypeSourceObjectSchema(context, typeNode);
|
|
1613
|
+
const mappedEntries = MappedTypeSchemaResolver.resolveMappedTypePropertyEntries(context, typeNode, mappedKeys, mappedTypeParameterName);
|
|
1614
|
+
if (mappedEntries === undefined) {
|
|
1615
|
+
DiagnosticReporter.report(context, typeNode, "jsonSchemaBuilder.diagnostic.unresolvedMappedTypeRemap", {
|
|
1616
|
+
typeName: typeNode.typeParameter.name.text
|
|
1617
|
+
});
|
|
1618
|
+
return MappedTypeSchemaResolver.buildMappedTypeFallbackSchema(context, typeNode, mappedKeys, mappedTypeParameterName, sourceObjectSchema);
|
|
1619
|
+
}
|
|
1620
|
+
if (sourceObjectSchema && JsonSchemaBuilder.isHomomorphicMappedType(typeNode)) {
|
|
1621
|
+
const mappedSchema = ObjectTransformer.toInlineUtilityObjectSchema(sourceObjectSchema);
|
|
1622
|
+
JsonSchemaBuilder.applyMappedTypeOptionality(mappedSchema, typeNode.questionToken);
|
|
1623
|
+
return mappedSchema;
|
|
1624
|
+
}
|
|
1625
|
+
const properties = {};
|
|
1626
|
+
for (const mappedEntry of mappedEntries) {
|
|
1627
|
+
const propertySchema = JsonSchemaBuilder.mapMappedTypePropertySchema(context, typeNode, mappedEntry.sourceKey, mappedTypeParameterName, sourceObjectSchema);
|
|
1628
|
+
if (propertySchema) {
|
|
1629
|
+
const existingPropertySchema = properties[mappedEntry.mappedKey];
|
|
1630
|
+
properties[mappedEntry.mappedKey] = existingPropertySchema
|
|
1631
|
+
? MappedTypeSchemaResolver.mergeMappedTypePropertySchemas(existingPropertySchema, propertySchema)
|
|
1632
|
+
: propertySchema;
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
if (mappedKeys.length > 0 && mappedEntries.length === 0) {
|
|
1636
|
+
return {
|
|
1637
|
+
type: "object"
|
|
1638
|
+
};
|
|
1639
|
+
}
|
|
1640
|
+
if (Object.keys(properties).length > 0) {
|
|
1641
|
+
let requiredPropertyKeys = Object.keys(properties);
|
|
1642
|
+
if (typeNode.questionToken === undefined &&
|
|
1643
|
+
sourceObjectSchema &&
|
|
1644
|
+
JsonSchemaBuilder.isMappedTypeIndexedValueByTypeParameter(typeNode, mappedTypeParameterName)) {
|
|
1645
|
+
const sourceRequiredKeys = MappedTypeSchemaResolver.resolveMappedTypeSourceRequiredPropertyKeys(mappedEntries, sourceObjectSchema);
|
|
1646
|
+
if (sourceRequiredKeys) {
|
|
1647
|
+
requiredPropertyKeys = sourceRequiredKeys;
|
|
1648
|
+
}
|
|
1649
|
+
}
|
|
1650
|
+
const mappedSchema = {
|
|
1651
|
+
type: "object",
|
|
1652
|
+
properties
|
|
1653
|
+
};
|
|
1654
|
+
JsonSchemaBuilder.applyMappedTypeRequiredKeys(mappedSchema, requiredPropertyKeys, typeNode.questionToken);
|
|
1655
|
+
return mappedSchema;
|
|
1656
|
+
}
|
|
1657
|
+
const additionalProperties = typeNode.type
|
|
1658
|
+
? (JsonSchemaBuilder.mapTypeNodeToSchema(context, typeNode.type) ?? {})
|
|
1659
|
+
: {};
|
|
1660
|
+
return {
|
|
1661
|
+
type: "object",
|
|
1662
|
+
additionalProperties
|
|
1663
|
+
};
|
|
1664
|
+
}
|
|
1665
|
+
/**
|
|
1666
|
+
* Determine whether a mapped type value uses indexed access with the mapped key parameter.
|
|
1667
|
+
* @param typeNode The mapped type node.
|
|
1668
|
+
* @param mappedTypeParameterName The mapped type parameter name.
|
|
1669
|
+
* @returns True if the value shape is based on source indexed access.
|
|
1670
|
+
*/
|
|
1671
|
+
static isMappedTypeIndexedValueByTypeParameter(typeNode, mappedTypeParameterName) {
|
|
1672
|
+
return (typeNode.type !== undefined &&
|
|
1673
|
+
// T[K] (value type is an indexed access)
|
|
1674
|
+
ts.isIndexedAccessTypeNode(typeNode.type) &&
|
|
1675
|
+
JsonSchemaBuilder.isMappedTypeParameterReference(typeNode.type.indexType, mappedTypeParameterName));
|
|
1676
|
+
}
|
|
1677
|
+
/**
|
|
1678
|
+
* Check whether a type node is allowed for schema generation.
|
|
1679
|
+
* @param context The generation context.
|
|
1680
|
+
* @param typeNode The type node to inspect.
|
|
1681
|
+
* @param propertyName The optional property name when the type is a property type.
|
|
1682
|
+
* @param enclosingObjectName The optional enclosing object name when the type is a property type.
|
|
1683
|
+
* @returns True when the type is allowed.
|
|
1684
|
+
*/
|
|
1685
|
+
static checkTypeNodeAllowed(context, typeNode, propertyName, enclosingObjectName) {
|
|
1686
|
+
const disallowedTypeName = DisallowedTypeGuard.getDisallowedTypeName(typeNode);
|
|
1687
|
+
if (disallowedTypeName) {
|
|
1688
|
+
if (Is.stringValue(propertyName) && Is.stringValue(enclosingObjectName)) {
|
|
1689
|
+
context.activeDisallowedType = {
|
|
1690
|
+
disallowedTypeName,
|
|
1691
|
+
propertyName,
|
|
1692
|
+
enclosingObjectName
|
|
1693
|
+
};
|
|
1694
|
+
}
|
|
1695
|
+
return false;
|
|
1696
|
+
}
|
|
1697
|
+
return true;
|
|
1698
|
+
}
|
|
1699
|
+
/**
|
|
1700
|
+
* Determine whether a property type should be treated as a function and skipped.
|
|
1701
|
+
* @param typeNode The property type node.
|
|
1702
|
+
* @returns True if the property type is function-like.
|
|
1703
|
+
*/
|
|
1704
|
+
static isFunctionPropertyType(typeNode) {
|
|
1705
|
+
if (ts.isParenthesizedTypeNode(typeNode)) {
|
|
1706
|
+
return JsonSchemaBuilder.isFunctionPropertyType(typeNode.type);
|
|
1707
|
+
}
|
|
1708
|
+
if (ts.isFunctionTypeNode(typeNode) || ts.isConstructorTypeNode(typeNode)) {
|
|
1709
|
+
return true;
|
|
1710
|
+
}
|
|
1711
|
+
if (ts.isTypeReferenceNode(typeNode)) {
|
|
1712
|
+
const typeName = ts.isIdentifier(typeNode.typeName)
|
|
1713
|
+
? typeNode.typeName.text
|
|
1714
|
+
: typeNode.typeName.right.text;
|
|
1715
|
+
return typeName === "Function";
|
|
1716
|
+
}
|
|
1717
|
+
return false;
|
|
1718
|
+
}
|
|
1719
|
+
/**
|
|
1720
|
+
* Determine whether a type node represents symbol or unique symbol.
|
|
1721
|
+
* @param typeNode The type node to inspect.
|
|
1722
|
+
* @returns True when the type is symbol or unique symbol.
|
|
1723
|
+
*/
|
|
1724
|
+
static isSymbolTypeNode(typeNode) {
|
|
1725
|
+
// (symbol) (parenthesised type, unwrap and recurse)
|
|
1726
|
+
if (ts.isParenthesizedTypeNode(typeNode)) {
|
|
1727
|
+
return JsonSchemaBuilder.isSymbolTypeNode(typeNode.type);
|
|
1728
|
+
}
|
|
1729
|
+
if (typeNode.kind === ts.SyntaxKind.SymbolKeyword) {
|
|
1730
|
+
return true;
|
|
1731
|
+
}
|
|
1732
|
+
// unique symbol (unique symbol type operator)
|
|
1733
|
+
if (ts.isTypeOperatorNode(typeNode) &&
|
|
1734
|
+
typeNode.operator === ts.SyntaxKind.UniqueKeyword &&
|
|
1735
|
+
typeNode.type.kind === ts.SyntaxKind.SymbolKeyword) {
|
|
1736
|
+
return true;
|
|
1737
|
+
}
|
|
1738
|
+
return false;
|
|
1739
|
+
}
|
|
1740
|
+
/**
|
|
1741
|
+
* Determine whether a computed property expression references a symbol.
|
|
1742
|
+
* Covers well-known symbols (Symbol.iterator, etc.) and const variables
|
|
1743
|
+
* declared with a unique symbol type annotation.
|
|
1744
|
+
* @param context The generation context.
|
|
1745
|
+
* @param expression The computed property expression.
|
|
1746
|
+
* @returns True when the key is symbol-based.
|
|
1747
|
+
*/
|
|
1748
|
+
static isSymbolKeyedComputedExpression(context, expression) {
|
|
1749
|
+
if (
|
|
1750
|
+
// Symbol.iterator (property access on the Symbol object)
|
|
1751
|
+
ts.isPropertyAccessExpression(expression) &&
|
|
1752
|
+
// Symbol (the object must be the bare "Symbol" identifier)
|
|
1753
|
+
ts.isIdentifier(expression.expression) &&
|
|
1754
|
+
expression.expression.text === "Symbol") {
|
|
1755
|
+
return true;
|
|
1756
|
+
}
|
|
1757
|
+
// mySymbol (identifier whose const declaration has a Symbol type)
|
|
1758
|
+
if (ts.isIdentifier(expression)) {
|
|
1759
|
+
const decl = JsonSchemaBuilder.findConstVariableDeclaration(context, expression.text);
|
|
1760
|
+
if (decl?.type && JsonSchemaBuilder.isSymbolTypeNode(decl.type)) {
|
|
1761
|
+
return true;
|
|
1762
|
+
}
|
|
1763
|
+
}
|
|
1764
|
+
return false;
|
|
1765
|
+
}
|
|
1766
|
+
/**
|
|
1767
|
+
* Extract a property name from TypeScript property syntax.
|
|
1768
|
+
* Computed names are resolved when they evaluate to concrete literals.
|
|
1769
|
+
* @param context The generation context.
|
|
1770
|
+
* @param propertyName The property name node.
|
|
1771
|
+
* @returns The normalized property name.
|
|
1772
|
+
*/
|
|
1773
|
+
static extractPropertyName(context, propertyName) {
|
|
1774
|
+
// myProp or #privateField (identifier or private-identifier property name)
|
|
1775
|
+
if (ts.isIdentifier(propertyName) || ts.isPrivateIdentifier(propertyName)) {
|
|
1776
|
+
return propertyName.text;
|
|
1777
|
+
}
|
|
1778
|
+
// "my-prop" or 0 (string or numeric literal property name)
|
|
1779
|
+
if (ts.isStringLiteral(propertyName) || ts.isNumericLiteral(propertyName)) {
|
|
1780
|
+
return propertyName.text;
|
|
1781
|
+
}
|
|
1782
|
+
// [computedKey] (computed property name)
|
|
1783
|
+
if (ts.isComputedPropertyName(propertyName)) {
|
|
1784
|
+
if (JsonSchemaBuilder.isSymbolKeyedComputedExpression(context, propertyName.expression)) {
|
|
1785
|
+
DiagnosticReporter.report(context, propertyName, "jsonSchemaBuilder.diagnostic.symbolKeyedMember", {
|
|
1786
|
+
memberName: propertyName.getText()
|
|
1787
|
+
});
|
|
1788
|
+
}
|
|
1789
|
+
else {
|
|
1790
|
+
const computedPropertyName = JsonSchemaBuilder.resolveComputedPropertyNameExpression(context, propertyName.expression, new Set());
|
|
1791
|
+
if (Is.stringValue(computedPropertyName)) {
|
|
1792
|
+
return computedPropertyName;
|
|
1793
|
+
}
|
|
1794
|
+
DiagnosticReporter.report(context, propertyName, "jsonSchemaBuilder.diagnostic.unsupportedComputedPropertyName", {
|
|
1795
|
+
propertyName: propertyName.getText()
|
|
1796
|
+
});
|
|
1797
|
+
}
|
|
1798
|
+
}
|
|
1799
|
+
return undefined;
|
|
1800
|
+
}
|
|
1801
|
+
/**
|
|
1802
|
+
* Find a variable declaration by name in a source file.
|
|
1803
|
+
* @param sourceFile The source file to search.
|
|
1804
|
+
* @param variableName The variable name.
|
|
1805
|
+
* @returns The declaration if found.
|
|
1806
|
+
*/
|
|
1807
|
+
static findVariableDeclaration(sourceFile, variableName) {
|
|
1808
|
+
if (!sourceFile) {
|
|
1809
|
+
return undefined;
|
|
1810
|
+
}
|
|
1811
|
+
for (const statement of sourceFile.statements) {
|
|
1812
|
+
// const foo = ... or let foo = ... (any variable statement)
|
|
1813
|
+
if (ts.isVariableStatement(statement)) {
|
|
1814
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
1815
|
+
// foo (plain identifier binding matching the requested name)
|
|
1816
|
+
if (ts.isIdentifier(declaration.name) && declaration.name.text === variableName) {
|
|
1817
|
+
return declaration;
|
|
1818
|
+
}
|
|
1819
|
+
}
|
|
1820
|
+
}
|
|
1821
|
+
}
|
|
1822
|
+
return undefined;
|
|
1823
|
+
}
|
|
1824
|
+
/**
|
|
1825
|
+
* Resolve a schema for an imported identifier used in a type query.
|
|
1826
|
+
* @param context The generation context.
|
|
1827
|
+
* @param localName The local imported symbol name.
|
|
1828
|
+
* @returns The mapped schema if resolvable.
|
|
1829
|
+
*/
|
|
1830
|
+
static resolveImportedTypeQuerySchema(context, localName) {
|
|
1831
|
+
const sourceFile = context.activeSourceFile;
|
|
1832
|
+
if (!sourceFile) {
|
|
1833
|
+
return undefined;
|
|
1834
|
+
}
|
|
1835
|
+
for (const statement of sourceFile.statements) {
|
|
1836
|
+
// import { localName } from "./module.js" (relative named import)
|
|
1837
|
+
if (ts.isImportDeclaration(statement) && ts.isStringLiteral(statement.moduleSpecifier)) {
|
|
1838
|
+
if (statement.moduleSpecifier.text.startsWith(".")) {
|
|
1839
|
+
const bindings = statement.importClause?.namedBindings;
|
|
1840
|
+
// { Named, Import } (named import bindings)
|
|
1841
|
+
if (bindings && ts.isNamedImports(bindings)) {
|
|
1842
|
+
const importElement = bindings.elements.find(el => el.name.text === localName);
|
|
1843
|
+
if (importElement) {
|
|
1844
|
+
const importedName = importElement.propertyName?.text ?? importElement.name.text;
|
|
1845
|
+
const resolvedPath = FileUtils.resolveImportSourceFilePath(sourceFile.fileName, statement.moduleSpecifier.text);
|
|
1846
|
+
if (!resolvedPath) {
|
|
1847
|
+
return undefined;
|
|
1848
|
+
}
|
|
1849
|
+
const importedSource = FileUtils.readFile(resolvedPath);
|
|
1850
|
+
if (!importedSource) {
|
|
1851
|
+
return undefined;
|
|
1852
|
+
}
|
|
1853
|
+
const importedSourceFile = ts.createSourceFile(resolvedPath, importedSource, ts.ScriptTarget.Latest, true);
|
|
1854
|
+
const importedDeclaration = JsonSchemaBuilder.findVariableDeclaration(importedSourceFile, importedName);
|
|
1855
|
+
if (!importedDeclaration) {
|
|
1856
|
+
return undefined;
|
|
1857
|
+
}
|
|
1858
|
+
const importedContext = {
|
|
1859
|
+
...context,
|
|
1860
|
+
activeSourceFile: importedSourceFile
|
|
1861
|
+
};
|
|
1862
|
+
if (importedDeclaration.type) {
|
|
1863
|
+
return (JsonSchemaBuilder.mapTypeNodeToSchema(importedContext, importedDeclaration.type) ?? {});
|
|
1864
|
+
}
|
|
1865
|
+
if (importedDeclaration.initializer) {
|
|
1866
|
+
return JsonSchemaBuilder.inferSchemaFromExpression(importedContext, importedDeclaration.initializer);
|
|
1867
|
+
}
|
|
1868
|
+
return undefined;
|
|
1869
|
+
}
|
|
1870
|
+
}
|
|
1871
|
+
}
|
|
1872
|
+
}
|
|
1873
|
+
}
|
|
1874
|
+
return undefined;
|
|
1875
|
+
}
|
|
1876
|
+
/**
|
|
1877
|
+
* Resolve computed property name expressions when they can be evaluated to concrete keys.
|
|
1878
|
+
* @param context The generation context.
|
|
1879
|
+
* @param expression The computed property expression.
|
|
1880
|
+
* @param resolvingIdentifiers Identifier names currently being resolved.
|
|
1881
|
+
* @returns The resolved property key.
|
|
1882
|
+
*/
|
|
1883
|
+
static resolveComputedPropertyNameExpression(context, expression, resolvingIdentifiers) {
|
|
1884
|
+
if (
|
|
1885
|
+
// "key" or `key` or 42 (literal expressions resolve directly)
|
|
1886
|
+
ts.isStringLiteral(expression) ||
|
|
1887
|
+
ts.isNoSubstitutionTemplateLiteral(expression) ||
|
|
1888
|
+
ts.isNumericLiteral(expression)) {
|
|
1889
|
+
return expression.text;
|
|
1890
|
+
}
|
|
1891
|
+
// ("key") (parenthesised expression, unwrap and recurse)
|
|
1892
|
+
if (ts.isParenthesizedExpression(expression)) {
|
|
1893
|
+
return JsonSchemaBuilder.resolveComputedPropertyNameExpression(context, expression.expression, resolvingIdentifiers);
|
|
1894
|
+
}
|
|
1895
|
+
// expr as Type or <Type>expr (cast expression, unwrap and recurse)
|
|
1896
|
+
if (ts.isAsExpression(expression) || ts.isTypeAssertionExpression(expression)) {
|
|
1897
|
+
return JsonSchemaBuilder.resolveComputedPropertyNameExpression(context, expression.expression, resolvingIdentifiers);
|
|
1898
|
+
}
|
|
1899
|
+
// expr satisfies Type (satisfies operator, unwrap and recurse)
|
|
1900
|
+
if (ts.isSatisfiesExpression(expression)) {
|
|
1901
|
+
return JsonSchemaBuilder.resolveComputedPropertyNameExpression(context, expression.expression, resolvingIdentifiers);
|
|
1902
|
+
}
|
|
1903
|
+
if (
|
|
1904
|
+
// -42 or +42 (prefix unary numeric expression)
|
|
1905
|
+
ts.isPrefixUnaryExpression(expression) &&
|
|
1906
|
+
(expression.operator === ts.SyntaxKind.MinusToken ||
|
|
1907
|
+
expression.operator === ts.SyntaxKind.PlusToken)) {
|
|
1908
|
+
const operandValue = JsonSchemaBuilder.resolveComputedPropertyNameExpression(context, expression.operand, resolvingIdentifiers);
|
|
1909
|
+
if (Is.stringValue(operandValue) && /^\d+(?:\.\d+)?$/.test(operandValue)) {
|
|
1910
|
+
return expression.operator === ts.SyntaxKind.MinusToken ? `-${operandValue}` : operandValue;
|
|
1911
|
+
}
|
|
1912
|
+
}
|
|
1913
|
+
// `prefix-${expr}` (template expression, resolve spans recursively)
|
|
1914
|
+
if (ts.isTemplateExpression(expression)) {
|
|
1915
|
+
let resolvedKey = expression.head.text;
|
|
1916
|
+
for (const span of expression.templateSpans) {
|
|
1917
|
+
const spanValue = JsonSchemaBuilder.resolveComputedPropertyNameExpression(context, span.expression, resolvingIdentifiers);
|
|
1918
|
+
if (!Is.stringValue(spanValue)) {
|
|
1919
|
+
return undefined;
|
|
1920
|
+
}
|
|
1921
|
+
resolvedKey += `${spanValue}${span.literal.text}`;
|
|
1922
|
+
}
|
|
1923
|
+
return resolvedKey;
|
|
1924
|
+
}
|
|
1925
|
+
// myConst (identifier, resolve via const variable declaration)
|
|
1926
|
+
if (ts.isIdentifier(expression)) {
|
|
1927
|
+
if (resolvingIdentifiers.has(expression.text)) {
|
|
1928
|
+
return undefined;
|
|
1929
|
+
}
|
|
1930
|
+
const constDeclaration = JsonSchemaBuilder.findConstVariableDeclaration(context, expression.text);
|
|
1931
|
+
if (constDeclaration?.initializer) {
|
|
1932
|
+
resolvingIdentifiers.add(expression.text);
|
|
1933
|
+
const identifierValue = JsonSchemaBuilder.resolveComputedPropertyNameExpression(context, constDeclaration.initializer, resolvingIdentifiers);
|
|
1934
|
+
resolvingIdentifiers.delete(expression.text);
|
|
1935
|
+
return identifierValue;
|
|
1936
|
+
}
|
|
1937
|
+
}
|
|
1938
|
+
// OBJ.property (property access on a known identifier, resolve via const object)
|
|
1939
|
+
if (ts.isPropertyAccessExpression(expression) && ts.isIdentifier(expression.expression)) {
|
|
1940
|
+
const objectName = expression.expression.text;
|
|
1941
|
+
const propertyName = expression.name.text;
|
|
1942
|
+
const importedValue = JsonSchemaBuilder.resolveConstObjectProperty(context, objectName, propertyName);
|
|
1943
|
+
if (importedValue !== undefined) {
|
|
1944
|
+
return `${importedValue}`;
|
|
1945
|
+
}
|
|
1946
|
+
const localValue = JsonSchemaBuilder.resolveLocalConstObjectProperty(context, objectName, propertyName);
|
|
1947
|
+
if (localValue !== undefined) {
|
|
1948
|
+
return `${localValue}`;
|
|
1949
|
+
}
|
|
1950
|
+
}
|
|
1951
|
+
return undefined;
|
|
1952
|
+
}
|
|
1953
|
+
/**
|
|
1954
|
+
* Find a local const variable declaration in the active source file.
|
|
1955
|
+
* @param context The generation context.
|
|
1956
|
+
* @param variableName The variable name.
|
|
1957
|
+
* @returns The declaration when found.
|
|
1958
|
+
*/
|
|
1959
|
+
static findConstVariableDeclaration(context, variableName) {
|
|
1960
|
+
const sourceFile = context.activeSourceFile;
|
|
1961
|
+
if (!sourceFile) {
|
|
1962
|
+
return undefined;
|
|
1963
|
+
}
|
|
1964
|
+
for (const statement of sourceFile.statements) {
|
|
1965
|
+
if (
|
|
1966
|
+
// const myVar = ... (const variable statement)
|
|
1967
|
+
ts.isVariableStatement(statement) &&
|
|
1968
|
+
statement.declarationList.flags === ts.NodeFlags.Const) {
|
|
1969
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
1970
|
+
// myVar (plain identifier binding matching the requested name)
|
|
1971
|
+
if (ts.isIdentifier(declaration.name) && declaration.name.text === variableName) {
|
|
1972
|
+
return declaration;
|
|
1973
|
+
}
|
|
1974
|
+
}
|
|
1975
|
+
}
|
|
1976
|
+
}
|
|
1977
|
+
return undefined;
|
|
1978
|
+
}
|
|
1979
|
+
/**
|
|
1980
|
+
* Resolve a property value from a local const object declaration.
|
|
1981
|
+
* @param context The generation context.
|
|
1982
|
+
* @param objectName The const object name.
|
|
1983
|
+
* @param propertyName The property to resolve.
|
|
1984
|
+
* @returns The resolved value when available.
|
|
1985
|
+
*/
|
|
1986
|
+
static resolveLocalConstObjectProperty(context, objectName, propertyName) {
|
|
1987
|
+
const objectDeclaration = JsonSchemaBuilder.findConstVariableDeclaration(context, objectName);
|
|
1988
|
+
if (!objectDeclaration) {
|
|
1989
|
+
return undefined;
|
|
1990
|
+
}
|
|
1991
|
+
const valueFromInitializer = JsonSchemaBuilder.extractConstObjectPropertyFromDeclarationInitializer(objectDeclaration, propertyName);
|
|
1992
|
+
if (valueFromInitializer !== undefined) {
|
|
1993
|
+
return valueFromInitializer;
|
|
1994
|
+
}
|
|
1995
|
+
if (objectDeclaration.type) {
|
|
1996
|
+
return JsonSchemaBuilder.extractConstObjectPropertyFromDeclarationType(objectDeclaration.type, propertyName);
|
|
1997
|
+
}
|
|
1998
|
+
return undefined;
|
|
1999
|
+
}
|
|
2000
|
+
/**
|
|
2001
|
+
* Map a tuple type node to schema.
|
|
2002
|
+
* @param context The generation context.
|
|
2003
|
+
* @param tupleTypeNode The tuple type node.
|
|
2004
|
+
* @returns The mapped tuple schema.
|
|
2005
|
+
*/
|
|
2006
|
+
static mapTupleTypeToSchema(context, tupleTypeNode) {
|
|
2007
|
+
const fixedSchemas = [];
|
|
2008
|
+
let restSchema;
|
|
2009
|
+
let restIndex = -1;
|
|
2010
|
+
for (const [index, element] of tupleTypeNode.elements.entries()) {
|
|
2011
|
+
const tupleElementType = JsonSchemaBuilder.extractTupleElementType(element);
|
|
2012
|
+
if (!tupleElementType) {
|
|
2013
|
+
return undefined;
|
|
2014
|
+
}
|
|
2015
|
+
const mappedSchema = JsonSchemaBuilder.mapTypeNodeToSchema(context, tupleElementType);
|
|
2016
|
+
if (!mappedSchema) {
|
|
2017
|
+
return undefined;
|
|
2018
|
+
}
|
|
2019
|
+
// ...string[] (rest element in tuple)
|
|
2020
|
+
if (ts.isRestTypeNode(element)) {
|
|
2021
|
+
restSchema = JsonSchemaBuilder.extractRestElementSchema(context, element.type);
|
|
2022
|
+
restIndex = index;
|
|
2023
|
+
if (!restSchema) {
|
|
2024
|
+
return undefined;
|
|
2025
|
+
}
|
|
2026
|
+
}
|
|
2027
|
+
else {
|
|
2028
|
+
fixedSchemas.push(mappedSchema);
|
|
2029
|
+
}
|
|
2030
|
+
}
|
|
2031
|
+
if (!restSchema) {
|
|
2032
|
+
return {
|
|
2033
|
+
type: "array",
|
|
2034
|
+
prefixItems: fixedSchemas,
|
|
2035
|
+
items: false,
|
|
2036
|
+
minItems: fixedSchemas.length,
|
|
2037
|
+
maxItems: fixedSchemas.length
|
|
2038
|
+
};
|
|
2039
|
+
}
|
|
2040
|
+
const prefixCount = restIndex > -1 ? restIndex : fixedSchemas.length;
|
|
2041
|
+
const prefixItems = fixedSchemas.slice(0, prefixCount);
|
|
2042
|
+
const suffixItems = restIndex > -1 ? fixedSchemas.slice(prefixCount) : [];
|
|
2043
|
+
if (suffixItems.length === 0) {
|
|
2044
|
+
return {
|
|
2045
|
+
type: "array",
|
|
2046
|
+
prefixItems: prefixItems.length > 0 ? prefixItems : undefined,
|
|
2047
|
+
items: restSchema,
|
|
2048
|
+
minItems: prefixItems.length
|
|
2049
|
+
};
|
|
2050
|
+
}
|
|
2051
|
+
const anyOfItemSchemas = [restSchema, ...suffixItems];
|
|
2052
|
+
const uniqueAnyOfSchemas = anyOfItemSchemas.filter((schema, index, allSchemas) => {
|
|
2053
|
+
const schemaKey = JsonHelper.canonicalize(schema);
|
|
2054
|
+
return allSchemas.findIndex(s => JsonHelper.canonicalize(s) === schemaKey) === index;
|
|
2055
|
+
});
|
|
2056
|
+
const effectivePrefixItems = prefixItems;
|
|
2057
|
+
const restSchemaKey = JsonHelper.canonicalize(restSchema);
|
|
2058
|
+
const uniqueSuffixCandidate = suffixItems.find(schema => JsonHelper.canonicalize(schema) !== restSchemaKey);
|
|
2059
|
+
const exactSingleUniqueSuffixConstraint = restIndex === 0 &&
|
|
2060
|
+
suffixItems.length > 1 &&
|
|
2061
|
+
uniqueSuffixCandidate &&
|
|
2062
|
+
suffixItems.filter(schema => JsonHelper.canonicalize(schema) === JsonHelper.canonicalize(uniqueSuffixCandidate)).length === 1
|
|
2063
|
+
? {
|
|
2064
|
+
contains: uniqueSuffixCandidate,
|
|
2065
|
+
minContains: 1,
|
|
2066
|
+
maxContains: 1
|
|
2067
|
+
}
|
|
2068
|
+
: {};
|
|
2069
|
+
return {
|
|
2070
|
+
type: "array",
|
|
2071
|
+
prefixItems: effectivePrefixItems.length > 0 ? effectivePrefixItems : undefined,
|
|
2072
|
+
items: uniqueAnyOfSchemas.length === 1 ? uniqueAnyOfSchemas[0] : { anyOf: uniqueAnyOfSchemas },
|
|
2073
|
+
minItems: effectivePrefixItems.length + suffixItems.length,
|
|
2074
|
+
...exactSingleUniqueSuffixConstraint
|
|
2075
|
+
};
|
|
2076
|
+
}
|
|
2077
|
+
/**
|
|
2078
|
+
* Extract schema for a tuple rest element.
|
|
2079
|
+
* @param context The generation context.
|
|
2080
|
+
* @param restElementType The rest element type.
|
|
2081
|
+
* @returns The mapped rest element schema.
|
|
2082
|
+
*/
|
|
2083
|
+
static extractRestElementSchema(context, restElementType) {
|
|
2084
|
+
// string[] (array form of rest element type, e.g. ...string[])
|
|
2085
|
+
if (ts.isArrayTypeNode(restElementType)) {
|
|
2086
|
+
return JsonSchemaBuilder.mapTypeNodeToSchema(context, restElementType.elementType);
|
|
2087
|
+
}
|
|
2088
|
+
return JsonSchemaBuilder.mapTypeNodeToSchema(context, restElementType);
|
|
2089
|
+
}
|
|
2090
|
+
/**
|
|
2091
|
+
* Find an existing schema id by title in known package schemas.
|
|
2092
|
+
* @param context The generation context.
|
|
2093
|
+
* @param schemaTitle The schema title to find.
|
|
2094
|
+
* @returns The existing schema id if found.
|
|
2095
|
+
*/
|
|
2096
|
+
static findExistingSchemaIdByTitle(context, schemaTitle) {
|
|
2097
|
+
for (const packageSchemaEntries of Object.values(context.schemas)) {
|
|
2098
|
+
const existingSchema = packageSchemaEntries[schemaTitle];
|
|
2099
|
+
if (existingSchema?.$id) {
|
|
2100
|
+
return existingSchema.$id;
|
|
2101
|
+
}
|
|
2102
|
+
}
|
|
2103
|
+
return undefined;
|
|
2104
|
+
}
|
|
2105
|
+
/**
|
|
2106
|
+
* Apply interface inheritance (`extends`) as `allOf` references.
|
|
2107
|
+
* @param context The generation context.
|
|
2108
|
+
* @param schema The schema to expand.
|
|
2109
|
+
* @param declaration The interface declaration.
|
|
2110
|
+
*/
|
|
2111
|
+
static applyInterfaceExtendsSchema(context, schema, declaration) {
|
|
2112
|
+
const extendsClause = declaration.heritageClauses?.find(clause => clause.token === ts.SyntaxKind.ExtendsKeyword);
|
|
2113
|
+
if (!extendsClause) {
|
|
2114
|
+
return;
|
|
2115
|
+
}
|
|
2116
|
+
const allOfRefs = [];
|
|
2117
|
+
for (const extendedType of extendsClause.types) {
|
|
2118
|
+
const expression = extendedType.expression;
|
|
2119
|
+
let typeName;
|
|
2120
|
+
// interface IFoo extends BaseType (plain identifier in extends clause)
|
|
2121
|
+
if (ts.isIdentifier(expression)) {
|
|
2122
|
+
typeName = expression.text;
|
|
2123
|
+
// interface IFoo extends Namespace.BaseType (qualified extends expression)
|
|
2124
|
+
}
|
|
2125
|
+
else if (ts.isPropertyAccessExpression(expression)) {
|
|
2126
|
+
typeName = expression.name.text;
|
|
2127
|
+
}
|
|
2128
|
+
if (typeName) {
|
|
2129
|
+
// interface IFoo extends Partial<T> (utility type in extends, identifier guard)
|
|
2130
|
+
// A heritage clause expression is an Identifier node, not a TypeReferenceNode,
|
|
2131
|
+
// so it cannot be passed directly to mapTypeNodeToSchema. A synthetic
|
|
2132
|
+
// TypeReferenceNode is constructed here using the same expression + type
|
|
2133
|
+
// arguments so the utility handler receives exactly the AST shape it expects.
|
|
2134
|
+
if (JsonSchemaBuilder._utilityTypeHandlers[typeName] && ts.isIdentifier(expression)) {
|
|
2135
|
+
const syntheticTypeNode = ts.factory.createTypeReferenceNode(expression, extendedType.typeArguments);
|
|
2136
|
+
const mappedSchema = JsonSchemaBuilder.mapTypeNodeToSchema(context, syntheticTypeNode);
|
|
2137
|
+
if (mappedSchema) {
|
|
2138
|
+
allOfRefs.push(mappedSchema);
|
|
2139
|
+
}
|
|
2140
|
+
}
|
|
2141
|
+
else {
|
|
2142
|
+
const title = StringHelper.stripPrefix(typeName);
|
|
2143
|
+
const existingSchemaId = JsonSchemaBuilder.findExistingSchemaIdByTitle(context, title);
|
|
2144
|
+
allOfRefs.push({
|
|
2145
|
+
$ref: existingSchemaId ?? `${context.namespace}${title}`
|
|
2146
|
+
});
|
|
2147
|
+
}
|
|
2148
|
+
}
|
|
2149
|
+
}
|
|
2150
|
+
if (allOfRefs.length > 0) {
|
|
2151
|
+
schema.allOf = allOfRefs;
|
|
2152
|
+
}
|
|
2153
|
+
}
|
|
2154
|
+
/**
|
|
2155
|
+
* Map Partial<T> to an object schema with no required properties.
|
|
2156
|
+
* @param context The generation context.
|
|
2157
|
+
* @param typeNode The Partial type reference.
|
|
2158
|
+
* @returns The mapped schema.
|
|
2159
|
+
*/
|
|
2160
|
+
static mapPartialUtilityType(context, typeNode) {
|
|
2161
|
+
return UtilityTypeSchemaMapper.mapPartialUtilityType(context, typeNode, (ctx, baseTypeNode) => JsonSchemaBuilder.resolveUtilityBaseObjectSchema(ctx, baseTypeNode));
|
|
2162
|
+
}
|
|
2163
|
+
/**
|
|
2164
|
+
* Map Required<T> to an object schema with all properties required.
|
|
2165
|
+
* @param context The generation context.
|
|
2166
|
+
* @param typeNode The Required type reference.
|
|
2167
|
+
* @returns The mapped schema.
|
|
2168
|
+
*/
|
|
2169
|
+
static mapRequiredUtilityType(context, typeNode) {
|
|
2170
|
+
return UtilityTypeSchemaMapper.mapRequiredUtilityType(context, typeNode, (ctx, baseTypeNode) => JsonSchemaBuilder.resolveUtilityBaseObjectSchema(ctx, baseTypeNode));
|
|
2171
|
+
}
|
|
2172
|
+
/**
|
|
2173
|
+
* Map Pick<T, K> to an object schema with selected keys preserved.
|
|
2174
|
+
* @param context The generation context.
|
|
2175
|
+
* @param typeNode The Pick type reference.
|
|
2176
|
+
* @returns The mapped schema.
|
|
2177
|
+
*/
|
|
2178
|
+
static mapPickUtilityType(context, typeNode) {
|
|
2179
|
+
return UtilityTypeSchemaMapper.mapPickUtilityType(context, typeNode, (ctx, baseTypeNode) => JsonSchemaBuilder.resolveUtilityBaseObjectSchema(ctx, baseTypeNode), (ctx, keysNode) => JsonSchemaBuilder.extractUtilityTypeKeys(ctx, keysNode));
|
|
2180
|
+
}
|
|
2181
|
+
/**
|
|
2182
|
+
* Map Omit<T, K> to an object schema with selected keys removed.
|
|
2183
|
+
* @param context The generation context.
|
|
2184
|
+
* @param typeNode The Omit type reference.
|
|
2185
|
+
* @returns The mapped schema.
|
|
2186
|
+
*/
|
|
2187
|
+
static mapOmitUtilityType(context, typeNode) {
|
|
2188
|
+
return UtilityTypeSchemaMapper.mapOmitUtilityType(context, typeNode, (ctx, baseTypeNode) => JsonSchemaBuilder.resolveUtilityBaseObjectSchema(ctx, baseTypeNode), (ctx, keysNode) => JsonSchemaBuilder.extractUtilityTypeKeys(ctx, keysNode));
|
|
2189
|
+
}
|
|
2190
|
+
/**
|
|
2191
|
+
* Map Exclude<T, U> to a schema that removes U members from T.
|
|
2192
|
+
* @param context The generation context.
|
|
2193
|
+
* @param typeNode The Exclude type reference.
|
|
2194
|
+
* @returns The mapped schema.
|
|
2195
|
+
*/
|
|
2196
|
+
static mapExcludeUtilityType(context, typeNode) {
|
|
2197
|
+
return UtilityTypeSchemaMapper.mapExcludeUtilityType(context, typeNode, (ctx, node) => JsonSchemaBuilder.mapTypeNodeToSchema(ctx, node));
|
|
2198
|
+
}
|
|
2199
|
+
/**
|
|
2200
|
+
* Map Extract<T, U> to a schema that keeps U members from T.
|
|
2201
|
+
* @param context The generation context.
|
|
2202
|
+
* @param typeNode The Extract type reference.
|
|
2203
|
+
* @returns The mapped schema.
|
|
2204
|
+
*/
|
|
2205
|
+
static mapExtractUtilityType(context, typeNode) {
|
|
2206
|
+
return UtilityTypeSchemaMapper.mapExtractUtilityType(context, typeNode, (ctx, node) => JsonSchemaBuilder.mapTypeNodeToSchema(ctx, node));
|
|
2207
|
+
}
|
|
2208
|
+
/**
|
|
2209
|
+
* Map NonNullable<T> by removing null and undefined branches from T.
|
|
2210
|
+
* @param context The generation context.
|
|
2211
|
+
* @param typeNode The NonNullable type reference.
|
|
2212
|
+
* @returns The mapped schema.
|
|
2213
|
+
*/
|
|
2214
|
+
static mapNonNullableUtilityType(context, typeNode) {
|
|
2215
|
+
return UtilityTypeSchemaMapper.mapNonNullableUtilityType(context, typeNode, (ctx, node) => JsonSchemaBuilder.mapTypeNodeToSchema(ctx, node));
|
|
2216
|
+
}
|
|
2217
|
+
/**
|
|
2218
|
+
* Map Record<K, V> to an object schema with key constraints where possible.
|
|
2219
|
+
* @param context The generation context.
|
|
2220
|
+
* @param typeNode The Record type reference.
|
|
2221
|
+
* @returns The mapped schema.
|
|
2222
|
+
*/
|
|
2223
|
+
static mapRecordUtilityType(context, typeNode) {
|
|
2224
|
+
return UtilityTypeSchemaMapper.mapRecordUtilityType(context, typeNode, (ctx, node) => JsonSchemaBuilder.mapTypeNodeToSchema(ctx, node));
|
|
2225
|
+
}
|
|
2226
|
+
/**
|
|
2227
|
+
* Determine whether a type node represents null or undefined.
|
|
2228
|
+
* @param typeNode The type node.
|
|
2229
|
+
* @returns True if the node is null or undefined.
|
|
2230
|
+
*/
|
|
2231
|
+
static isNullOrUndefinedTypeNode(typeNode) {
|
|
2232
|
+
if (
|
|
2233
|
+
// null or undefined (keyword type nodes)
|
|
2234
|
+
typeNode.kind === ts.SyntaxKind.NullKeyword ||
|
|
2235
|
+
typeNode.kind === ts.SyntaxKind.UndefinedKeyword) {
|
|
2236
|
+
return true;
|
|
2237
|
+
}
|
|
2238
|
+
// null (literal type wrapping the null keyword, e.g. from union members)
|
|
2239
|
+
if (ts.isLiteralTypeNode(typeNode)) {
|
|
2240
|
+
return typeNode.literal.kind === ts.SyntaxKind.NullKeyword;
|
|
2241
|
+
}
|
|
2242
|
+
// undefined (type reference whose name is the identifier "undefined")
|
|
2243
|
+
if (ts.isTypeReferenceNode(typeNode) && ts.isIdentifier(typeNode.typeName)) {
|
|
2244
|
+
return typeNode.typeName.text === "undefined";
|
|
2245
|
+
}
|
|
2246
|
+
return false;
|
|
2247
|
+
}
|
|
2248
|
+
/**
|
|
2249
|
+
* Extract literal keys from a Record key type argument.
|
|
2250
|
+
* @param keyTypeNode The key type argument node.
|
|
2251
|
+
* @returns The extracted literal keys.
|
|
2252
|
+
*/
|
|
2253
|
+
static extractRecordLiteralKeys(keyTypeNode) {
|
|
2254
|
+
// "key" (single string literal key type, e.g. Record<"key", V>)
|
|
2255
|
+
if (ts.isLiteralTypeNode(keyTypeNode) && ts.isStringLiteral(keyTypeNode.literal)) {
|
|
2256
|
+
return [keyTypeNode.literal.text];
|
|
2257
|
+
}
|
|
2258
|
+
// "a" | "b" | "c" (union of string literal key types)
|
|
2259
|
+
if (ts.isUnionTypeNode(keyTypeNode)) {
|
|
2260
|
+
const keys = keyTypeNode.types
|
|
2261
|
+
// "key" (each member must be a string literal type)
|
|
2262
|
+
.filter(type => ts.isLiteralTypeNode(type) && ts.isStringLiteral(type.literal))
|
|
2263
|
+
.map(type => type.literal)
|
|
2264
|
+
.map(literal => literal.text);
|
|
2265
|
+
return keys.length === keyTypeNode.types.length ? keys : [];
|
|
2266
|
+
}
|
|
2267
|
+
return [];
|
|
2268
|
+
}
|
|
2269
|
+
/**
|
|
2270
|
+
* Map JsonLdObject utility types using key-removal and optional key-addition rules.
|
|
2271
|
+
* @param context The generation context.
|
|
2272
|
+
* @param typeNode The JsonLdObject utility type reference.
|
|
2273
|
+
* @param options Mapping options.
|
|
2274
|
+
* @param options.keysToRemove Keys to remove from the base schema.
|
|
2275
|
+
* @param options.keyToAdd Optional key to add to the base schema.
|
|
2276
|
+
* @param options.isAddedKeyRequired Whether the added key should be required.
|
|
2277
|
+
* @returns The mapped schema.
|
|
2278
|
+
*/
|
|
2279
|
+
static mapJsonLdObjectUtilityType(context, typeNode, options) {
|
|
2280
|
+
return UtilityTypeSchemaMapper.mapJsonLdObjectUtilityType(context, typeNode, options, (ctx, baseTypeNode) => JsonSchemaBuilder.resolveUtilityBaseObjectSchema(ctx, baseTypeNode), (ctx, node) => JsonSchemaBuilder.mapTypeNodeToSchema(ctx, node));
|
|
2281
|
+
}
|
|
2282
|
+
/**
|
|
2283
|
+
* Resolve a default schema for JsonLdObject utility key additions when the type argument is omitted.
|
|
2284
|
+
* @param baseSchema The base object schema.
|
|
2285
|
+
* @param keyToAdd The key being added by the utility.
|
|
2286
|
+
* @returns The resolved schema.
|
|
2287
|
+
*/
|
|
2288
|
+
static mapJsonLdObjectDefaultSchemaByKey(baseSchema, keyToAdd) {
|
|
2289
|
+
if (keyToAdd === "id" || keyToAdd === "@id") {
|
|
2290
|
+
return JsonSchemaBuilder.mapJsonLdObjectWithIdDefaultIdSchema(baseSchema);
|
|
2291
|
+
}
|
|
2292
|
+
if (keyToAdd === "@context") {
|
|
2293
|
+
return JsonSchemaBuilder.mapJsonLdObjectWithContextDefaultContextSchema(baseSchema);
|
|
2294
|
+
}
|
|
2295
|
+
return JsonSchemaBuilder.mapJsonLdObjectWithTypeDefaultTypeSchema(baseSchema);
|
|
2296
|
+
}
|
|
2297
|
+
/**
|
|
2298
|
+
* Resolve default id schema for JsonLdObjectWithId when Id type argument is omitted.
|
|
2299
|
+
* @param baseSchema The base object schema.
|
|
2300
|
+
* @returns The resolved id schema.
|
|
2301
|
+
*/
|
|
2302
|
+
static mapJsonLdObjectWithIdDefaultIdSchema(baseSchema) {
|
|
2303
|
+
return JsonSchemaBuilder.mapJsonLdObjectDefaultEitherSchema(baseSchema, "id", "@id", {
|
|
2304
|
+
type: "string"
|
|
2305
|
+
});
|
|
2306
|
+
}
|
|
2307
|
+
/**
|
|
2308
|
+
* Resolve default type schema for JsonLdObjectWithType when Type argument is omitted.
|
|
2309
|
+
* @param baseSchema The base object schema.
|
|
2310
|
+
* @returns The resolved type schema.
|
|
2311
|
+
*/
|
|
2312
|
+
static mapJsonLdObjectWithTypeDefaultTypeSchema(baseSchema) {
|
|
2313
|
+
return JsonSchemaBuilder.mapJsonLdObjectDefaultEitherSchema(baseSchema, "type", "@type", {
|
|
2314
|
+
anyOf: [{ type: "string" }, { type: "array", items: { type: "string" } }]
|
|
2315
|
+
});
|
|
2316
|
+
}
|
|
2317
|
+
/**
|
|
2318
|
+
* Resolve default context schema for JsonLdObjectWithContext when Context argument is omitted.
|
|
2319
|
+
* @param baseSchema The base object schema.
|
|
2320
|
+
* @returns The resolved context schema.
|
|
2321
|
+
*/
|
|
2322
|
+
static mapJsonLdObjectWithContextDefaultContextSchema(baseSchema) {
|
|
2323
|
+
return JsonSchemaBuilder.mapJsonLdObjectDefaultEitherSchema(baseSchema, "@context", "@context", {
|
|
2324
|
+
type: "array",
|
|
2325
|
+
items: { type: "string" }
|
|
2326
|
+
});
|
|
2327
|
+
}
|
|
2328
|
+
/**
|
|
2329
|
+
* Resolve default schema from either of two source keys, with fallback when both are absent.
|
|
2330
|
+
* @param baseSchema The base object schema.
|
|
2331
|
+
* @param firstKey The primary key to resolve.
|
|
2332
|
+
* @param secondKey The secondary key to resolve.
|
|
2333
|
+
* @param fallbackSchema The fallback schema.
|
|
2334
|
+
* @returns The resolved schema.
|
|
2335
|
+
*/
|
|
2336
|
+
static mapJsonLdObjectDefaultEitherSchema(baseSchema, firstKey, secondKey, fallbackSchema) {
|
|
2337
|
+
const firstSchema = ObjectTransformer.resolvePropertySchemaFromObjectSchema(baseSchema, firstKey);
|
|
2338
|
+
const secondSchema = ObjectTransformer.resolvePropertySchemaFromObjectSchema(baseSchema, secondKey);
|
|
2339
|
+
if (firstSchema && secondSchema) {
|
|
2340
|
+
if (JsonHelper.canonicalize(firstSchema) === JsonHelper.canonicalize(secondSchema)) {
|
|
2341
|
+
return firstSchema;
|
|
2342
|
+
}
|
|
2343
|
+
return {
|
|
2344
|
+
anyOf: [firstSchema, secondSchema]
|
|
2345
|
+
};
|
|
2346
|
+
}
|
|
2347
|
+
if (firstSchema) {
|
|
2348
|
+
return firstSchema;
|
|
2349
|
+
}
|
|
2350
|
+
if (secondSchema) {
|
|
2351
|
+
return secondSchema;
|
|
2352
|
+
}
|
|
2353
|
+
return fallbackSchema;
|
|
2354
|
+
}
|
|
2355
|
+
/**
|
|
2356
|
+
* Map ObjectOrArray<T> to a schema accepting T or T[].
|
|
2357
|
+
* @param context The generation context.
|
|
2358
|
+
* @param typeNode The ObjectOrArray type reference.
|
|
2359
|
+
* @returns The mapped schema.
|
|
2360
|
+
*/
|
|
2361
|
+
static mapObjectOrArrayUtilityType(context, typeNode) {
|
|
2362
|
+
return UtilityTypeSchemaMapper.mapObjectOrArrayUtilityType(context, typeNode, (ctx, node) => JsonSchemaBuilder.mapTypeNodeToSchema(ctx, node));
|
|
2363
|
+
}
|
|
2364
|
+
/**
|
|
2365
|
+
* Map SingleOccurrenceArray<T, U> to a non-empty array containing exactly one U.
|
|
2366
|
+
* @param context The generation context.
|
|
2367
|
+
* @param typeNode The SingleOccurrenceArray type reference.
|
|
2368
|
+
* @returns The mapped schema.
|
|
2369
|
+
*/
|
|
2370
|
+
static mapSingleOccurrenceArrayUtilityType(context, typeNode) {
|
|
2371
|
+
return UtilityTypeSchemaMapper.mapSingleOccurrenceArrayUtilityType(context, typeNode, (ctx, node) => JsonSchemaBuilder.mapTypeNodeToSchema(ctx, node));
|
|
2372
|
+
}
|
|
2373
|
+
/**
|
|
2374
|
+
* Add a comment to schemas inlined for utility type transformations.
|
|
2375
|
+
* @param schema The schema to annotate.
|
|
2376
|
+
* @param baseTypeDescription The utility base type description.
|
|
2377
|
+
* @returns The annotated schema.
|
|
2378
|
+
*/
|
|
2379
|
+
static annotateUtilityInlineSchema(schema, baseTypeDescription) {
|
|
2380
|
+
const annotatedSchema = ObjectHelper.clone(schema);
|
|
2381
|
+
annotatedSchema.$comment ??= `Inlined utility base type ${baseTypeDescription} so utility transformations can operate on concrete properties instead of a $ref.`;
|
|
2382
|
+
return annotatedSchema;
|
|
2383
|
+
}
|
|
2384
|
+
/**
|
|
2385
|
+
* Resolve a utility base type node to an object schema.
|
|
2386
|
+
* @param context The generation context.
|
|
2387
|
+
* @param baseTypeNode The utility base type node.
|
|
2388
|
+
* @returns The resolved object schema.
|
|
2389
|
+
* @throws GeneralError when a named type reference cannot be resolved to a schema.
|
|
2390
|
+
*/
|
|
2391
|
+
static resolveUtilityBaseObjectSchema(context, baseTypeNode) {
|
|
2392
|
+
// Guard against recursive utility type resolution cycles
|
|
2393
|
+
// This prevents infinite loops when processing complex types with indexed access and keyof
|
|
2394
|
+
context.resolvingUtilityTypes ??= new Set();
|
|
2395
|
+
// { prop: string } (inline type literal as the base of the utility type)
|
|
2396
|
+
if (ts.isTypeLiteralNode(baseTypeNode)) {
|
|
2397
|
+
return JsonSchemaBuilder.buildTypeLiteralSchema(context, baseTypeNode);
|
|
2398
|
+
}
|
|
2399
|
+
// BaseType (named type reference as the base of the utility type)
|
|
2400
|
+
if (ts.isTypeReferenceNode(baseTypeNode)) {
|
|
2401
|
+
// BaseType (plain identifier) vs Namespace.BaseType (qualified name)
|
|
2402
|
+
const typeName = ts.isIdentifier(baseTypeNode.typeName)
|
|
2403
|
+
? baseTypeNode.typeName.text
|
|
2404
|
+
: baseTypeNode.typeName.right.text;
|
|
2405
|
+
const baseTypeDescription = StringHelper.stripPrefix(typeName);
|
|
2406
|
+
// Prevent recursive processing of the same utility base type
|
|
2407
|
+
if (context.resolvingUtilityTypes.has(typeName)) {
|
|
2408
|
+
return undefined;
|
|
2409
|
+
}
|
|
2410
|
+
const utilityHandler = JsonSchemaBuilder._utilityTypeHandlers[typeName];
|
|
2411
|
+
if (utilityHandler) {
|
|
2412
|
+
context.resolvingUtilityTypes.add(typeName);
|
|
2413
|
+
try {
|
|
2414
|
+
const utilitySchema = utilityHandler(context, baseTypeNode);
|
|
2415
|
+
const resolvedUtilitySchema = utilitySchema
|
|
2416
|
+
? JsonSchemaBuilder.resolveMappedUtilityBaseObjectSchema(context, utilitySchema)
|
|
2417
|
+
: undefined;
|
|
2418
|
+
if (resolvedUtilitySchema) {
|
|
2419
|
+
return JsonSchemaBuilder.annotateUtilityInlineSchema(resolvedUtilitySchema, baseTypeDescription);
|
|
2420
|
+
}
|
|
2421
|
+
}
|
|
2422
|
+
finally {
|
|
2423
|
+
context.resolvingUtilityTypes.delete(typeName);
|
|
2424
|
+
}
|
|
2425
|
+
}
|
|
2426
|
+
const typeParameterBinding = JsonSchemaBuilder.getTypeParameterBinding(context, typeName);
|
|
2427
|
+
if (typeParameterBinding !== undefined) {
|
|
2428
|
+
return typeParameterBinding
|
|
2429
|
+
? JsonSchemaBuilder.resolveUtilityBaseObjectSchema(context, typeParameterBinding)
|
|
2430
|
+
: undefined;
|
|
2431
|
+
}
|
|
2432
|
+
const directSchema = JsonSchemaBuilder.resolveObjectTypeSchemaForUtility(context, typeName);
|
|
2433
|
+
if (directSchema) {
|
|
2434
|
+
const expandedSchema = JsonSchemaBuilder.expandAllOfReferences(context, directSchema, StringHelper.stripPrefix(typeName));
|
|
2435
|
+
return JsonSchemaBuilder.annotateUtilityInlineSchema(expandedSchema, baseTypeDescription);
|
|
2436
|
+
}
|
|
2437
|
+
}
|
|
2438
|
+
const mappedSchema = JsonSchemaBuilder.mapTypeNodeToSchema(context, baseTypeNode);
|
|
2439
|
+
if (mappedSchema) {
|
|
2440
|
+
// If we got a $ref, try to resolve it to an actual object schema
|
|
2441
|
+
if (mappedSchema.$ref) {
|
|
2442
|
+
const resolvedSchema = JsonSchemaBuilder.resolveMappedUtilityBaseObjectSchema(context, mappedSchema);
|
|
2443
|
+
if (resolvedSchema) {
|
|
2444
|
+
return JsonSchemaBuilder.annotateUtilityInlineSchema(resolvedSchema, baseTypeNode.getText());
|
|
2445
|
+
}
|
|
2446
|
+
// $ref exists but can't be expanded to an inline object schema (e.g. the type
|
|
2447
|
+
// maps to an external schema URL). Return undefined so the utility type handler
|
|
2448
|
+
// degrades gracefully rather than throwing; the type is known, just not inlineable.
|
|
2449
|
+
return undefined;
|
|
2450
|
+
}
|
|
2451
|
+
// If we got a direct schema (non-$ref), try to resolve it as an object
|
|
2452
|
+
const resolvedSchema = JsonSchemaBuilder.resolveMappedUtilityBaseObjectSchema(context, mappedSchema);
|
|
2453
|
+
if (resolvedSchema) {
|
|
2454
|
+
return JsonSchemaBuilder.annotateUtilityInlineSchema(resolvedSchema, baseTypeNode.getText());
|
|
2455
|
+
}
|
|
2456
|
+
}
|
|
2457
|
+
// mapTypeNodeToSchema produced nothing at all - the type is genuinely unknown.
|
|
2458
|
+
// For named type references that are not part of a recursive resolution cycle, throw.
|
|
2459
|
+
if (ts.isTypeReferenceNode(baseTypeNode)) {
|
|
2460
|
+
const typeName = ts.isIdentifier(baseTypeNode.typeName)
|
|
2461
|
+
? baseTypeNode.typeName.text
|
|
2462
|
+
: baseTypeNode.typeName.right.text;
|
|
2463
|
+
if (!context.resolvingUtilityTypes?.has(typeName)) {
|
|
2464
|
+
const importedTypeReference = JsonSchemaBuilder.findImportedTypeReference(context, typeName);
|
|
2465
|
+
const importSource = importedTypeReference?.moduleSpecifier ?? "";
|
|
2466
|
+
throw new GeneralError(JsonSchemaBuilder.CLASS_NAME, "missingTypeReferenceSchema", {
|
|
2467
|
+
typeName,
|
|
2468
|
+
importSource: importSource ? ` from module "${importSource}"` : ""
|
|
2469
|
+
});
|
|
2470
|
+
}
|
|
2471
|
+
}
|
|
2472
|
+
return undefined;
|
|
2473
|
+
}
|
|
2474
|
+
/**
|
|
2475
|
+
* Resolve a mapped utility schema to an object schema when possible.
|
|
2476
|
+
* @param context The generation context.
|
|
2477
|
+
* @param mappedSchema The mapped schema.
|
|
2478
|
+
* @returns The resolved object schema.
|
|
2479
|
+
*/
|
|
2480
|
+
static resolveMappedUtilityBaseObjectSchema(context, mappedSchema) {
|
|
2481
|
+
if (mappedSchema.type === "object" && mappedSchema.properties) {
|
|
2482
|
+
return ObjectHelper.clone(mappedSchema);
|
|
2483
|
+
}
|
|
2484
|
+
if (mappedSchema.$ref) {
|
|
2485
|
+
for (const packageSchemaEntries of Object.values(context.schemas)) {
|
|
2486
|
+
for (const referencedSchema of Object.values(packageSchemaEntries)) {
|
|
2487
|
+
if (referencedSchema.$id === mappedSchema.$ref &&
|
|
2488
|
+
referencedSchema.type === "object" &&
|
|
2489
|
+
referencedSchema.properties) {
|
|
2490
|
+
return JsonSchemaBuilder.expandAllOfReferences(context, ObjectHelper.clone(referencedSchema), referencedSchema.title);
|
|
2491
|
+
}
|
|
2492
|
+
}
|
|
2493
|
+
}
|
|
2494
|
+
// Fall back to resolving by ref title in case a schema was registered
|
|
2495
|
+
// without a matching $id, or the ref has been normalised differently.
|
|
2496
|
+
const refTitle = mappedSchema.$ref.split("/").pop();
|
|
2497
|
+
if (refTitle) {
|
|
2498
|
+
const localRefSchema = context.schemas[context.packageName]?.[refTitle];
|
|
2499
|
+
if (localRefSchema?.type === "object" && localRefSchema.properties) {
|
|
2500
|
+
return JsonSchemaBuilder.expandAllOfReferences(context, ObjectHelper.clone(localRefSchema), localRefSchema.title ?? refTitle);
|
|
2501
|
+
}
|
|
2502
|
+
for (const packageSchemaEntries of Object.values(context.schemas)) {
|
|
2503
|
+
const refSchema = packageSchemaEntries[refTitle];
|
|
2504
|
+
if (refSchema?.type === "object" && refSchema.properties) {
|
|
2505
|
+
return JsonSchemaBuilder.expandAllOfReferences(context, ObjectHelper.clone(refSchema), refSchema.title ?? refTitle);
|
|
2506
|
+
}
|
|
2507
|
+
}
|
|
2508
|
+
}
|
|
2509
|
+
}
|
|
2510
|
+
return undefined;
|
|
2511
|
+
}
|
|
2512
|
+
/**
|
|
2513
|
+
* Expand allOf references in a schema by inlining referenced schemas.
|
|
2514
|
+
* This handles inheritance cases where a type extends another type.
|
|
2515
|
+
* When applying utility operations (Omit, Pick) to types with allOf inheritance,
|
|
2516
|
+
* the referenced schemas must be expanded so properties are available for omit/pick.
|
|
2517
|
+
* @param context The generation context.
|
|
2518
|
+
* @param schema The schema potentially containing allOf with references.
|
|
2519
|
+
* @param currentSchemaTitle The title of the current schema being processed, used for $comment annotations.
|
|
2520
|
+
* @returns The schema with expanded references merged into properties.
|
|
2521
|
+
*/
|
|
2522
|
+
static expandAllOfReferences(context, schema, currentSchemaTitle) {
|
|
2523
|
+
if (!Is.array(schema.allOf) || schema.allOf.length === 0) {
|
|
2524
|
+
return schema;
|
|
2525
|
+
}
|
|
2526
|
+
const expandedSchema = ObjectHelper.clone(schema);
|
|
2527
|
+
const mergedProperties = {};
|
|
2528
|
+
const inheritedPropertySources = {};
|
|
2529
|
+
const mergedRequired = new Set();
|
|
2530
|
+
const allOf = Is.array(expandedSchema.allOf) ? expandedSchema.allOf : [];
|
|
2531
|
+
// Expand all allOf branches
|
|
2532
|
+
for (const branch of allOf) {
|
|
2533
|
+
if (Is.object(branch)) {
|
|
2534
|
+
const branchRecord = branch;
|
|
2535
|
+
let branchSourceTitle;
|
|
2536
|
+
let referencedSchema;
|
|
2537
|
+
// If the branch is a reference, resolve it
|
|
2538
|
+
if (Is.stringValue(branchRecord.$ref) && !branchRecord.properties) {
|
|
2539
|
+
const refTitle = branchRecord.$ref.split("/").pop();
|
|
2540
|
+
if (refTitle) {
|
|
2541
|
+
// Look up the referenced schema in the context
|
|
2542
|
+
referencedSchema =
|
|
2543
|
+
context.schemas[context.packageName]?.[refTitle] ??
|
|
2544
|
+
Object.values(context.schemas)
|
|
2545
|
+
.flatMap(entries => Object.values(entries))
|
|
2546
|
+
.find(s => s.$id === branchRecord.$ref || s.title === refTitle);
|
|
2547
|
+
referencedSchema ??= JsonSchemaBuilder.tryLoadExternalSchemaByTitle(context, refTitle);
|
|
2548
|
+
if (referencedSchema) {
|
|
2549
|
+
branchSourceTitle = referencedSchema.title ?? refTitle;
|
|
2550
|
+
referencedSchema = JsonSchemaBuilder.expandAllOfReferences(context, referencedSchema, branchSourceTitle);
|
|
2551
|
+
// Merge properties from the referenced schema
|
|
2552
|
+
if (Is.object(referencedSchema.properties)) {
|
|
2553
|
+
for (const [propertyKey, propertySchema] of Object.entries(referencedSchema.properties)) {
|
|
2554
|
+
mergedProperties[propertyKey] = ObjectHelper.clone(propertySchema);
|
|
2555
|
+
if (branchSourceTitle) {
|
|
2556
|
+
inheritedPropertySources[propertyKey] = branchSourceTitle;
|
|
2557
|
+
}
|
|
2558
|
+
}
|
|
2559
|
+
}
|
|
2560
|
+
// Collect required fields from the referenced schema
|
|
2561
|
+
if (Is.array(referencedSchema.required)) {
|
|
2562
|
+
for (const field of referencedSchema.required) {
|
|
2563
|
+
if (Is.stringValue(field)) {
|
|
2564
|
+
mergedRequired.add(field);
|
|
2565
|
+
}
|
|
2566
|
+
}
|
|
2567
|
+
}
|
|
2568
|
+
}
|
|
2569
|
+
}
|
|
2570
|
+
}
|
|
2571
|
+
else {
|
|
2572
|
+
// Merge properties from direct schema in allOf
|
|
2573
|
+
if (Is.object(branchRecord.properties)) {
|
|
2574
|
+
for (const [propertyKey, propertySchema] of Object.entries(branchRecord.properties)) {
|
|
2575
|
+
if (Is.object(propertySchema)) {
|
|
2576
|
+
mergedProperties[propertyKey] = ObjectHelper.clone(propertySchema);
|
|
2577
|
+
if (branchSourceTitle) {
|
|
2578
|
+
inheritedPropertySources[propertyKey] = branchSourceTitle;
|
|
2579
|
+
}
|
|
2580
|
+
}
|
|
2581
|
+
}
|
|
2582
|
+
}
|
|
2583
|
+
// Merge required fields
|
|
2584
|
+
if (Is.array(branchRecord.required)) {
|
|
2585
|
+
for (const field of branchRecord.required) {
|
|
2586
|
+
if (Is.stringValue(field)) {
|
|
2587
|
+
mergedRequired.add(field);
|
|
2588
|
+
}
|
|
2589
|
+
}
|
|
2590
|
+
}
|
|
2591
|
+
}
|
|
2592
|
+
}
|
|
2593
|
+
}
|
|
2594
|
+
// If we expanded any references, merge them into the schema
|
|
2595
|
+
if (Object.keys(mergedProperties).length > 0 || mergedRequired.size > 0) {
|
|
2596
|
+
if (Object.keys(mergedProperties).length > 0) {
|
|
2597
|
+
// Step 1: stamp $comment on base (merged) properties that arrived via $ref expansion.
|
|
2598
|
+
// The guard `!propertySchema.$comment` ensures that a comment set by a deeper
|
|
2599
|
+
// ancestor during a recursive call is never overwritten by a shallower ancestor.
|
|
2600
|
+
for (const [propertyKey, propertySchema] of Object.entries(mergedProperties)) {
|
|
2601
|
+
const inheritedSource = inheritedPropertySources[propertyKey];
|
|
2602
|
+
if (inheritedSource && !propertySchema.$comment) {
|
|
2603
|
+
propertySchema.$comment = `Inherited from ${inheritedSource}`;
|
|
2604
|
+
}
|
|
2605
|
+
}
|
|
2606
|
+
// Step 2: stamp $comment on the derived type's own properties so that consumers
|
|
2607
|
+
// of the expanded schema know which type introduced each property. The `??=`
|
|
2608
|
+
// guard preserves any $comment already written by a deeper recursive call.
|
|
2609
|
+
if (currentSchemaTitle && Is.object(expandedSchema.properties)) {
|
|
2610
|
+
for (const [propertyKey, propertySchema] of Object.entries(expandedSchema.properties)) {
|
|
2611
|
+
if (Is.object(propertySchema)) {
|
|
2612
|
+
const typedPropertySchema = propertySchema;
|
|
2613
|
+
typedPropertySchema.$comment ??= `Inherited from ${currentSchemaTitle}`;
|
|
2614
|
+
inheritedPropertySources[propertyKey] = currentSchemaTitle;
|
|
2615
|
+
}
|
|
2616
|
+
}
|
|
2617
|
+
}
|
|
2618
|
+
// Step 3: merge — base properties first, derived properties on top so that a
|
|
2619
|
+
// derived type can override a base property (key order: base insertion order,
|
|
2620
|
+
// values from the derived spread win for overlapping keys).
|
|
2621
|
+
expandedSchema.properties = {
|
|
2622
|
+
...mergedProperties,
|
|
2623
|
+
...(expandedSchema.properties ?? {})
|
|
2624
|
+
};
|
|
2625
|
+
}
|
|
2626
|
+
if (mergedRequired.size > 0 || Is.array(expandedSchema.required)) {
|
|
2627
|
+
// Build required array in property order: first merged, then existing
|
|
2628
|
+
const resultRequired = [];
|
|
2629
|
+
const mergedRequiredSet = new Set(mergedRequired);
|
|
2630
|
+
const existingRequired = Is.array(expandedSchema.required) ? expandedSchema.required : [];
|
|
2631
|
+
// Add merged required fields in property order
|
|
2632
|
+
for (const key of Object.keys(expandedSchema.properties ?? {})) {
|
|
2633
|
+
if (mergedRequiredSet.has(key)) {
|
|
2634
|
+
resultRequired.push(key);
|
|
2635
|
+
}
|
|
2636
|
+
}
|
|
2637
|
+
// Add existing required fields that aren't in merged
|
|
2638
|
+
for (const key of existingRequired) {
|
|
2639
|
+
if (Is.stringValue(key) && !mergedRequiredSet.has(key)) {
|
|
2640
|
+
resultRequired.push(key);
|
|
2641
|
+
}
|
|
2642
|
+
}
|
|
2643
|
+
if (resultRequired.length > 0) {
|
|
2644
|
+
expandedSchema.required = resultRequired;
|
|
2645
|
+
}
|
|
2646
|
+
}
|
|
2647
|
+
// Remove allOf since we've expanded everything
|
|
2648
|
+
delete expandedSchema.allOf;
|
|
2649
|
+
}
|
|
2650
|
+
return expandedSchema;
|
|
2651
|
+
}
|
|
2652
|
+
/**
|
|
2653
|
+
* Attempt to load an external schema by title from imported module declarations.
|
|
2654
|
+
* @param context The generation context.
|
|
2655
|
+
* @param schemaTitle The schema title.
|
|
2656
|
+
* @returns The loaded schema.
|
|
2657
|
+
*/
|
|
2658
|
+
static tryLoadExternalSchemaByTitle(context, schemaTitle) {
|
|
2659
|
+
const existingSchema = Object.values(context.schemas)
|
|
2660
|
+
.flatMap(entries => Object.values(entries))
|
|
2661
|
+
.find(schema => schema.title === schemaTitle || schema.$id?.endsWith(`/${schemaTitle}`));
|
|
2662
|
+
if (existingSchema) {
|
|
2663
|
+
return ObjectHelper.clone(existingSchema);
|
|
2664
|
+
}
|
|
2665
|
+
const sourceFile = context.activeSourceFile;
|
|
2666
|
+
if (!sourceFile) {
|
|
2667
|
+
return undefined;
|
|
2668
|
+
}
|
|
2669
|
+
const moduleSpecifiers = [
|
|
2670
|
+
...new Set(sourceFile.statements
|
|
2671
|
+
.filter((statement) => ts.isImportDeclaration(statement))
|
|
2672
|
+
.flatMap(statement => {
|
|
2673
|
+
if (!ts.isStringLiteral(statement.moduleSpecifier)) {
|
|
2674
|
+
return [];
|
|
2675
|
+
}
|
|
2676
|
+
const moduleSpecifier = statement.moduleSpecifier.text;
|
|
2677
|
+
return moduleSpecifier.startsWith(".") ? [] : [moduleSpecifier];
|
|
2678
|
+
}))
|
|
2679
|
+
];
|
|
2680
|
+
const candidateTypeNames = [`I${schemaTitle}`, schemaTitle];
|
|
2681
|
+
for (const moduleSpecifier of moduleSpecifiers) {
|
|
2682
|
+
for (const candidateTypeName of candidateTypeNames) {
|
|
2683
|
+
const declarationResult = Resolver.resolveTypeDeclarationAst(moduleSpecifier, candidateTypeName);
|
|
2684
|
+
if (declarationResult) {
|
|
2685
|
+
const mappedReference = JsonSchemaBuilder.resolveReferenceMappingTarget(context, moduleSpecifier, candidateTypeName);
|
|
2686
|
+
const externalContext = {
|
|
2687
|
+
namespace: mappedReference?.namespace ?? context.namespace,
|
|
2688
|
+
packageName: moduleSpecifier,
|
|
2689
|
+
schemas: context.schemas,
|
|
2690
|
+
activeSourceFile: context.activeSourceFile,
|
|
2691
|
+
options: context.options
|
|
2692
|
+
};
|
|
2693
|
+
JsonSchemaBuilder.parseAllObjectSchemas(externalContext, declarationResult.sourceFile.fileName, declarationResult.sourceFile.getFullText(), []);
|
|
2694
|
+
const loadedSchema = context.schemas[moduleSpecifier]?.[schemaTitle] ??
|
|
2695
|
+
Object.values(context.schemas[moduleSpecifier] ?? {}).find(schema => schema.title === schemaTitle || schema.$id?.endsWith(`/${schemaTitle}`));
|
|
2696
|
+
if (loadedSchema) {
|
|
2697
|
+
return ObjectHelper.clone(loadedSchema);
|
|
2698
|
+
}
|
|
2699
|
+
}
|
|
2700
|
+
}
|
|
2701
|
+
}
|
|
2702
|
+
return undefined;
|
|
2703
|
+
}
|
|
2704
|
+
/**
|
|
2705
|
+
* Resolve an object schema for utility type application.
|
|
2706
|
+
* @param context The generation context.
|
|
2707
|
+
* @param typeName The referenced type name.
|
|
2708
|
+
* @returns The resolved object schema.
|
|
2709
|
+
*/
|
|
2710
|
+
static resolveObjectTypeSchemaForUtility(context, typeName) {
|
|
2711
|
+
const typeParameterBinding = JsonSchemaBuilder.getTypeParameterBinding(context, typeName);
|
|
2712
|
+
if (typeParameterBinding !== undefined) {
|
|
2713
|
+
return typeParameterBinding
|
|
2714
|
+
? JsonSchemaBuilder.resolveUtilityBaseObjectSchema(context, typeParameterBinding)
|
|
2715
|
+
: undefined;
|
|
2716
|
+
}
|
|
2717
|
+
const title = StringHelper.stripPrefix(typeName);
|
|
2718
|
+
const localSchema = context.schemas[context.packageName]?.[title];
|
|
2719
|
+
if (localSchema?.type === "object" && localSchema.properties) {
|
|
2720
|
+
return ObjectHelper.clone(localSchema);
|
|
2721
|
+
}
|
|
2722
|
+
const declarationSchema = JsonSchemaBuilder.mapObjectTypeFromLocalDeclaration(context, typeName);
|
|
2723
|
+
if (declarationSchema) {
|
|
2724
|
+
return declarationSchema;
|
|
2725
|
+
}
|
|
2726
|
+
for (const packageEntry of Object.values(context.schemas)) {
|
|
2727
|
+
const packageSchema = packageEntry[title];
|
|
2728
|
+
if (packageSchema?.type === "object" && packageSchema.properties) {
|
|
2729
|
+
return ObjectHelper.clone(packageSchema);
|
|
2730
|
+
}
|
|
2731
|
+
}
|
|
2732
|
+
const importedTypeReference = JsonSchemaBuilder.findImportedTypeReference(context, typeName);
|
|
2733
|
+
if (importedTypeReference) {
|
|
2734
|
+
const importedSchema = JsonSchemaBuilder.resolveImportedObjectTypeSchemaForUtility(context, importedTypeReference.moduleSpecifier, importedTypeReference.candidateTypeName, title);
|
|
2735
|
+
if (importedSchema) {
|
|
2736
|
+
return importedSchema;
|
|
2737
|
+
}
|
|
2738
|
+
}
|
|
2739
|
+
return undefined;
|
|
2740
|
+
}
|
|
2741
|
+
/**
|
|
2742
|
+
* Find an imported type reference from the active source file by local or exported symbol name.
|
|
2743
|
+
* @param context The generation context.
|
|
2744
|
+
* @param typeName The local or exported type name to find.
|
|
2745
|
+
* @returns The module specifier and exported candidate type name when found.
|
|
2746
|
+
*/
|
|
2747
|
+
static findImportedTypeReference(context, typeName) {
|
|
2748
|
+
if (!context.activeSourceFile) {
|
|
2749
|
+
return undefined;
|
|
2750
|
+
}
|
|
2751
|
+
for (const statement of context.activeSourceFile.statements) {
|
|
2752
|
+
if (ts.isImportDeclaration(statement) && ts.isStringLiteral(statement.moduleSpecifier)) {
|
|
2753
|
+
if (statement.importClause?.namedBindings &&
|
|
2754
|
+
ts.isNamedImports(statement.importClause.namedBindings)) {
|
|
2755
|
+
const importedElement = Array.from(statement.importClause.namedBindings.elements).find(e => e.name.text === typeName || (e.propertyName?.text ?? e.name.text) === typeName);
|
|
2756
|
+
if (importedElement) {
|
|
2757
|
+
return {
|
|
2758
|
+
moduleSpecifier: statement.moduleSpecifier.text,
|
|
2759
|
+
candidateTypeName: importedElement.propertyName?.text ?? importedElement.name.text
|
|
2760
|
+
};
|
|
2761
|
+
}
|
|
2762
|
+
}
|
|
2763
|
+
}
|
|
2764
|
+
}
|
|
2765
|
+
return undefined;
|
|
2766
|
+
}
|
|
2767
|
+
/**
|
|
2768
|
+
* Resolve an imported object schema for utility type application.
|
|
2769
|
+
* @param context The generation context.
|
|
2770
|
+
* @param moduleSpecifier The module where the type is imported from.
|
|
2771
|
+
* @param candidateTypeName The exported candidate type name.
|
|
2772
|
+
* @param title The stripped title of the requested type.
|
|
2773
|
+
* @returns The resolved object schema.
|
|
2774
|
+
*/
|
|
2775
|
+
static resolveImportedObjectTypeSchemaForUtility(context, moduleSpecifier, candidateTypeName, title) {
|
|
2776
|
+
context.resolvingImportedObjectSchemas ??= new Set();
|
|
2777
|
+
const resolvingKey = `${context.packageName}:${moduleSpecifier}:${candidateTypeName}`;
|
|
2778
|
+
if (context.resolvingImportedObjectSchemas.has(resolvingKey)) {
|
|
2779
|
+
return undefined;
|
|
2780
|
+
}
|
|
2781
|
+
context.resolvingImportedObjectSchemas.add(resolvingKey);
|
|
2782
|
+
try {
|
|
2783
|
+
if (moduleSpecifier.startsWith(".")) {
|
|
2784
|
+
if (!context.activeSourceFile) {
|
|
2785
|
+
return undefined;
|
|
2786
|
+
}
|
|
2787
|
+
const resolvedImportPath = JsonSchemaBuilder.resolveImportDeclarationSourceFile(context.activeSourceFile.fileName, moduleSpecifier);
|
|
2788
|
+
if (!resolvedImportPath) {
|
|
2789
|
+
return undefined;
|
|
2790
|
+
}
|
|
2791
|
+
const importedSource = FileUtils.readFile(resolvedImportPath);
|
|
2792
|
+
if (!importedSource) {
|
|
2793
|
+
return undefined;
|
|
2794
|
+
}
|
|
2795
|
+
JsonSchemaBuilder.parseAllObjectSchemas(context, resolvedImportPath, importedSource, []);
|
|
2796
|
+
const localLoadedSchema = context.schemas[context.packageName]?.[title] ??
|
|
2797
|
+
context.schemas[context.packageName]?.[StringHelper.stripPrefix(candidateTypeName)] ??
|
|
2798
|
+
Object.values(context.schemas[context.packageName] ?? {}).find(s => s.title === title ||
|
|
2799
|
+
s.title === StringHelper.stripPrefix(candidateTypeName) ||
|
|
2800
|
+
s.$id?.endsWith(`/${title}`));
|
|
2801
|
+
if (localLoadedSchema?.type === "object" && localLoadedSchema.properties) {
|
|
2802
|
+
return ObjectHelper.clone(localLoadedSchema);
|
|
2803
|
+
}
|
|
2804
|
+
return undefined;
|
|
2805
|
+
}
|
|
2806
|
+
const declarationResult = Resolver.resolveTypeDeclarationAst(moduleSpecifier, candidateTypeName);
|
|
2807
|
+
if (!declarationResult) {
|
|
2808
|
+
return undefined;
|
|
2809
|
+
}
|
|
2810
|
+
const mappedReference = JsonSchemaBuilder.resolveReferenceMappingTarget(context, moduleSpecifier, candidateTypeName);
|
|
2811
|
+
const externalContext = {
|
|
2812
|
+
namespace: mappedReference?.namespace ?? context.namespace,
|
|
2813
|
+
packageName: moduleSpecifier,
|
|
2814
|
+
schemas: context.schemas,
|
|
2815
|
+
activeSourceFile: context.activeSourceFile,
|
|
2816
|
+
resolvingImportedObjectSchemas: context.resolvingImportedObjectSchemas,
|
|
2817
|
+
options: context.options
|
|
2818
|
+
};
|
|
2819
|
+
JsonSchemaBuilder.parseAllObjectSchemas(externalContext, declarationResult.sourceFile.fileName, declarationResult.sourceFile.getFullText(), []);
|
|
2820
|
+
const loadedSchema = context.schemas[moduleSpecifier]?.[title] ??
|
|
2821
|
+
Object.values(context.schemas[moduleSpecifier] ?? {}).find(s => s.title === title || s.$id?.endsWith(`/${title}`));
|
|
2822
|
+
if (loadedSchema?.type === "object" && loadedSchema.properties) {
|
|
2823
|
+
return ObjectHelper.clone(loadedSchema);
|
|
2824
|
+
}
|
|
2825
|
+
return undefined;
|
|
2826
|
+
}
|
|
2827
|
+
finally {
|
|
2828
|
+
context.resolvingImportedObjectSchemas.delete(resolvingKey);
|
|
2829
|
+
}
|
|
2830
|
+
}
|
|
2831
|
+
/**
|
|
2832
|
+
* Map a local interface or type alias declaration to an object schema.
|
|
2833
|
+
* @param context The generation context.
|
|
2834
|
+
* @param typeName The referenced type name.
|
|
2835
|
+
* @returns The mapped schema.
|
|
2836
|
+
*/
|
|
2837
|
+
static mapObjectTypeFromLocalDeclaration(context, typeName) {
|
|
2838
|
+
const sourceFile = context.activeSourceFile;
|
|
2839
|
+
if (!sourceFile) {
|
|
2840
|
+
return undefined;
|
|
2841
|
+
}
|
|
2842
|
+
context.resolvingTypeNames ??= new Set();
|
|
2843
|
+
if (context.resolvingTypeNames.has(typeName)) {
|
|
2844
|
+
return {};
|
|
2845
|
+
}
|
|
2846
|
+
const declaration = sourceFile.statements.find(statement => {
|
|
2847
|
+
if (ts.isInterfaceDeclaration(statement) || ts.isTypeAliasDeclaration(statement)) {
|
|
2848
|
+
return statement.name.text === typeName;
|
|
2849
|
+
}
|
|
2850
|
+
return false;
|
|
2851
|
+
});
|
|
2852
|
+
if (!declaration) {
|
|
2853
|
+
return undefined;
|
|
2854
|
+
}
|
|
2855
|
+
context.resolvingTypeNames.add(typeName);
|
|
2856
|
+
try {
|
|
2857
|
+
if (ts.isInterfaceDeclaration(declaration)) {
|
|
2858
|
+
const boundContext = JsonSchemaBuilder.withTypeParameterBindings(context, declaration.typeParameters);
|
|
2859
|
+
const { properties, required } = JsonSchemaBuilder.buildObjectMembersSchema(boundContext, declaration.members);
|
|
2860
|
+
return {
|
|
2861
|
+
type: "object",
|
|
2862
|
+
properties: Object.keys(properties).length > 0 ? properties : undefined,
|
|
2863
|
+
required: required.length > 0 ? required : undefined
|
|
2864
|
+
};
|
|
2865
|
+
}
|
|
2866
|
+
if (ts.isTypeAliasDeclaration(declaration) && ts.isTypeLiteralNode(declaration.type)) {
|
|
2867
|
+
const boundContext = JsonSchemaBuilder.withTypeParameterBindings(context, declaration.typeParameters);
|
|
2868
|
+
return JsonSchemaBuilder.buildTypeLiteralSchema(boundContext, declaration.type);
|
|
2869
|
+
}
|
|
2870
|
+
}
|
|
2871
|
+
finally {
|
|
2872
|
+
context.resolvingTypeNames.delete(typeName);
|
|
2873
|
+
}
|
|
2874
|
+
return undefined;
|
|
2875
|
+
}
|
|
2876
|
+
/**
|
|
2877
|
+
* Extract string literal keys from a utility type key argument (e.g. Pick or Omit).
|
|
2878
|
+
* @param context The generation context.
|
|
2879
|
+
* @param keysTypeNode The keys type node.
|
|
2880
|
+
* @returns The extracted keys.
|
|
2881
|
+
*/
|
|
2882
|
+
static extractUtilityTypeKeys(context, keysTypeNode) {
|
|
2883
|
+
if (!keysTypeNode) {
|
|
2884
|
+
return [];
|
|
2885
|
+
}
|
|
2886
|
+
// readonly T[] or keyof T (type operator node)
|
|
2887
|
+
if (ts.isTypeOperatorNode(keysTypeNode)) {
|
|
2888
|
+
if (keysTypeNode.operator === ts.SyntaxKind.ReadonlyKeyword) {
|
|
2889
|
+
return JsonSchemaBuilder.extractUtilityTypeKeys(context, keysTypeNode.type);
|
|
2890
|
+
}
|
|
2891
|
+
if (keysTypeNode.operator === ts.SyntaxKind.KeyOfKeyword) {
|
|
2892
|
+
return JsonSchemaBuilder.extractKeyofTypeKeys(context, keysTypeNode.type);
|
|
2893
|
+
}
|
|
2894
|
+
}
|
|
2895
|
+
// "key" (string literal type as a single key)
|
|
2896
|
+
if (ts.isLiteralTypeNode(keysTypeNode) && ts.isStringLiteral(keysTypeNode.literal)) {
|
|
2897
|
+
return [keysTypeNode.literal.text];
|
|
2898
|
+
}
|
|
2899
|
+
// "a" | "b" (union of string literal keys)
|
|
2900
|
+
if (ts.isUnionTypeNode(keysTypeNode)) {
|
|
2901
|
+
return (keysTypeNode.types
|
|
2902
|
+
// "key" (only string literal type members are supported)
|
|
2903
|
+
.filter(type => ts.isLiteralTypeNode(type) && ts.isStringLiteral(type.literal))
|
|
2904
|
+
.map(type => type.literal)
|
|
2905
|
+
.map(literal => literal.text));
|
|
2906
|
+
}
|
|
2907
|
+
return [];
|
|
2908
|
+
}
|
|
2909
|
+
/**
|
|
2910
|
+
* Extract property keys from an indexed access index type.
|
|
2911
|
+
* @param context The generation context.
|
|
2912
|
+
* @param indexTypeNode The index type node.
|
|
2913
|
+
* @returns The extracted keys.
|
|
2914
|
+
*/
|
|
2915
|
+
static extractIndexedAccessKeys(context, indexTypeNode) {
|
|
2916
|
+
// "key" or 42 (literal type index)
|
|
2917
|
+
if (ts.isLiteralTypeNode(indexTypeNode)) {
|
|
2918
|
+
// "key" (string literal index)
|
|
2919
|
+
if (ts.isStringLiteral(indexTypeNode.literal)) {
|
|
2920
|
+
return [indexTypeNode.literal.text];
|
|
2921
|
+
}
|
|
2922
|
+
// 0 (numeric literal index)
|
|
2923
|
+
if (ts.isNumericLiteral(indexTypeNode.literal)) {
|
|
2924
|
+
return [indexTypeNode.literal.text];
|
|
2925
|
+
}
|
|
2926
|
+
}
|
|
2927
|
+
// "a" | "b" (union of index types, recurse into each branch)
|
|
2928
|
+
if (ts.isUnionTypeNode(indexTypeNode)) {
|
|
2929
|
+
const keys = indexTypeNode.types.flatMap(unionType => JsonSchemaBuilder.extractIndexedAccessKeys(context, unionType));
|
|
2930
|
+
return [...new Set(keys)];
|
|
2931
|
+
}
|
|
2932
|
+
return JsonSchemaBuilder.extractUtilityTypeKeys(context, indexTypeNode);
|
|
2933
|
+
}
|
|
2934
|
+
/**
|
|
2935
|
+
* Extract keys for a mapped type constraint.
|
|
2936
|
+
* @param context The generation context.
|
|
2937
|
+
* @param constraintTypeNode The mapped type constraint.
|
|
2938
|
+
* @returns The extracted keys.
|
|
2939
|
+
*/
|
|
2940
|
+
static extractMappedTypeKeys(context, constraintTypeNode) {
|
|
2941
|
+
if (!constraintTypeNode) {
|
|
2942
|
+
return [];
|
|
2943
|
+
}
|
|
2944
|
+
// `prefix-${T}` (template literal constraint, keys cannot be statically enumerated)
|
|
2945
|
+
if (ts.isTemplateLiteralTypeNode(constraintTypeNode)) {
|
|
2946
|
+
return [];
|
|
2947
|
+
}
|
|
2948
|
+
// TypeName (type reference constraint, resolve to its declaration)
|
|
2949
|
+
if (ts.isTypeReferenceNode(constraintTypeNode)) {
|
|
2950
|
+
const referencedTypeNode = JsonSchemaBuilder.resolveReferencedTypeNodeFromLocalDeclaration(context, constraintTypeNode);
|
|
2951
|
+
if (referencedTypeNode) {
|
|
2952
|
+
return JsonSchemaBuilder.extractMappedTypeKeys(context, referencedTypeNode);
|
|
2953
|
+
}
|
|
2954
|
+
}
|
|
2955
|
+
return JsonSchemaBuilder.extractUtilityTypeKeys(context, constraintTypeNode);
|
|
2956
|
+
}
|
|
2957
|
+
/**
|
|
2958
|
+
* Resolve a referenced type node from a local type alias declaration.
|
|
2959
|
+
* @param context The generation context.
|
|
2960
|
+
* @param typeNode The type reference node.
|
|
2961
|
+
* @returns The referenced type node, if found locally.
|
|
2962
|
+
*/
|
|
2963
|
+
static resolveReferencedTypeNodeFromLocalDeclaration(context, typeNode) {
|
|
2964
|
+
// TypeName (plain identifier) vs Namespace.TypeName (qualified name)
|
|
2965
|
+
const typeName = ts.isIdentifier(typeNode.typeName)
|
|
2966
|
+
? typeNode.typeName.text
|
|
2967
|
+
: typeNode.typeName.right.text;
|
|
2968
|
+
const typeParameterBinding = JsonSchemaBuilder.getTypeParameterBinding(context, typeName);
|
|
2969
|
+
if (typeParameterBinding !== undefined) {
|
|
2970
|
+
return typeParameterBinding ?? undefined;
|
|
2971
|
+
}
|
|
2972
|
+
const sourceFile = context.activeSourceFile;
|
|
2973
|
+
if (!sourceFile) {
|
|
2974
|
+
return undefined;
|
|
2975
|
+
}
|
|
2976
|
+
const declaration = sourceFile.statements.find(
|
|
2977
|
+
// type TypeName = ... (type alias declaration matching the resolved name)
|
|
2978
|
+
(statement) => ts.isTypeAliasDeclaration(statement) && statement.name.text === typeName);
|
|
2979
|
+
return declaration?.type;
|
|
2980
|
+
}
|
|
2981
|
+
/**
|
|
2982
|
+
* Create a nested mapping context with generic parameters bound.
|
|
2983
|
+
* @param context The current generation context.
|
|
2984
|
+
* @param typeParameters The generic type parameters for the declaration.
|
|
2985
|
+
* @param typeArguments Explicit type arguments, when provided.
|
|
2986
|
+
* @returns The scoped mapping context.
|
|
2987
|
+
*/
|
|
2988
|
+
static withTypeParameterBindings(context, typeParameters, typeArguments) {
|
|
2989
|
+
if (!typeParameters || typeParameters.length === 0) {
|
|
2990
|
+
return context;
|
|
2991
|
+
}
|
|
2992
|
+
const typeParameterBindings = context.typeParameterBindings ?? {};
|
|
2993
|
+
for (const [index, typeParameter] of typeParameters.entries()) {
|
|
2994
|
+
typeParameterBindings[typeParameter.name.text] =
|
|
2995
|
+
typeArguments?.[index] ?? typeParameter.default ?? typeParameter.constraint ?? null;
|
|
2996
|
+
}
|
|
2997
|
+
return {
|
|
2998
|
+
...context,
|
|
2999
|
+
typeParameterBindings
|
|
3000
|
+
};
|
|
3001
|
+
}
|
|
3002
|
+
/**
|
|
3003
|
+
* Get a bound generic type parameter for the current mapping scope.
|
|
3004
|
+
* @param context The current generation context.
|
|
3005
|
+
* @param typeName The type name to resolve.
|
|
3006
|
+
* @returns The bound type node, null for unresolved generic parameters, or undefined when not generic.
|
|
3007
|
+
*/
|
|
3008
|
+
static getTypeParameterBinding(context, typeName) {
|
|
3009
|
+
if (!context.typeParameterBindings) {
|
|
3010
|
+
return undefined;
|
|
3011
|
+
}
|
|
3012
|
+
return Object.prototype.hasOwnProperty.call(context.typeParameterBindings, typeName)
|
|
3013
|
+
? context.typeParameterBindings[typeName]
|
|
3014
|
+
: undefined;
|
|
3015
|
+
}
|
|
3016
|
+
/**
|
|
3017
|
+
* Determine whether a mapped type is a homomorphic source-preserving form.
|
|
3018
|
+
* @param typeNode The mapped type node.
|
|
3019
|
+
* @returns True if the mapped type mirrors an existing object shape.
|
|
3020
|
+
*/
|
|
3021
|
+
static isHomomorphicMappedType(typeNode) {
|
|
3022
|
+
const mappedTypeParameterName = typeNode.typeParameter.name.text;
|
|
3023
|
+
const constraintTypeNode = typeNode.typeParameter.constraint;
|
|
3024
|
+
const mappedValueType = typeNode.type;
|
|
3025
|
+
// keyof T (constraint must be a type operator for the type to be homomorphic)
|
|
3026
|
+
if (!constraintTypeNode || !mappedValueType || !ts.isTypeOperatorNode(constraintTypeNode)) {
|
|
3027
|
+
return false;
|
|
3028
|
+
}
|
|
3029
|
+
if (constraintTypeNode.operator !== ts.SyntaxKind.KeyOfKeyword) {
|
|
3030
|
+
return false;
|
|
3031
|
+
}
|
|
3032
|
+
// T[K] (mapped value must be an indexed access of the source object)
|
|
3033
|
+
if (!ts.isIndexedAccessTypeNode(mappedValueType)) {
|
|
3034
|
+
return false;
|
|
3035
|
+
}
|
|
3036
|
+
if (!JsonSchemaBuilder.isMappedTypeParameterReference(mappedValueType.indexType, mappedTypeParameterName)) {
|
|
3037
|
+
return false;
|
|
3038
|
+
}
|
|
3039
|
+
return (mappedValueType.objectType.getText() === constraintTypeNode.type.getText() &&
|
|
3040
|
+
typeNode.nameType === undefined);
|
|
3041
|
+
}
|
|
3042
|
+
/**
|
|
3043
|
+
* Resolve a source object schema for a mapped type when one exists.
|
|
3044
|
+
* @param context The generation context.
|
|
3045
|
+
* @param typeNode The mapped type node.
|
|
3046
|
+
* @returns The resolved source object schema.
|
|
3047
|
+
*/
|
|
3048
|
+
static resolveMappedTypeSourceObjectSchema(context, typeNode) {
|
|
3049
|
+
const constraintTypeNode = typeNode.typeParameter.constraint;
|
|
3050
|
+
if (constraintTypeNode &&
|
|
3051
|
+
// keyof T (homomorphic mapped type, constraint is keyof the source object)
|
|
3052
|
+
ts.isTypeOperatorNode(constraintTypeNode) &&
|
|
3053
|
+
constraintTypeNode.operator === ts.SyntaxKind.KeyOfKeyword) {
|
|
3054
|
+
return JsonSchemaBuilder.resolveUtilityBaseObjectSchema(context, constraintTypeNode.type);
|
|
3055
|
+
}
|
|
3056
|
+
return undefined;
|
|
3057
|
+
}
|
|
3058
|
+
/**
|
|
3059
|
+
* Map a single mapped-type property schema.
|
|
3060
|
+
* @param context The generation context.
|
|
3061
|
+
* @param typeNode The mapped type node.
|
|
3062
|
+
* @param sourcePropertyKey The original property key from the source object, used for lookup when the mapped type is homomorphic.
|
|
3063
|
+
* @param mappedTypeParameterName The mapped type parameter name.
|
|
3064
|
+
* @param sourceObjectSchema The optional source object schema.
|
|
3065
|
+
* @returns The mapped property schema.
|
|
3066
|
+
*/
|
|
3067
|
+
static mapMappedTypePropertySchema(context, typeNode, sourcePropertyKey, mappedTypeParameterName, sourceObjectSchema) {
|
|
3068
|
+
if (!typeNode.type) {
|
|
3069
|
+
return {};
|
|
3070
|
+
}
|
|
3071
|
+
if (
|
|
3072
|
+
// T[K] (indexed access where the index is the mapped type parameter)
|
|
3073
|
+
ts.isIndexedAccessTypeNode(typeNode.type) &&
|
|
3074
|
+
JsonSchemaBuilder.isMappedTypeParameterReference(typeNode.type.indexType, mappedTypeParameterName)) {
|
|
3075
|
+
const indexedPropertySchema = JsonSchemaBuilder.resolveIndexedPropertySchema(context, typeNode.type.objectType, sourcePropertyKey, sourceObjectSchema);
|
|
3076
|
+
if (indexedPropertySchema) {
|
|
3077
|
+
return indexedPropertySchema;
|
|
3078
|
+
}
|
|
3079
|
+
}
|
|
3080
|
+
return JsonSchemaBuilder.mapTypeNodeToSchema(context, typeNode.type);
|
|
3081
|
+
}
|
|
3082
|
+
/**
|
|
3083
|
+
* Resolve a concrete property schema for an indexed access object type and key.
|
|
3084
|
+
* @param context The generation context.
|
|
3085
|
+
* @param objectTypeNode The source object type node.
|
|
3086
|
+
* @param propertyKey The property key to resolve.
|
|
3087
|
+
* @param sourceObjectSchema The optional pre-resolved source object schema.
|
|
3088
|
+
* @returns The resolved property schema.
|
|
3089
|
+
*/
|
|
3090
|
+
static resolveIndexedPropertySchema(context, objectTypeNode, propertyKey, sourceObjectSchema) {
|
|
3091
|
+
const objectSchema = sourceObjectSchema ??
|
|
3092
|
+
JsonSchemaBuilder.resolveUtilityBaseObjectSchema(context, objectTypeNode);
|
|
3093
|
+
if (!objectSchema) {
|
|
3094
|
+
return undefined;
|
|
3095
|
+
}
|
|
3096
|
+
return ObjectTransformer.resolvePropertySchemaFromObjectSchema(objectSchema, propertyKey);
|
|
3097
|
+
}
|
|
3098
|
+
/**
|
|
3099
|
+
* Determine whether a type node is a reference to the mapped type parameter.
|
|
3100
|
+
* @param typeNode The type node.
|
|
3101
|
+
* @param mappedTypeParameterName The mapped type parameter name.
|
|
3102
|
+
* @returns True if the node references the mapped parameter.
|
|
3103
|
+
*/
|
|
3104
|
+
static isMappedTypeParameterReference(typeNode, mappedTypeParameterName) {
|
|
3105
|
+
return (
|
|
3106
|
+
// K (type reference to the mapped parameter, plain identifier)
|
|
3107
|
+
ts.isTypeReferenceNode(typeNode) &&
|
|
3108
|
+
// K (identifier node matching the parameter name)
|
|
3109
|
+
ts.isIdentifier(typeNode.typeName) &&
|
|
3110
|
+
typeNode.typeName.text === mappedTypeParameterName);
|
|
3111
|
+
}
|
|
3112
|
+
/**
|
|
3113
|
+
* Apply mapped-type optionality to a schema cloned from a source object.
|
|
3114
|
+
* @param schema The mapped schema.
|
|
3115
|
+
* @param optionalToken The mapped type optional token.
|
|
3116
|
+
*/
|
|
3117
|
+
static applyMappedTypeOptionality(schema, optionalToken) {
|
|
3118
|
+
if (!optionalToken) {
|
|
3119
|
+
return;
|
|
3120
|
+
}
|
|
3121
|
+
if (optionalToken.kind === ts.SyntaxKind.QuestionToken ||
|
|
3122
|
+
optionalToken.kind === ts.SyntaxKind.PlusToken) {
|
|
3123
|
+
delete schema.required;
|
|
3124
|
+
return;
|
|
3125
|
+
}
|
|
3126
|
+
if (optionalToken.kind === ts.SyntaxKind.MinusToken && Is.object(schema.properties)) {
|
|
3127
|
+
schema.required = Object.keys(schema.properties);
|
|
3128
|
+
}
|
|
3129
|
+
}
|
|
3130
|
+
/**
|
|
3131
|
+
* Apply mapped-type required keys for generated property sets.
|
|
3132
|
+
* @param schema The mapped schema.
|
|
3133
|
+
* @param propertyKeys The generated property keys.
|
|
3134
|
+
* @param optionalToken The mapped type optional token.
|
|
3135
|
+
*/
|
|
3136
|
+
static applyMappedTypeRequiredKeys(schema, propertyKeys, optionalToken) {
|
|
3137
|
+
if (optionalToken &&
|
|
3138
|
+
(optionalToken.kind === ts.SyntaxKind.QuestionToken ||
|
|
3139
|
+
optionalToken.kind === ts.SyntaxKind.PlusToken)) {
|
|
3140
|
+
return;
|
|
3141
|
+
}
|
|
3142
|
+
schema.required = propertyKeys;
|
|
3143
|
+
}
|
|
3144
|
+
/**
|
|
3145
|
+
* Extract key names from a keyof operand where possible.
|
|
3146
|
+
* @param context The generation context.
|
|
3147
|
+
* @param keysOperandNode The operand used with keyof.
|
|
3148
|
+
* @returns The extracted keys.
|
|
3149
|
+
*/
|
|
3150
|
+
static extractKeyofTypeKeys(context, keysOperandNode) {
|
|
3151
|
+
// (string | number) (parenthesised type, unwrap and recurse)
|
|
3152
|
+
if (ts.isParenthesizedTypeNode(keysOperandNode)) {
|
|
3153
|
+
return JsonSchemaBuilder.extractKeyofTypeKeys(context, keysOperandNode.type);
|
|
3154
|
+
}
|
|
3155
|
+
if (ts.isTypeReferenceNode(keysOperandNode) && ts.isIdentifier(keysOperandNode.typeName)) {
|
|
3156
|
+
const typeParameterBinding = JsonSchemaBuilder.getTypeParameterBinding(context, keysOperandNode.typeName.text);
|
|
3157
|
+
if (typeParameterBinding !== undefined) {
|
|
3158
|
+
return typeParameterBinding
|
|
3159
|
+
? JsonSchemaBuilder.extractKeyofTypeKeys(context, typeParameterBinding)
|
|
3160
|
+
: [];
|
|
3161
|
+
}
|
|
3162
|
+
}
|
|
3163
|
+
// { prop: string } (type literal, enumerate property names as keys)
|
|
3164
|
+
if (ts.isTypeLiteralNode(keysOperandNode)) {
|
|
3165
|
+
const keyNames = keysOperandNode.members
|
|
3166
|
+
.filter(
|
|
3167
|
+
// prop: string (property signature with a name)
|
|
3168
|
+
(member) => ts.isPropertySignature(member) && member.name !== undefined)
|
|
3169
|
+
.map(member => JsonSchemaBuilder.extractPropertyName(context, member.name))
|
|
3170
|
+
.filter((keyName) => Is.stringValue(keyName));
|
|
3171
|
+
return [...new Set(keyNames)];
|
|
3172
|
+
}
|
|
3173
|
+
const objectSchema = JsonSchemaBuilder.resolveUtilityBaseObjectSchema(context, keysOperandNode);
|
|
3174
|
+
if (objectSchema?.properties && Is.object(objectSchema.properties)) {
|
|
3175
|
+
return Object.keys(objectSchema.properties);
|
|
3176
|
+
}
|
|
3177
|
+
return [];
|
|
3178
|
+
}
|
|
3179
|
+
/**
|
|
3180
|
+
* Determine whether a keyof operand is a generic type parameter reference.
|
|
3181
|
+
* @param context The generation context.
|
|
3182
|
+
* @param keysOperandNode The operand used with keyof.
|
|
3183
|
+
* @returns True if the operand is a generic parameter.
|
|
3184
|
+
*/
|
|
3185
|
+
static isGenericKeyofOperand(context, keysOperandNode) {
|
|
3186
|
+
if (ts.isParenthesizedTypeNode(keysOperandNode)) {
|
|
3187
|
+
return JsonSchemaBuilder.isGenericKeyofOperand(context, keysOperandNode.type);
|
|
3188
|
+
}
|
|
3189
|
+
if (!ts.isTypeReferenceNode(keysOperandNode) || !ts.isIdentifier(keysOperandNode.typeName)) {
|
|
3190
|
+
return false;
|
|
3191
|
+
}
|
|
3192
|
+
const typeName = keysOperandNode.typeName.text;
|
|
3193
|
+
if (JsonSchemaBuilder.getTypeParameterBinding(context, typeName) !== undefined) {
|
|
3194
|
+
return true;
|
|
3195
|
+
}
|
|
3196
|
+
let currentNode = keysOperandNode;
|
|
3197
|
+
while (currentNode) {
|
|
3198
|
+
const typedNode = currentNode;
|
|
3199
|
+
if (typedNode.typeParameters?.some(typeParameter => typeParameter.name.text === typeName)) {
|
|
3200
|
+
return true;
|
|
3201
|
+
}
|
|
3202
|
+
currentNode = currentNode.parent;
|
|
3203
|
+
}
|
|
3204
|
+
return false;
|
|
3205
|
+
}
|
|
3206
|
+
/**
|
|
3207
|
+
* Resolve a schema id for an external imported type reference.
|
|
3208
|
+
* @param context The generation context.
|
|
3209
|
+
* @param typeNode The type reference node.
|
|
3210
|
+
* @param typeName The referenced type name.
|
|
3211
|
+
* @returns The resolved schema id.
|
|
3212
|
+
*/
|
|
3213
|
+
static resolveExternalTypeReferenceSchemaId(context, typeNode, typeName) {
|
|
3214
|
+
const activeSourceFile = context.activeSourceFile;
|
|
3215
|
+
if (!activeSourceFile) {
|
|
3216
|
+
return undefined;
|
|
3217
|
+
}
|
|
3218
|
+
const moduleSpecifier = JsonSchemaBuilder.findImportedModuleSpecifier(activeSourceFile, typeNode, typeName);
|
|
3219
|
+
if (!moduleSpecifier) {
|
|
3220
|
+
return undefined;
|
|
3221
|
+
}
|
|
3222
|
+
if (moduleSpecifier.startsWith(".")) {
|
|
3223
|
+
return JsonSchemaBuilder.resolveReferenceMappingTarget(context, moduleSpecifier, typeName)
|
|
3224
|
+
?.schemaId;
|
|
3225
|
+
}
|
|
3226
|
+
const title = StringHelper.stripPrefix(typeName);
|
|
3227
|
+
const cachedSchemaId = context.schemas[moduleSpecifier]?.[title]?.$id;
|
|
3228
|
+
if (cachedSchemaId) {
|
|
3229
|
+
return cachedSchemaId;
|
|
3230
|
+
}
|
|
3231
|
+
const mappedReference = JsonSchemaBuilder.resolveReferenceMappingTarget(context, moduleSpecifier, typeName);
|
|
3232
|
+
const declarationResult = Resolver.resolveTypeDeclarationAst(moduleSpecifier, typeName);
|
|
3233
|
+
if (!declarationResult) {
|
|
3234
|
+
return mappedReference?.schemaId;
|
|
3235
|
+
}
|
|
3236
|
+
const externalContext = {
|
|
3237
|
+
namespace: mappedReference?.namespace ?? context.namespace,
|
|
3238
|
+
packageName: moduleSpecifier,
|
|
3239
|
+
schemas: context.schemas,
|
|
3240
|
+
activeSourceFile: context.activeSourceFile,
|
|
3241
|
+
options: context.options
|
|
3242
|
+
};
|
|
3243
|
+
JsonSchemaBuilder.parseAllObjectSchemas(externalContext, declarationResult.sourceFile.fileName, declarationResult.sourceFile.getFullText(), []);
|
|
3244
|
+
return context.schemas[moduleSpecifier]?.[title]?.$id ?? mappedReference?.schemaId;
|
|
3245
|
+
}
|
|
3246
|
+
/**
|
|
3247
|
+
* Resolve a mapped schema target for an imported reference.
|
|
3248
|
+
* @param context The generation context.
|
|
3249
|
+
* @param packageName The referenced package or module specifier.
|
|
3250
|
+
* @param typeName The imported type name.
|
|
3251
|
+
* @returns The mapped schema id and optional namespace if one matches.
|
|
3252
|
+
*/
|
|
3253
|
+
static resolveReferenceMappingTarget(context, packageName, typeName) {
|
|
3254
|
+
const externalReferences = context.options?.externalReferences;
|
|
3255
|
+
if (!externalReferences) {
|
|
3256
|
+
return undefined;
|
|
3257
|
+
}
|
|
3258
|
+
const derivedTitle = StringHelper.stripPrefix(typeName);
|
|
3259
|
+
if (externalReferences[packageName]) {
|
|
3260
|
+
return {
|
|
3261
|
+
schemaId: `${externalReferences[packageName]}${derivedTitle}`,
|
|
3262
|
+
namespace: externalReferences[packageName]
|
|
3263
|
+
};
|
|
3264
|
+
}
|
|
3265
|
+
if (externalReferences[typeName]) {
|
|
3266
|
+
return {
|
|
3267
|
+
schemaId: `${externalReferences[typeName]}${derivedTitle}`,
|
|
3268
|
+
namespace: externalReferences[typeName]
|
|
3269
|
+
};
|
|
3270
|
+
}
|
|
3271
|
+
if (externalReferences[derivedTitle]) {
|
|
3272
|
+
return {
|
|
3273
|
+
schemaId: `${externalReferences[derivedTitle]}${derivedTitle}`,
|
|
3274
|
+
namespace: externalReferences[derivedTitle]
|
|
3275
|
+
};
|
|
3276
|
+
}
|
|
3277
|
+
for (const [pattern, mappedNamespace] of Object.entries(externalReferences)) {
|
|
3278
|
+
if (RegEx.isReferencePatternMatch(pattern, packageName, typeName, derivedTitle)) {
|
|
3279
|
+
if (/\$\d+/u.test(mappedNamespace)) {
|
|
3280
|
+
const schemaId = RegEx.applyReferencePatternReplacement(pattern, mappedNamespace, packageName, typeName, derivedTitle);
|
|
3281
|
+
if (schemaId) {
|
|
3282
|
+
return {
|
|
3283
|
+
schemaId,
|
|
3284
|
+
namespace: schemaId.endsWith(derivedTitle)
|
|
3285
|
+
? schemaId.slice(0, -derivedTitle.length)
|
|
3286
|
+
: undefined
|
|
3287
|
+
};
|
|
3288
|
+
}
|
|
3289
|
+
}
|
|
3290
|
+
return {
|
|
3291
|
+
schemaId: `${mappedNamespace}${derivedTitle}`,
|
|
3292
|
+
namespace: mappedNamespace
|
|
3293
|
+
};
|
|
3294
|
+
}
|
|
3295
|
+
}
|
|
3296
|
+
return undefined;
|
|
3297
|
+
}
|
|
3298
|
+
/**
|
|
3299
|
+
* Find a module specifier for a referenced type in the active source file imports.
|
|
3300
|
+
* @param sourceFile The active source file.
|
|
3301
|
+
* @param typeNode The referenced type node.
|
|
3302
|
+
* @param typeName The referenced type name.
|
|
3303
|
+
* @returns The module specifier.
|
|
3304
|
+
*/
|
|
3305
|
+
static findImportedModuleSpecifier(sourceFile, typeNode, typeName) {
|
|
3306
|
+
for (const statement of sourceFile.statements) {
|
|
3307
|
+
// import { ... } from "./module.js" (import declaration with string specifier)
|
|
3308
|
+
if (ts.isImportDeclaration(statement) && ts.isStringLiteral(statement.moduleSpecifier)) {
|
|
3309
|
+
const importClause = statement.importClause;
|
|
3310
|
+
if (importClause) {
|
|
3311
|
+
if (importClause.name?.text === typeName) {
|
|
3312
|
+
return statement.moduleSpecifier.text;
|
|
3313
|
+
}
|
|
3314
|
+
const namedBindings = importClause.namedBindings;
|
|
3315
|
+
if (namedBindings) {
|
|
3316
|
+
// import * as Namespace and Namespace.TypeName (namespace import + qualified usage)
|
|
3317
|
+
if (ts.isNamespaceImport(namedBindings) && ts.isQualifiedName(typeNode.typeName)) {
|
|
3318
|
+
if (namedBindings.name.text === typeNode.typeName.left.getText()) {
|
|
3319
|
+
return statement.moduleSpecifier.text;
|
|
3320
|
+
}
|
|
3321
|
+
}
|
|
3322
|
+
// import { Named, Import } (named import bindings)
|
|
3323
|
+
if (ts.isNamedImports(namedBindings)) {
|
|
3324
|
+
for (const importSpecifier of namedBindings.elements) {
|
|
3325
|
+
if (importSpecifier.name.text === typeName ||
|
|
3326
|
+
importSpecifier.propertyName?.text === typeName) {
|
|
3327
|
+
return statement.moduleSpecifier.text;
|
|
3328
|
+
}
|
|
3329
|
+
}
|
|
3330
|
+
}
|
|
3331
|
+
}
|
|
3332
|
+
}
|
|
3333
|
+
}
|
|
3334
|
+
}
|
|
3335
|
+
return undefined;
|
|
3336
|
+
}
|
|
3337
|
+
/**
|
|
3338
|
+
* Map custom tag key names to JSON schema property names.
|
|
3339
|
+
* @param key The raw tag key.
|
|
3340
|
+
* @returns The schema key.
|
|
3341
|
+
*/
|
|
3342
|
+
static mapJsonSchemaTagKey(key) {
|
|
3343
|
+
if (key === "id") {
|
|
3344
|
+
return "$id";
|
|
3345
|
+
}
|
|
3346
|
+
if (key === "ref") {
|
|
3347
|
+
return "$ref";
|
|
3348
|
+
}
|
|
3349
|
+
if (key === "comment") {
|
|
3350
|
+
return "$comment";
|
|
3351
|
+
}
|
|
3352
|
+
return key;
|
|
3353
|
+
}
|
|
3354
|
+
/**
|
|
3355
|
+
* Extract the inner type node from a named or rest tuple element.
|
|
3356
|
+
* Named tuple members and rest elements both wrap an inner type node; this unwraps them.
|
|
3357
|
+
* Plain type nodes are returned as-is.
|
|
3358
|
+
* @param element The tuple element.
|
|
3359
|
+
* @returns The inner type node.
|
|
3360
|
+
*/
|
|
3361
|
+
static extractTupleElementType(element) {
|
|
3362
|
+
// label: string (named tuple member, e.g. [label: string, count: number])
|
|
3363
|
+
if (ts.isNamedTupleMember(element)) {
|
|
3364
|
+
return element.type;
|
|
3365
|
+
}
|
|
3366
|
+
// ...string[] (rest element in a tuple, e.g. [first: string, ...rest: string[]])
|
|
3367
|
+
if (ts.isRestTypeNode(element)) {
|
|
3368
|
+
return element.type;
|
|
3369
|
+
}
|
|
3370
|
+
return element;
|
|
3371
|
+
}
|
|
3372
|
+
}
|
|
3373
|
+
//# sourceMappingURL=jsonSchemaBuilder.js.map
|