@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/dist/cli.js ADDED
@@ -0,0 +1,937 @@
1
+ #!/usr/bin/env node
2
+ import * as fs from "node:fs";
3
+ import * as path$1 from "node:path";
4
+ import * as path from "node:path";
5
+ import { parseArgs } from "node:util";
6
+ import * as ts from "typescript";
7
+ import { pathToFileURL } from "node:url";
8
+
9
+ //#region src/schemaExtraction.ts
10
+ /**
11
+ * Zod v4 stores `.describe()` strings in `globalThis.__zod_globalRegistry`,
12
+ * a WeakMap-backed `$ZodRegistry<GlobalMeta>`. We access it via globalThis
13
+ * because zod is an optional peer dependency.
14
+ */
15
+ function getZodGlobalRegistry() {
16
+ const reg = globalThis.__zod_globalRegistry;
17
+ return reg && typeof reg.get === "function" ? reg : null;
18
+ }
19
+ /** Runtime check: does this value look like a `$ZodType` (has `_zod.def`)? */
20
+ function isZodSchema(value) {
21
+ if (value == null || typeof value !== "object") return false;
22
+ const zod = value._zod;
23
+ return zod != null && typeof zod === "object" && "def" in zod;
24
+ }
25
+ /** Get the object shape from a Zod object schema, if applicable. */
26
+ function zodObjectShape(schema) {
27
+ const def = schema._zod.def;
28
+ if (def.type === "object" && "shape" in def) return def.shape;
29
+ return null;
30
+ }
31
+ /** Get the element schema from a Zod array schema, if applicable. */
32
+ function zodArrayElement(schema) {
33
+ const def = schema._zod.def;
34
+ if (def.type === "array" && "element" in def) return def.element;
35
+ return null;
36
+ }
37
+ /** Wrapper def types whose inner schema is accessible via `innerType` or `in`. */
38
+ const wrapperDefTypes = new Set([
39
+ "optional",
40
+ "nullable",
41
+ "nonoptional",
42
+ "default",
43
+ "prefault",
44
+ "catch",
45
+ "readonly",
46
+ "pipe",
47
+ "transform",
48
+ "promise",
49
+ "lazy"
50
+ ]);
51
+ /**
52
+ * Extract the wrapped inner schema from a wrapper def.
53
+ * Most wrappers use `innerType`; `pipe` uses `in`.
54
+ */
55
+ function getWrappedInner(def) {
56
+ if ("innerType" in def) return def.innerType;
57
+ if ("in" in def) return def.in;
58
+ return null;
59
+ }
60
+ /** Unwrap wrapper types (optional, nullable, default, readonly, etc.) to get the inner schema. */
61
+ function unwrapZodSchema(schema) {
62
+ let current = schema;
63
+ const seen = /* @__PURE__ */ new Set();
64
+ while (!seen.has(current)) {
65
+ seen.add(current);
66
+ const def = current._zod.def;
67
+ if (!wrapperDefTypes.has(def.type)) break;
68
+ const inner = getWrappedInner(def);
69
+ if (!inner) break;
70
+ current = inner;
71
+ }
72
+ return current;
73
+ }
74
+ /**
75
+ * Walk a Zod schema and collect description strings at each property path.
76
+ * Returns `null` if the value is not a Zod schema or has no descriptions.
77
+ */
78
+ function extractZodDescriptions(schema) {
79
+ if (!isZodSchema(schema)) return null;
80
+ const registry = getZodGlobalRegistry();
81
+ if (!registry) return null;
82
+ const map = { properties: /* @__PURE__ */ new Map() };
83
+ let hasAny = false;
84
+ const topMeta = registry.get(schema);
85
+ if (topMeta?.description) {
86
+ map.self = topMeta.description;
87
+ hasAny = true;
88
+ }
89
+ walkZodShape(schema, "", {
90
+ registry,
91
+ map
92
+ });
93
+ if (map.properties.size > 0) hasAny = true;
94
+ return hasAny ? map : null;
95
+ }
96
+ function walkZodShape(schema, prefix, ctx) {
97
+ const unwrapped = unwrapZodSchema(schema);
98
+ const element = zodArrayElement(unwrapped);
99
+ if (element) {
100
+ const unwrappedElement = unwrapZodSchema(element);
101
+ const elemMeta = ctx.registry.get(element);
102
+ const innerElemMeta = unwrappedElement !== element ? ctx.registry.get(unwrappedElement) : void 0;
103
+ const elemDesc = elemMeta?.description ?? innerElemMeta?.description;
104
+ if (elemDesc) {
105
+ const itemsPath = prefix ? `${prefix}.[]` : "[]";
106
+ ctx.map.properties.set(itemsPath, elemDesc);
107
+ }
108
+ walkZodShape(element, prefix, ctx);
109
+ return;
110
+ }
111
+ const shape = zodObjectShape(unwrapped);
112
+ if (!shape) return;
113
+ for (const [key, fieldSchema] of Object.entries(shape)) {
114
+ const path$2 = prefix ? `${prefix}.${key}` : key;
115
+ const meta = ctx.registry.get(fieldSchema);
116
+ const unwrappedField = unwrapZodSchema(fieldSchema);
117
+ const innerMeta = unwrappedField !== fieldSchema ? ctx.registry.get(unwrappedField) : void 0;
118
+ const description = meta?.description ?? innerMeta?.description;
119
+ if (description) ctx.map.properties.set(path$2, description);
120
+ walkZodShape(unwrappedField, path$2, ctx);
121
+ }
122
+ }
123
+ /** Check whether a value looks like a tRPC router instance at runtime. */
124
+ function isRouterInstance(value) {
125
+ if (value == null) return false;
126
+ const obj = value;
127
+ const def = obj["_def"];
128
+ return typeof obj === "object" && def != null && typeof def === "object" && def["record"] != null && typeof def["record"] === "object";
129
+ }
130
+ /**
131
+ * Search a module's exports for a tRPC router instance.
132
+ *
133
+ * Tries (in order):
134
+ * 1. Exact `exportName` match
135
+ * 2. lcfirst variant (`AppRouter` → `appRouter`)
136
+ * 3. First export that looks like a router
137
+ */
138
+ function findRouterExport(mod, exportName) {
139
+ if (isRouterInstance(mod[exportName])) return mod[exportName];
140
+ const lcFirst = exportName.charAt(0).toLowerCase() + exportName.slice(1);
141
+ if (lcFirst !== exportName && isRouterInstance(mod[lcFirst])) return mod[lcFirst];
142
+ for (const value of Object.values(mod)) if (isRouterInstance(value)) return value;
143
+ return null;
144
+ }
145
+ /**
146
+ * Try to dynamically import the router file and extract a tRPC router
147
+ * instance. Returns `null` if the import fails (e.g. no TS loader) or
148
+ * no router export is found.
149
+ */
150
+ async function tryImportRouter(resolvedPath, exportName) {
151
+ try {
152
+ const mod = await import(pathToFileURL(resolvedPath).href);
153
+ return findRouterExport(mod, exportName);
154
+ } catch {
155
+ return null;
156
+ }
157
+ }
158
+ /**
159
+ * Walk a runtime tRPC router/record and collect Zod `.describe()` strings
160
+ * keyed by procedure path.
161
+ */
162
+ function collectRuntimeDescriptions(routerOrRecord, prefix, result) {
163
+ const record = isRouterInstance(routerOrRecord) ? routerOrRecord._def.record : routerOrRecord;
164
+ for (const [key, value] of Object.entries(record)) {
165
+ const fullPath = prefix ? `${prefix}.${key}` : key;
166
+ if (isProcedure(value)) {
167
+ const def = value._def;
168
+ let inputDescs = null;
169
+ for (const input of def.inputs) {
170
+ const descs = extractZodDescriptions(input);
171
+ if (descs) {
172
+ inputDescs ??= { properties: /* @__PURE__ */ new Map() };
173
+ inputDescs.self = descs.self ?? inputDescs.self;
174
+ for (const [p, d] of descs.properties) inputDescs.properties.set(p, d);
175
+ }
176
+ }
177
+ let outputDescs = null;
178
+ const outputParser = def["output"];
179
+ if (outputParser) outputDescs = extractZodDescriptions(outputParser);
180
+ if (inputDescs || outputDescs) result.set(fullPath, {
181
+ input: inputDescs,
182
+ output: outputDescs
183
+ });
184
+ } else collectRuntimeDescriptions(value, fullPath, result);
185
+ }
186
+ }
187
+ /** Type guard: check if a RouterRecord value is a procedure (callable). */
188
+ function isProcedure(value) {
189
+ return typeof value === "function";
190
+ }
191
+ /**
192
+ * Overlay description strings from a `DescriptionMap` onto an existing
193
+ * JSON schema produced by the TypeScript type checker. Mutates in place.
194
+ */
195
+ function applyDescriptions(schema, descs) {
196
+ if (descs.self) schema.description = descs.self;
197
+ for (const [propPath, description] of descs.properties) setNestedDescription(schema, propPath.split("."), description);
198
+ }
199
+ function setNestedDescription(schema, pathParts, description) {
200
+ if (pathParts.length === 0) return;
201
+ const [head, ...rest] = pathParts;
202
+ if (!head) return;
203
+ if (head === "[]") {
204
+ const items = schema.type === "array" && schema.items && typeof schema.items === "object" ? schema.items : null;
205
+ if (!items) return;
206
+ if (rest.length === 0) items.description = description;
207
+ else setNestedDescription(items, rest, description);
208
+ return;
209
+ }
210
+ const propSchema = schema.properties?.[head];
211
+ if (!propSchema || typeof propSchema !== "object") return;
212
+ if (rest.length === 0) propSchema.description = description;
213
+ else {
214
+ const target = propSchema.type === "array" && propSchema.items && typeof propSchema.items === "object" ? propSchema.items : propSchema;
215
+ setNestedDescription(target, rest, description);
216
+ }
217
+ }
218
+
219
+ //#endregion
220
+ //#region src/generate.ts
221
+ const log = console;
222
+ const PRIMITIVE_FLAGS = ts.TypeFlags.String | ts.TypeFlags.Number | ts.TypeFlags.Boolean | ts.TypeFlags.StringLiteral | ts.TypeFlags.NumberLiteral | ts.TypeFlags.BooleanLiteral;
223
+ function hasFlag(type, flag) {
224
+ return (type.getFlags() & flag) !== 0;
225
+ }
226
+ function isPrimitive(type) {
227
+ return hasFlag(type, PRIMITIVE_FLAGS);
228
+ }
229
+ function isObjectType(type) {
230
+ return hasFlag(type, ts.TypeFlags.Object);
231
+ }
232
+ function isOptionalSymbol(sym) {
233
+ return (sym.flags & ts.SymbolFlags.Optional) !== 0;
234
+ }
235
+ /**
236
+ * If `type` is a branded intersection (primitive & object), return just the
237
+ * primitive part. Otherwise return the type as-is.
238
+ */
239
+ function unwrapBrand(type) {
240
+ if (!type.isIntersection()) return type;
241
+ const primitives = type.types.filter(isPrimitive);
242
+ const hasObject = type.types.some(isObjectType);
243
+ const [first] = primitives;
244
+ if (first && hasObject) return first;
245
+ return type;
246
+ }
247
+ const ANONYMOUS_NAMES = new Set([
248
+ "__type",
249
+ "__object",
250
+ "Object",
251
+ ""
252
+ ]);
253
+ /** Try to determine a meaningful name for a TS type (type alias or interface). */
254
+ function getTypeName(type) {
255
+ const aliasName = type.aliasSymbol?.getName();
256
+ if (aliasName && !ANONYMOUS_NAMES.has(aliasName)) return aliasName;
257
+ const symName = type.getSymbol()?.getName();
258
+ if (symName && !ANONYMOUS_NAMES.has(symName) && !symName.startsWith("__")) return symName;
259
+ return null;
260
+ }
261
+ function ensureUniqueName(name, existing) {
262
+ if (!(name in existing)) return name;
263
+ let i = 2;
264
+ while (`${name}${i}` in existing) i++;
265
+ return `${name}${i}`;
266
+ }
267
+ function schemaRef(name) {
268
+ return { $ref: `#/components/schemas/${name}` };
269
+ }
270
+ function isNonEmptySchema(s) {
271
+ for (const _ in s) return true;
272
+ return false;
273
+ }
274
+ /**
275
+ * Convert a TS type to a JSON Schema. If the type has been pre-registered
276
+ * (or has a meaningful TS name), it is stored in `ctx.schemas` and a `$ref`
277
+ * is returned instead of an inline schema.
278
+ */
279
+ function typeToJsonSchema(type, ctx, depth = 0) {
280
+ if (depth > 20) {
281
+ log.warn(`[openapi] Schema conversion reached maximum depth (20) for type "${ctx.checker.typeToString(type)}". The resulting schema will be incomplete.`);
282
+ return {};
283
+ }
284
+ const refName = ctx.typeToRef.get(type);
285
+ if (refName) {
286
+ if (refName in ctx.schemas) return schemaRef(refName);
287
+ ctx.schemas[refName] = {};
288
+ const schema$1 = convertTypeToSchema(type, ctx, depth);
289
+ ctx.schemas[refName] = schema$1;
290
+ return schemaRef(refName);
291
+ }
292
+ const schema = convertTypeToSchema(type, ctx, depth);
293
+ if (!schema.description && !schema.$ref && type.aliasSymbol) {
294
+ const aliasJsDoc = getJsDocComment(type.aliasSymbol, ctx.checker);
295
+ if (aliasJsDoc) schema.description = aliasJsDoc;
296
+ }
297
+ return schema;
298
+ }
299
+ /**
300
+ * When we encounter a type we're already visiting, it's recursive.
301
+ * Register it as a named schema and return a $ref.
302
+ */
303
+ function handleCyclicRef(type, ctx) {
304
+ let refName = ctx.typeToRef.get(type);
305
+ if (!refName) {
306
+ const name = getTypeName(type) ?? "RecursiveType";
307
+ refName = ensureUniqueName(name, ctx.schemas);
308
+ ctx.typeToRef.set(type, refName);
309
+ ctx.schemas[refName] = {};
310
+ }
311
+ return schemaRef(refName);
312
+ }
313
+ function convertPrimitiveOrLiteral(type, flags, checker) {
314
+ if (flags & ts.TypeFlags.String) return { type: "string" };
315
+ if (flags & ts.TypeFlags.Number) return { type: "number" };
316
+ if (flags & ts.TypeFlags.Boolean) return { type: "boolean" };
317
+ if (flags & ts.TypeFlags.Null) return { type: "null" };
318
+ if (flags & ts.TypeFlags.Undefined) return {};
319
+ if (flags & ts.TypeFlags.Void) return {};
320
+ if (flags & ts.TypeFlags.Any || flags & ts.TypeFlags.Unknown) return {};
321
+ if (flags & ts.TypeFlags.Never) return { not: {} };
322
+ if (flags & ts.TypeFlags.BigInt || flags & ts.TypeFlags.BigIntLiteral) return {
323
+ type: "integer",
324
+ format: "bigint"
325
+ };
326
+ if (flags & ts.TypeFlags.StringLiteral) return {
327
+ type: "string",
328
+ const: type.value
329
+ };
330
+ if (flags & ts.TypeFlags.NumberLiteral) return {
331
+ type: "number",
332
+ const: type.value
333
+ };
334
+ if (flags & ts.TypeFlags.BooleanLiteral) {
335
+ const isTrue = checker.typeToString(type) === "true";
336
+ return {
337
+ type: "boolean",
338
+ const: isTrue
339
+ };
340
+ }
341
+ return null;
342
+ }
343
+ function convertUnionType(type, ctx, depth) {
344
+ const members = type.types;
345
+ const defined = members.filter((m) => !hasFlag(m, ts.TypeFlags.Undefined | ts.TypeFlags.Void));
346
+ if (defined.length === 0) return {};
347
+ const hasNull = defined.some((m) => hasFlag(m, ts.TypeFlags.Null));
348
+ const nonNull = defined.filter((m) => !hasFlag(m, ts.TypeFlags.Null));
349
+ const boolLiterals = nonNull.filter((m) => hasFlag(unwrapBrand(m), ts.TypeFlags.BooleanLiteral));
350
+ const hasBoolPair = boolLiterals.length === 2 && boolLiterals.some((m) => ctx.checker.typeToString(unwrapBrand(m)) === "true") && boolLiterals.some((m) => ctx.checker.typeToString(unwrapBrand(m)) === "false");
351
+ const effective = hasBoolPair ? nonNull.filter((m) => !hasFlag(unwrapBrand(m), ts.TypeFlags.BooleanLiteral)) : nonNull;
352
+ if (hasBoolPair && effective.length === 0) return hasNull ? { type: ["boolean", "null"] } : { type: "boolean" };
353
+ const collapsedEnum = tryCollapseLiteralUnion(effective, hasNull);
354
+ if (collapsedEnum) return collapsedEnum;
355
+ const schemas = effective.map((m) => typeToJsonSchema(m, ctx, depth + 1)).filter(isNonEmptySchema);
356
+ if (hasBoolPair) schemas.push({ type: "boolean" });
357
+ if (hasNull) schemas.push({ type: "null" });
358
+ if (schemas.length === 0) return {};
359
+ const [firstSchema] = schemas;
360
+ if (schemas.length === 1 && firstSchema !== void 0) return firstSchema;
361
+ if (schemas.every(isSimpleTypeSchema)) return { type: schemas.map((s) => s.type) };
362
+ const discriminatorProp = detectDiscriminatorProperty(schemas);
363
+ if (discriminatorProp) return {
364
+ oneOf: schemas,
365
+ discriminator: { propertyName: discriminatorProp }
366
+ };
367
+ return { oneOf: schemas };
368
+ }
369
+ /**
370
+ * If every schema in a oneOf is an object with a common required property
371
+ * whose value is a `const`, return that property name. Otherwise return null.
372
+ */
373
+ function detectDiscriminatorProperty(schemas) {
374
+ if (schemas.length < 2) return null;
375
+ if (!schemas.every((s) => s.type === "object" && s.properties)) return null;
376
+ const first = schemas[0];
377
+ if (!first?.properties) return null;
378
+ const firstProps = Object.keys(first.properties);
379
+ for (const prop of firstProps) {
380
+ const allHaveConst = schemas.every((s) => {
381
+ const propSchema = s.properties?.[prop];
382
+ return propSchema !== void 0 && propSchema.const !== void 0 && s.required?.includes(prop);
383
+ });
384
+ if (allHaveConst) return prop;
385
+ }
386
+ return null;
387
+ }
388
+ /** A schema that is just `{ type: "somePrimitive" }` with no other keys. */
389
+ function isSimpleTypeSchema(s) {
390
+ const keys = Object.keys(s);
391
+ return keys.length === 1 && keys[0] === "type" && typeof s.type === "string";
392
+ }
393
+ /**
394
+ * If every non-null member is a string or number literal of the same kind,
395
+ * collapse them into a single `{ type, enum }` schema.
396
+ */
397
+ function tryCollapseLiteralUnion(nonNull, hasNull) {
398
+ if (nonNull.length <= 1) return null;
399
+ const allLiterals = nonNull.every((m) => hasFlag(m, ts.TypeFlags.StringLiteral | ts.TypeFlags.NumberLiteral));
400
+ if (!allLiterals) return null;
401
+ const [first] = nonNull;
402
+ if (!first) return null;
403
+ const isString = hasFlag(first, ts.TypeFlags.StringLiteral);
404
+ const targetFlag = isString ? ts.TypeFlags.StringLiteral : ts.TypeFlags.NumberLiteral;
405
+ const allSameKind = nonNull.every((m) => hasFlag(m, targetFlag));
406
+ if (!allSameKind) return null;
407
+ const values = nonNull.map((m) => isString ? m.value : m.value);
408
+ const baseType = isString ? "string" : "number";
409
+ return {
410
+ type: hasNull ? [baseType, "null"] : baseType,
411
+ enum: values
412
+ };
413
+ }
414
+ function convertIntersectionType(type, ctx, depth) {
415
+ const hasPrimitiveMember = type.types.some(isPrimitive);
416
+ const nonBrand = hasPrimitiveMember ? type.types.filter((m) => !isObjectType(m)) : type.types;
417
+ const schemas = nonBrand.map((m) => typeToJsonSchema(m, ctx, depth + 1)).filter(isNonEmptySchema);
418
+ if (schemas.length === 0) return {};
419
+ const [onlySchema] = schemas;
420
+ if (schemas.length === 1 && onlySchema !== void 0) return onlySchema;
421
+ if (schemas.every(isInlineObjectSchema)) return mergeObjectSchemas(schemas);
422
+ return { allOf: schemas };
423
+ }
424
+ /** True when the schema is an inline `{ type: "object", ... }` (not a $ref). */
425
+ function isInlineObjectSchema(s) {
426
+ return s.type === "object" && !s.$ref;
427
+ }
428
+ /**
429
+ * Merge multiple `{ type: "object" }` schemas into one.
430
+ * Falls back to `allOf` if any property names conflict across schemas.
431
+ */
432
+ function mergeObjectSchemas(schemas) {
433
+ const seen = /* @__PURE__ */ new Set();
434
+ for (const s of schemas) if (s.properties) for (const prop of Object.keys(s.properties)) {
435
+ if (seen.has(prop)) return { allOf: schemas };
436
+ seen.add(prop);
437
+ }
438
+ const properties = {};
439
+ const required = [];
440
+ let additionalProperties;
441
+ for (const s of schemas) {
442
+ if (s.properties) Object.assign(properties, s.properties);
443
+ if (s.required) required.push(...s.required);
444
+ if (s.additionalProperties !== void 0) additionalProperties = s.additionalProperties;
445
+ }
446
+ const result = { type: "object" };
447
+ if (Object.keys(properties).length > 0) result.properties = properties;
448
+ if (required.length > 0) result.required = required;
449
+ if (additionalProperties !== void 0) result.additionalProperties = additionalProperties;
450
+ return result;
451
+ }
452
+ function convertWellKnownType(type, ctx, depth) {
453
+ const symName = type.getSymbol()?.getName();
454
+ if (symName === "Date") return {
455
+ type: "string",
456
+ format: "date-time"
457
+ };
458
+ if (symName === "Uint8Array" || symName === "Buffer") return {
459
+ type: "string",
460
+ format: "binary"
461
+ };
462
+ if (symName === "Promise") {
463
+ const [inner] = ctx.checker.getTypeArguments(type);
464
+ return inner ? typeToJsonSchema(inner, ctx, depth + 1) : {};
465
+ }
466
+ return null;
467
+ }
468
+ function convertArrayType(type, ctx, depth) {
469
+ const [elem] = ctx.checker.getTypeArguments(type);
470
+ const schema = { type: "array" };
471
+ if (elem) schema.items = typeToJsonSchema(elem, ctx, depth + 1);
472
+ return schema;
473
+ }
474
+ function convertTupleType(type, ctx, depth) {
475
+ const args = ctx.checker.getTypeArguments(type);
476
+ const schemas = args.map((a) => typeToJsonSchema(a, ctx, depth + 1));
477
+ return {
478
+ type: "array",
479
+ prefixItems: schemas,
480
+ items: false,
481
+ minItems: args.length,
482
+ maxItems: args.length
483
+ };
484
+ }
485
+ function convertPlainObject(type, ctx, depth) {
486
+ const { checker } = ctx;
487
+ const stringIndexType = type.getStringIndexType();
488
+ const typeProps = type.getProperties();
489
+ if (typeProps.length === 0 && stringIndexType) return {
490
+ type: "object",
491
+ additionalProperties: typeToJsonSchema(stringIndexType, ctx, depth + 1)
492
+ };
493
+ let autoRegName = null;
494
+ const tsName = getTypeName(type);
495
+ const isNamedUnregisteredType = tsName !== null && typeProps.length > 0 && !ctx.typeToRef.has(type);
496
+ if (isNamedUnregisteredType) {
497
+ autoRegName = ensureUniqueName(tsName, ctx.schemas);
498
+ ctx.typeToRef.set(type, autoRegName);
499
+ ctx.schemas[autoRegName] = {};
500
+ }
501
+ ctx.visited.add(type);
502
+ const properties = {};
503
+ const required = [];
504
+ for (const prop of typeProps) {
505
+ const propType = checker.getTypeOfSymbol(prop);
506
+ const propSchema = typeToJsonSchema(propType, ctx, depth + 1);
507
+ const jsDoc = getJsDocComment(prop, checker);
508
+ if (jsDoc && !propSchema.description && !propSchema.$ref) propSchema.description = jsDoc;
509
+ properties[prop.name] = propSchema;
510
+ if (!isOptionalSymbol(prop)) required.push(prop.name);
511
+ }
512
+ ctx.visited.delete(type);
513
+ const result = { type: "object" };
514
+ if (Object.keys(properties).length > 0) result.properties = properties;
515
+ if (required.length > 0) result.required = required;
516
+ if (stringIndexType) result.additionalProperties = typeToJsonSchema(stringIndexType, ctx, depth + 1);
517
+ else if (Object.keys(properties).length > 0) result.additionalProperties = false;
518
+ const registeredName = autoRegName ?? ctx.typeToRef.get(type);
519
+ if (registeredName) {
520
+ ctx.schemas[registeredName] = result;
521
+ return schemaRef(registeredName);
522
+ }
523
+ return result;
524
+ }
525
+ function convertObjectType(type, ctx, depth) {
526
+ const wellKnown = convertWellKnownType(type, ctx, depth);
527
+ if (wellKnown) return wellKnown;
528
+ if (ctx.checker.isArrayType(type)) return convertArrayType(type, ctx, depth);
529
+ if (ctx.checker.isTupleType(type)) return convertTupleType(type, ctx, depth);
530
+ return convertPlainObject(type, ctx, depth);
531
+ }
532
+ /** Core type-to-schema conversion (no ref handling). */
533
+ function convertTypeToSchema(type, ctx, depth) {
534
+ if (ctx.visited.has(type)) return handleCyclicRef(type, ctx);
535
+ const flags = type.getFlags();
536
+ const primitive = convertPrimitiveOrLiteral(type, flags, ctx.checker);
537
+ if (primitive) return primitive;
538
+ if (type.isUnion()) return convertUnionType(type, ctx, depth);
539
+ if (type.isIntersection()) return convertIntersectionType(type, ctx, depth);
540
+ if (isObjectType(type)) return convertObjectType(type, ctx, depth);
541
+ return {};
542
+ }
543
+ /**
544
+ * Inspect `_def.type` and return the procedure type string, or null if this is
545
+ * not a procedure (e.g. a nested router).
546
+ */
547
+ function getProcedureTypeName(defType, checker) {
548
+ const typeSym = defType.getProperty("type");
549
+ if (!typeSym) return null;
550
+ const typeType = checker.getTypeOfSymbol(typeSym);
551
+ const raw = checker.typeToString(typeType).replace(/['"]/g, "");
552
+ if (raw === "query" || raw === "mutation" || raw === "subscription") return raw;
553
+ return null;
554
+ }
555
+ function isVoidLikeInput(inputType) {
556
+ if (!inputType) return true;
557
+ const isVoidOrUndefinedOrNever = hasFlag(inputType, ts.TypeFlags.Void | ts.TypeFlags.Undefined | ts.TypeFlags.Never);
558
+ if (isVoidOrUndefinedOrNever) return true;
559
+ const isUnionOfVoids = inputType.isUnion() && inputType.types.every((t) => hasFlag(t, ts.TypeFlags.Void | ts.TypeFlags.Undefined));
560
+ return isUnionOfVoids;
561
+ }
562
+ function extractProcedure(def, ctx) {
563
+ const { schemaCtx } = ctx;
564
+ const { checker } = schemaCtx;
565
+ const $typesSym = def.defType.getProperty("$types");
566
+ if (!$typesSym) return;
567
+ const $typesType = checker.getTypeOfSymbol($typesSym);
568
+ const inputSym = $typesType.getProperty("input");
569
+ const outputSym = $typesType.getProperty("output");
570
+ const inputType = inputSym ? checker.getTypeOfSymbol(inputSym) : null;
571
+ const outputType = outputSym ? checker.getTypeOfSymbol(outputSym) : null;
572
+ const inputSchema = !inputType || isVoidLikeInput(inputType) ? null : typeToJsonSchema(inputType, schemaCtx);
573
+ const outputSchema = outputType ? typeToJsonSchema(outputType, schemaCtx) : null;
574
+ const runtimeDescs = ctx.runtimeDescriptions.get(def.path);
575
+ if (runtimeDescs) {
576
+ if (inputSchema && runtimeDescs.input) applyDescriptions(inputSchema, runtimeDescs.input);
577
+ if (outputSchema && runtimeDescs.output) applyDescriptions(outputSchema, runtimeDescs.output);
578
+ }
579
+ ctx.procedures.push({
580
+ path: def.path,
581
+ type: def.typeName,
582
+ inputSchema,
583
+ outputSchema,
584
+ description: def.description
585
+ });
586
+ }
587
+ /** Extract the JSDoc comment text from a symbol, if any. */
588
+ function getJsDocComment(sym, checker) {
589
+ const parts = sym.getDocumentationComment(checker);
590
+ if (parts.length === 0) return void 0;
591
+ const text = parts.map((p) => p.text).join("");
592
+ return text || void 0;
593
+ }
594
+ function walkType(opts) {
595
+ const { type, ctx, currentPath, description } = opts;
596
+ if (ctx.seen.has(type)) return;
597
+ const defSym = type.getProperty("_def");
598
+ if (!defSym) {
599
+ if (isObjectType(type)) {
600
+ ctx.seen.add(type);
601
+ walkRecord(type, ctx, currentPath);
602
+ ctx.seen.delete(type);
603
+ }
604
+ return;
605
+ }
606
+ const { checker } = ctx.schemaCtx;
607
+ const defType = checker.getTypeOfSymbol(defSym);
608
+ const procedureTypeName = getProcedureTypeName(defType, checker);
609
+ if (procedureTypeName) {
610
+ extractProcedure({
611
+ defType,
612
+ typeName: procedureTypeName,
613
+ path: currentPath,
614
+ description
615
+ }, ctx);
616
+ return;
617
+ }
618
+ const routerSym = defType.getProperty("router");
619
+ if (!routerSym) return;
620
+ const isRouter = checker.typeToString(checker.getTypeOfSymbol(routerSym)) === "true";
621
+ if (!isRouter) return;
622
+ const recordSym = defType.getProperty("record");
623
+ if (!recordSym) return;
624
+ ctx.seen.add(type);
625
+ const recordType = checker.getTypeOfSymbol(recordSym);
626
+ walkRecord(recordType, ctx, currentPath);
627
+ ctx.seen.delete(type);
628
+ }
629
+ function walkRecord(recordType, ctx, prefix) {
630
+ for (const prop of recordType.getProperties()) {
631
+ const propType = ctx.schemaCtx.checker.getTypeOfSymbol(prop);
632
+ const fullPath = prefix ? `${prefix}.${prop.name}` : prop.name;
633
+ const description = getJsDocComment(prop, ctx.schemaCtx.checker);
634
+ walkType({
635
+ type: propType,
636
+ ctx,
637
+ currentPath: fullPath,
638
+ description
639
+ });
640
+ }
641
+ }
642
+ function loadCompilerOptions(startDir) {
643
+ const configPath = ts.findConfigFile(startDir, (f) => ts.sys.fileExists(f), "tsconfig.json");
644
+ if (!configPath) return {
645
+ target: ts.ScriptTarget.ES2020,
646
+ moduleResolution: ts.ModuleResolutionKind.Bundler,
647
+ skipLibCheck: true,
648
+ noEmit: true
649
+ };
650
+ const configFile = ts.readConfigFile(configPath, (f) => ts.sys.readFile(f));
651
+ const parsed = ts.parseJsonConfigFileContent(configFile.config, ts.sys, path$1.dirname(configPath));
652
+ const options = {
653
+ ...parsed.options,
654
+ noEmit: true
655
+ };
656
+ if (options.moduleResolution === void 0) {
657
+ const mod = options.module;
658
+ if (mod === ts.ModuleKind.Node16 || mod === ts.ModuleKind.NodeNext) options.moduleResolution = ts.ModuleResolutionKind.NodeNext;
659
+ else if (mod === ts.ModuleKind.Preserve || mod === ts.ModuleKind.ES2022 || mod === ts.ModuleKind.ESNext) options.moduleResolution = ts.ModuleResolutionKind.Bundler;
660
+ else options.moduleResolution = ts.ModuleResolutionKind.Node10;
661
+ }
662
+ return options;
663
+ }
664
+ /**
665
+ * Walk `_def._config.$types.errorShape` on the router type and convert
666
+ * it to a JSON Schema. Returns `null` when the path cannot be resolved
667
+ * (e.g. older tRPC versions or missing type info).
668
+ */
669
+ function extractErrorSchema(routerType, checker, schemaCtx) {
670
+ const walk = (type, keys) => {
671
+ const [head, ...rest] = keys;
672
+ if (!head) return type;
673
+ const sym = type.getProperty(head);
674
+ if (!sym) return null;
675
+ return walk(checker.getTypeOfSymbol(sym), rest);
676
+ };
677
+ const errorShapeType = walk(routerType, [
678
+ "_def",
679
+ "_config",
680
+ "$types",
681
+ "errorShape"
682
+ ]);
683
+ if (!errorShapeType) return null;
684
+ if (hasFlag(errorShapeType, ts.TypeFlags.Any)) return null;
685
+ return typeToJsonSchema(errorShapeType, schemaCtx);
686
+ }
687
+ /** Fallback error schema when the router type doesn't expose an error shape. */
688
+ const DEFAULT_ERROR_SCHEMA = {
689
+ type: "object",
690
+ properties: {
691
+ message: { type: "string" },
692
+ code: { type: "string" },
693
+ data: { type: "object" }
694
+ },
695
+ required: ["message", "code"]
696
+ };
697
+ /**
698
+ * Wrap a procedure's output schema in the tRPC success envelope.
699
+ *
700
+ * tRPC HTTP responses are always serialised as:
701
+ * `{ result: { data: T } }`
702
+ *
703
+ * When the procedure has no output the envelope is still present but
704
+ * the `data` property is omitted.
705
+ */
706
+ function wrapInSuccessEnvelope(outputSchema) {
707
+ const hasOutput = outputSchema !== null && isNonEmptySchema(outputSchema);
708
+ const resultSchema = {
709
+ type: "object",
710
+ properties: { ...hasOutput ? { data: outputSchema } : {} },
711
+ ...hasOutput ? { required: ["data"] } : {}
712
+ };
713
+ return {
714
+ type: "object",
715
+ properties: { result: resultSchema },
716
+ required: ["result"]
717
+ };
718
+ }
719
+ function buildProcedureOperation(proc, method) {
720
+ const operation = {
721
+ operationId: proc.path,
722
+ ...proc.description ? { description: proc.description } : {},
723
+ tags: [proc.path.split(".")[0]],
724
+ responses: {
725
+ "200": {
726
+ description: "Successful response",
727
+ content: { "application/json": { schema: wrapInSuccessEnvelope(proc.outputSchema) } }
728
+ },
729
+ default: { $ref: "#/components/responses/Error" }
730
+ }
731
+ };
732
+ if (proc.inputSchema === null) return operation;
733
+ if (method === "get") operation["parameters"] = [{
734
+ name: "input",
735
+ in: "query",
736
+ required: true,
737
+ style: "deepObject",
738
+ content: { "application/json": { schema: proc.inputSchema } }
739
+ }];
740
+ else operation["requestBody"] = {
741
+ required: true,
742
+ content: { "application/json": { schema: proc.inputSchema } }
743
+ };
744
+ return operation;
745
+ }
746
+ function buildOpenAPIDocument(procedures, options, meta = { errorSchema: null }) {
747
+ const paths = {};
748
+ for (const proc of procedures) {
749
+ if (proc.type === "subscription") continue;
750
+ const opPath = `/${proc.path}`;
751
+ const method = proc.type === "query" ? "get" : "post";
752
+ const pathItem = paths[opPath] ?? {};
753
+ paths[opPath] = pathItem;
754
+ pathItem[method] = buildProcedureOperation(proc, method);
755
+ }
756
+ const hasNamedSchemas = meta.schemas !== void 0 && Object.keys(meta.schemas).length > 0;
757
+ return {
758
+ openapi: "3.1.1",
759
+ jsonSchemaDialect: "https://spec.openapis.org/oas/3.1/dialect/base",
760
+ info: {
761
+ title: options.title ?? "tRPC API",
762
+ version: options.version ?? "0.0.0"
763
+ },
764
+ paths,
765
+ components: {
766
+ ...hasNamedSchemas ? { schemas: meta.schemas } : {},
767
+ responses: { Error: {
768
+ description: "Error response",
769
+ content: { "application/json": { schema: {
770
+ type: "object",
771
+ properties: { error: meta.errorSchema ?? DEFAULT_ERROR_SCHEMA },
772
+ required: ["error"]
773
+ } } }
774
+ } }
775
+ }
776
+ };
777
+ }
778
+ /**
779
+ * Analyse the given TypeScript router file using the TypeScript compiler and
780
+ * return an OpenAPI 3.1 document describing all query and mutation procedures.
781
+ *
782
+ * @param routerFilePath - Absolute or relative path to the file that exports
783
+ * the AppRouter.
784
+ * @param options - Optional generation settings (export name, title, version).
785
+ */
786
+ async function generateOpenAPIDocument(routerFilePath, options = {}) {
787
+ const resolvedPath = path$1.resolve(routerFilePath);
788
+ const exportName = options.exportName ?? "AppRouter";
789
+ const compilerOptions = loadCompilerOptions(path$1.dirname(resolvedPath));
790
+ const program = ts.createProgram([resolvedPath], compilerOptions);
791
+ const checker = program.getTypeChecker();
792
+ const sourceFile = program.getSourceFile(resolvedPath);
793
+ if (!sourceFile) throw new Error(`Could not load TypeScript file: ${resolvedPath}`);
794
+ const moduleSymbol = checker.getSymbolAtLocation(sourceFile);
795
+ if (!moduleSymbol) throw new Error(`No module exports found in: ${resolvedPath}`);
796
+ const tsExports = checker.getExportsOfModule(moduleSymbol);
797
+ const routerSymbol = tsExports.find((sym) => sym.getName() === exportName);
798
+ if (!routerSymbol) {
799
+ const available = tsExports.map((e) => e.getName()).join(", ");
800
+ throw new Error(`No export named '${exportName}' found in: ${resolvedPath}\nAvailable exports: ${available || "(none)"}`);
801
+ }
802
+ let routerType;
803
+ if (routerSymbol.valueDeclaration) routerType = checker.getTypeOfSymbolAtLocation(routerSymbol, routerSymbol.valueDeclaration);
804
+ else routerType = checker.getDeclaredTypeOfSymbol(routerSymbol);
805
+ const schemaCtx = {
806
+ checker,
807
+ visited: /* @__PURE__ */ new Set(),
808
+ schemas: {},
809
+ typeToRef: /* @__PURE__ */ new Map()
810
+ };
811
+ const runtimeDescriptions = /* @__PURE__ */ new Map();
812
+ const router = await tryImportRouter(resolvedPath, exportName);
813
+ if (router) collectRuntimeDescriptions(router, "", runtimeDescriptions);
814
+ const walkCtx = {
815
+ procedures: [],
816
+ seen: /* @__PURE__ */ new Set(),
817
+ schemaCtx,
818
+ runtimeDescriptions
819
+ };
820
+ walkType({
821
+ type: routerType,
822
+ ctx: walkCtx,
823
+ currentPath: ""
824
+ });
825
+ const errorSchema = extractErrorSchema(routerType, checker, schemaCtx);
826
+ return buildOpenAPIDocument(walkCtx.procedures, options, {
827
+ errorSchema,
828
+ schemas: schemaCtx.schemas
829
+ });
830
+ }
831
+
832
+ //#endregion
833
+ //#region src/cli.ts
834
+ function parseArgs$1(argv) {
835
+ let parsed;
836
+ try {
837
+ parsed = parseArgs({
838
+ args: argv.slice(2),
839
+ options: {
840
+ help: {
841
+ type: "boolean",
842
+ short: "h",
843
+ default: false
844
+ },
845
+ export: {
846
+ type: "string",
847
+ short: "e",
848
+ default: "AppRouter"
849
+ },
850
+ output: {
851
+ type: "string",
852
+ short: "o",
853
+ default: "openapi.json"
854
+ },
855
+ title: {
856
+ type: "string",
857
+ default: "tRPC API"
858
+ },
859
+ version: {
860
+ type: "string",
861
+ default: "0.0.0"
862
+ }
863
+ },
864
+ strict: true,
865
+ allowPositionals: true
866
+ });
867
+ } catch (err) {
868
+ const message = err instanceof Error ? err.message : String(err);
869
+ console.error(`Unknown option: ${message}`);
870
+ process.exit(1);
871
+ }
872
+ return {
873
+ file: parsed.positionals[0],
874
+ exportName: parsed.values.export,
875
+ output: parsed.values.output,
876
+ title: parsed.values.title,
877
+ version: parsed.values.version,
878
+ help: parsed.values.help
879
+ };
880
+ }
881
+ const HELP = `
882
+ trpc-openapi – Generate an OpenAPI 3.1 document from a tRPC AppRouter type.
883
+
884
+ Usage:
885
+ trpc-openapi <router-file> [options]
886
+
887
+ Arguments:
888
+ router-file Path to the TypeScript file that exports the router.
889
+
890
+ Options:
891
+ -e, --export <name> Name of the exported router symbol [default: AppRouter]
892
+ -o, --output <file> Output file path [default: openapi.json]
893
+ --title <text> OpenAPI info.title [default: tRPC API]
894
+ --version <ver> OpenAPI info.version [default: 0.0.0]
895
+ -h, --help Show this help message
896
+
897
+ Examples:
898
+ trpc-openapi ./src/server/router.ts
899
+ trpc-openapi ./src/server/router.ts --output api.json --title "My API" --version 1.0.0
900
+ trpc-openapi ./src/server/router.ts --export appRouter
901
+ `.trim();
902
+ async function main() {
903
+ const args = parseArgs$1(process.argv);
904
+ if (args.help) {
905
+ console.log(HELP);
906
+ process.exit(0);
907
+ }
908
+ if (!args.file) {
909
+ console.error("Error: router-file argument is required.\n");
910
+ console.error(HELP);
911
+ process.exit(1);
912
+ }
913
+ const routerFile = path.resolve(args.file);
914
+ if (!fs.existsSync(routerFile)) {
915
+ console.error(`Error: File not found: ${routerFile}`);
916
+ process.exit(1);
917
+ }
918
+ console.log(`Generating OpenAPI document from: ${routerFile}`);
919
+ let doc;
920
+ try {
921
+ doc = await generateOpenAPIDocument(routerFile, {
922
+ exportName: args.exportName,
923
+ title: args.title,
924
+ version: args.version
925
+ });
926
+ } catch (err) {
927
+ const message = err instanceof Error ? err.message : String(err);
928
+ console.error(`Error: ${message}`);
929
+ process.exit(1);
930
+ }
931
+ const outputPath = path.resolve(args.output);
932
+ fs.writeFileSync(outputPath, JSON.stringify(doc, null, 2) + "\n", "utf8");
933
+ console.log(`OpenAPI document written to: ${outputPath}`);
934
+ }
935
+ main();
936
+
937
+ //#endregion