@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.
@@ -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,2 @@
1
+ export { generateOpenAPIDocument } from './generate';
2
+ export type { GenerateOptions, JsonSchema, OpenAPIDocument } from './generate';
@@ -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
+ }