@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
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
FetchClient as HeyApiFetchClient,
|
|
3
|
+
UserConfig,
|
|
4
|
+
} from '@hey-api/openapi-ts';
|
|
5
|
+
import type {
|
|
6
|
+
TRPCCombinedDataTransformer,
|
|
7
|
+
TRPCDataTransformer,
|
|
8
|
+
} from '@trpc/server';
|
|
9
|
+
|
|
10
|
+
export type DataTransformerOptions =
|
|
11
|
+
| TRPCDataTransformer
|
|
12
|
+
| TRPCCombinedDataTransformer;
|
|
13
|
+
|
|
14
|
+
type HeyAPIResolvers = Exclude<
|
|
15
|
+
Extract<
|
|
16
|
+
Exclude<UserConfig['plugins'], undefined | string>[number],
|
|
17
|
+
{ name: '@hey-api/typescript' }
|
|
18
|
+
>['~resolvers'],
|
|
19
|
+
undefined
|
|
20
|
+
>;
|
|
21
|
+
|
|
22
|
+
function resolveTransformer(
|
|
23
|
+
transformer: DataTransformerOptions,
|
|
24
|
+
): TRPCCombinedDataTransformer {
|
|
25
|
+
if ('input' in transformer) {
|
|
26
|
+
return transformer;
|
|
27
|
+
}
|
|
28
|
+
return { input: transformer, output: transformer };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface TRPCHeyApiClientOptions {
|
|
32
|
+
transformer?: DataTransformerOptions;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type HeyApiConfig = ReturnType<HeyApiFetchClient['getConfig']>;
|
|
36
|
+
export type TRPCHeyApiClientConfig = Required<
|
|
37
|
+
Pick<HeyApiConfig, 'querySerializer'>
|
|
38
|
+
> &
|
|
39
|
+
Pick<HeyApiConfig, 'bodySerializer' | 'responseTransformer'>;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Returns the `~resolvers` object for the `@hey-api/typescript` plugin.
|
|
43
|
+
*
|
|
44
|
+
* Maps `date` and `date-time` string formats to `Date` so that the
|
|
45
|
+
* generated SDK uses `Date` instead of `string` for those fields.
|
|
46
|
+
*
|
|
47
|
+
* @example
|
|
48
|
+
* ```ts
|
|
49
|
+
* import { createClient } from '@hey-api/openapi-ts';
|
|
50
|
+
* import { createTRPCHeyApiTypeResolvers } from '@trpc/openapi/heyapi';
|
|
51
|
+
*
|
|
52
|
+
* await createClient({
|
|
53
|
+
* plugins: [
|
|
54
|
+
* { name: '@hey-api/typescript', '~resolvers': createTRPCHeyApiTypeResolvers() },
|
|
55
|
+
* ],
|
|
56
|
+
* });
|
|
57
|
+
* ```
|
|
58
|
+
*/
|
|
59
|
+
export function createTRPCHeyApiTypeResolvers(): HeyAPIResolvers {
|
|
60
|
+
return {
|
|
61
|
+
string(ctx) {
|
|
62
|
+
if (ctx.schema.format === 'date-time' || ctx.schema.format === 'date') {
|
|
63
|
+
return ctx.$.type('Date');
|
|
64
|
+
}
|
|
65
|
+
return undefined;
|
|
66
|
+
},
|
|
67
|
+
number(ctx) {
|
|
68
|
+
if (ctx.schema.format === 'bigint') {
|
|
69
|
+
return ctx.$.type('bigint');
|
|
70
|
+
}
|
|
71
|
+
return undefined;
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* @internal - Prefer `configureTRPCHeyApiClient`
|
|
78
|
+
*/
|
|
79
|
+
export function createTRPCHeyApiClientConfig(opts?: TRPCHeyApiClientOptions) {
|
|
80
|
+
const transformer = opts?.transformer
|
|
81
|
+
? resolveTransformer(opts.transformer)
|
|
82
|
+
: undefined;
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
querySerializer: (query: Record<string, unknown>) => {
|
|
86
|
+
const params = new URLSearchParams();
|
|
87
|
+
|
|
88
|
+
for (const [key, value] of Object.entries(query)) {
|
|
89
|
+
if (value === undefined) {
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (key === 'input' && transformer) {
|
|
94
|
+
params.append(
|
|
95
|
+
key,
|
|
96
|
+
JSON.stringify(transformer.input.serialize(value)),
|
|
97
|
+
);
|
|
98
|
+
} else {
|
|
99
|
+
params.append(key, JSON.stringify(value));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return params.toString();
|
|
104
|
+
},
|
|
105
|
+
|
|
106
|
+
...(transformer && {
|
|
107
|
+
bodySerializer: (body: unknown) => {
|
|
108
|
+
return JSON.stringify(transformer.input.serialize(body));
|
|
109
|
+
},
|
|
110
|
+
|
|
111
|
+
responseTransformer: async (data: unknown) => {
|
|
112
|
+
if (!!data && typeof data === 'object' && 'result' in data) {
|
|
113
|
+
const result = (data as any).result;
|
|
114
|
+
if (!result.type || result.type === 'data') {
|
|
115
|
+
result.data = transformer.output.deserialize(result.data);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return data;
|
|
120
|
+
},
|
|
121
|
+
}),
|
|
122
|
+
} as const satisfies TRPCHeyApiClientConfig;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* @internal - Prefer `configureTRPCHeyApiClient`
|
|
127
|
+
*/
|
|
128
|
+
export function createTRPCErrorInterceptor(
|
|
129
|
+
transformerOpts: DataTransformerOptions,
|
|
130
|
+
) {
|
|
131
|
+
const transformer = resolveTransformer(transformerOpts);
|
|
132
|
+
return (error: unknown) => {
|
|
133
|
+
if (!!error && typeof error === 'object' && 'error' in error) {
|
|
134
|
+
(error as any).error = transformer.output.deserialize(
|
|
135
|
+
(error as any).error,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
return error;
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Configures a hey-api client for use with a tRPC OpenAPI backend.
|
|
144
|
+
*
|
|
145
|
+
* Sets up querySerializer, bodySerializer, responseTransformer, and
|
|
146
|
+
* an error interceptor (for transformer-based error deserialization)
|
|
147
|
+
* in a single call.
|
|
148
|
+
*
|
|
149
|
+
* @example
|
|
150
|
+
* ```ts
|
|
151
|
+
* import { configureTRPCHeyApiClient } from '@trpc/openapi/heyapi';
|
|
152
|
+
* import superjson from 'superjson';
|
|
153
|
+
* import { client } from './generated/client.gen';
|
|
154
|
+
*
|
|
155
|
+
* configureTRPCHeyApiClient(client, {
|
|
156
|
+
* baseUrl: 'http://localhost:3000',
|
|
157
|
+
* transformer: superjson,
|
|
158
|
+
* });
|
|
159
|
+
* ```
|
|
160
|
+
*/
|
|
161
|
+
export function configureTRPCHeyApiClient(
|
|
162
|
+
client: HeyApiFetchClient,
|
|
163
|
+
opts: TRPCHeyApiClientOptions &
|
|
164
|
+
Omit<HeyApiConfig, keyof TRPCHeyApiClientConfig>,
|
|
165
|
+
) {
|
|
166
|
+
const { transformer, ...heyConfig } = opts;
|
|
167
|
+
const trpcConfig = createTRPCHeyApiClientConfig({ transformer });
|
|
168
|
+
|
|
169
|
+
client.setConfig({ ...heyConfig, ...trpcConfig });
|
|
170
|
+
|
|
171
|
+
if (transformer) {
|
|
172
|
+
client.interceptors.error.use(createTRPCErrorInterceptor(transformer));
|
|
173
|
+
}
|
|
174
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
import { pathToFileURL } from 'node:url';
|
|
2
|
+
import type {
|
|
3
|
+
AnyTRPCProcedure,
|
|
4
|
+
AnyTRPCRouter,
|
|
5
|
+
TRPCRouterRecord,
|
|
6
|
+
} from '@trpc/server';
|
|
7
|
+
import type {
|
|
8
|
+
$ZodArrayDef,
|
|
9
|
+
$ZodObjectDef,
|
|
10
|
+
$ZodRegistry,
|
|
11
|
+
$ZodShape,
|
|
12
|
+
$ZodType,
|
|
13
|
+
$ZodTypeDef,
|
|
14
|
+
GlobalMeta,
|
|
15
|
+
} from 'zod/v4/core';
|
|
16
|
+
import type { JsonSchema } from './generate';
|
|
17
|
+
|
|
18
|
+
/** Description strings extracted from Zod `.describe()` calls, keyed by dot-delimited property path. */
|
|
19
|
+
export interface DescriptionMap {
|
|
20
|
+
/** Top-level description on the schema itself (empty-string key). */
|
|
21
|
+
self?: string;
|
|
22
|
+
/** Property-path → description, e.g. `"name"` or `"address.street"`. */
|
|
23
|
+
properties: Map<string, string>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface RuntimeDescriptions {
|
|
27
|
+
input: DescriptionMap | null;
|
|
28
|
+
output: DescriptionMap | null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Zod shape walking — extract .describe() strings
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Zod v4 stores `.describe()` strings in `globalThis.__zod_globalRegistry`,
|
|
37
|
+
* a WeakMap-backed `$ZodRegistry<GlobalMeta>`. We access it via globalThis
|
|
38
|
+
* because zod is an optional peer dependency.
|
|
39
|
+
*/
|
|
40
|
+
function getZodGlobalRegistry(): $ZodRegistry<GlobalMeta> | null {
|
|
41
|
+
const reg = (
|
|
42
|
+
globalThis as { __zod_globalRegistry?: $ZodRegistry<GlobalMeta> }
|
|
43
|
+
).__zod_globalRegistry;
|
|
44
|
+
return reg && typeof reg.get === 'function' ? reg : null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Runtime check: does this value look like a `$ZodType` (has `_zod.def`)? */
|
|
48
|
+
function isZodSchema(value: unknown): value is $ZodType {
|
|
49
|
+
if (value == null || typeof value !== 'object') return false;
|
|
50
|
+
const zod = (value as { _zod?: unknown })._zod;
|
|
51
|
+
return zod != null && typeof zod === 'object' && 'def' in zod;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Get the object shape from a Zod object schema, if applicable. */
|
|
55
|
+
function zodObjectShape(schema: $ZodType): $ZodShape | null {
|
|
56
|
+
const def = schema._zod.def;
|
|
57
|
+
if (def.type === 'object' && 'shape' in def) {
|
|
58
|
+
return (def as $ZodObjectDef).shape;
|
|
59
|
+
}
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Get the element schema from a Zod array schema, if applicable. */
|
|
64
|
+
function zodArrayElement(schema: $ZodType): $ZodType | null {
|
|
65
|
+
const def = schema._zod.def;
|
|
66
|
+
if (def.type === 'array' && 'element' in def) {
|
|
67
|
+
return (def as $ZodArrayDef).element;
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Wrapper def types whose inner schema is accessible via `innerType` or `in`. */
|
|
73
|
+
const wrapperDefTypes: ReadonlySet<$ZodTypeDef['type']> = new Set([
|
|
74
|
+
'optional',
|
|
75
|
+
'nullable',
|
|
76
|
+
'nonoptional',
|
|
77
|
+
'default',
|
|
78
|
+
'prefault',
|
|
79
|
+
'catch',
|
|
80
|
+
'readonly',
|
|
81
|
+
'pipe',
|
|
82
|
+
'transform',
|
|
83
|
+
'promise',
|
|
84
|
+
'lazy',
|
|
85
|
+
]);
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Extract the wrapped inner schema from a wrapper def.
|
|
89
|
+
* Most wrappers use `innerType`; `pipe` uses `in`.
|
|
90
|
+
*/
|
|
91
|
+
function getWrappedInner(def: $ZodTypeDef): $ZodType | null {
|
|
92
|
+
if ('innerType' in def) return (def as { innerType: $ZodType }).innerType;
|
|
93
|
+
if ('in' in def) return (def as { in: $ZodType }).in;
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Unwrap wrapper types (optional, nullable, default, readonly, etc.) to get the inner schema. */
|
|
98
|
+
function unwrapZodSchema(schema: $ZodType): $ZodType {
|
|
99
|
+
let current: $ZodType = schema;
|
|
100
|
+
const seen = new Set<$ZodType>();
|
|
101
|
+
while (!seen.has(current)) {
|
|
102
|
+
seen.add(current);
|
|
103
|
+
const def = current._zod.def;
|
|
104
|
+
if (!wrapperDefTypes.has(def.type)) break;
|
|
105
|
+
const inner = getWrappedInner(def);
|
|
106
|
+
if (!inner) break;
|
|
107
|
+
current = inner;
|
|
108
|
+
}
|
|
109
|
+
return current;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Walk a Zod schema and collect description strings at each property path.
|
|
114
|
+
* Returns `null` if the value is not a Zod schema or has no descriptions.
|
|
115
|
+
*/
|
|
116
|
+
export function extractZodDescriptions(schema: unknown): DescriptionMap | null {
|
|
117
|
+
if (!isZodSchema(schema)) return null;
|
|
118
|
+
const registry = getZodGlobalRegistry();
|
|
119
|
+
if (!registry) return null;
|
|
120
|
+
|
|
121
|
+
const map: DescriptionMap = { properties: new Map() };
|
|
122
|
+
let hasAny = false;
|
|
123
|
+
|
|
124
|
+
// Check top-level description
|
|
125
|
+
const topMeta = registry.get(schema);
|
|
126
|
+
if (topMeta?.description) {
|
|
127
|
+
map.self = topMeta.description;
|
|
128
|
+
hasAny = true;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Walk object shape
|
|
132
|
+
walkZodShape(schema, '', { registry, map });
|
|
133
|
+
if (map.properties.size > 0) hasAny = true;
|
|
134
|
+
|
|
135
|
+
return hasAny ? map : null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function walkZodShape(
|
|
139
|
+
schema: $ZodType,
|
|
140
|
+
prefix: string,
|
|
141
|
+
ctx: { registry: $ZodRegistry<GlobalMeta>; map: DescriptionMap },
|
|
142
|
+
): void {
|
|
143
|
+
const unwrapped = unwrapZodSchema(schema);
|
|
144
|
+
|
|
145
|
+
// If this is an array, check for a description on the element schema itself
|
|
146
|
+
// (stored as `[]` in the path) and recurse into the element's shape.
|
|
147
|
+
const element = zodArrayElement(unwrapped);
|
|
148
|
+
if (element) {
|
|
149
|
+
const unwrappedElement = unwrapZodSchema(element);
|
|
150
|
+
const elemMeta = ctx.registry.get(element);
|
|
151
|
+
const innerElemMeta =
|
|
152
|
+
unwrappedElement !== element
|
|
153
|
+
? ctx.registry.get(unwrappedElement)
|
|
154
|
+
: undefined;
|
|
155
|
+
const elemDesc = elemMeta?.description ?? innerElemMeta?.description;
|
|
156
|
+
if (elemDesc) {
|
|
157
|
+
const itemsPath = prefix ? `${prefix}.[]` : '[]';
|
|
158
|
+
ctx.map.properties.set(itemsPath, elemDesc);
|
|
159
|
+
}
|
|
160
|
+
walkZodShape(element, prefix, ctx);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const shape = zodObjectShape(unwrapped);
|
|
165
|
+
if (!shape) return;
|
|
166
|
+
|
|
167
|
+
for (const [key, fieldSchema] of Object.entries(shape)) {
|
|
168
|
+
const path = prefix ? `${prefix}.${key}` : key;
|
|
169
|
+
|
|
170
|
+
// Check for description on the field — may be on the wrapper or inner schema
|
|
171
|
+
const meta = ctx.registry.get(fieldSchema);
|
|
172
|
+
const unwrappedField = unwrapZodSchema(fieldSchema);
|
|
173
|
+
const innerMeta =
|
|
174
|
+
unwrappedField !== fieldSchema
|
|
175
|
+
? ctx.registry.get(unwrappedField)
|
|
176
|
+
: undefined;
|
|
177
|
+
const description = meta?.description ?? innerMeta?.description;
|
|
178
|
+
if (description) {
|
|
179
|
+
ctx.map.properties.set(path, description);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Recurse into nested objects and arrays
|
|
183
|
+
walkZodShape(unwrappedField, path, ctx);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
// Router detection & dynamic import
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
/** Check whether a value looks like a tRPC router instance at runtime. */
|
|
192
|
+
function isRouterInstance(value: unknown): value is AnyTRPCRouter {
|
|
193
|
+
if (value == null) return false;
|
|
194
|
+
const obj = value as Record<string, unknown>;
|
|
195
|
+
const def = obj['_def'];
|
|
196
|
+
return (
|
|
197
|
+
typeof obj === 'object' &&
|
|
198
|
+
def != null &&
|
|
199
|
+
typeof def === 'object' &&
|
|
200
|
+
(def as Record<string, unknown>)['record'] != null &&
|
|
201
|
+
typeof (def as Record<string, unknown>)['record'] === 'object'
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Search a module's exports for a tRPC router instance.
|
|
207
|
+
*
|
|
208
|
+
* Tries (in order):
|
|
209
|
+
* 1. Exact `exportName` match
|
|
210
|
+
* 2. lcfirst variant (`AppRouter` → `appRouter`)
|
|
211
|
+
* 3. First export that looks like a router
|
|
212
|
+
*/
|
|
213
|
+
export function findRouterExport(
|
|
214
|
+
mod: Record<string, unknown>,
|
|
215
|
+
exportName: string,
|
|
216
|
+
): AnyTRPCRouter | null {
|
|
217
|
+
// 1. Exact match
|
|
218
|
+
if (isRouterInstance(mod[exportName])) {
|
|
219
|
+
return mod[exportName];
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// 2. lcfirst variant (e.g. AppRouter → appRouter)
|
|
223
|
+
const lcFirst = exportName.charAt(0).toLowerCase() + exportName.slice(1);
|
|
224
|
+
if (lcFirst !== exportName && isRouterInstance(mod[lcFirst])) {
|
|
225
|
+
return mod[lcFirst];
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// 3. Any export that looks like a router
|
|
229
|
+
for (const value of Object.values(mod)) {
|
|
230
|
+
if (isRouterInstance(value)) {
|
|
231
|
+
return value;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Try to dynamically import the router file and extract a tRPC router
|
|
240
|
+
* instance. Returns `null` if the import fails (e.g. no TS loader) or
|
|
241
|
+
* no router export is found.
|
|
242
|
+
*/
|
|
243
|
+
export async function tryImportRouter(
|
|
244
|
+
resolvedPath: string,
|
|
245
|
+
exportName: string,
|
|
246
|
+
): Promise<AnyTRPCRouter | null> {
|
|
247
|
+
try {
|
|
248
|
+
const mod = await import(pathToFileURL(resolvedPath).href);
|
|
249
|
+
return findRouterExport(mod as Record<string, unknown>, exportName);
|
|
250
|
+
} catch {
|
|
251
|
+
// Dynamic import not available (no TS loader registered) — that's fine,
|
|
252
|
+
// we fall back to type-checker-only schemas.
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ---------------------------------------------------------------------------
|
|
258
|
+
// Router walker — collect descriptions per procedure
|
|
259
|
+
// ---------------------------------------------------------------------------
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Walk a runtime tRPC router/record and collect Zod `.describe()` strings
|
|
263
|
+
* keyed by procedure path.
|
|
264
|
+
*/
|
|
265
|
+
export function collectRuntimeDescriptions(
|
|
266
|
+
routerOrRecord: AnyTRPCRouter | TRPCRouterRecord,
|
|
267
|
+
prefix: string,
|
|
268
|
+
result: Map<string, RuntimeDescriptions>,
|
|
269
|
+
): void {
|
|
270
|
+
// Unwrap router to its record; plain RouterRecords are used as-is.
|
|
271
|
+
const record: TRPCRouterRecord = isRouterInstance(routerOrRecord)
|
|
272
|
+
? routerOrRecord._def.record
|
|
273
|
+
: routerOrRecord;
|
|
274
|
+
|
|
275
|
+
for (const [key, value] of Object.entries(record)) {
|
|
276
|
+
const fullPath = prefix ? `${prefix}.${key}` : key;
|
|
277
|
+
|
|
278
|
+
if (isProcedure(value)) {
|
|
279
|
+
// Procedure — extract descriptions from input and output Zod schemas
|
|
280
|
+
const def = value._def;
|
|
281
|
+
let inputDescs: DescriptionMap | null = null;
|
|
282
|
+
for (const input of def.inputs) {
|
|
283
|
+
const descs = extractZodDescriptions(input);
|
|
284
|
+
if (descs) {
|
|
285
|
+
// Merge multiple .input() descriptions (last wins for conflicts)
|
|
286
|
+
inputDescs ??= { properties: new Map() };
|
|
287
|
+
inputDescs.self = descs.self ?? inputDescs.self;
|
|
288
|
+
for (const [p, d] of descs.properties) {
|
|
289
|
+
inputDescs.properties.set(p, d);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
let outputDescs: DescriptionMap | null = null;
|
|
295
|
+
// `output` exists at runtime on the procedure def (from the builder)
|
|
296
|
+
// but is not part of the public Procedure type.
|
|
297
|
+
const outputParser = (def as Record<string, unknown>)['output'];
|
|
298
|
+
if (outputParser) {
|
|
299
|
+
outputDescs = extractZodDescriptions(outputParser);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (inputDescs || outputDescs) {
|
|
303
|
+
result.set(fullPath, { input: inputDescs, output: outputDescs });
|
|
304
|
+
}
|
|
305
|
+
} else {
|
|
306
|
+
// Sub-router or nested RouterRecord — recurse
|
|
307
|
+
collectRuntimeDescriptions(value, fullPath, result);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Type guard: check if a RouterRecord value is a procedure (callable). */
|
|
313
|
+
function isProcedure(
|
|
314
|
+
value: AnyTRPCProcedure | TRPCRouterRecord,
|
|
315
|
+
): value is AnyTRPCProcedure {
|
|
316
|
+
return typeof value === 'function';
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// ---------------------------------------------------------------------------
|
|
320
|
+
// Apply descriptions to JSON schemas
|
|
321
|
+
// ---------------------------------------------------------------------------
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Overlay description strings from a `DescriptionMap` onto an existing
|
|
325
|
+
* JSON schema produced by the TypeScript type checker. Mutates in place.
|
|
326
|
+
*/
|
|
327
|
+
export function applyDescriptions(
|
|
328
|
+
schema: JsonSchema,
|
|
329
|
+
descs: DescriptionMap,
|
|
330
|
+
): void {
|
|
331
|
+
if (descs.self) {
|
|
332
|
+
schema.description = descs.self;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
for (const [propPath, description] of descs.properties) {
|
|
336
|
+
setNestedDescription(schema, propPath.split('.'), description);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function setNestedDescription(
|
|
341
|
+
schema: JsonSchema,
|
|
342
|
+
pathParts: string[],
|
|
343
|
+
description: string,
|
|
344
|
+
): void {
|
|
345
|
+
if (pathParts.length === 0) return;
|
|
346
|
+
|
|
347
|
+
const [head, ...rest] = pathParts;
|
|
348
|
+
if (!head) return;
|
|
349
|
+
|
|
350
|
+
// `[]` means "array items" — navigate to the `items` sub-schema
|
|
351
|
+
if (head === '[]') {
|
|
352
|
+
const items =
|
|
353
|
+
schema.type === 'array' &&
|
|
354
|
+
schema.items &&
|
|
355
|
+
typeof schema.items === 'object'
|
|
356
|
+
? schema.items
|
|
357
|
+
: null;
|
|
358
|
+
if (!items) return;
|
|
359
|
+
if (rest.length === 0) {
|
|
360
|
+
items.description = description;
|
|
361
|
+
} else {
|
|
362
|
+
setNestedDescription(items, rest, description);
|
|
363
|
+
}
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const propSchema = schema.properties?.[head];
|
|
368
|
+
if (!propSchema || typeof propSchema !== 'object') return;
|
|
369
|
+
|
|
370
|
+
if (rest.length === 0) {
|
|
371
|
+
// Leaf — Zod .describe() takes priority over JSDoc
|
|
372
|
+
propSchema.description = description;
|
|
373
|
+
} else {
|
|
374
|
+
// For arrays, step through `items` transparently
|
|
375
|
+
const target =
|
|
376
|
+
propSchema.type === 'array' &&
|
|
377
|
+
propSchema.items &&
|
|
378
|
+
typeof propSchema.items === 'object'
|
|
379
|
+
? propSchema.items
|
|
380
|
+
: propSchema;
|
|
381
|
+
setNestedDescription(target, rest, description);
|
|
382
|
+
}
|
|
383
|
+
}
|