@trpc/openapi 0.0.0-alpha.0 → 11.13.2-alpha
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/LICENSE +21 -0
- package/README.md +14 -0
- package/dist/cli.js +937 -0
- package/dist/heyapi/index.cjs +141 -0
- package/dist/heyapi/index.d.cts +67 -0
- package/dist/heyapi/index.d.cts.map +1 -0
- package/dist/heyapi/index.d.mts +67 -0
- package/dist/heyapi/index.d.mts.map +1 -0
- package/dist/heyapi/index.mjs +139 -0
- package/dist/heyapi/index.mjs.map +1 -0
- package/dist/index.cjs +834 -0
- package/dist/index.d.cts +63 -0
- package/dist/index.d.cts.map +1 -0
- package/dist/index.d.mts +63 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +835 -0
- package/dist/index.mjs.map +1 -0
- package/dist/objectSpread2-Cw30I7tb.cjs +131 -0
- package/dist/objectSpread2-UxrN8MPM.mjs +114 -0
- package/dist/objectSpread2-UxrN8MPM.mjs.map +1 -0
- package/package.json +101 -1
- package/src/cli.ts +133 -0
- package/src/generate.ts +1265 -0
- package/src/heyapi/index.ts +174 -0
- package/src/index.ts +2 -0
- package/src/schemaExtraction.ts +383 -0
package/src/generate.ts
ADDED
|
@@ -0,0 +1,1265 @@
|
|
|
1
|
+
import * as path from 'node:path';
|
|
2
|
+
import * as ts from 'typescript';
|
|
3
|
+
import {
|
|
4
|
+
applyDescriptions,
|
|
5
|
+
collectRuntimeDescriptions,
|
|
6
|
+
tryImportRouter,
|
|
7
|
+
type RuntimeDescriptions,
|
|
8
|
+
} from './schemaExtraction';
|
|
9
|
+
|
|
10
|
+
const log = console;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A minimal JSON Schema subset used for OpenAPI 3.1 schemas.
|
|
14
|
+
*/
|
|
15
|
+
export interface JsonSchema {
|
|
16
|
+
$ref?: string;
|
|
17
|
+
$defs?: Record<string, JsonSchema>;
|
|
18
|
+
type?: string | string[];
|
|
19
|
+
properties?: Record<string, JsonSchema>;
|
|
20
|
+
required?: string[];
|
|
21
|
+
items?: JsonSchema | false;
|
|
22
|
+
prefixItems?: JsonSchema[];
|
|
23
|
+
const?: string | number | boolean | null;
|
|
24
|
+
enum?: (string | number | boolean | null)[];
|
|
25
|
+
oneOf?: JsonSchema[];
|
|
26
|
+
anyOf?: JsonSchema[];
|
|
27
|
+
allOf?: JsonSchema[];
|
|
28
|
+
not?: JsonSchema;
|
|
29
|
+
additionalProperties?: JsonSchema | boolean;
|
|
30
|
+
discriminator?: { propertyName: string; mapping?: Record<string, string> };
|
|
31
|
+
format?: string;
|
|
32
|
+
description?: string;
|
|
33
|
+
minItems?: number;
|
|
34
|
+
maxItems?: number;
|
|
35
|
+
$schema?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface ProcedureInfo {
|
|
39
|
+
path: string;
|
|
40
|
+
type: 'query' | 'mutation' | 'subscription';
|
|
41
|
+
inputSchema: JsonSchema | null;
|
|
42
|
+
outputSchema: JsonSchema | null;
|
|
43
|
+
description?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** State extracted from the router's root config. */
|
|
47
|
+
interface RouterMeta {
|
|
48
|
+
errorSchema: JsonSchema | null;
|
|
49
|
+
schemas?: Record<string, JsonSchema>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface GenerateOptions {
|
|
53
|
+
/**
|
|
54
|
+
* The name of the exported router symbol.
|
|
55
|
+
* @default 'AppRouter'
|
|
56
|
+
*/
|
|
57
|
+
exportName?: string;
|
|
58
|
+
/** Title for the generated OpenAPI `info` object. */
|
|
59
|
+
title?: string;
|
|
60
|
+
/** Version string for the generated OpenAPI `info` object. */
|
|
61
|
+
version?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface OpenAPIDocument {
|
|
65
|
+
openapi: string;
|
|
66
|
+
jsonSchemaDialect?: string;
|
|
67
|
+
info: { title: string; version: string };
|
|
68
|
+
paths: Record<string, Record<string, unknown>>;
|
|
69
|
+
components: Record<string, unknown>;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// Flag helpers
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
const PRIMITIVE_FLAGS =
|
|
77
|
+
ts.TypeFlags.String |
|
|
78
|
+
ts.TypeFlags.Number |
|
|
79
|
+
ts.TypeFlags.Boolean |
|
|
80
|
+
ts.TypeFlags.StringLiteral |
|
|
81
|
+
ts.TypeFlags.NumberLiteral |
|
|
82
|
+
ts.TypeFlags.BooleanLiteral;
|
|
83
|
+
|
|
84
|
+
function hasFlag(type: ts.Type, flag: ts.TypeFlags): boolean {
|
|
85
|
+
return (type.getFlags() & flag) !== 0;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function isPrimitive(type: ts.Type): boolean {
|
|
89
|
+
return hasFlag(type, PRIMITIVE_FLAGS);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function isObjectType(type: ts.Type): boolean {
|
|
93
|
+
return hasFlag(type, ts.TypeFlags.Object);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function isOptionalSymbol(sym: ts.Symbol): boolean {
|
|
97
|
+
return (sym.flags & ts.SymbolFlags.Optional) !== 0;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
// JSON Schema conversion — shared state
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
/** Shared state threaded through the type-to-schema recursion. */
|
|
105
|
+
interface SchemaCtx {
|
|
106
|
+
checker: ts.TypeChecker;
|
|
107
|
+
visited: Set<ts.Type>;
|
|
108
|
+
/** Collected named schemas for components/schemas. */
|
|
109
|
+
schemas: Record<string, JsonSchema>;
|
|
110
|
+
/** Map from TS type identity to its registered schema name. */
|
|
111
|
+
typeToRef: Map<ts.Type, string>;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
// Brand unwrapping
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* If `type` is a branded intersection (primitive & object), return just the
|
|
120
|
+
* primitive part. Otherwise return the type as-is.
|
|
121
|
+
*/
|
|
122
|
+
function unwrapBrand(type: ts.Type): ts.Type {
|
|
123
|
+
if (!type.isIntersection()) {
|
|
124
|
+
return type;
|
|
125
|
+
}
|
|
126
|
+
const primitives = type.types.filter(isPrimitive);
|
|
127
|
+
const hasObject = type.types.some(isObjectType);
|
|
128
|
+
const [first] = primitives;
|
|
129
|
+
if (first && hasObject) {
|
|
130
|
+
return first;
|
|
131
|
+
}
|
|
132
|
+
return type;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
// Schema naming helpers
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
const ANONYMOUS_NAMES = new Set(['__type', '__object', 'Object', '']);
|
|
140
|
+
|
|
141
|
+
/** Try to determine a meaningful name for a TS type (type alias or interface). */
|
|
142
|
+
function getTypeName(type: ts.Type): string | null {
|
|
143
|
+
const aliasName = type.aliasSymbol?.getName();
|
|
144
|
+
if (aliasName && !ANONYMOUS_NAMES.has(aliasName)) {
|
|
145
|
+
return aliasName;
|
|
146
|
+
}
|
|
147
|
+
const symName = type.getSymbol()?.getName();
|
|
148
|
+
if (symName && !ANONYMOUS_NAMES.has(symName) && !symName.startsWith('__')) {
|
|
149
|
+
return symName;
|
|
150
|
+
}
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function ensureUniqueName(
|
|
155
|
+
name: string,
|
|
156
|
+
existing: Record<string, unknown>,
|
|
157
|
+
): string {
|
|
158
|
+
if (!(name in existing)) {
|
|
159
|
+
return name;
|
|
160
|
+
}
|
|
161
|
+
let i = 2;
|
|
162
|
+
while (`${name}${i}` in existing) {
|
|
163
|
+
i++;
|
|
164
|
+
}
|
|
165
|
+
return `${name}${i}`;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function schemaRef(name: string): JsonSchema {
|
|
169
|
+
return { $ref: `#/components/schemas/${name}` };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function isNonEmptySchema(s: JsonSchema): boolean {
|
|
173
|
+
for (const _ in s) return true;
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
// Type → JSON Schema (with component extraction)
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Convert a TS type to a JSON Schema. If the type has been pre-registered
|
|
183
|
+
* (or has a meaningful TS name), it is stored in `ctx.schemas` and a `$ref`
|
|
184
|
+
* is returned instead of an inline schema.
|
|
185
|
+
*/
|
|
186
|
+
function typeToJsonSchema(
|
|
187
|
+
type: ts.Type,
|
|
188
|
+
ctx: SchemaCtx,
|
|
189
|
+
depth = 0,
|
|
190
|
+
): JsonSchema {
|
|
191
|
+
if (depth > 20) {
|
|
192
|
+
log.warn(
|
|
193
|
+
`[openapi] Schema conversion reached maximum depth (20) for type "${ctx.checker.typeToString(type)}". The resulting schema will be incomplete.`,
|
|
194
|
+
);
|
|
195
|
+
return {};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// If this type is already registered as a named schema, return a $ref.
|
|
199
|
+
const refName = ctx.typeToRef.get(type);
|
|
200
|
+
if (refName) {
|
|
201
|
+
if (refName in ctx.schemas) {
|
|
202
|
+
return schemaRef(refName);
|
|
203
|
+
}
|
|
204
|
+
// First encounter: set placeholder (circular ref guard), convert, store.
|
|
205
|
+
ctx.schemas[refName] = {};
|
|
206
|
+
const schema = convertTypeToSchema(type, ctx, depth);
|
|
207
|
+
ctx.schemas[refName] = schema;
|
|
208
|
+
return schemaRef(refName);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const schema = convertTypeToSchema(type, ctx, depth);
|
|
212
|
+
|
|
213
|
+
// Extract JSDoc from type alias symbol (e.g. `/** desc */ type Foo = string`)
|
|
214
|
+
if (!schema.description && !schema.$ref && type.aliasSymbol) {
|
|
215
|
+
const aliasJsDoc = getJsDocComment(type.aliasSymbol, ctx.checker);
|
|
216
|
+
if (aliasJsDoc) {
|
|
217
|
+
schema.description = aliasJsDoc;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return schema;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ---------------------------------------------------------------------------
|
|
225
|
+
// Cyclic reference handling
|
|
226
|
+
// ---------------------------------------------------------------------------
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* When we encounter a type we're already visiting, it's recursive.
|
|
230
|
+
* Register it as a named schema and return a $ref.
|
|
231
|
+
*/
|
|
232
|
+
function handleCyclicRef(type: ts.Type, ctx: SchemaCtx): JsonSchema {
|
|
233
|
+
let refName = ctx.typeToRef.get(type);
|
|
234
|
+
if (!refName) {
|
|
235
|
+
const name = getTypeName(type) ?? 'RecursiveType';
|
|
236
|
+
refName = ensureUniqueName(name, ctx.schemas);
|
|
237
|
+
ctx.typeToRef.set(type, refName);
|
|
238
|
+
ctx.schemas[refName] = {}; // placeholder — filled by the outer call
|
|
239
|
+
}
|
|
240
|
+
return schemaRef(refName);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ---------------------------------------------------------------------------
|
|
244
|
+
// Primitive & literal type conversion
|
|
245
|
+
// ---------------------------------------------------------------------------
|
|
246
|
+
|
|
247
|
+
function convertPrimitiveOrLiteral(
|
|
248
|
+
type: ts.Type,
|
|
249
|
+
flags: ts.TypeFlags,
|
|
250
|
+
checker: ts.TypeChecker,
|
|
251
|
+
): JsonSchema | null {
|
|
252
|
+
if (flags & ts.TypeFlags.String) {
|
|
253
|
+
return { type: 'string' };
|
|
254
|
+
}
|
|
255
|
+
if (flags & ts.TypeFlags.Number) {
|
|
256
|
+
return { type: 'number' };
|
|
257
|
+
}
|
|
258
|
+
if (flags & ts.TypeFlags.Boolean) {
|
|
259
|
+
return { type: 'boolean' };
|
|
260
|
+
}
|
|
261
|
+
if (flags & ts.TypeFlags.Null) {
|
|
262
|
+
return { type: 'null' };
|
|
263
|
+
}
|
|
264
|
+
if (flags & ts.TypeFlags.Undefined) {
|
|
265
|
+
return {};
|
|
266
|
+
}
|
|
267
|
+
if (flags & ts.TypeFlags.Void) {
|
|
268
|
+
return {};
|
|
269
|
+
}
|
|
270
|
+
if (flags & ts.TypeFlags.Any || flags & ts.TypeFlags.Unknown) {
|
|
271
|
+
return {};
|
|
272
|
+
}
|
|
273
|
+
if (flags & ts.TypeFlags.Never) {
|
|
274
|
+
return { not: {} };
|
|
275
|
+
}
|
|
276
|
+
if (flags & ts.TypeFlags.BigInt || flags & ts.TypeFlags.BigIntLiteral) {
|
|
277
|
+
return { type: 'integer', format: 'bigint' };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (flags & ts.TypeFlags.StringLiteral) {
|
|
281
|
+
return { type: 'string', const: (type as ts.StringLiteralType).value };
|
|
282
|
+
}
|
|
283
|
+
if (flags & ts.TypeFlags.NumberLiteral) {
|
|
284
|
+
return { type: 'number', const: (type as ts.NumberLiteralType).value };
|
|
285
|
+
}
|
|
286
|
+
if (flags & ts.TypeFlags.BooleanLiteral) {
|
|
287
|
+
const isTrue = checker.typeToString(type) === 'true';
|
|
288
|
+
return { type: 'boolean', const: isTrue };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ---------------------------------------------------------------------------
|
|
295
|
+
// Union type conversion
|
|
296
|
+
// ---------------------------------------------------------------------------
|
|
297
|
+
|
|
298
|
+
function convertUnionType(
|
|
299
|
+
type: ts.UnionType,
|
|
300
|
+
ctx: SchemaCtx,
|
|
301
|
+
depth: number,
|
|
302
|
+
): JsonSchema {
|
|
303
|
+
const members = type.types;
|
|
304
|
+
|
|
305
|
+
// Strip undefined / void members (they make the field optional, not typed)
|
|
306
|
+
const defined = members.filter(
|
|
307
|
+
(m) => !hasFlag(m, ts.TypeFlags.Undefined | ts.TypeFlags.Void),
|
|
308
|
+
);
|
|
309
|
+
if (defined.length === 0) {
|
|
310
|
+
return {};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const hasNull = defined.some((m) => hasFlag(m, ts.TypeFlags.Null));
|
|
314
|
+
const nonNull = defined.filter((m) => !hasFlag(m, ts.TypeFlags.Null));
|
|
315
|
+
|
|
316
|
+
// TypeScript represents `boolean` as `true | false`. Collapse boolean
|
|
317
|
+
// literal pairs back into a single boolean, even when mixed with other types.
|
|
318
|
+
// e.g. `string | true | false` → treat as `string | boolean`
|
|
319
|
+
const boolLiterals = nonNull.filter((m) =>
|
|
320
|
+
hasFlag(unwrapBrand(m), ts.TypeFlags.BooleanLiteral),
|
|
321
|
+
);
|
|
322
|
+
const hasBoolPair =
|
|
323
|
+
boolLiterals.length === 2 &&
|
|
324
|
+
boolLiterals.some(
|
|
325
|
+
(m) => ctx.checker.typeToString(unwrapBrand(m)) === 'true',
|
|
326
|
+
) &&
|
|
327
|
+
boolLiterals.some(
|
|
328
|
+
(m) => ctx.checker.typeToString(unwrapBrand(m)) === 'false',
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
// Build the effective non-null members, collapsing boolean literal pairs
|
|
332
|
+
const effective = hasBoolPair
|
|
333
|
+
? nonNull.filter(
|
|
334
|
+
(m) => !hasFlag(unwrapBrand(m), ts.TypeFlags.BooleanLiteral),
|
|
335
|
+
)
|
|
336
|
+
: nonNull;
|
|
337
|
+
|
|
338
|
+
// Pure boolean (or boolean | null) — no other types
|
|
339
|
+
if (hasBoolPair && effective.length === 0) {
|
|
340
|
+
return hasNull ? { type: ['boolean', 'null'] } : { type: 'boolean' };
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Collapse unions of same-type literals into a single `enum` array.
|
|
344
|
+
// e.g. "FOO" | "BAR" → { type: "string", enum: ["FOO", "BAR"] }
|
|
345
|
+
const collapsedEnum = tryCollapseLiteralUnion(effective, hasNull);
|
|
346
|
+
if (collapsedEnum) {
|
|
347
|
+
return collapsedEnum;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const schemas = effective
|
|
351
|
+
.map((m) => typeToJsonSchema(m, ctx, depth + 1))
|
|
352
|
+
.filter(isNonEmptySchema);
|
|
353
|
+
|
|
354
|
+
// Re-inject the collapsed boolean
|
|
355
|
+
if (hasBoolPair) {
|
|
356
|
+
schemas.push({ type: 'boolean' });
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
if (hasNull) {
|
|
360
|
+
schemas.push({ type: 'null' });
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if (schemas.length === 0) {
|
|
364
|
+
return {};
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const [firstSchema] = schemas;
|
|
368
|
+
if (schemas.length === 1 && firstSchema !== undefined) {
|
|
369
|
+
return firstSchema;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// When all schemas are simple type-only schemas (no other properties),
|
|
373
|
+
// collapse into a single `type` array. e.g. string | null → type: ["string", "null"]
|
|
374
|
+
if (schemas.every(isSimpleTypeSchema)) {
|
|
375
|
+
return { type: schemas.map((s) => s.type as string) };
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Detect discriminated unions: all oneOf members are objects sharing a common
|
|
379
|
+
// required property whose value is a `const`. If found, add a `discriminator`.
|
|
380
|
+
const discriminatorProp = detectDiscriminatorProperty(schemas);
|
|
381
|
+
if (discriminatorProp) {
|
|
382
|
+
return {
|
|
383
|
+
oneOf: schemas,
|
|
384
|
+
discriminator: { propertyName: discriminatorProp },
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
return { oneOf: schemas };
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* If every schema in a oneOf is an object with a common required property
|
|
393
|
+
* whose value is a `const`, return that property name. Otherwise return null.
|
|
394
|
+
*/
|
|
395
|
+
function detectDiscriminatorProperty(schemas: JsonSchema[]): string | null {
|
|
396
|
+
if (schemas.length < 2) {
|
|
397
|
+
return null;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// All schemas must be object types with properties
|
|
401
|
+
if (!schemas.every((s) => s.type === 'object' && s.properties)) {
|
|
402
|
+
return null;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Find properties that exist in every schema, are required, and have a `const` value
|
|
406
|
+
const first = schemas[0];
|
|
407
|
+
if (!first?.properties) {
|
|
408
|
+
return null;
|
|
409
|
+
}
|
|
410
|
+
const firstProps = Object.keys(first.properties);
|
|
411
|
+
for (const prop of firstProps) {
|
|
412
|
+
const allHaveConst = schemas.every((s) => {
|
|
413
|
+
const propSchema = s.properties?.[prop];
|
|
414
|
+
return (
|
|
415
|
+
propSchema !== undefined &&
|
|
416
|
+
propSchema.const !== undefined &&
|
|
417
|
+
s.required?.includes(prop)
|
|
418
|
+
);
|
|
419
|
+
});
|
|
420
|
+
if (allHaveConst) {
|
|
421
|
+
return prop;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
return null;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** A schema that is just `{ type: "somePrimitive" }` with no other keys. */
|
|
429
|
+
function isSimpleTypeSchema(s: JsonSchema): boolean {
|
|
430
|
+
const keys = Object.keys(s);
|
|
431
|
+
return keys.length === 1 && keys[0] === 'type' && typeof s.type === 'string';
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* If every non-null member is a string or number literal of the same kind,
|
|
436
|
+
* collapse them into a single `{ type, enum }` schema.
|
|
437
|
+
*/
|
|
438
|
+
function tryCollapseLiteralUnion(
|
|
439
|
+
nonNull: ts.Type[],
|
|
440
|
+
hasNull: boolean,
|
|
441
|
+
): JsonSchema | null {
|
|
442
|
+
if (nonNull.length <= 1) {
|
|
443
|
+
return null;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const allLiterals = nonNull.every((m) =>
|
|
447
|
+
hasFlag(m, ts.TypeFlags.StringLiteral | ts.TypeFlags.NumberLiteral),
|
|
448
|
+
);
|
|
449
|
+
if (!allLiterals) {
|
|
450
|
+
return null;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const [first] = nonNull;
|
|
454
|
+
if (!first) {
|
|
455
|
+
return null;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const isString = hasFlag(first, ts.TypeFlags.StringLiteral);
|
|
459
|
+
const targetFlag = isString
|
|
460
|
+
? ts.TypeFlags.StringLiteral
|
|
461
|
+
: ts.TypeFlags.NumberLiteral;
|
|
462
|
+
const allSameKind = nonNull.every((m) => hasFlag(m, targetFlag));
|
|
463
|
+
if (!allSameKind) {
|
|
464
|
+
return null;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const values = nonNull.map((m) =>
|
|
468
|
+
isString
|
|
469
|
+
? (m as ts.StringLiteralType).value
|
|
470
|
+
: (m as ts.NumberLiteralType).value,
|
|
471
|
+
);
|
|
472
|
+
const baseType = isString ? 'string' : 'number';
|
|
473
|
+
return {
|
|
474
|
+
type: hasNull ? [baseType, 'null'] : baseType,
|
|
475
|
+
enum: values,
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// ---------------------------------------------------------------------------
|
|
480
|
+
// Intersection type conversion
|
|
481
|
+
// ---------------------------------------------------------------------------
|
|
482
|
+
|
|
483
|
+
function convertIntersectionType(
|
|
484
|
+
type: ts.IntersectionType,
|
|
485
|
+
ctx: SchemaCtx,
|
|
486
|
+
depth: number,
|
|
487
|
+
): JsonSchema {
|
|
488
|
+
// Branded types (e.g. z.string().brand<'X'>()) appear as an intersection of
|
|
489
|
+
// a primitive with a phantom object. Strip the object members — they are
|
|
490
|
+
// always brand metadata.
|
|
491
|
+
const hasPrimitiveMember = type.types.some(isPrimitive);
|
|
492
|
+
const nonBrand = hasPrimitiveMember
|
|
493
|
+
? type.types.filter((m) => !isObjectType(m))
|
|
494
|
+
: type.types;
|
|
495
|
+
|
|
496
|
+
const schemas = nonBrand
|
|
497
|
+
.map((m) => typeToJsonSchema(m, ctx, depth + 1))
|
|
498
|
+
.filter(isNonEmptySchema);
|
|
499
|
+
|
|
500
|
+
if (schemas.length === 0) {
|
|
501
|
+
return {};
|
|
502
|
+
}
|
|
503
|
+
const [onlySchema] = schemas;
|
|
504
|
+
if (schemas.length === 1 && onlySchema !== undefined) {
|
|
505
|
+
return onlySchema;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// When all members are plain inline object schemas (no $ref), merge them
|
|
509
|
+
// into a single object instead of wrapping in allOf.
|
|
510
|
+
if (schemas.every(isInlineObjectSchema)) {
|
|
511
|
+
return mergeObjectSchemas(schemas);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
return { allOf: schemas };
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/** True when the schema is an inline `{ type: "object", ... }` (not a $ref). */
|
|
518
|
+
function isInlineObjectSchema(s: JsonSchema): boolean {
|
|
519
|
+
return s.type === 'object' && !s.$ref;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Merge multiple `{ type: "object" }` schemas into one.
|
|
524
|
+
* Falls back to `allOf` if any property names conflict across schemas.
|
|
525
|
+
*/
|
|
526
|
+
function mergeObjectSchemas(schemas: JsonSchema[]): JsonSchema {
|
|
527
|
+
// Check for property name conflicts before merging.
|
|
528
|
+
const seen = new Set<string>();
|
|
529
|
+
for (const s of schemas) {
|
|
530
|
+
if (s.properties) {
|
|
531
|
+
for (const prop of Object.keys(s.properties)) {
|
|
532
|
+
if (seen.has(prop)) {
|
|
533
|
+
// Conflicting property — fall back to allOf to preserve both definitions.
|
|
534
|
+
return { allOf: schemas };
|
|
535
|
+
}
|
|
536
|
+
seen.add(prop);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
const properties: Record<string, JsonSchema> = {};
|
|
542
|
+
const required: string[] = [];
|
|
543
|
+
let additionalProperties: JsonSchema | boolean | undefined;
|
|
544
|
+
|
|
545
|
+
for (const s of schemas) {
|
|
546
|
+
if (s.properties) {
|
|
547
|
+
Object.assign(properties, s.properties);
|
|
548
|
+
}
|
|
549
|
+
if (s.required) {
|
|
550
|
+
required.push(...s.required);
|
|
551
|
+
}
|
|
552
|
+
if (s.additionalProperties !== undefined) {
|
|
553
|
+
additionalProperties = s.additionalProperties;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
const result: JsonSchema = { type: 'object' };
|
|
558
|
+
if (Object.keys(properties).length > 0) {
|
|
559
|
+
result.properties = properties;
|
|
560
|
+
}
|
|
561
|
+
if (required.length > 0) {
|
|
562
|
+
result.required = required;
|
|
563
|
+
}
|
|
564
|
+
if (additionalProperties !== undefined) {
|
|
565
|
+
result.additionalProperties = additionalProperties;
|
|
566
|
+
}
|
|
567
|
+
return result;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// ---------------------------------------------------------------------------
|
|
571
|
+
// Object type conversion
|
|
572
|
+
// ---------------------------------------------------------------------------
|
|
573
|
+
|
|
574
|
+
function convertWellKnownType(
|
|
575
|
+
type: ts.Type,
|
|
576
|
+
ctx: SchemaCtx,
|
|
577
|
+
depth: number,
|
|
578
|
+
): JsonSchema | null {
|
|
579
|
+
const symName = type.getSymbol()?.getName();
|
|
580
|
+
if (symName === 'Date') {
|
|
581
|
+
return { type: 'string', format: 'date-time' };
|
|
582
|
+
}
|
|
583
|
+
if (symName === 'Uint8Array' || symName === 'Buffer') {
|
|
584
|
+
return { type: 'string', format: 'binary' };
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// Unwrap Promise<T>
|
|
588
|
+
if (symName === 'Promise') {
|
|
589
|
+
const [inner] = ctx.checker.getTypeArguments(type as ts.TypeReference);
|
|
590
|
+
return inner ? typeToJsonSchema(inner, ctx, depth + 1) : {};
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
return null;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function convertArrayType(
|
|
597
|
+
type: ts.Type,
|
|
598
|
+
ctx: SchemaCtx,
|
|
599
|
+
depth: number,
|
|
600
|
+
): JsonSchema {
|
|
601
|
+
const [elem] = ctx.checker.getTypeArguments(type as ts.TypeReference);
|
|
602
|
+
const schema: JsonSchema = { type: 'array' };
|
|
603
|
+
if (elem) {
|
|
604
|
+
schema.items = typeToJsonSchema(elem, ctx, depth + 1);
|
|
605
|
+
}
|
|
606
|
+
return schema;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function convertTupleType(
|
|
610
|
+
type: ts.Type,
|
|
611
|
+
ctx: SchemaCtx,
|
|
612
|
+
depth: number,
|
|
613
|
+
): JsonSchema {
|
|
614
|
+
const args = ctx.checker.getTypeArguments(type as ts.TypeReference);
|
|
615
|
+
const schemas = args.map((a) => typeToJsonSchema(a, ctx, depth + 1));
|
|
616
|
+
return {
|
|
617
|
+
type: 'array',
|
|
618
|
+
prefixItems: schemas,
|
|
619
|
+
items: false,
|
|
620
|
+
minItems: args.length,
|
|
621
|
+
maxItems: args.length,
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function convertPlainObject(
|
|
626
|
+
type: ts.Type,
|
|
627
|
+
ctx: SchemaCtx,
|
|
628
|
+
depth: number,
|
|
629
|
+
): JsonSchema {
|
|
630
|
+
const { checker } = ctx;
|
|
631
|
+
const stringIndexType = type.getStringIndexType();
|
|
632
|
+
const typeProps = type.getProperties();
|
|
633
|
+
|
|
634
|
+
// Pure index-signature Record type (no named props)
|
|
635
|
+
if (typeProps.length === 0 && stringIndexType) {
|
|
636
|
+
return {
|
|
637
|
+
type: 'object',
|
|
638
|
+
additionalProperties: typeToJsonSchema(stringIndexType, ctx, depth + 1),
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// Auto-register types with a meaningful TS name BEFORE converting
|
|
643
|
+
// properties, so that circular or shared refs discovered during recursion
|
|
644
|
+
// resolve to a $ref via the `typeToJsonSchema` wrapper.
|
|
645
|
+
let autoRegName: string | null = null;
|
|
646
|
+
const tsName = getTypeName(type);
|
|
647
|
+
const isNamedUnregisteredType =
|
|
648
|
+
tsName !== null && typeProps.length > 0 && !ctx.typeToRef.has(type);
|
|
649
|
+
if (isNamedUnregisteredType) {
|
|
650
|
+
autoRegName = ensureUniqueName(tsName, ctx.schemas);
|
|
651
|
+
ctx.typeToRef.set(type, autoRegName);
|
|
652
|
+
ctx.schemas[autoRegName] = {}; // placeholder for circular ref guard
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
ctx.visited.add(type);
|
|
656
|
+
const properties: Record<string, JsonSchema> = {};
|
|
657
|
+
const required: string[] = [];
|
|
658
|
+
|
|
659
|
+
for (const prop of typeProps) {
|
|
660
|
+
const propType = checker.getTypeOfSymbol(prop);
|
|
661
|
+
const propSchema = typeToJsonSchema(propType, ctx, depth + 1);
|
|
662
|
+
|
|
663
|
+
// Extract JSDoc comment from the property symbol as a description
|
|
664
|
+
const jsDoc = getJsDocComment(prop, checker);
|
|
665
|
+
if (jsDoc && !propSchema.description && !propSchema.$ref) {
|
|
666
|
+
propSchema.description = jsDoc;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
properties[prop.name] = propSchema;
|
|
670
|
+
if (!isOptionalSymbol(prop)) {
|
|
671
|
+
required.push(prop.name);
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
ctx.visited.delete(type);
|
|
676
|
+
|
|
677
|
+
const result: JsonSchema = { type: 'object' };
|
|
678
|
+
if (Object.keys(properties).length > 0) {
|
|
679
|
+
result.properties = properties;
|
|
680
|
+
}
|
|
681
|
+
if (required.length > 0) {
|
|
682
|
+
result.required = required;
|
|
683
|
+
}
|
|
684
|
+
if (stringIndexType) {
|
|
685
|
+
result.additionalProperties = typeToJsonSchema(
|
|
686
|
+
stringIndexType,
|
|
687
|
+
ctx,
|
|
688
|
+
depth + 1,
|
|
689
|
+
);
|
|
690
|
+
} else if (Object.keys(properties).length > 0) {
|
|
691
|
+
result.additionalProperties = false;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// autoRegName covers named types (early-registered). For anonymous
|
|
695
|
+
// recursive types, a recursive call may have registered this type during
|
|
696
|
+
// property conversion — check typeToRef as a fallback.
|
|
697
|
+
const registeredName = autoRegName ?? ctx.typeToRef.get(type);
|
|
698
|
+
if (registeredName) {
|
|
699
|
+
ctx.schemas[registeredName] = result;
|
|
700
|
+
return schemaRef(registeredName);
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
return result;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function convertObjectType(
|
|
707
|
+
type: ts.Type,
|
|
708
|
+
ctx: SchemaCtx,
|
|
709
|
+
depth: number,
|
|
710
|
+
): JsonSchema {
|
|
711
|
+
const wellKnown = convertWellKnownType(type, ctx, depth);
|
|
712
|
+
if (wellKnown) {
|
|
713
|
+
return wellKnown;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
if (ctx.checker.isArrayType(type)) {
|
|
717
|
+
return convertArrayType(type, ctx, depth);
|
|
718
|
+
}
|
|
719
|
+
if (ctx.checker.isTupleType(type)) {
|
|
720
|
+
return convertTupleType(type, ctx, depth);
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
return convertPlainObject(type, ctx, depth);
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
// ---------------------------------------------------------------------------
|
|
727
|
+
// Core dispatcher
|
|
728
|
+
// ---------------------------------------------------------------------------
|
|
729
|
+
|
|
730
|
+
/** Core type-to-schema conversion (no ref handling). */
|
|
731
|
+
function convertTypeToSchema(
|
|
732
|
+
type: ts.Type,
|
|
733
|
+
ctx: SchemaCtx,
|
|
734
|
+
depth: number,
|
|
735
|
+
): JsonSchema {
|
|
736
|
+
if (ctx.visited.has(type)) {
|
|
737
|
+
return handleCyclicRef(type, ctx);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
const flags = type.getFlags();
|
|
741
|
+
|
|
742
|
+
const primitive = convertPrimitiveOrLiteral(type, flags, ctx.checker);
|
|
743
|
+
if (primitive) {
|
|
744
|
+
return primitive;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
if (type.isUnion()) {
|
|
748
|
+
return convertUnionType(type, ctx, depth);
|
|
749
|
+
}
|
|
750
|
+
if (type.isIntersection()) {
|
|
751
|
+
return convertIntersectionType(type, ctx, depth);
|
|
752
|
+
}
|
|
753
|
+
if (isObjectType(type)) {
|
|
754
|
+
return convertObjectType(type, ctx, depth);
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
return {};
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
// ---------------------------------------------------------------------------
|
|
761
|
+
// Router / procedure type walker
|
|
762
|
+
// ---------------------------------------------------------------------------
|
|
763
|
+
|
|
764
|
+
/** State shared across the router-walk recursion. */
|
|
765
|
+
interface WalkCtx {
|
|
766
|
+
procedures: ProcedureInfo[];
|
|
767
|
+
seen: Set<ts.Type>;
|
|
768
|
+
schemaCtx: SchemaCtx;
|
|
769
|
+
/** Runtime descriptions keyed by procedure path (when a router instance is available). */
|
|
770
|
+
runtimeDescriptions: Map<string, RuntimeDescriptions>;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/**
|
|
774
|
+
* Inspect `_def.type` and return the procedure type string, or null if this is
|
|
775
|
+
* not a procedure (e.g. a nested router).
|
|
776
|
+
*/
|
|
777
|
+
function getProcedureTypeName(
|
|
778
|
+
defType: ts.Type,
|
|
779
|
+
checker: ts.TypeChecker,
|
|
780
|
+
): string | null {
|
|
781
|
+
const typeSym = defType.getProperty('type');
|
|
782
|
+
if (!typeSym) {
|
|
783
|
+
return null;
|
|
784
|
+
}
|
|
785
|
+
const typeType = checker.getTypeOfSymbol(typeSym);
|
|
786
|
+
const raw = checker.typeToString(typeType).replace(/['"]/g, '');
|
|
787
|
+
if (raw === 'query' || raw === 'mutation' || raw === 'subscription') {
|
|
788
|
+
return raw;
|
|
789
|
+
}
|
|
790
|
+
return null;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
function isVoidLikeInput(inputType: ts.Type | null): boolean {
|
|
794
|
+
if (!inputType) {
|
|
795
|
+
return true;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
const isVoidOrUndefinedOrNever = hasFlag(
|
|
799
|
+
inputType,
|
|
800
|
+
ts.TypeFlags.Void | ts.TypeFlags.Undefined | ts.TypeFlags.Never,
|
|
801
|
+
);
|
|
802
|
+
if (isVoidOrUndefinedOrNever) {
|
|
803
|
+
return true;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
const isUnionOfVoids =
|
|
807
|
+
inputType.isUnion() &&
|
|
808
|
+
inputType.types.every((t) =>
|
|
809
|
+
hasFlag(t, ts.TypeFlags.Void | ts.TypeFlags.Undefined),
|
|
810
|
+
);
|
|
811
|
+
return isUnionOfVoids;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
interface ProcedureDef {
|
|
815
|
+
defType: ts.Type;
|
|
816
|
+
typeName: string;
|
|
817
|
+
path: string;
|
|
818
|
+
description?: string;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
function extractProcedure(def: ProcedureDef, ctx: WalkCtx): void {
|
|
822
|
+
const { schemaCtx } = ctx;
|
|
823
|
+
const { checker } = schemaCtx;
|
|
824
|
+
|
|
825
|
+
const $typesSym = def.defType.getProperty('$types');
|
|
826
|
+
if (!$typesSym) {
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
829
|
+
const $typesType = checker.getTypeOfSymbol($typesSym);
|
|
830
|
+
|
|
831
|
+
const inputSym = $typesType.getProperty('input');
|
|
832
|
+
const outputSym = $typesType.getProperty('output');
|
|
833
|
+
|
|
834
|
+
const inputType = inputSym ? checker.getTypeOfSymbol(inputSym) : null;
|
|
835
|
+
const outputType = outputSym ? checker.getTypeOfSymbol(outputSym) : null;
|
|
836
|
+
|
|
837
|
+
const inputSchema =
|
|
838
|
+
!inputType || isVoidLikeInput(inputType)
|
|
839
|
+
? null
|
|
840
|
+
: typeToJsonSchema(inputType, schemaCtx);
|
|
841
|
+
|
|
842
|
+
const outputSchema: JsonSchema | null = outputType
|
|
843
|
+
? typeToJsonSchema(outputType, schemaCtx)
|
|
844
|
+
: null;
|
|
845
|
+
|
|
846
|
+
// Overlay extracted schema descriptions onto the type-checker-generated schemas.
|
|
847
|
+
const runtimeDescs = ctx.runtimeDescriptions.get(def.path);
|
|
848
|
+
if (runtimeDescs) {
|
|
849
|
+
if (inputSchema && runtimeDescs.input) {
|
|
850
|
+
applyDescriptions(inputSchema, runtimeDescs.input);
|
|
851
|
+
}
|
|
852
|
+
if (outputSchema && runtimeDescs.output) {
|
|
853
|
+
applyDescriptions(outputSchema, runtimeDescs.output);
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
ctx.procedures.push({
|
|
858
|
+
path: def.path,
|
|
859
|
+
type: def.typeName as 'query' | 'mutation' | 'subscription',
|
|
860
|
+
inputSchema,
|
|
861
|
+
outputSchema,
|
|
862
|
+
description: def.description,
|
|
863
|
+
});
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/** Extract the JSDoc comment text from a symbol, if any. */
|
|
867
|
+
function getJsDocComment(
|
|
868
|
+
sym: ts.Symbol,
|
|
869
|
+
checker: ts.TypeChecker,
|
|
870
|
+
): string | undefined {
|
|
871
|
+
const parts = sym.getDocumentationComment(checker);
|
|
872
|
+
if (parts.length === 0) {
|
|
873
|
+
return undefined;
|
|
874
|
+
}
|
|
875
|
+
const text = parts.map((p) => p.text).join('');
|
|
876
|
+
return text || undefined;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
interface WalkTypeOpts {
|
|
880
|
+
type: ts.Type;
|
|
881
|
+
ctx: WalkCtx;
|
|
882
|
+
currentPath: string;
|
|
883
|
+
description?: string;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
function walkType(opts: WalkTypeOpts): void {
|
|
887
|
+
const { type, ctx, currentPath, description } = opts;
|
|
888
|
+
if (ctx.seen.has(type)) {
|
|
889
|
+
return;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
const defSym = type.getProperty('_def');
|
|
893
|
+
|
|
894
|
+
if (!defSym) {
|
|
895
|
+
// No `_def` — this is a plain RouterRecord or an unrecognised type.
|
|
896
|
+
// Walk its own properties so nested procedures are found.
|
|
897
|
+
if (isObjectType(type)) {
|
|
898
|
+
ctx.seen.add(type);
|
|
899
|
+
walkRecord(type, ctx, currentPath);
|
|
900
|
+
ctx.seen.delete(type);
|
|
901
|
+
}
|
|
902
|
+
return;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
const { checker } = ctx.schemaCtx;
|
|
906
|
+
const defType = checker.getTypeOfSymbol(defSym);
|
|
907
|
+
|
|
908
|
+
const procedureTypeName = getProcedureTypeName(defType, checker);
|
|
909
|
+
if (procedureTypeName) {
|
|
910
|
+
extractProcedure(
|
|
911
|
+
{ defType, typeName: procedureTypeName, path: currentPath, description },
|
|
912
|
+
ctx,
|
|
913
|
+
);
|
|
914
|
+
return;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
// Router? (_def.router === true)
|
|
918
|
+
const routerSym = defType.getProperty('router');
|
|
919
|
+
if (!routerSym) {
|
|
920
|
+
return;
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
const isRouter =
|
|
924
|
+
checker.typeToString(checker.getTypeOfSymbol(routerSym)) === 'true';
|
|
925
|
+
if (!isRouter) {
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
const recordSym = defType.getProperty('record');
|
|
930
|
+
if (!recordSym) {
|
|
931
|
+
return;
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
ctx.seen.add(type);
|
|
935
|
+
const recordType = checker.getTypeOfSymbol(recordSym);
|
|
936
|
+
walkRecord(recordType, ctx, currentPath);
|
|
937
|
+
ctx.seen.delete(type);
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
function walkRecord(recordType: ts.Type, ctx: WalkCtx, prefix: string): void {
|
|
941
|
+
for (const prop of recordType.getProperties()) {
|
|
942
|
+
const propType = ctx.schemaCtx.checker.getTypeOfSymbol(prop);
|
|
943
|
+
const fullPath = prefix ? `${prefix}.${prop.name}` : prop.name;
|
|
944
|
+
const description = getJsDocComment(prop, ctx.schemaCtx.checker);
|
|
945
|
+
walkType({ type: propType, ctx, currentPath: fullPath, description });
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
// ---------------------------------------------------------------------------
|
|
950
|
+
// TypeScript program helpers
|
|
951
|
+
// ---------------------------------------------------------------------------
|
|
952
|
+
|
|
953
|
+
function loadCompilerOptions(startDir: string): ts.CompilerOptions {
|
|
954
|
+
const configPath = ts.findConfigFile(
|
|
955
|
+
startDir,
|
|
956
|
+
(f) => ts.sys.fileExists(f),
|
|
957
|
+
'tsconfig.json',
|
|
958
|
+
);
|
|
959
|
+
if (!configPath) {
|
|
960
|
+
return {
|
|
961
|
+
target: ts.ScriptTarget.ES2020,
|
|
962
|
+
moduleResolution: ts.ModuleResolutionKind.Bundler,
|
|
963
|
+
skipLibCheck: true,
|
|
964
|
+
noEmit: true,
|
|
965
|
+
};
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
const configFile = ts.readConfigFile(configPath, (f) => ts.sys.readFile(f));
|
|
969
|
+
const parsed = ts.parseJsonConfigFileContent(
|
|
970
|
+
configFile.config,
|
|
971
|
+
ts.sys,
|
|
972
|
+
path.dirname(configPath),
|
|
973
|
+
);
|
|
974
|
+
const options: ts.CompilerOptions = { ...parsed.options, noEmit: true };
|
|
975
|
+
|
|
976
|
+
// `parseJsonConfigFileContent` only returns explicitly-set values. TypeScript
|
|
977
|
+
// itself infers moduleResolution from `module` at compile time, but we have to
|
|
978
|
+
// do it manually here for the compiler host to resolve imports correctly.
|
|
979
|
+
if (options.moduleResolution === undefined) {
|
|
980
|
+
const mod = options.module;
|
|
981
|
+
if (mod === ts.ModuleKind.Node16 || mod === ts.ModuleKind.NodeNext) {
|
|
982
|
+
options.moduleResolution = ts.ModuleResolutionKind.NodeNext;
|
|
983
|
+
} else if (
|
|
984
|
+
mod === ts.ModuleKind.Preserve ||
|
|
985
|
+
mod === ts.ModuleKind.ES2022 ||
|
|
986
|
+
mod === ts.ModuleKind.ESNext
|
|
987
|
+
) {
|
|
988
|
+
options.moduleResolution = ts.ModuleResolutionKind.Bundler;
|
|
989
|
+
} else {
|
|
990
|
+
options.moduleResolution = ts.ModuleResolutionKind.Node10;
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
return options;
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
// ---------------------------------------------------------------------------
|
|
998
|
+
// Error shape extraction
|
|
999
|
+
// ---------------------------------------------------------------------------
|
|
1000
|
+
|
|
1001
|
+
/**
|
|
1002
|
+
* Walk `_def._config.$types.errorShape` on the router type and convert
|
|
1003
|
+
* it to a JSON Schema. Returns `null` when the path cannot be resolved
|
|
1004
|
+
* (e.g. older tRPC versions or missing type info).
|
|
1005
|
+
*/
|
|
1006
|
+
function extractErrorSchema(
|
|
1007
|
+
routerType: ts.Type,
|
|
1008
|
+
checker: ts.TypeChecker,
|
|
1009
|
+
schemaCtx: SchemaCtx,
|
|
1010
|
+
): JsonSchema | null {
|
|
1011
|
+
const walk = (type: ts.Type, keys: string[]): ts.Type | null => {
|
|
1012
|
+
const [head, ...rest] = keys;
|
|
1013
|
+
if (!head) {
|
|
1014
|
+
return type;
|
|
1015
|
+
}
|
|
1016
|
+
const sym = type.getProperty(head);
|
|
1017
|
+
if (!sym) {
|
|
1018
|
+
return null;
|
|
1019
|
+
}
|
|
1020
|
+
return walk(checker.getTypeOfSymbol(sym), rest);
|
|
1021
|
+
};
|
|
1022
|
+
|
|
1023
|
+
const errorShapeType = walk(routerType, [
|
|
1024
|
+
'_def',
|
|
1025
|
+
'_config',
|
|
1026
|
+
'$types',
|
|
1027
|
+
'errorShape',
|
|
1028
|
+
]);
|
|
1029
|
+
if (!errorShapeType) {
|
|
1030
|
+
return null;
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
if (hasFlag(errorShapeType, ts.TypeFlags.Any)) {
|
|
1034
|
+
return null;
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
return typeToJsonSchema(errorShapeType, schemaCtx);
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
// ---------------------------------------------------------------------------
|
|
1041
|
+
// OpenAPI document builder
|
|
1042
|
+
// ---------------------------------------------------------------------------
|
|
1043
|
+
|
|
1044
|
+
/** Fallback error schema when the router type doesn't expose an error shape. */
|
|
1045
|
+
const DEFAULT_ERROR_SCHEMA: JsonSchema = {
|
|
1046
|
+
type: 'object',
|
|
1047
|
+
properties: {
|
|
1048
|
+
message: { type: 'string' },
|
|
1049
|
+
code: { type: 'string' },
|
|
1050
|
+
data: { type: 'object' },
|
|
1051
|
+
},
|
|
1052
|
+
required: ['message', 'code'],
|
|
1053
|
+
};
|
|
1054
|
+
|
|
1055
|
+
/**
|
|
1056
|
+
* Wrap a procedure's output schema in the tRPC success envelope.
|
|
1057
|
+
*
|
|
1058
|
+
* tRPC HTTP responses are always serialised as:
|
|
1059
|
+
* `{ result: { data: T } }`
|
|
1060
|
+
*
|
|
1061
|
+
* When the procedure has no output the envelope is still present but
|
|
1062
|
+
* the `data` property is omitted.
|
|
1063
|
+
*/
|
|
1064
|
+
function wrapInSuccessEnvelope(outputSchema: JsonSchema | null): JsonSchema {
|
|
1065
|
+
const hasOutput = outputSchema !== null && isNonEmptySchema(outputSchema);
|
|
1066
|
+
const resultSchema: JsonSchema = {
|
|
1067
|
+
type: 'object',
|
|
1068
|
+
properties: {
|
|
1069
|
+
...(hasOutput ? { data: outputSchema } : {}),
|
|
1070
|
+
},
|
|
1071
|
+
...(hasOutput ? { required: ['data' as const] } : {}),
|
|
1072
|
+
};
|
|
1073
|
+
return {
|
|
1074
|
+
type: 'object',
|
|
1075
|
+
properties: {
|
|
1076
|
+
result: resultSchema,
|
|
1077
|
+
},
|
|
1078
|
+
required: ['result'],
|
|
1079
|
+
};
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
function buildProcedureOperation(
|
|
1083
|
+
proc: ProcedureInfo,
|
|
1084
|
+
method: 'get' | 'post',
|
|
1085
|
+
): Record<string, unknown> {
|
|
1086
|
+
const operation: Record<string, unknown> = {
|
|
1087
|
+
operationId: proc.path,
|
|
1088
|
+
...(proc.description ? { description: proc.description } : {}),
|
|
1089
|
+
tags: [proc.path.split('.')[0]],
|
|
1090
|
+
responses: {
|
|
1091
|
+
'200': {
|
|
1092
|
+
description: 'Successful response',
|
|
1093
|
+
content: {
|
|
1094
|
+
'application/json': {
|
|
1095
|
+
schema: wrapInSuccessEnvelope(proc.outputSchema),
|
|
1096
|
+
},
|
|
1097
|
+
},
|
|
1098
|
+
},
|
|
1099
|
+
default: { $ref: '#/components/responses/Error' },
|
|
1100
|
+
},
|
|
1101
|
+
};
|
|
1102
|
+
|
|
1103
|
+
if (proc.inputSchema === null) {
|
|
1104
|
+
return operation;
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
if (method === 'get') {
|
|
1108
|
+
operation['parameters'] = [
|
|
1109
|
+
{
|
|
1110
|
+
name: 'input',
|
|
1111
|
+
in: 'query',
|
|
1112
|
+
required: true,
|
|
1113
|
+
// FIXME: OAS 3.1.1 says a parameter MUST use either schema+style OR content, not both.
|
|
1114
|
+
// style should be removed here, but hey-api requires it to generate a correct query serializer.
|
|
1115
|
+
style: 'deepObject',
|
|
1116
|
+
content: { 'application/json': { schema: proc.inputSchema } },
|
|
1117
|
+
},
|
|
1118
|
+
];
|
|
1119
|
+
} else {
|
|
1120
|
+
operation['requestBody'] = {
|
|
1121
|
+
required: true,
|
|
1122
|
+
content: { 'application/json': { schema: proc.inputSchema } },
|
|
1123
|
+
};
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
return operation;
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
function buildOpenAPIDocument(
|
|
1130
|
+
procedures: ProcedureInfo[],
|
|
1131
|
+
options: GenerateOptions,
|
|
1132
|
+
meta: RouterMeta = { errorSchema: null },
|
|
1133
|
+
): OpenAPIDocument {
|
|
1134
|
+
const paths: Record<string, Record<string, unknown>> = {};
|
|
1135
|
+
|
|
1136
|
+
for (const proc of procedures) {
|
|
1137
|
+
if (proc.type === 'subscription') {
|
|
1138
|
+
continue;
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
const opPath = `/${proc.path}`;
|
|
1142
|
+
const method = proc.type === 'query' ? 'get' : 'post';
|
|
1143
|
+
|
|
1144
|
+
const pathItem: Record<string, unknown> = paths[opPath] ?? {};
|
|
1145
|
+
paths[opPath] = pathItem;
|
|
1146
|
+
pathItem[method] = buildProcedureOperation(proc, method);
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
const hasNamedSchemas =
|
|
1150
|
+
meta.schemas !== undefined && Object.keys(meta.schemas).length > 0;
|
|
1151
|
+
|
|
1152
|
+
return {
|
|
1153
|
+
openapi: '3.1.1',
|
|
1154
|
+
jsonSchemaDialect: 'https://spec.openapis.org/oas/3.1/dialect/base',
|
|
1155
|
+
info: {
|
|
1156
|
+
title: options.title ?? 'tRPC API',
|
|
1157
|
+
version: options.version ?? '0.0.0',
|
|
1158
|
+
},
|
|
1159
|
+
paths,
|
|
1160
|
+
components: {
|
|
1161
|
+
...(hasNamedSchemas ? { schemas: meta.schemas } : {}),
|
|
1162
|
+
responses: {
|
|
1163
|
+
Error: {
|
|
1164
|
+
description: 'Error response',
|
|
1165
|
+
content: {
|
|
1166
|
+
'application/json': {
|
|
1167
|
+
schema: {
|
|
1168
|
+
type: 'object',
|
|
1169
|
+
properties: {
|
|
1170
|
+
error: meta.errorSchema ?? DEFAULT_ERROR_SCHEMA,
|
|
1171
|
+
},
|
|
1172
|
+
required: ['error'],
|
|
1173
|
+
},
|
|
1174
|
+
},
|
|
1175
|
+
},
|
|
1176
|
+
},
|
|
1177
|
+
},
|
|
1178
|
+
},
|
|
1179
|
+
};
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
// ---------------------------------------------------------------------------
|
|
1183
|
+
// Public API
|
|
1184
|
+
// ---------------------------------------------------------------------------
|
|
1185
|
+
|
|
1186
|
+
/**
|
|
1187
|
+
* Analyse the given TypeScript router file using the TypeScript compiler and
|
|
1188
|
+
* return an OpenAPI 3.1 document describing all query and mutation procedures.
|
|
1189
|
+
*
|
|
1190
|
+
* @param routerFilePath - Absolute or relative path to the file that exports
|
|
1191
|
+
* the AppRouter.
|
|
1192
|
+
* @param options - Optional generation settings (export name, title, version).
|
|
1193
|
+
*/
|
|
1194
|
+
export async function generateOpenAPIDocument(
|
|
1195
|
+
routerFilePath: string,
|
|
1196
|
+
options: GenerateOptions = {},
|
|
1197
|
+
): Promise<OpenAPIDocument> {
|
|
1198
|
+
const resolvedPath = path.resolve(routerFilePath);
|
|
1199
|
+
const exportName = options.exportName ?? 'AppRouter';
|
|
1200
|
+
|
|
1201
|
+
const compilerOptions = loadCompilerOptions(path.dirname(resolvedPath));
|
|
1202
|
+
const program = ts.createProgram([resolvedPath], compilerOptions);
|
|
1203
|
+
const checker = program.getTypeChecker();
|
|
1204
|
+
const sourceFile = program.getSourceFile(resolvedPath);
|
|
1205
|
+
|
|
1206
|
+
if (!sourceFile) {
|
|
1207
|
+
throw new Error(`Could not load TypeScript file: ${resolvedPath}`);
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
const moduleSymbol = checker.getSymbolAtLocation(sourceFile);
|
|
1211
|
+
if (!moduleSymbol) {
|
|
1212
|
+
throw new Error(`No module exports found in: ${resolvedPath}`);
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
const tsExports = checker.getExportsOfModule(moduleSymbol);
|
|
1216
|
+
const routerSymbol = tsExports.find((sym) => sym.getName() === exportName);
|
|
1217
|
+
|
|
1218
|
+
if (!routerSymbol) {
|
|
1219
|
+
const available = tsExports.map((e) => e.getName()).join(', ');
|
|
1220
|
+
throw new Error(
|
|
1221
|
+
`No export named '${exportName}' found in: ${resolvedPath}\n` +
|
|
1222
|
+
`Available exports: ${available || '(none)'}`,
|
|
1223
|
+
);
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
// Prefer the value declaration for value exports; fall back to the declared
|
|
1227
|
+
// type for `export type AppRouter = …` aliases.
|
|
1228
|
+
let routerType: ts.Type;
|
|
1229
|
+
if (routerSymbol.valueDeclaration) {
|
|
1230
|
+
routerType = checker.getTypeOfSymbolAtLocation(
|
|
1231
|
+
routerSymbol,
|
|
1232
|
+
routerSymbol.valueDeclaration,
|
|
1233
|
+
);
|
|
1234
|
+
} else {
|
|
1235
|
+
routerType = checker.getDeclaredTypeOfSymbol(routerSymbol);
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
const schemaCtx: SchemaCtx = {
|
|
1239
|
+
checker,
|
|
1240
|
+
visited: new Set(),
|
|
1241
|
+
schemas: {},
|
|
1242
|
+
typeToRef: new Map(),
|
|
1243
|
+
};
|
|
1244
|
+
|
|
1245
|
+
// Try to dynamically import the router to extract schema descriptions
|
|
1246
|
+
const runtimeDescriptions = new Map<string, RuntimeDescriptions>();
|
|
1247
|
+
const router = await tryImportRouter(resolvedPath, exportName);
|
|
1248
|
+
if (router) {
|
|
1249
|
+
collectRuntimeDescriptions(router, '', runtimeDescriptions);
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
const walkCtx: WalkCtx = {
|
|
1253
|
+
procedures: [],
|
|
1254
|
+
seen: new Set(),
|
|
1255
|
+
schemaCtx,
|
|
1256
|
+
runtimeDescriptions,
|
|
1257
|
+
};
|
|
1258
|
+
walkType({ type: routerType, ctx: walkCtx, currentPath: '' });
|
|
1259
|
+
|
|
1260
|
+
const errorSchema = extractErrorSchema(routerType, checker, schemaCtx);
|
|
1261
|
+
return buildOpenAPIDocument(walkCtx.procedures, options, {
|
|
1262
|
+
errorSchema,
|
|
1263
|
+
schemas: schemaCtx.schemas,
|
|
1264
|
+
});
|
|
1265
|
+
}
|