@trpc/openapi 11.15.1-alpha → 11.15.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/README.md +1 -1
- package/dist/chunk-CJ2cON1m.mjs +33 -0
- package/dist/cli.js +219 -38
- package/dist/heyapi/index.mjs +2 -1
- package/dist/heyapi/index.mjs.map +1 -1
- package/dist/index.cjs +244 -39
- package/dist/index.d.cts +93 -35
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +94 -35
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +241 -41
- package/dist/index.mjs.map +1 -1
- package/dist/{objectSpread2-UxrN8MPM.mjs → objectSpread2-Blgb1OZh.mjs} +3 -27
- package/dist/{objectSpread2-UxrN8MPM.mjs.map → objectSpread2-Blgb1OZh.mjs.map} +1 -1
- package/package.json +8 -4
- package/skills/openapi/SKILL.md +1 -1
- package/src/generate.ts +393 -112
- package/src/index.ts +2 -1
- package/src/schemaExtraction.ts +99 -29
- package/src/types.ts +151 -0
package/src/generate.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
1
2
|
import * as path from 'node:path';
|
|
2
3
|
import * as ts from 'typescript';
|
|
3
4
|
import {
|
|
@@ -6,47 +7,26 @@ import {
|
|
|
6
7
|
tryImportRouter,
|
|
7
8
|
type RuntimeDescriptions,
|
|
8
9
|
} from './schemaExtraction';
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
}
|
|
10
|
+
import type {
|
|
11
|
+
Document,
|
|
12
|
+
OperationObject,
|
|
13
|
+
PathItemObject,
|
|
14
|
+
PathsObject,
|
|
15
|
+
SchemaObject,
|
|
16
|
+
} from './types';
|
|
37
17
|
|
|
38
18
|
interface ProcedureInfo {
|
|
39
19
|
path: string;
|
|
40
20
|
type: 'query' | 'mutation' | 'subscription';
|
|
41
|
-
inputSchema:
|
|
42
|
-
outputSchema:
|
|
21
|
+
inputSchema: SchemaObject | null;
|
|
22
|
+
outputSchema: SchemaObject | null;
|
|
43
23
|
description?: string;
|
|
44
24
|
}
|
|
45
25
|
|
|
46
26
|
/** State extracted from the router's root config. */
|
|
47
27
|
interface RouterMeta {
|
|
48
|
-
errorSchema:
|
|
49
|
-
schemas?: Record<string,
|
|
28
|
+
errorSchema: SchemaObject | null;
|
|
29
|
+
schemas?: Record<string, SchemaObject>;
|
|
50
30
|
}
|
|
51
31
|
|
|
52
32
|
export interface GenerateOptions {
|
|
@@ -61,14 +41,6 @@ export interface GenerateOptions {
|
|
|
61
41
|
version?: string;
|
|
62
42
|
}
|
|
63
43
|
|
|
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
44
|
// ---------------------------------------------------------------------------
|
|
73
45
|
// Flag helpers
|
|
74
46
|
// ---------------------------------------------------------------------------
|
|
@@ -106,7 +78,7 @@ interface SchemaCtx {
|
|
|
106
78
|
checker: ts.TypeChecker;
|
|
107
79
|
visited: Set<ts.Type>;
|
|
108
80
|
/** Collected named schemas for components/schemas. */
|
|
109
|
-
schemas: Record<string,
|
|
81
|
+
schemas: Record<string, SchemaObject>;
|
|
110
82
|
/** Map from TS type identity to its registered schema name. */
|
|
111
83
|
typeToRef: Map<ts.Type, string>;
|
|
112
84
|
}
|
|
@@ -137,6 +109,7 @@ function unwrapBrand(type: ts.Type): ts.Type {
|
|
|
137
109
|
// ---------------------------------------------------------------------------
|
|
138
110
|
|
|
139
111
|
const ANONYMOUS_NAMES = new Set(['__type', '__object', 'Object', '']);
|
|
112
|
+
const INTERNAL_COMPUTED_PROPERTY_SYMBOL = /^__@.*@\d+$/;
|
|
140
113
|
|
|
141
114
|
/** Try to determine a meaningful name for a TS type (type alias or interface). */
|
|
142
115
|
function getTypeName(type: ts.Type): string | null {
|
|
@@ -151,6 +124,34 @@ function getTypeName(type: ts.Type): string | null {
|
|
|
151
124
|
return null;
|
|
152
125
|
}
|
|
153
126
|
|
|
127
|
+
// Skips asyncGenerator and branded symbols etc when creating types
|
|
128
|
+
// Symbols can't be serialised
|
|
129
|
+
function shouldSkipPropertySymbol(prop: ts.Symbol): boolean {
|
|
130
|
+
return (
|
|
131
|
+
prop.declarations?.some((declaration) => {
|
|
132
|
+
const declarationName = ts.getNameOfDeclaration(declaration);
|
|
133
|
+
if (!declarationName || !ts.isComputedPropertyName(declarationName)) {
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return INTERNAL_COMPUTED_PROPERTY_SYMBOL.test(prop.getName());
|
|
138
|
+
}) ?? false
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function getReferencedSchema(
|
|
143
|
+
schema: SchemaObject | null,
|
|
144
|
+
schemas: Record<string, SchemaObject>,
|
|
145
|
+
): SchemaObject | null {
|
|
146
|
+
const ref = schema?.$ref;
|
|
147
|
+
if (!ref?.startsWith('#/components/schemas/')) {
|
|
148
|
+
return schema;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const refName = ref.slice('#/components/schemas/'.length);
|
|
152
|
+
return refName ? (schemas[refName] ?? null) : schema;
|
|
153
|
+
}
|
|
154
|
+
|
|
154
155
|
function ensureUniqueName(
|
|
155
156
|
name: string,
|
|
156
157
|
existing: Record<string, unknown>,
|
|
@@ -165,11 +166,15 @@ function ensureUniqueName(
|
|
|
165
166
|
return `${name}${i}`;
|
|
166
167
|
}
|
|
167
168
|
|
|
168
|
-
function schemaRef(name: string):
|
|
169
|
+
function schemaRef(name: string): SchemaObject {
|
|
169
170
|
return { $ref: `#/components/schemas/${name}` };
|
|
170
171
|
}
|
|
171
172
|
|
|
172
|
-
function
|
|
173
|
+
function isSelfSchemaRef(schema: SchemaObject, name: string): boolean {
|
|
174
|
+
return schema.$ref === schemaRef(name).$ref;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function isNonEmptySchema(s: SchemaObject): boolean {
|
|
173
178
|
for (const _ in s) return true;
|
|
174
179
|
return false;
|
|
175
180
|
}
|
|
@@ -182,34 +187,56 @@ function isNonEmptySchema(s: JsonSchema): boolean {
|
|
|
182
187
|
* Convert a TS type to a JSON Schema. If the type has been pre-registered
|
|
183
188
|
* (or has a meaningful TS name), it is stored in `ctx.schemas` and a `$ref`
|
|
184
189
|
* is returned instead of an inline schema.
|
|
190
|
+
*
|
|
191
|
+
* Named types (type aliases, interfaces) are auto-registered before conversion
|
|
192
|
+
* so that recursive references (including through unions and intersections)
|
|
193
|
+
* resolve to a `$ref` instead of causing infinite recursion.
|
|
185
194
|
*/
|
|
186
195
|
function typeToJsonSchema(
|
|
187
196
|
type: ts.Type,
|
|
188
197
|
ctx: SchemaCtx,
|
|
189
198
|
depth = 0,
|
|
190
|
-
):
|
|
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
|
-
|
|
199
|
+
): SchemaObject {
|
|
198
200
|
// If this type is already registered as a named schema, return a $ref.
|
|
199
|
-
const
|
|
200
|
-
if (
|
|
201
|
-
|
|
202
|
-
|
|
201
|
+
const existingRef = ctx.typeToRef.get(type);
|
|
202
|
+
if (existingRef) {
|
|
203
|
+
const storedSchema = ctx.schemas[existingRef];
|
|
204
|
+
if (
|
|
205
|
+
storedSchema &&
|
|
206
|
+
(isNonEmptySchema(storedSchema) || ctx.visited.has(type))
|
|
207
|
+
) {
|
|
208
|
+
return schemaRef(existingRef);
|
|
203
209
|
}
|
|
204
|
-
|
|
205
|
-
|
|
210
|
+
|
|
211
|
+
// First encounter for a pre-registered placeholder: convert once, but keep
|
|
212
|
+
// returning $ref for recursive edges while the type is actively visiting.
|
|
213
|
+
ctx.schemas[existingRef] = storedSchema ?? {};
|
|
206
214
|
const schema = convertTypeToSchema(type, ctx, depth);
|
|
207
|
-
|
|
208
|
-
|
|
215
|
+
if (!isSelfSchemaRef(schema, existingRef)) {
|
|
216
|
+
ctx.schemas[existingRef] = schema;
|
|
217
|
+
}
|
|
218
|
+
return schemaRef(existingRef);
|
|
209
219
|
}
|
|
210
220
|
|
|
211
221
|
const schema = convertTypeToSchema(type, ctx, depth);
|
|
212
222
|
|
|
223
|
+
// If a recursive reference was detected during conversion (via handleCyclicRef
|
|
224
|
+
// or convertPlainObject's auto-registration), the type is now registered in
|
|
225
|
+
// typeToRef. If the stored schema is still the empty placeholder, fill it in
|
|
226
|
+
// with the actual converted schema. Either way, return a $ref.
|
|
227
|
+
const postConvertRef = ctx.typeToRef.get(type);
|
|
228
|
+
if (postConvertRef) {
|
|
229
|
+
const stored = ctx.schemas[postConvertRef];
|
|
230
|
+
if (
|
|
231
|
+
stored &&
|
|
232
|
+
!isNonEmptySchema(stored) &&
|
|
233
|
+
!isSelfSchemaRef(schema, postConvertRef)
|
|
234
|
+
) {
|
|
235
|
+
ctx.schemas[postConvertRef] = schema;
|
|
236
|
+
}
|
|
237
|
+
return schemaRef(postConvertRef);
|
|
238
|
+
}
|
|
239
|
+
|
|
213
240
|
// Extract JSDoc from type alias symbol (e.g. `/** desc */ type Foo = string`)
|
|
214
241
|
if (!schema.description && !schema.$ref && type.aliasSymbol) {
|
|
215
242
|
const aliasJsDoc = getJsDocComment(type.aliasSymbol, ctx.checker);
|
|
@@ -229,7 +256,7 @@ function typeToJsonSchema(
|
|
|
229
256
|
* When we encounter a type we're already visiting, it's recursive.
|
|
230
257
|
* Register it as a named schema and return a $ref.
|
|
231
258
|
*/
|
|
232
|
-
function handleCyclicRef(type: ts.Type, ctx: SchemaCtx):
|
|
259
|
+
function handleCyclicRef(type: ts.Type, ctx: SchemaCtx): SchemaObject {
|
|
233
260
|
let refName = ctx.typeToRef.get(type);
|
|
234
261
|
if (!refName) {
|
|
235
262
|
const name = getTypeName(type) ?? 'RecursiveType';
|
|
@@ -248,7 +275,7 @@ function convertPrimitiveOrLiteral(
|
|
|
248
275
|
type: ts.Type,
|
|
249
276
|
flags: ts.TypeFlags,
|
|
250
277
|
checker: ts.TypeChecker,
|
|
251
|
-
):
|
|
278
|
+
): SchemaObject | null {
|
|
252
279
|
if (flags & ts.TypeFlags.String) {
|
|
253
280
|
return { type: 'string' };
|
|
254
281
|
}
|
|
@@ -299,7 +326,7 @@ function convertUnionType(
|
|
|
299
326
|
type: ts.UnionType,
|
|
300
327
|
ctx: SchemaCtx,
|
|
301
328
|
depth: number,
|
|
302
|
-
):
|
|
329
|
+
): SchemaObject {
|
|
303
330
|
const members = type.types;
|
|
304
331
|
|
|
305
332
|
// Strip undefined / void members (they make the field optional, not typed)
|
|
@@ -392,7 +419,7 @@ function convertUnionType(
|
|
|
392
419
|
* If every schema in a oneOf is an object with a common required property
|
|
393
420
|
* whose value is a `const`, return that property name. Otherwise return null.
|
|
394
421
|
*/
|
|
395
|
-
function detectDiscriminatorProperty(schemas:
|
|
422
|
+
function detectDiscriminatorProperty(schemas: SchemaObject[]): string | null {
|
|
396
423
|
if (schemas.length < 2) {
|
|
397
424
|
return null;
|
|
398
425
|
}
|
|
@@ -426,7 +453,7 @@ function detectDiscriminatorProperty(schemas: JsonSchema[]): string | null {
|
|
|
426
453
|
}
|
|
427
454
|
|
|
428
455
|
/** A schema that is just `{ type: "somePrimitive" }` with no other keys. */
|
|
429
|
-
function isSimpleTypeSchema(s:
|
|
456
|
+
function isSimpleTypeSchema(s: SchemaObject): boolean {
|
|
430
457
|
const keys = Object.keys(s);
|
|
431
458
|
return keys.length === 1 && keys[0] === 'type' && typeof s.type === 'string';
|
|
432
459
|
}
|
|
@@ -438,7 +465,7 @@ function isSimpleTypeSchema(s: JsonSchema): boolean {
|
|
|
438
465
|
function tryCollapseLiteralUnion(
|
|
439
466
|
nonNull: ts.Type[],
|
|
440
467
|
hasNull: boolean,
|
|
441
|
-
):
|
|
468
|
+
): SchemaObject | null {
|
|
442
469
|
if (nonNull.length <= 1) {
|
|
443
470
|
return null;
|
|
444
471
|
}
|
|
@@ -484,7 +511,7 @@ function convertIntersectionType(
|
|
|
484
511
|
type: ts.IntersectionType,
|
|
485
512
|
ctx: SchemaCtx,
|
|
486
513
|
depth: number,
|
|
487
|
-
):
|
|
514
|
+
): SchemaObject {
|
|
488
515
|
// Branded types (e.g. z.string().brand<'X'>()) appear as an intersection of
|
|
489
516
|
// a primitive with a phantom object. Strip the object members — they are
|
|
490
517
|
// always brand metadata.
|
|
@@ -515,7 +542,7 @@ function convertIntersectionType(
|
|
|
515
542
|
}
|
|
516
543
|
|
|
517
544
|
/** True when the schema is an inline `{ type: "object", ... }` (not a $ref). */
|
|
518
|
-
function isInlineObjectSchema(s:
|
|
545
|
+
function isInlineObjectSchema(s: SchemaObject): boolean {
|
|
519
546
|
return s.type === 'object' && !s.$ref;
|
|
520
547
|
}
|
|
521
548
|
|
|
@@ -523,7 +550,7 @@ function isInlineObjectSchema(s: JsonSchema): boolean {
|
|
|
523
550
|
* Merge multiple `{ type: "object" }` schemas into one.
|
|
524
551
|
* Falls back to `allOf` if any property names conflict across schemas.
|
|
525
552
|
*/
|
|
526
|
-
function mergeObjectSchemas(schemas:
|
|
553
|
+
function mergeObjectSchemas(schemas: SchemaObject[]): SchemaObject {
|
|
527
554
|
// Check for property name conflicts before merging.
|
|
528
555
|
const seen = new Set<string>();
|
|
529
556
|
for (const s of schemas) {
|
|
@@ -538,9 +565,9 @@ function mergeObjectSchemas(schemas: JsonSchema[]): JsonSchema {
|
|
|
538
565
|
}
|
|
539
566
|
}
|
|
540
567
|
|
|
541
|
-
const properties: Record<string,
|
|
568
|
+
const properties: Record<string, SchemaObject> = {};
|
|
542
569
|
const required: string[] = [];
|
|
543
|
-
let additionalProperties:
|
|
570
|
+
let additionalProperties: SchemaObject | boolean | undefined;
|
|
544
571
|
|
|
545
572
|
for (const s of schemas) {
|
|
546
573
|
if (s.properties) {
|
|
@@ -554,7 +581,7 @@ function mergeObjectSchemas(schemas: JsonSchema[]): JsonSchema {
|
|
|
554
581
|
}
|
|
555
582
|
}
|
|
556
583
|
|
|
557
|
-
const result:
|
|
584
|
+
const result: SchemaObject = { type: 'object' };
|
|
558
585
|
if (Object.keys(properties).length > 0) {
|
|
559
586
|
result.properties = properties;
|
|
560
587
|
}
|
|
@@ -575,7 +602,7 @@ function convertWellKnownType(
|
|
|
575
602
|
type: ts.Type,
|
|
576
603
|
ctx: SchemaCtx,
|
|
577
604
|
depth: number,
|
|
578
|
-
):
|
|
605
|
+
): SchemaObject | null {
|
|
579
606
|
const symName = type.getSymbol()?.getName();
|
|
580
607
|
if (symName === 'Date') {
|
|
581
608
|
return { type: 'string', format: 'date-time' };
|
|
@@ -597,9 +624,9 @@ function convertArrayType(
|
|
|
597
624
|
type: ts.Type,
|
|
598
625
|
ctx: SchemaCtx,
|
|
599
626
|
depth: number,
|
|
600
|
-
):
|
|
627
|
+
): SchemaObject {
|
|
601
628
|
const [elem] = ctx.checker.getTypeArguments(type as ts.TypeReference);
|
|
602
|
-
const schema:
|
|
629
|
+
const schema: SchemaObject = { type: 'array' };
|
|
603
630
|
if (elem) {
|
|
604
631
|
schema.items = typeToJsonSchema(elem, ctx, depth + 1);
|
|
605
632
|
}
|
|
@@ -610,7 +637,7 @@ function convertTupleType(
|
|
|
610
637
|
type: ts.Type,
|
|
611
638
|
ctx: SchemaCtx,
|
|
612
639
|
depth: number,
|
|
613
|
-
):
|
|
640
|
+
): SchemaObject {
|
|
614
641
|
const args = ctx.checker.getTypeArguments(type as ts.TypeReference);
|
|
615
642
|
const schemas = args.map((a) => typeToJsonSchema(a, ctx, depth + 1));
|
|
616
643
|
return {
|
|
@@ -626,7 +653,7 @@ function convertPlainObject(
|
|
|
626
653
|
type: ts.Type,
|
|
627
654
|
ctx: SchemaCtx,
|
|
628
655
|
depth: number,
|
|
629
|
-
):
|
|
656
|
+
): SchemaObject {
|
|
630
657
|
const { checker } = ctx;
|
|
631
658
|
const stringIndexType = type.getStringIndexType();
|
|
632
659
|
const typeProps = type.getProperties();
|
|
@@ -653,10 +680,14 @@ function convertPlainObject(
|
|
|
653
680
|
}
|
|
654
681
|
|
|
655
682
|
ctx.visited.add(type);
|
|
656
|
-
const properties: Record<string,
|
|
683
|
+
const properties: Record<string, SchemaObject> = {};
|
|
657
684
|
const required: string[] = [];
|
|
658
685
|
|
|
659
686
|
for (const prop of typeProps) {
|
|
687
|
+
if (shouldSkipPropertySymbol(prop)) {
|
|
688
|
+
continue;
|
|
689
|
+
}
|
|
690
|
+
|
|
660
691
|
const propType = checker.getTypeOfSymbol(prop);
|
|
661
692
|
const propSchema = typeToJsonSchema(propType, ctx, depth + 1);
|
|
662
693
|
|
|
@@ -674,7 +705,7 @@ function convertPlainObject(
|
|
|
674
705
|
|
|
675
706
|
ctx.visited.delete(type);
|
|
676
707
|
|
|
677
|
-
const result:
|
|
708
|
+
const result: SchemaObject = { type: 'object' };
|
|
678
709
|
if (Object.keys(properties).length > 0) {
|
|
679
710
|
result.properties = properties;
|
|
680
711
|
}
|
|
@@ -707,7 +738,7 @@ function convertObjectType(
|
|
|
707
738
|
type: ts.Type,
|
|
708
739
|
ctx: SchemaCtx,
|
|
709
740
|
depth: number,
|
|
710
|
-
):
|
|
741
|
+
): SchemaObject {
|
|
711
742
|
const wellKnown = convertWellKnownType(type, ctx, depth);
|
|
712
743
|
if (wellKnown) {
|
|
713
744
|
return wellKnown;
|
|
@@ -732,7 +763,7 @@ function convertTypeToSchema(
|
|
|
732
763
|
type: ts.Type,
|
|
733
764
|
ctx: SchemaCtx,
|
|
734
765
|
depth: number,
|
|
735
|
-
):
|
|
766
|
+
): SchemaObject {
|
|
736
767
|
if (ctx.visited.has(type)) {
|
|
737
768
|
return handleCyclicRef(type, ctx);
|
|
738
769
|
}
|
|
@@ -745,10 +776,16 @@ function convertTypeToSchema(
|
|
|
745
776
|
}
|
|
746
777
|
|
|
747
778
|
if (type.isUnion()) {
|
|
748
|
-
|
|
779
|
+
ctx.visited.add(type);
|
|
780
|
+
const result = convertUnionType(type, ctx, depth);
|
|
781
|
+
ctx.visited.delete(type);
|
|
782
|
+
return result;
|
|
749
783
|
}
|
|
750
784
|
if (type.isIntersection()) {
|
|
751
|
-
|
|
785
|
+
ctx.visited.add(type);
|
|
786
|
+
const result = convertIntersectionType(type, ctx, depth);
|
|
787
|
+
ctx.visited.delete(type);
|
|
788
|
+
return result;
|
|
752
789
|
}
|
|
753
790
|
if (isObjectType(type)) {
|
|
754
791
|
return convertObjectType(type, ctx, depth);
|
|
@@ -777,7 +814,7 @@ interface WalkCtx {
|
|
|
777
814
|
function getProcedureTypeName(
|
|
778
815
|
defType: ts.Type,
|
|
779
816
|
checker: ts.TypeChecker,
|
|
780
|
-
):
|
|
817
|
+
): ProcedureInfo['type'] | null {
|
|
781
818
|
const typeSym = defType.getProperty('type');
|
|
782
819
|
if (!typeSym) {
|
|
783
820
|
return null;
|
|
@@ -816,6 +853,134 @@ interface ProcedureDef {
|
|
|
816
853
|
typeName: string;
|
|
817
854
|
path: string;
|
|
818
855
|
description?: string;
|
|
856
|
+
symbol: ts.Symbol;
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
function shouldIncludeProcedureInOpenAPI(type: ProcedureInfo['type']): boolean {
|
|
860
|
+
return type !== 'subscription';
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
function getProcedureInputTypeName(type: ts.Type, path: string): string {
|
|
864
|
+
const directName = getTypeName(type);
|
|
865
|
+
if (directName) {
|
|
866
|
+
return directName;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
for (const sym of [type.aliasSymbol, type.getSymbol()].filter(
|
|
870
|
+
(candidate): candidate is ts.Symbol => !!candidate,
|
|
871
|
+
)) {
|
|
872
|
+
for (const declaration of sym.declarations ?? []) {
|
|
873
|
+
const declarationName = ts.getNameOfDeclaration(declaration)?.getText();
|
|
874
|
+
if (
|
|
875
|
+
declarationName &&
|
|
876
|
+
!ANONYMOUS_NAMES.has(declarationName) &&
|
|
877
|
+
!declarationName.startsWith('__')
|
|
878
|
+
) {
|
|
879
|
+
return declarationName;
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
const fallbackName = path
|
|
885
|
+
.split('.')
|
|
886
|
+
.filter(Boolean)
|
|
887
|
+
.map((segment) =>
|
|
888
|
+
segment
|
|
889
|
+
.split(/[^A-Za-z0-9]+/)
|
|
890
|
+
.filter(Boolean)
|
|
891
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
892
|
+
.join(''),
|
|
893
|
+
)
|
|
894
|
+
.join('');
|
|
895
|
+
|
|
896
|
+
return `${fallbackName || 'Procedure'}Input`;
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
function isUnknownLikeType(type: ts.Type): boolean {
|
|
900
|
+
return hasFlag(type, ts.TypeFlags.Unknown | ts.TypeFlags.Any);
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
function isCollapsedProcedureInputType(type: ts.Type): boolean {
|
|
904
|
+
return (
|
|
905
|
+
isUnknownLikeType(type) ||
|
|
906
|
+
(isObjectType(type) &&
|
|
907
|
+
type.getProperties().length === 0 &&
|
|
908
|
+
!type.getStringIndexType())
|
|
909
|
+
);
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
function recoverProcedureInputType(
|
|
913
|
+
def: ProcedureDef,
|
|
914
|
+
checker: ts.TypeChecker,
|
|
915
|
+
): ts.Type | null {
|
|
916
|
+
let initializer: ts.Expression | null = null;
|
|
917
|
+
for (const declaration of def.symbol.declarations ?? []) {
|
|
918
|
+
if (ts.isPropertyAssignment(declaration)) {
|
|
919
|
+
initializer = declaration.initializer;
|
|
920
|
+
break;
|
|
921
|
+
}
|
|
922
|
+
if (ts.isVariableDeclaration(declaration) && declaration.initializer) {
|
|
923
|
+
initializer = declaration.initializer;
|
|
924
|
+
break;
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
if (!initializer) {
|
|
928
|
+
return null;
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
let recovered: ts.Type | null = null;
|
|
932
|
+
// Walk the builder chain and keep the last `.input(...)` parser output type.
|
|
933
|
+
const visit = (expr: ts.Expression): void => {
|
|
934
|
+
if (!ts.isCallExpression(expr)) {
|
|
935
|
+
return;
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
const callee = expr.expression;
|
|
939
|
+
if (!ts.isPropertyAccessExpression(callee)) {
|
|
940
|
+
return;
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
visit(callee.expression);
|
|
944
|
+
if (callee.name.text !== 'input') {
|
|
945
|
+
return;
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
const [parserExpr] = expr.arguments;
|
|
949
|
+
if (!parserExpr) {
|
|
950
|
+
return;
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
const parserType = checker.getTypeAtLocation(parserExpr);
|
|
954
|
+
const standardSym = parserType.getProperty('~standard');
|
|
955
|
+
if (!standardSym) {
|
|
956
|
+
return;
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
const standardType = checker.getTypeOfSymbolAtLocation(
|
|
960
|
+
standardSym,
|
|
961
|
+
parserExpr,
|
|
962
|
+
);
|
|
963
|
+
const typesSym = standardType.getProperty('types');
|
|
964
|
+
if (!typesSym) {
|
|
965
|
+
return;
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
const typesType = checker.getNonNullableType(
|
|
969
|
+
checker.getTypeOfSymbolAtLocation(typesSym, parserExpr),
|
|
970
|
+
);
|
|
971
|
+
const outputSym = typesType.getProperty('output');
|
|
972
|
+
if (!outputSym) {
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
const outputType = checker.getTypeOfSymbolAtLocation(outputSym, parserExpr);
|
|
977
|
+
if (!isUnknownLikeType(outputType)) {
|
|
978
|
+
recovered = outputType;
|
|
979
|
+
}
|
|
980
|
+
};
|
|
981
|
+
visit(initializer);
|
|
982
|
+
|
|
983
|
+
return recovered;
|
|
819
984
|
}
|
|
820
985
|
|
|
821
986
|
function extractProcedure(def: ProcedureDef, ctx: WalkCtx): void {
|
|
@@ -833,24 +998,75 @@ function extractProcedure(def: ProcedureDef, ctx: WalkCtx): void {
|
|
|
833
998
|
|
|
834
999
|
const inputType = inputSym ? checker.getTypeOfSymbol(inputSym) : null;
|
|
835
1000
|
const outputType = outputSym ? checker.getTypeOfSymbol(outputSym) : null;
|
|
1001
|
+
const resolvedInputType =
|
|
1002
|
+
inputType && isCollapsedProcedureInputType(inputType)
|
|
1003
|
+
? (recoverProcedureInputType(def, checker) ?? inputType)
|
|
1004
|
+
: inputType;
|
|
1005
|
+
|
|
1006
|
+
let inputSchema: SchemaObject | null = null;
|
|
1007
|
+
if (!resolvedInputType || isVoidLikeInput(resolvedInputType)) {
|
|
1008
|
+
// null is fine
|
|
1009
|
+
} else {
|
|
1010
|
+
// Pre-register recovered parser output types so recursive edges resolve to a
|
|
1011
|
+
// stable component ref instead of collapsing into `{}`.
|
|
1012
|
+
const ensureRecoveredInputRegistration = (type: ts.Type): void => {
|
|
1013
|
+
if (schemaCtx.typeToRef.has(type)) {
|
|
1014
|
+
return;
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
const refName = ensureUniqueName(
|
|
1018
|
+
getProcedureInputTypeName(type, def.path),
|
|
1019
|
+
schemaCtx.schemas,
|
|
1020
|
+
);
|
|
1021
|
+
schemaCtx.typeToRef.set(type, refName);
|
|
1022
|
+
schemaCtx.schemas[refName] = {};
|
|
1023
|
+
};
|
|
836
1024
|
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
1025
|
+
if (resolvedInputType !== inputType) {
|
|
1026
|
+
ensureRecoveredInputRegistration(resolvedInputType);
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
const initialSchema = typeToJsonSchema(resolvedInputType, schemaCtx);
|
|
1030
|
+
if (
|
|
1031
|
+
!isNonEmptySchema(initialSchema) &&
|
|
1032
|
+
!schemaCtx.typeToRef.has(resolvedInputType)
|
|
1033
|
+
) {
|
|
1034
|
+
ensureRecoveredInputRegistration(resolvedInputType);
|
|
1035
|
+
inputSchema = typeToJsonSchema(resolvedInputType, schemaCtx);
|
|
1036
|
+
} else {
|
|
1037
|
+
inputSchema = initialSchema;
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
841
1040
|
|
|
842
|
-
const outputSchema:
|
|
1041
|
+
const outputSchema: SchemaObject | null = outputType
|
|
843
1042
|
? typeToJsonSchema(outputType, schemaCtx)
|
|
844
1043
|
: null;
|
|
845
1044
|
|
|
846
1045
|
// Overlay extracted schema descriptions onto the type-checker-generated schemas.
|
|
847
1046
|
const runtimeDescs = ctx.runtimeDescriptions.get(def.path);
|
|
848
1047
|
if (runtimeDescs) {
|
|
849
|
-
|
|
850
|
-
|
|
1048
|
+
const resolvedInputSchema = getReferencedSchema(
|
|
1049
|
+
inputSchema,
|
|
1050
|
+
schemaCtx.schemas,
|
|
1051
|
+
);
|
|
1052
|
+
const resolvedOutputSchema = getReferencedSchema(
|
|
1053
|
+
outputSchema,
|
|
1054
|
+
schemaCtx.schemas,
|
|
1055
|
+
);
|
|
1056
|
+
|
|
1057
|
+
if (resolvedInputSchema && runtimeDescs.input) {
|
|
1058
|
+
applyDescriptions(
|
|
1059
|
+
resolvedInputSchema,
|
|
1060
|
+
runtimeDescs.input,
|
|
1061
|
+
schemaCtx.schemas,
|
|
1062
|
+
);
|
|
851
1063
|
}
|
|
852
|
-
if (
|
|
853
|
-
applyDescriptions(
|
|
1064
|
+
if (resolvedOutputSchema && runtimeDescs.output) {
|
|
1065
|
+
applyDescriptions(
|
|
1066
|
+
resolvedOutputSchema,
|
|
1067
|
+
runtimeDescs.output,
|
|
1068
|
+
schemaCtx.schemas,
|
|
1069
|
+
);
|
|
854
1070
|
}
|
|
855
1071
|
}
|
|
856
1072
|
|
|
@@ -868,6 +1084,48 @@ function getJsDocComment(
|
|
|
868
1084
|
sym: ts.Symbol,
|
|
869
1085
|
checker: ts.TypeChecker,
|
|
870
1086
|
): string | undefined {
|
|
1087
|
+
const isWithinPath = (candidate: string, parent: string): boolean => {
|
|
1088
|
+
const rel = path.relative(parent, candidate);
|
|
1089
|
+
return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
|
|
1090
|
+
};
|
|
1091
|
+
|
|
1092
|
+
const normalize = (filePath: string): string => filePath.replace(/\\/g, '/');
|
|
1093
|
+
const workspaceRoot = normalize(process.cwd());
|
|
1094
|
+
|
|
1095
|
+
const declarations = sym.declarations ?? [];
|
|
1096
|
+
const isExternalNodeModulesDeclaration =
|
|
1097
|
+
declarations.length > 0 &&
|
|
1098
|
+
declarations.every((declaration) => {
|
|
1099
|
+
const sourceFile = declaration.getSourceFile();
|
|
1100
|
+
if (!sourceFile.isDeclarationFile) {
|
|
1101
|
+
return false;
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
const declarationPath = normalize(sourceFile.fileName);
|
|
1105
|
+
if (!declarationPath.includes('/node_modules/')) {
|
|
1106
|
+
return false;
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
try {
|
|
1110
|
+
const realPath = normalize(fs.realpathSync.native(sourceFile.fileName));
|
|
1111
|
+
// Keep JSDoc for workspace packages linked into node_modules
|
|
1112
|
+
// (e.g. monorepos using pnpm/yarn workspaces).
|
|
1113
|
+
if (
|
|
1114
|
+
isWithinPath(realPath, workspaceRoot) &&
|
|
1115
|
+
!realPath.includes('/node_modules/')
|
|
1116
|
+
) {
|
|
1117
|
+
return false;
|
|
1118
|
+
}
|
|
1119
|
+
} catch {
|
|
1120
|
+
// Fall back to treating the declaration as external.
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
return true;
|
|
1124
|
+
});
|
|
1125
|
+
if (isExternalNodeModulesDeclaration) {
|
|
1126
|
+
return undefined;
|
|
1127
|
+
}
|
|
1128
|
+
|
|
871
1129
|
const parts = sym.getDocumentationComment(checker);
|
|
872
1130
|
if (parts.length === 0) {
|
|
873
1131
|
return undefined;
|
|
@@ -881,10 +1139,11 @@ interface WalkTypeOpts {
|
|
|
881
1139
|
ctx: WalkCtx;
|
|
882
1140
|
currentPath: string;
|
|
883
1141
|
description?: string;
|
|
1142
|
+
symbol?: ts.Symbol;
|
|
884
1143
|
}
|
|
885
1144
|
|
|
886
1145
|
function walkType(opts: WalkTypeOpts): void {
|
|
887
|
-
const { type, ctx, currentPath, description } = opts;
|
|
1146
|
+
const { type, ctx, currentPath, description, symbol } = opts;
|
|
888
1147
|
if (ctx.seen.has(type)) {
|
|
889
1148
|
return;
|
|
890
1149
|
}
|
|
@@ -907,8 +1166,18 @@ function walkType(opts: WalkTypeOpts): void {
|
|
|
907
1166
|
|
|
908
1167
|
const procedureTypeName = getProcedureTypeName(defType, checker);
|
|
909
1168
|
if (procedureTypeName) {
|
|
1169
|
+
if (!shouldIncludeProcedureInOpenAPI(procedureTypeName)) {
|
|
1170
|
+
return;
|
|
1171
|
+
}
|
|
1172
|
+
|
|
910
1173
|
extractProcedure(
|
|
911
|
-
{
|
|
1174
|
+
{
|
|
1175
|
+
defType,
|
|
1176
|
+
typeName: procedureTypeName,
|
|
1177
|
+
path: currentPath,
|
|
1178
|
+
description,
|
|
1179
|
+
symbol: symbol ?? type.getSymbol() ?? defSym,
|
|
1180
|
+
},
|
|
912
1181
|
ctx,
|
|
913
1182
|
);
|
|
914
1183
|
return;
|
|
@@ -942,7 +1211,13 @@ function walkRecord(recordType: ts.Type, ctx: WalkCtx, prefix: string): void {
|
|
|
942
1211
|
const propType = ctx.schemaCtx.checker.getTypeOfSymbol(prop);
|
|
943
1212
|
const fullPath = prefix ? `${prefix}.${prop.name}` : prop.name;
|
|
944
1213
|
const description = getJsDocComment(prop, ctx.schemaCtx.checker);
|
|
945
|
-
walkType({
|
|
1214
|
+
walkType({
|
|
1215
|
+
type: propType,
|
|
1216
|
+
ctx,
|
|
1217
|
+
currentPath: fullPath,
|
|
1218
|
+
description,
|
|
1219
|
+
symbol: prop,
|
|
1220
|
+
});
|
|
946
1221
|
}
|
|
947
1222
|
}
|
|
948
1223
|
|
|
@@ -1007,7 +1282,7 @@ function extractErrorSchema(
|
|
|
1007
1282
|
routerType: ts.Type,
|
|
1008
1283
|
checker: ts.TypeChecker,
|
|
1009
1284
|
schemaCtx: SchemaCtx,
|
|
1010
|
-
):
|
|
1285
|
+
): SchemaObject | null {
|
|
1011
1286
|
const walk = (type: ts.Type, keys: string[]): ts.Type | null => {
|
|
1012
1287
|
const [head, ...rest] = keys;
|
|
1013
1288
|
if (!head) {
|
|
@@ -1042,7 +1317,7 @@ function extractErrorSchema(
|
|
|
1042
1317
|
// ---------------------------------------------------------------------------
|
|
1043
1318
|
|
|
1044
1319
|
/** Fallback error schema when the router type doesn't expose an error shape. */
|
|
1045
|
-
const DEFAULT_ERROR_SCHEMA:
|
|
1320
|
+
const DEFAULT_ERROR_SCHEMA: SchemaObject = {
|
|
1046
1321
|
type: 'object',
|
|
1047
1322
|
properties: {
|
|
1048
1323
|
message: { type: 'string' },
|
|
@@ -1061,14 +1336,16 @@ const DEFAULT_ERROR_SCHEMA: JsonSchema = {
|
|
|
1061
1336
|
* When the procedure has no output the envelope is still present but
|
|
1062
1337
|
* the `data` property is omitted.
|
|
1063
1338
|
*/
|
|
1064
|
-
function wrapInSuccessEnvelope(
|
|
1339
|
+
function wrapInSuccessEnvelope(
|
|
1340
|
+
outputSchema: SchemaObject | null,
|
|
1341
|
+
): SchemaObject {
|
|
1065
1342
|
const hasOutput = outputSchema !== null && isNonEmptySchema(outputSchema);
|
|
1066
|
-
const resultSchema:
|
|
1343
|
+
const resultSchema: SchemaObject = {
|
|
1067
1344
|
type: 'object',
|
|
1068
1345
|
properties: {
|
|
1069
1346
|
...(hasOutput ? { data: outputSchema } : {}),
|
|
1070
1347
|
},
|
|
1071
|
-
...(hasOutput ? { required: ['data'
|
|
1348
|
+
...(hasOutput ? { required: ['data'] } : {}),
|
|
1072
1349
|
};
|
|
1073
1350
|
return {
|
|
1074
1351
|
type: 'object',
|
|
@@ -1082,11 +1359,12 @@ function wrapInSuccessEnvelope(outputSchema: JsonSchema | null): JsonSchema {
|
|
|
1082
1359
|
function buildProcedureOperation(
|
|
1083
1360
|
proc: ProcedureInfo,
|
|
1084
1361
|
method: 'get' | 'post',
|
|
1085
|
-
):
|
|
1086
|
-
const
|
|
1362
|
+
): OperationObject {
|
|
1363
|
+
const [tag = proc.path] = proc.path.split('.');
|
|
1364
|
+
const operation: OperationObject = {
|
|
1087
1365
|
operationId: proc.path,
|
|
1088
1366
|
...(proc.description ? { description: proc.description } : {}),
|
|
1089
|
-
tags: [
|
|
1367
|
+
tags: [tag],
|
|
1090
1368
|
responses: {
|
|
1091
1369
|
'200': {
|
|
1092
1370
|
description: 'Successful response',
|
|
@@ -1105,7 +1383,7 @@ function buildProcedureOperation(
|
|
|
1105
1383
|
}
|
|
1106
1384
|
|
|
1107
1385
|
if (method === 'get') {
|
|
1108
|
-
operation
|
|
1386
|
+
operation.parameters = [
|
|
1109
1387
|
{
|
|
1110
1388
|
name: 'input',
|
|
1111
1389
|
in: 'query',
|
|
@@ -1117,7 +1395,7 @@ function buildProcedureOperation(
|
|
|
1117
1395
|
},
|
|
1118
1396
|
];
|
|
1119
1397
|
} else {
|
|
1120
|
-
operation
|
|
1398
|
+
operation.requestBody = {
|
|
1121
1399
|
required: true,
|
|
1122
1400
|
content: { 'application/json': { schema: proc.inputSchema } },
|
|
1123
1401
|
};
|
|
@@ -1130,20 +1408,23 @@ function buildOpenAPIDocument(
|
|
|
1130
1408
|
procedures: ProcedureInfo[],
|
|
1131
1409
|
options: GenerateOptions,
|
|
1132
1410
|
meta: RouterMeta = { errorSchema: null },
|
|
1133
|
-
):
|
|
1134
|
-
const paths:
|
|
1411
|
+
): Document {
|
|
1412
|
+
const paths: PathsObject = {};
|
|
1135
1413
|
|
|
1136
1414
|
for (const proc of procedures) {
|
|
1137
|
-
if (proc.type
|
|
1415
|
+
if (!shouldIncludeProcedureInOpenAPI(proc.type)) {
|
|
1138
1416
|
continue;
|
|
1139
1417
|
}
|
|
1140
1418
|
|
|
1141
1419
|
const opPath = `/${proc.path}`;
|
|
1142
1420
|
const method = proc.type === 'query' ? 'get' : 'post';
|
|
1143
1421
|
|
|
1144
|
-
const pathItem:
|
|
1422
|
+
const pathItem: PathItemObject = paths[opPath] ?? {};
|
|
1145
1423
|
paths[opPath] = pathItem;
|
|
1146
|
-
pathItem[method] = buildProcedureOperation(
|
|
1424
|
+
pathItem[method] = buildProcedureOperation(
|
|
1425
|
+
proc,
|
|
1426
|
+
method,
|
|
1427
|
+
) as PathItemObject[typeof method];
|
|
1147
1428
|
}
|
|
1148
1429
|
|
|
1149
1430
|
const hasNamedSchemas =
|
|
@@ -1158,7 +1439,7 @@ function buildOpenAPIDocument(
|
|
|
1158
1439
|
},
|
|
1159
1440
|
paths,
|
|
1160
1441
|
components: {
|
|
1161
|
-
...(hasNamedSchemas ? { schemas: meta.schemas } : {}),
|
|
1442
|
+
...(hasNamedSchemas && meta.schemas ? { schemas: meta.schemas } : {}),
|
|
1162
1443
|
responses: {
|
|
1163
1444
|
Error: {
|
|
1164
1445
|
description: 'Error response',
|
|
@@ -1194,7 +1475,7 @@ function buildOpenAPIDocument(
|
|
|
1194
1475
|
export async function generateOpenAPIDocument(
|
|
1195
1476
|
routerFilePath: string,
|
|
1196
1477
|
options: GenerateOptions = {},
|
|
1197
|
-
): Promise<
|
|
1478
|
+
): Promise<Document> {
|
|
1198
1479
|
const resolvedPath = path.resolve(routerFilePath);
|
|
1199
1480
|
const exportName = options.exportName ?? 'AppRouter';
|
|
1200
1481
|
|