@workos/oagen-emitters 0.0.1 → 0.2.1
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/.github/workflows/release-please.yml +9 -1
- package/.husky/commit-msg +0 -0
- package/.husky/pre-commit +1 -0
- package/.husky/pre-push +1 -0
- package/.oxfmtrc.json +8 -1
- package/.prettierignore +1 -0
- package/.release-please-manifest.json +3 -0
- package/.vscode/settings.json +3 -0
- package/CHANGELOG.md +61 -0
- package/README.md +2 -2
- package/dist/index.d.mts +7 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +4070 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +14 -18
- package/release-please-config.json +11 -0
- package/smoke/sdk-dotnet.ts +17 -3
- package/smoke/sdk-elixir.ts +17 -3
- package/smoke/sdk-go.ts +21 -4
- package/smoke/sdk-kotlin.ts +23 -4
- package/smoke/sdk-node.ts +15 -3
- package/smoke/sdk-ruby.ts +17 -3
- package/smoke/sdk-rust.ts +16 -3
- package/src/node/client.ts +521 -206
- package/src/node/common.ts +74 -4
- package/src/node/config.ts +1 -0
- package/src/node/enums.ts +53 -9
- package/src/node/errors.ts +82 -3
- package/src/node/fixtures.ts +87 -16
- package/src/node/index.ts +66 -10
- package/src/node/manifest.ts +4 -2
- package/src/node/models.ts +251 -124
- package/src/node/naming.ts +107 -3
- package/src/node/resources.ts +1162 -108
- package/src/node/serializers.ts +512 -52
- package/src/node/tests.ts +650 -110
- package/src/node/type-map.ts +89 -11
- package/src/node/utils.ts +426 -113
- package/test/node/client.test.ts +1083 -20
- package/test/node/enums.test.ts +73 -4
- package/test/node/errors.test.ts +4 -21
- package/test/node/models.test.ts +499 -5
- package/test/node/naming.test.ts +14 -7
- package/test/node/resources.test.ts +1568 -9
- package/test/node/serializers.test.ts +241 -5
- package/tsconfig.json +2 -3
- package/{tsup.config.ts → tsdown.config.ts} +1 -1
- package/dist/index.d.ts +0 -5
- package/dist/index.js +0 -2158
package/src/node/resources.ts
CHANGED
|
@@ -1,27 +1,437 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
// @oagen-ignore: Operation.async — all TypeScript SDK methods are async by nature
|
|
2
|
+
|
|
3
|
+
import type { Service, Operation, EmitterContext, GeneratedFile, TypeRef, Model } from '@workos/oagen';
|
|
4
|
+
import { planOperation, toPascalCase, toCamelCase } from '@workos/oagen';
|
|
3
5
|
import type { OperationPlan } from '@workos/oagen';
|
|
4
6
|
import { mapTypeRef } from './type-map.js';
|
|
5
7
|
import {
|
|
6
8
|
fieldName,
|
|
9
|
+
wireFieldName,
|
|
7
10
|
fileName,
|
|
8
|
-
|
|
11
|
+
resolveServiceDir,
|
|
9
12
|
resolveMethodName,
|
|
10
13
|
resolveInterfaceName,
|
|
11
14
|
resolveServiceName,
|
|
12
|
-
buildServiceNameMap,
|
|
13
15
|
wireInterfaceName,
|
|
14
16
|
} from './naming.js';
|
|
15
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
docComment,
|
|
19
|
+
createServiceDirResolver,
|
|
20
|
+
isServiceCoveredByExisting,
|
|
21
|
+
hasMethodsAbsentFromBaseline,
|
|
22
|
+
uncoveredOperations,
|
|
23
|
+
} from './utils.js';
|
|
24
|
+
import { assignEnumsToServices } from './enums.js';
|
|
25
|
+
import { unwrapListModel } from './fixtures.js';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Check whether the baseline (hand-written) class has a constructor compatible
|
|
29
|
+
* with the generated pattern `constructor(private readonly workos: WorkOS)`.
|
|
30
|
+
* Returns true when no baseline exists (fresh generation) or when compatible.
|
|
31
|
+
*/
|
|
32
|
+
export function hasCompatibleConstructor(className: string, ctx: EmitterContext): boolean {
|
|
33
|
+
const baselineClass = ctx.apiSurface?.classes?.[className];
|
|
34
|
+
if (!baselineClass) return true; // No baseline — fresh generation
|
|
35
|
+
const params = baselineClass.constructorParams;
|
|
36
|
+
if (!params || params.length === 0) return true; // No-arg constructor is compatible
|
|
37
|
+
// Compatible if there is a single `workos` param whose type contains "WorkOS"
|
|
38
|
+
return params.some((p) => p.name === 'workos' && p.type.includes('WorkOS'));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Resolve the resource class name for a service, accounting for constructor
|
|
43
|
+
* compatibility with the baseline class.
|
|
44
|
+
*
|
|
45
|
+
* When the overlay-resolved class has an incompatible constructor (e.g., a
|
|
46
|
+
* hand-written `Webhooks` class that takes `CryptoProvider` instead of `WorkOS`),
|
|
47
|
+
* falls back to the IR name (`toPascalCase(service.name)`). If the IR name
|
|
48
|
+
* collides with the overlay name, appends an `Endpoints` suffix.
|
|
49
|
+
*/
|
|
50
|
+
export function resolveResourceClassName(service: Service, ctx: EmitterContext): string {
|
|
51
|
+
const overlayName = resolveServiceName(service, ctx);
|
|
52
|
+
if (hasCompatibleConstructor(overlayName, ctx)) {
|
|
53
|
+
return overlayName;
|
|
54
|
+
}
|
|
55
|
+
// Incompatible constructor — fall back to IR name
|
|
56
|
+
const irName = toPascalCase(service.name);
|
|
57
|
+
if (irName === overlayName) {
|
|
58
|
+
return irName + 'Endpoints';
|
|
59
|
+
}
|
|
60
|
+
return irName;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Standard pagination query params handled by PaginationOptions — not imported individually. */
|
|
64
|
+
const PAGINATION_PARAM_NAMES = new Set(['limit', 'before', 'after', 'order']);
|
|
65
|
+
|
|
66
|
+
/** Map HTTP status codes to their corresponding exception class names for @throws docs. */
|
|
67
|
+
const STATUS_TO_EXCEPTION_NAME: Record<number, string> = {
|
|
68
|
+
400: 'BadRequestException',
|
|
69
|
+
401: 'UnauthorizedException',
|
|
70
|
+
404: 'NotFoundException',
|
|
71
|
+
409: 'ConflictException',
|
|
72
|
+
422: 'UnprocessableEntityException',
|
|
73
|
+
429: 'RateLimitExceededException',
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Compute the options interface name for a paginated method.
|
|
78
|
+
* When the method name is simply "list", prefix with the service name to avoid
|
|
79
|
+
* naming collisions at barrel-export level (e.g. "ConnectionsListOptions"
|
|
80
|
+
* instead of the generic "ListOptions").
|
|
81
|
+
*/
|
|
82
|
+
function paginatedOptionsName(method: string, resolvedServiceName: string): string {
|
|
83
|
+
if (method === 'list') {
|
|
84
|
+
return `${toPascalCase(resolvedServiceName)}ListOptions`;
|
|
85
|
+
}
|
|
86
|
+
return toPascalCase(method) + 'Options';
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** HTTP methods that require a body argument even when the spec has no request body. */
|
|
90
|
+
function httpMethodNeedsBody(method: string): boolean {
|
|
91
|
+
return method === 'post' || method === 'put' || method === 'patch';
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
// Method-name reconciliation helpers
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
/** Map HTTP methods to expected CRUD verb prefixes for method name matching. */
|
|
99
|
+
const HTTP_VERB_PREFIXES: Record<string, string[]> = {
|
|
100
|
+
get: ['list', 'get', 'fetch', 'retrieve', 'find'],
|
|
101
|
+
post: ['create', 'add', 'insert', 'send'],
|
|
102
|
+
put: ['set', 'update', 'replace', 'put'],
|
|
103
|
+
patch: ['update', 'patch', 'modify'],
|
|
104
|
+
delete: ['delete', 'remove', 'revoke'],
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/** Split a camelCase/PascalCase name into lowercase word parts. */
|
|
108
|
+
function splitCamelWords(name: string): string[] {
|
|
109
|
+
const parts: string[] = [];
|
|
110
|
+
let start = 0;
|
|
111
|
+
for (let i = 1; i < name.length; i++) {
|
|
112
|
+
if (name[i] >= 'A' && name[i] <= 'Z') {
|
|
113
|
+
parts.push(name.slice(start, i).toLowerCase());
|
|
114
|
+
start = i;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
parts.push(name.slice(start).toLowerCase());
|
|
118
|
+
return parts;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Naive singularize: strip trailing 's' unless it ends in 'ss'. */
|
|
122
|
+
function singularize(word: string): string {
|
|
123
|
+
return word.endsWith('s') && !word.endsWith('ss') ? word.slice(0, -1) : word;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Batch-reconcile generated method names against the api-surface class methods.
|
|
128
|
+
*
|
|
129
|
+
* When the overlay doesn't map an operation (missing from previous manifest or
|
|
130
|
+
* fuzzy-matching failed), the emitter falls back to the spec-derived name which
|
|
131
|
+
* often doesn't match the hand-written SDK. This function uses three passes
|
|
132
|
+
* with progressive exclusion to find the best surface match:
|
|
133
|
+
*
|
|
134
|
+
* 1. **Overlay-resolved** — already correct, mark as taken.
|
|
135
|
+
* 2. **Word-set match** — same content words regardless of order (handles
|
|
136
|
+
* `listRolesOrganizations` ↔ `listOrganizationRoles`).
|
|
137
|
+
* 3. **Path-context match** — all surface-method content words appear in the
|
|
138
|
+
* operation's URL path segments (handles `findById` ↔ `getResource`).
|
|
139
|
+
*
|
|
140
|
+
* After each pass, matched surface methods are removed from the pool so that
|
|
141
|
+
* ambiguous cases (e.g., `listEnvironmentRoles` vs `listOrganizationRoles`)
|
|
142
|
+
* resolve by elimination.
|
|
143
|
+
*/
|
|
144
|
+
function reconcileMethodNames(
|
|
145
|
+
plans: Array<{ op: Operation; plan: OperationPlan; method: string }>,
|
|
146
|
+
service: Service,
|
|
147
|
+
ctx: EmitterContext,
|
|
148
|
+
): void {
|
|
149
|
+
const className = resolveResourceClassName(service, ctx);
|
|
150
|
+
const classMethods = ctx.apiSurface?.classes?.[className]?.methods;
|
|
151
|
+
if (!classMethods) return;
|
|
152
|
+
|
|
153
|
+
const available = new Set(Object.keys(classMethods));
|
|
154
|
+
const resolved = new Map<(typeof plans)[number], string>();
|
|
155
|
+
|
|
156
|
+
// Exclude surface methods that are overlay-mapped by OTHER services'
|
|
157
|
+
// operations. This prevents the reconciliation from stealing methods
|
|
158
|
+
// that belong to a different service (e.g., `createPermission` belongs
|
|
159
|
+
// to the Permissions service, not the Authorization service).
|
|
160
|
+
const thisServicePaths = new Set(service.operations.map((op) => op.path));
|
|
161
|
+
if (ctx.overlayLookup?.methodByOperation) {
|
|
162
|
+
for (const [httpKey, info] of ctx.overlayLookup.methodByOperation) {
|
|
163
|
+
if (info.className !== className) continue; // different class
|
|
164
|
+
const path = httpKey.split(' ')[1];
|
|
165
|
+
if (thisServicePaths.has(path)) continue; // same service
|
|
166
|
+
// This method is mapped to an operation in a different service
|
|
167
|
+
available.delete(info.methodName);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Determine which plans are already overlay-resolved
|
|
172
|
+
const overlayResolved = new Set<(typeof plans)[number]>();
|
|
173
|
+
for (const plan of plans) {
|
|
174
|
+
const httpKey = `${plan.op.httpMethod.toUpperCase()} ${plan.op.path}`;
|
|
175
|
+
if (ctx.overlayLookup?.methodByOperation?.get(httpKey)) {
|
|
176
|
+
overlayResolved.add(plan);
|
|
177
|
+
if (available.has(plan.method)) {
|
|
178
|
+
resolved.set(plan, plan.method);
|
|
179
|
+
available.delete(plan.method);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Helper: check verb compatibility.
|
|
185
|
+
// When specVerb is provided, require the surface method to use the same verb
|
|
186
|
+
// subgroup (e.g., "list" operations only match "list" methods, not "get").
|
|
187
|
+
const verbMatches = (methodName: string, httpMethod: string, specVerb?: string): boolean => {
|
|
188
|
+
const prefixes = HTTP_VERB_PREFIXES[httpMethod] ?? [];
|
|
189
|
+
const lower = methodName.toLowerCase();
|
|
190
|
+
if (!prefixes.some((p) => lower.startsWith(p))) return false;
|
|
191
|
+
if (specVerb) {
|
|
192
|
+
const surfaceVerb = splitCamelWords(methodName)[0];
|
|
193
|
+
// "list" is a distinct verb subgroup from "get/find/fetch/retrieve"
|
|
194
|
+
if (specVerb === 'list' && surfaceVerb !== 'list') return false;
|
|
195
|
+
if (specVerb !== 'list' && surfaceVerb === 'list') return false;
|
|
196
|
+
}
|
|
197
|
+
return true;
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
// Pass 1: Word-set matching (handles word-order differences)
|
|
201
|
+
for (const plan of plans) {
|
|
202
|
+
if (resolved.has(plan)) continue;
|
|
203
|
+
const specVerb = splitCamelWords(plan.method)[0];
|
|
204
|
+
const specWords = splitCamelWords(plan.method).slice(1).map(singularize); // skip verb
|
|
205
|
+
const specSet = new Set(specWords);
|
|
206
|
+
if (specSet.size === 0) continue;
|
|
207
|
+
|
|
208
|
+
let match: string | null = null;
|
|
209
|
+
for (const name of available) {
|
|
210
|
+
if (!verbMatches(name, plan.op.httpMethod, specVerb)) continue;
|
|
211
|
+
const methodWords = splitCamelWords(name).slice(1).map(singularize);
|
|
212
|
+
if (methodWords.length !== specWords.length) continue;
|
|
213
|
+
const methodSet = new Set(methodWords);
|
|
214
|
+
if (specSet.size === methodSet.size && [...specSet].every((w) => methodSet.has(w))) {
|
|
215
|
+
if (match !== null) {
|
|
216
|
+
match = null; // ambiguous — more than one match
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
219
|
+
match = name;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
if (match) {
|
|
223
|
+
resolved.set(plan, match);
|
|
224
|
+
available.delete(match);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Pass 2: Path-context matching (handles different naming e.g., findById → getResource)
|
|
229
|
+
// To avoid false matches (e.g., `createPermission` matching a role-permission path
|
|
230
|
+
// just because "permission" appears somewhere in the URL), require that the method's
|
|
231
|
+
// content words reference the LEAF resource of the path, not just any segment.
|
|
232
|
+
for (const plan of plans) {
|
|
233
|
+
if (resolved.has(plan)) continue;
|
|
234
|
+
const specVerb = splitCamelWords(plan.method)[0];
|
|
235
|
+
const pathSegments = plan.op.path.split('/').filter((s) => s && !s.startsWith('{'));
|
|
236
|
+
const pathWords = new Set(pathSegments.flatMap((s) => s.split('_')).map(singularize));
|
|
237
|
+
// The "leaf" words come from the last non-param segment — the most specific resource.
|
|
238
|
+
let bestMatch: string | null = null;
|
|
239
|
+
let bestLen = 0;
|
|
240
|
+
let ambiguous = false;
|
|
241
|
+
for (const name of available) {
|
|
242
|
+
if (!verbMatches(name, plan.op.httpMethod, specVerb)) continue;
|
|
243
|
+
const methodWords = splitCamelWords(name).slice(1).map(singularize);
|
|
244
|
+
if (methodWords.length === 0) continue;
|
|
245
|
+
// All method content words must appear in path context
|
|
246
|
+
if (!methodWords.every((w) => pathWords.has(w))) continue;
|
|
247
|
+
// For paths with 3+ non-param segments (nested sub-resources like
|
|
248
|
+
// /authorization/roles/{slug}/permissions), require at least 2 content
|
|
249
|
+
// words. A single-word method like `createPermission` should only match
|
|
250
|
+
// a top-level path like /authorization/permissions, not a nested one.
|
|
251
|
+
if (methodWords.length < 2 && pathSegments.length > 2) continue;
|
|
252
|
+
if (methodWords.length > bestLen) {
|
|
253
|
+
bestMatch = name;
|
|
254
|
+
bestLen = methodWords.length;
|
|
255
|
+
ambiguous = false;
|
|
256
|
+
} else if (methodWords.length === bestLen) {
|
|
257
|
+
ambiguous = true;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
if (bestMatch && !ambiguous) {
|
|
261
|
+
resolved.set(plan, bestMatch);
|
|
262
|
+
available.delete(bestMatch);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Pass 3: Retry word-set and path-context for still-unresolved plans
|
|
267
|
+
// (earlier passes may have eliminated ambiguous candidates)
|
|
268
|
+
for (const plan of plans) {
|
|
269
|
+
if (resolved.has(plan)) continue;
|
|
270
|
+
const specVerb = splitCamelWords(plan.method)[0];
|
|
271
|
+
|
|
272
|
+
// Retry word-set
|
|
273
|
+
const specWords = splitCamelWords(plan.method).slice(1).map(singularize);
|
|
274
|
+
const specSet = new Set(specWords);
|
|
275
|
+
let match: string | null = null;
|
|
276
|
+
for (const name of available) {
|
|
277
|
+
if (!verbMatches(name, plan.op.httpMethod, specVerb)) continue;
|
|
278
|
+
const methodWords = splitCamelWords(name).slice(1).map(singularize);
|
|
279
|
+
if (methodWords.length !== specWords.length) continue;
|
|
280
|
+
const methodSet = new Set(methodWords);
|
|
281
|
+
if (specSet.size === methodSet.size && [...specSet].every((w) => methodSet.has(w))) {
|
|
282
|
+
if (match !== null) {
|
|
283
|
+
match = null;
|
|
284
|
+
break;
|
|
285
|
+
}
|
|
286
|
+
match = name;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
if (match) {
|
|
290
|
+
resolved.set(plan, match);
|
|
291
|
+
available.delete(match);
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Retry path-context (with leaf-word check)
|
|
296
|
+
const pathSegments = plan.op.path.split('/').filter((s) => s && !s.startsWith('{'));
|
|
297
|
+
const pathWords = new Set(pathSegments.flatMap((s) => s.split('_')).map(singularize));
|
|
298
|
+
let bestMatch: string | null = null;
|
|
299
|
+
let bestLen = 0;
|
|
300
|
+
let ambiguous = false;
|
|
301
|
+
for (const name of available) {
|
|
302
|
+
if (!verbMatches(name, plan.op.httpMethod, specVerb)) continue;
|
|
303
|
+
const methodWords = splitCamelWords(name).slice(1).map(singularize);
|
|
304
|
+
if (methodWords.length === 0) continue;
|
|
305
|
+
if (!methodWords.every((w) => pathWords.has(w))) continue;
|
|
306
|
+
if (methodWords.length < 2 && pathSegments.length > 2) continue;
|
|
307
|
+
if (methodWords.length > bestLen) {
|
|
308
|
+
bestMatch = name;
|
|
309
|
+
bestLen = methodWords.length;
|
|
310
|
+
ambiguous = false;
|
|
311
|
+
} else if (methodWords.length === bestLen) {
|
|
312
|
+
ambiguous = true;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
if (bestMatch && !ambiguous) {
|
|
316
|
+
resolved.set(plan, bestMatch);
|
|
317
|
+
available.delete(bestMatch);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Apply resolved names (only for non-overlay plans)
|
|
322
|
+
for (const plan of plans) {
|
|
323
|
+
if (overlayResolved.has(plan)) continue;
|
|
324
|
+
const name = resolved.get(plan);
|
|
325
|
+
if (name) plan.method = name;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Deduplicate method names within the plans array.
|
|
331
|
+
*
|
|
332
|
+
* When `disambiguateOperationNames()` in `@workos/oagen` fails (e.g., for
|
|
333
|
+
* single-segment paths like `/organizations`), two operations can resolve to
|
|
334
|
+
* the same method name. Disambiguate by appending a path-derived suffix.
|
|
335
|
+
*/
|
|
336
|
+
function deduplicateMethodNames(
|
|
337
|
+
plans: Array<{ op: Operation; plan: OperationPlan; method: string }>,
|
|
338
|
+
_ctx: EmitterContext,
|
|
339
|
+
): void {
|
|
340
|
+
const nameCount = new Map<string, number>();
|
|
341
|
+
for (const p of plans) {
|
|
342
|
+
nameCount.set(p.method, (nameCount.get(p.method) ?? 0) + 1);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
for (const [name, count] of nameCount) {
|
|
346
|
+
if (count <= 1) continue;
|
|
347
|
+
const dupes = plans.filter((p) => p.method === name);
|
|
348
|
+
|
|
349
|
+
// If all duplicates are on the SAME base path (different HTTP methods),
|
|
350
|
+
// trust the names — they represent the same resource.
|
|
351
|
+
const basePaths = new Set(dupes.map((d) => d.op.path.replace(/\/\{[^}]+\}$/, '')));
|
|
352
|
+
if (basePaths.size <= 1) continue;
|
|
353
|
+
|
|
354
|
+
// Disambiguate: keep the name for the plan whose path best matches,
|
|
355
|
+
// append path suffix for the others.
|
|
356
|
+
// Score: how many words in the method name appear in the path segments
|
|
357
|
+
const nameWords = new Set(splitCamelWords(name).map(singularize));
|
|
358
|
+
const scored = dupes.map((d) => {
|
|
359
|
+
const pathWords = d.op.path
|
|
360
|
+
.split('/')
|
|
361
|
+
.filter((s) => s && !s.startsWith('{'))
|
|
362
|
+
.flatMap((s) => s.split('_'))
|
|
363
|
+
.map(singularize);
|
|
364
|
+
const overlap = pathWords.filter((w) => nameWords.has(w)).length;
|
|
365
|
+
return { plan: d, score: overlap };
|
|
366
|
+
});
|
|
367
|
+
scored.sort((a, b) => b.score - a.score);
|
|
368
|
+
|
|
369
|
+
// The best-scoring plan keeps the name; others get disambiguated
|
|
370
|
+
for (let i = 1; i < scored.length; i++) {
|
|
371
|
+
const dupe = scored[i].plan;
|
|
372
|
+
const segments = dupe.op.path.split('/').filter((s) => s && !s.startsWith('{'));
|
|
373
|
+
// Use first segment as suffix (the resource collection name)
|
|
374
|
+
const suffix = segments[0] ?? '';
|
|
375
|
+
if (suffix) {
|
|
376
|
+
dupe.method = toCamelCase(`${name}_${suffix}`);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// If still colliding after suffix, append index
|
|
381
|
+
const stillDuped = new Map<string, typeof dupes>();
|
|
382
|
+
for (const dupe of dupes) {
|
|
383
|
+
const group = stillDuped.get(dupe.method) ?? [];
|
|
384
|
+
group.push(dupe);
|
|
385
|
+
stillDuped.set(dupe.method, group);
|
|
386
|
+
}
|
|
387
|
+
for (const [, group] of stillDuped) {
|
|
388
|
+
if (group.length <= 1) continue;
|
|
389
|
+
for (let i = 1; i < group.length; i++) {
|
|
390
|
+
group[i].method = `${group[i].method}${i + 1}`;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
}
|
|
16
395
|
|
|
17
396
|
export function generateResources(services: Service[], ctx: EmitterContext): GeneratedFile[] {
|
|
18
397
|
if (services.length === 0) return [];
|
|
19
|
-
|
|
398
|
+
const files: GeneratedFile[] = [];
|
|
399
|
+
|
|
400
|
+
for (const service of services) {
|
|
401
|
+
if (isServiceCoveredByExisting(service, ctx)) {
|
|
402
|
+
// Fully covered: generate with ALL operations so the merger's docstring
|
|
403
|
+
// refresh pass can update JSDoc on existing methods.
|
|
404
|
+
const file = generateResourceClass(service, ctx);
|
|
405
|
+
// When the baseline class is missing methods for some operations,
|
|
406
|
+
// remove skipIfExists so the merger adds the new methods.
|
|
407
|
+
if (hasMethodsAbsentFromBaseline(service, ctx)) {
|
|
408
|
+
delete file.skipIfExists;
|
|
409
|
+
}
|
|
410
|
+
files.push(file);
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const ops = uncoveredOperations(service, ctx);
|
|
415
|
+
if (ops.length === 0) continue;
|
|
416
|
+
|
|
417
|
+
if (ops.length < service.operations.length) {
|
|
418
|
+
// Partial coverage: generate with ALL operations so JSDoc is available
|
|
419
|
+
// for both covered and uncovered methods. Remove skipIfExists so the
|
|
420
|
+
// merger adds new methods AND refreshes existing JSDoc.
|
|
421
|
+
const file = generateResourceClass(service, ctx);
|
|
422
|
+
delete file.skipIfExists;
|
|
423
|
+
files.push(file);
|
|
424
|
+
} else {
|
|
425
|
+
files.push(generateResourceClass(service, ctx));
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
return files;
|
|
20
430
|
}
|
|
21
431
|
|
|
22
432
|
function generateResourceClass(service: Service, ctx: EmitterContext): GeneratedFile {
|
|
23
|
-
const resolvedName =
|
|
24
|
-
const serviceDir =
|
|
433
|
+
const resolvedName = resolveResourceClassName(service, ctx);
|
|
434
|
+
const serviceDir = resolveServiceDir(resolvedName);
|
|
25
435
|
const serviceClass = resolvedName;
|
|
26
436
|
const resourcePath = `src/${serviceDir}/${fileName(resolvedName)}.ts`;
|
|
27
437
|
|
|
@@ -31,20 +441,97 @@ function generateResourceClass(service: Service, ctx: EmitterContext): Generated
|
|
|
31
441
|
method: resolveMethodName(op, service, ctx),
|
|
32
442
|
}));
|
|
33
443
|
|
|
444
|
+
// Reconcile method names against the api-surface class methods.
|
|
445
|
+
// This fixes cases where the overlay is missing mappings and the
|
|
446
|
+
// spec-derived name doesn't match the hand-written SDK name.
|
|
447
|
+
reconcileMethodNames(plans, service, ctx);
|
|
448
|
+
|
|
449
|
+
// Deduplicate method names within the class (e.g., two operations both
|
|
450
|
+
// resolving to "create" for different paths).
|
|
451
|
+
deduplicateMethodNames(plans, ctx);
|
|
452
|
+
|
|
453
|
+
// Sort plans to match the existing file's method order.
|
|
454
|
+
// When the merger integrates generated content with existing files, its
|
|
455
|
+
// URL-fingerprint fallback (pass 2) matches by position among methods that
|
|
456
|
+
// share the same endpoint path. If the spec lists POST before GET for a
|
|
457
|
+
// path (common in OpenAPI) but the existing class has them in a different
|
|
458
|
+
// order, JSDoc comments get attached to the wrong methods (list↔create,
|
|
459
|
+
// add↔set swaps). Sorting by the overlay's method order ensures the
|
|
460
|
+
// generated output matches the existing file's method order.
|
|
461
|
+
if (ctx.overlayLookup?.methodByOperation) {
|
|
462
|
+
const methodOrder = new Map<string, number>();
|
|
463
|
+
let pos = 0;
|
|
464
|
+
for (const [, info] of ctx.overlayLookup.methodByOperation) {
|
|
465
|
+
if (!methodOrder.has(info.methodName)) {
|
|
466
|
+
methodOrder.set(info.methodName, pos++);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
if (methodOrder.size > 0) {
|
|
470
|
+
plans.sort((a, b) => {
|
|
471
|
+
const aPos = methodOrder.get(a.method) ?? Number.MAX_SAFE_INTEGER;
|
|
472
|
+
const bPos = methodOrder.get(b.method) ?? Number.MAX_SAFE_INTEGER;
|
|
473
|
+
return aPos - bPos;
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
34
478
|
const hasPaginated = plans.some((p) => p.plan.isPaginated);
|
|
479
|
+
const modelMap = new Map(ctx.spec.models.map((m) => [m.name, m]));
|
|
35
480
|
|
|
36
|
-
// Collect models for imports
|
|
481
|
+
// Collect models for imports — only include models that are actually used
|
|
482
|
+
// in method signatures (not all union variants from the spec)
|
|
37
483
|
const responseModels = new Set<string>();
|
|
38
484
|
const requestModels = new Set<string>();
|
|
485
|
+
const paramEnums = new Set<string>();
|
|
486
|
+
const paramModels = new Set<string>();
|
|
39
487
|
for (const { op, plan } of plans) {
|
|
40
|
-
if (plan.
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
488
|
+
if (plan.isPaginated && op.pagination?.itemType.kind === 'model') {
|
|
489
|
+
// For paginated operations, import the item type (e.g., Connection)
|
|
490
|
+
// rather than the list wrapper type (e.g., ConnectionList).
|
|
491
|
+
// fetchAndDeserialize handles the list envelope internally.
|
|
492
|
+
// The IR's itemType may point to a list wrapper model — unwrap to the actual item.
|
|
493
|
+
let itemName = op.pagination.itemType.name;
|
|
494
|
+
const itemModel = modelMap.get(itemName);
|
|
495
|
+
if (itemModel) {
|
|
496
|
+
const unwrapped = unwrapListModel(itemModel, modelMap);
|
|
497
|
+
if (unwrapped) {
|
|
498
|
+
itemName = unwrapped.name;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
responseModels.add(itemName);
|
|
502
|
+
} else if (plan.responseModelName) {
|
|
503
|
+
responseModels.add(plan.responseModelName);
|
|
504
|
+
}
|
|
505
|
+
// Import request body model(s) — handles both single models and union variants.
|
|
506
|
+
const bodyInfo = extractRequestBodyType(op, ctx);
|
|
507
|
+
if (bodyInfo?.kind === 'model') {
|
|
508
|
+
requestModels.add(bodyInfo.name);
|
|
509
|
+
} else if (bodyInfo?.kind === 'union') {
|
|
510
|
+
if (bodyInfo.discriminator) {
|
|
511
|
+
// Discriminated union: import variant models with serializers so we can
|
|
512
|
+
// dispatch to the correct serializer at runtime based on the discriminator.
|
|
513
|
+
for (const name of bodyInfo.modelNames) {
|
|
514
|
+
requestModels.add(name);
|
|
515
|
+
}
|
|
516
|
+
} else {
|
|
517
|
+
// Non-discriminated union: import variant models with serializers so we
|
|
518
|
+
// can dispatch to the correct serializer at runtime via field guards.
|
|
519
|
+
for (const name of bodyInfo.modelNames) {
|
|
520
|
+
requestModels.add(name);
|
|
521
|
+
}
|
|
44
522
|
}
|
|
45
523
|
}
|
|
524
|
+
// Collect types referenced in query and path parameters.
|
|
525
|
+
// For paginated operations, skip standard pagination params (limit, before, after, order)
|
|
526
|
+
// since they're handled by PaginationOptions and don't need explicit imports.
|
|
527
|
+
const queryParams = plan.isPaginated
|
|
528
|
+
? op.queryParams.filter((p) => !PAGINATION_PARAM_NAMES.has(p.name))
|
|
529
|
+
: op.queryParams;
|
|
530
|
+
for (const param of [...queryParams, ...op.pathParams]) {
|
|
531
|
+
collectParamTypeRefs(param.type, paramEnums, paramModels);
|
|
532
|
+
}
|
|
46
533
|
}
|
|
47
|
-
const allModels = new Set([...responseModels, ...requestModels]);
|
|
534
|
+
const allModels = new Set([...responseModels, ...requestModels, ...paramModels]);
|
|
48
535
|
|
|
49
536
|
const lines: string[] = [];
|
|
50
537
|
|
|
@@ -52,63 +539,126 @@ function generateResourceClass(service: Service, ctx: EmitterContext): Generated
|
|
|
52
539
|
lines.push("import type { WorkOS } from '../workos';");
|
|
53
540
|
if (hasPaginated) {
|
|
54
541
|
lines.push("import type { PaginationOptions } from '../common/interfaces/pagination-options.interface';");
|
|
55
|
-
lines.push("import { AutoPaginatable } from '../common/utils/pagination';");
|
|
56
|
-
lines.push("import {
|
|
542
|
+
lines.push("import type { AutoPaginatable } from '../common/utils/pagination';");
|
|
543
|
+
lines.push("import { createPaginatedList } from '../common/utils/fetch-and-deserialize';");
|
|
57
544
|
}
|
|
58
545
|
|
|
59
|
-
// Check if any operation
|
|
546
|
+
// Check if any operation needs PostOptions (idempotent POST or custom encoding)
|
|
60
547
|
const hasIdempotentPost = plans.some((p) => p.plan.isIdempotentPost);
|
|
61
|
-
|
|
548
|
+
const hasCustomEncoding = plans.some(
|
|
549
|
+
(p) => p.op.requestBodyEncoding && p.op.requestBodyEncoding !== 'json' && p.plan.hasBody,
|
|
550
|
+
);
|
|
551
|
+
if (hasIdempotentPost || hasCustomEncoding) {
|
|
62
552
|
lines.push("import type { PostOptions } from '../common/interfaces/post-options.interface';");
|
|
63
553
|
}
|
|
64
554
|
|
|
65
555
|
// Compute model-to-service mapping for correct cross-service import paths
|
|
66
|
-
const modelToService =
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
556
|
+
const { modelToService, resolveDir } = createServiceDirResolver(ctx.spec.models, ctx.spec.services, ctx);
|
|
557
|
+
|
|
558
|
+
// Wire (Response) types are only needed for models used as response types in method signatures.
|
|
559
|
+
// Request models and param models only need the domain type.
|
|
560
|
+
const usedWireTypes = new Set<string>();
|
|
561
|
+
for (const name of responseModels) {
|
|
562
|
+
usedWireTypes.add(resolveInterfaceName(name, ctx));
|
|
563
|
+
}
|
|
70
564
|
|
|
565
|
+
// Track imported resolved names to prevent duplicate type name collisions
|
|
566
|
+
const importedTypeNames = new Set<string>();
|
|
71
567
|
for (const name of allModels) {
|
|
72
568
|
const resolved = resolveInterfaceName(name, ctx);
|
|
569
|
+
if (importedTypeNames.has(resolved)) continue; // Skip duplicate resolved names
|
|
570
|
+
importedTypeNames.add(resolved);
|
|
73
571
|
const modelDir = modelToService.get(name);
|
|
74
572
|
const modelServiceDir = resolveDir(modelDir);
|
|
75
573
|
const relPath =
|
|
76
574
|
modelServiceDir === serviceDir
|
|
77
575
|
? `./interfaces/${fileName(name)}.interface`
|
|
78
576
|
: `../${modelServiceDir}/interfaces/${fileName(name)}.interface`;
|
|
79
|
-
|
|
577
|
+
if (usedWireTypes.has(resolved)) {
|
|
578
|
+
lines.push(`import type { ${resolved}, ${wireInterfaceName(resolved)} } from '${relPath}';`);
|
|
579
|
+
} else {
|
|
580
|
+
lines.push(`import type { ${resolved} } from '${relPath}';`);
|
|
581
|
+
}
|
|
80
582
|
}
|
|
81
583
|
|
|
584
|
+
// Collect serializer imports by module path so we can merge deserialize and
|
|
585
|
+
// serialize imports from the same module into a single import statement.
|
|
586
|
+
const serializerImportsByPath = new Map<string, string[]>();
|
|
587
|
+
|
|
588
|
+
const importedDeserializers = new Set<string>();
|
|
82
589
|
for (const name of responseModels) {
|
|
83
590
|
const resolved = resolveInterfaceName(name, ctx);
|
|
591
|
+
if (importedDeserializers.has(resolved)) continue;
|
|
592
|
+
importedDeserializers.add(resolved);
|
|
84
593
|
const modelDir = modelToService.get(name);
|
|
85
594
|
const modelServiceDir = resolveDir(modelDir);
|
|
86
595
|
const relPath =
|
|
87
596
|
modelServiceDir === serviceDir
|
|
88
597
|
? `./serializers/${fileName(name)}.serializer`
|
|
89
598
|
: `../${modelServiceDir}/serializers/${fileName(name)}.serializer`;
|
|
90
|
-
|
|
599
|
+
const existing = serializerImportsByPath.get(relPath) ?? [];
|
|
600
|
+
existing.push(`deserialize${resolved}`);
|
|
601
|
+
serializerImportsByPath.set(relPath, existing);
|
|
91
602
|
}
|
|
92
603
|
|
|
604
|
+
const importedSerializers = new Set<string>();
|
|
93
605
|
for (const name of requestModels) {
|
|
94
606
|
const resolved = resolveInterfaceName(name, ctx);
|
|
607
|
+
if (importedSerializers.has(resolved)) continue;
|
|
608
|
+
importedSerializers.add(resolved);
|
|
95
609
|
const modelDir = modelToService.get(name);
|
|
96
610
|
const modelServiceDir = resolveDir(modelDir);
|
|
97
611
|
const relPath =
|
|
98
612
|
modelServiceDir === serviceDir
|
|
99
613
|
? `./serializers/${fileName(name)}.serializer`
|
|
100
614
|
: `../${modelServiceDir}/serializers/${fileName(name)}.serializer`;
|
|
101
|
-
|
|
615
|
+
const existing = serializerImportsByPath.get(relPath) ?? [];
|
|
616
|
+
existing.push(`serialize${resolved}`);
|
|
617
|
+
serializerImportsByPath.set(relPath, existing);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// Emit merged serializer imports
|
|
621
|
+
for (const [relPath, specifiers] of serializerImportsByPath) {
|
|
622
|
+
lines.push(`import { ${specifiers.join(', ')} } from '${relPath}';`);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// Build a set of global enum names — used to distinguish named enums (with files)
|
|
626
|
+
// from inline enums (no file, must be rendered as string literal unions).
|
|
627
|
+
const specEnumNames = new Set(ctx.spec.enums.map((e) => e.name));
|
|
628
|
+
|
|
629
|
+
// Import enum types referenced in query/path parameters.
|
|
630
|
+
// Only import enums that actually exist in the spec's global enums list —
|
|
631
|
+
// inline string unions may have kind 'enum' but no corresponding file.
|
|
632
|
+
if (paramEnums.size > 0) {
|
|
633
|
+
const enumToService = assignEnumsToServices(ctx.spec.enums, ctx.spec.services);
|
|
634
|
+
for (const name of paramEnums) {
|
|
635
|
+
if (allModels.has(name)) continue; // Already imported as a model
|
|
636
|
+
if (!specEnumNames.has(name)) continue; // No file generated for this enum
|
|
637
|
+
const enumDir = enumToService.get(name);
|
|
638
|
+
const enumServiceDir = resolveDir(enumDir);
|
|
639
|
+
const relPath =
|
|
640
|
+
enumServiceDir === serviceDir
|
|
641
|
+
? `./interfaces/${fileName(name)}.interface`
|
|
642
|
+
: `../${enumServiceDir}/interfaces/${fileName(name)}.interface`;
|
|
643
|
+
lines.push(`import type { ${name} } from '${relPath}';`);
|
|
644
|
+
}
|
|
102
645
|
}
|
|
103
646
|
|
|
104
647
|
lines.push('');
|
|
105
648
|
|
|
106
|
-
//
|
|
649
|
+
// Options interfaces for operations with query params.
|
|
650
|
+
// Paginated operations extend PaginationOptions; non-paginated operations get standalone interfaces.
|
|
107
651
|
for (const { op, plan, method } of plans) {
|
|
108
652
|
if (plan.isPaginated) {
|
|
109
|
-
const extraParams = op.queryParams.filter((p) => !
|
|
653
|
+
const extraParams = op.queryParams.filter((p) => !PAGINATION_PARAM_NAMES.has(p.name));
|
|
110
654
|
if (extraParams.length > 0) {
|
|
111
|
-
const optionsName =
|
|
655
|
+
const optionsName = paginatedOptionsName(method, resolvedName);
|
|
656
|
+
// Always generate the options interface locally in the resource file.
|
|
657
|
+
// Previously we skipped generation when a baseline interface with a matching
|
|
658
|
+
// name existed, but the baseline interface may live in a different module
|
|
659
|
+
// (e.g., `user-management/` vs `user-management-users/`) and would not be
|
|
660
|
+
// available without an import. Generating locally is safe and avoids
|
|
661
|
+
// cross-module import resolution issues.
|
|
112
662
|
lines.push(`export interface ${optionsName} extends PaginationOptions {`);
|
|
113
663
|
for (const param of extraParams) {
|
|
114
664
|
const opt = !param.required ? '?' : '';
|
|
@@ -118,11 +668,28 @@ function generateResourceClass(service: Service, ctx: EmitterContext): Generated
|
|
|
118
668
|
if (param.deprecated) parts.push('@deprecated');
|
|
119
669
|
lines.push(...docComment(parts.join('\n'), 2));
|
|
120
670
|
}
|
|
121
|
-
lines.push(` ${fieldName(param.name)}${opt}: ${
|
|
671
|
+
lines.push(` ${fieldName(param.name)}${opt}: ${mapParamType(param.type, specEnumNames)};`);
|
|
122
672
|
}
|
|
123
673
|
lines.push('}');
|
|
124
674
|
lines.push('');
|
|
125
675
|
}
|
|
676
|
+
} else if (!plan.isPaginated && !plan.hasBody && !plan.isDelete && op.queryParams.length > 0) {
|
|
677
|
+
// Non-paginated GET or void methods with query params get a typed options interface
|
|
678
|
+
// instead of falling back to Record<string, unknown>.
|
|
679
|
+
const optionsName = toPascalCase(method) + 'Options';
|
|
680
|
+
lines.push(`export interface ${optionsName} {`);
|
|
681
|
+
for (const param of op.queryParams) {
|
|
682
|
+
const opt = !param.required ? '?' : '';
|
|
683
|
+
if (param.description || param.deprecated) {
|
|
684
|
+
const parts: string[] = [];
|
|
685
|
+
if (param.description) parts.push(param.description);
|
|
686
|
+
if (param.deprecated) parts.push('@deprecated');
|
|
687
|
+
lines.push(...docComment(parts.join('\n'), 2));
|
|
688
|
+
}
|
|
689
|
+
lines.push(` ${fieldName(param.name)}${opt}: ${mapParamType(param.type, specEnumNames)};`);
|
|
690
|
+
}
|
|
691
|
+
lines.push('}');
|
|
692
|
+
lines.push('');
|
|
126
693
|
}
|
|
127
694
|
}
|
|
128
695
|
|
|
@@ -135,7 +702,7 @@ function generateResourceClass(service: Service, ctx: EmitterContext): Generated
|
|
|
135
702
|
|
|
136
703
|
for (const { op, plan, method } of plans) {
|
|
137
704
|
lines.push('');
|
|
138
|
-
lines.push(...renderMethod(op, plan, method, service, ctx));
|
|
705
|
+
lines.push(...renderMethod(op, plan, method, service, ctx, modelMap, specEnumNames));
|
|
139
706
|
}
|
|
140
707
|
|
|
141
708
|
lines.push('}');
|
|
@@ -149,29 +716,125 @@ function renderMethod(
|
|
|
149
716
|
method: string,
|
|
150
717
|
service: Service,
|
|
151
718
|
ctx: EmitterContext,
|
|
719
|
+
modelMap: Map<string, Model>,
|
|
720
|
+
specEnumNames?: Set<string>,
|
|
152
721
|
): string[] {
|
|
153
722
|
const lines: string[] = [];
|
|
154
723
|
const responseModel = plan.responseModelName ? resolveInterfaceName(plan.responseModelName, ctx) : null;
|
|
155
724
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
725
|
+
const pathStr = buildPathStr(op);
|
|
726
|
+
|
|
727
|
+
// Build set of valid param names to filter @param tags.
|
|
728
|
+
// Prefer the overlay (existing method signature) if available;
|
|
729
|
+
// otherwise compute from what the render path will actually include.
|
|
730
|
+
const httpKey = `${op.httpMethod.toUpperCase()} ${op.path}`;
|
|
731
|
+
const overlayMethod = ctx.overlayLookup?.methodByOperation?.get(httpKey);
|
|
732
|
+
let validParamNames: Set<string> | null = null;
|
|
733
|
+
if (overlayMethod) {
|
|
734
|
+
validParamNames = new Set(overlayMethod.params.map((p) => p.name));
|
|
735
|
+
} else {
|
|
736
|
+
// Compute actual params based on render path to avoid documenting params
|
|
737
|
+
// that won't appear in the method signature
|
|
738
|
+
const actualParams = new Set<string>();
|
|
739
|
+
for (const p of op.pathParams) actualParams.add(fieldName(p.name));
|
|
740
|
+
if (plan.hasBody) actualParams.add('payload');
|
|
741
|
+
if (plan.isPaginated) actualParams.add('options');
|
|
742
|
+
// renderGetMethod adds options when there are non-paginated query params
|
|
743
|
+
if (!plan.isPaginated && op.queryParams.length > 0 && !plan.isDelete && responseModel) {
|
|
744
|
+
actualParams.add('options');
|
|
745
|
+
}
|
|
746
|
+
validParamNames = actualParams;
|
|
747
|
+
}
|
|
160
748
|
|
|
161
749
|
const docParts: string[] = [];
|
|
162
750
|
if (op.description) docParts.push(op.description);
|
|
163
751
|
for (const param of op.pathParams) {
|
|
752
|
+
const paramName = fieldName(param.name);
|
|
753
|
+
if (validParamNames && !validParamNames.has(paramName)) continue;
|
|
754
|
+
const deprecatedPrefix = param.deprecated ? '(deprecated) ' : '';
|
|
164
755
|
if (param.description) {
|
|
165
|
-
docParts.push(`@param ${
|
|
756
|
+
docParts.push(`@param ${paramName} - ${deprecatedPrefix}${param.description}`);
|
|
757
|
+
} else if (param.deprecated) {
|
|
758
|
+
docParts.push(`@param ${paramName} - (deprecated)`);
|
|
166
759
|
}
|
|
760
|
+
if (param.default !== undefined) docParts.push(`@default ${JSON.stringify(param.default)}`);
|
|
761
|
+
if (param.example !== undefined) docParts.push(`@example ${JSON.stringify(param.example)}`);
|
|
167
762
|
}
|
|
168
|
-
|
|
763
|
+
// Document query params for non-paginated operations
|
|
764
|
+
if (!plan.isPaginated) {
|
|
765
|
+
// Only document query params if the method will have an options parameter
|
|
766
|
+
if (validParamNames && (validParamNames.has('options') || overlayMethod)) {
|
|
767
|
+
for (const param of op.queryParams) {
|
|
768
|
+
const paramName = `options.${fieldName(param.name)}`;
|
|
769
|
+
if (validParamNames && !validParamNames.has('options') && !validParamNames.has(fieldName(param.name))) continue;
|
|
770
|
+
const deprecatedPrefix = param.deprecated ? '(deprecated) ' : '';
|
|
771
|
+
if (param.description) {
|
|
772
|
+
docParts.push(`@param ${paramName} - ${deprecatedPrefix}${param.description}`);
|
|
773
|
+
} else if (param.deprecated) {
|
|
774
|
+
docParts.push(`@param ${paramName} - (deprecated)`);
|
|
775
|
+
}
|
|
776
|
+
if (param.default !== undefined) docParts.push(`@default ${JSON.stringify(param.default)}`);
|
|
777
|
+
if (param.example !== undefined) docParts.push(`@example ${JSON.stringify(param.example)}`);
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
// Skip header and cookie params in JSDoc — they are not exposed in the method signature.
|
|
782
|
+
// The SDK handles headers and cookies internally, so documenting them would be misleading.
|
|
783
|
+
// Document payload parameter when there is a request body
|
|
784
|
+
if (plan.hasBody) {
|
|
785
|
+
const bodyInfo = extractRequestBodyType(op, ctx);
|
|
786
|
+
if (bodyInfo?.kind === 'model') {
|
|
787
|
+
const bodyModel = ctx.spec.models.find((m) => m.name === bodyInfo.name);
|
|
788
|
+
let payloadDesc: string;
|
|
789
|
+
if (bodyModel?.description) {
|
|
790
|
+
payloadDesc = `@param payload - ${bodyModel.description}`;
|
|
791
|
+
} else if (bodyModel) {
|
|
792
|
+
// When the model lacks a description, list its required fields to help
|
|
793
|
+
// callers understand what must be provided.
|
|
794
|
+
const requiredFieldNames = bodyModel.fields.filter((f) => f.required).map((f) => fieldName(f.name));
|
|
795
|
+
payloadDesc =
|
|
796
|
+
requiredFieldNames.length > 0
|
|
797
|
+
? `@param payload - Object containing ${requiredFieldNames.join(', ')}.`
|
|
798
|
+
: '@param payload - The request body.';
|
|
799
|
+
} else {
|
|
800
|
+
payloadDesc = '@param payload - The request body.';
|
|
801
|
+
}
|
|
802
|
+
docParts.push(payloadDesc);
|
|
803
|
+
} else {
|
|
804
|
+
docParts.push('@param payload - The request body.');
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
// Document options parameter for paginated operations
|
|
808
|
+
if (plan.isPaginated) {
|
|
809
|
+
docParts.push('@param options - Pagination and filter options.');
|
|
810
|
+
} else if (op.queryParams.length > 0) {
|
|
811
|
+
docParts.push('@param options - Additional query options.');
|
|
812
|
+
}
|
|
813
|
+
// @returns for the primary response model (use item type for paginated operations).
|
|
814
|
+
// Unwrap list wrapper models to match the actual return type — the method returns
|
|
815
|
+
// AutoPaginatable<ItemType>, not the list wrapper.
|
|
816
|
+
if (plan.isPaginated && op.pagination?.itemType.kind === 'model') {
|
|
817
|
+
let itemRawName = op.pagination.itemType.name;
|
|
818
|
+
const pModel = modelMap.get(itemRawName);
|
|
819
|
+
if (pModel) {
|
|
820
|
+
const unwrapped = unwrapListModel(pModel, modelMap);
|
|
821
|
+
if (unwrapped) itemRawName = unwrapped.name;
|
|
822
|
+
}
|
|
823
|
+
const itemTypeName = resolveInterfaceName(itemRawName, ctx);
|
|
824
|
+
docParts.push(`@returns {AutoPaginatable<${itemTypeName}>}`);
|
|
825
|
+
} else if (responseModel) {
|
|
826
|
+
docParts.push(`@returns {${responseModel}}`);
|
|
827
|
+
} else {
|
|
828
|
+
docParts.push('@returns {void}');
|
|
829
|
+
}
|
|
830
|
+
// @throws for error responses
|
|
169
831
|
for (const err of op.errors) {
|
|
170
|
-
const exceptionName =
|
|
832
|
+
const exceptionName = STATUS_TO_EXCEPTION_NAME[err.statusCode];
|
|
171
833
|
if (exceptionName) {
|
|
172
834
|
docParts.push(`@throws {${exceptionName}} ${err.statusCode}`);
|
|
173
835
|
}
|
|
174
836
|
}
|
|
837
|
+
if (op.deprecated) docParts.push('@deprecated');
|
|
175
838
|
|
|
176
839
|
if (docParts.length > 0) {
|
|
177
840
|
// Flatten all parts, splitting multiline descriptions into individual lines
|
|
@@ -192,22 +855,59 @@ function renderMethod(
|
|
|
192
855
|
}
|
|
193
856
|
}
|
|
194
857
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
858
|
+
const preDecisionCount = lines.length;
|
|
859
|
+
|
|
860
|
+
if (plan.isPaginated && op.pagination && op.httpMethod === 'get') {
|
|
861
|
+
// For paginated operations, use the item type from pagination metadata
|
|
862
|
+
// (e.g., Connection) rather than the list wrapper type (e.g., ConnectionList).
|
|
863
|
+
// Unwrap list wrapper models to get the actual item type name.
|
|
864
|
+
let paginatedItemRawName = op.pagination.itemType.kind === 'model' ? op.pagination.itemType.name : null;
|
|
865
|
+
if (paginatedItemRawName) {
|
|
866
|
+
const pModel = modelMap.get(paginatedItemRawName);
|
|
867
|
+
if (pModel) {
|
|
868
|
+
const unwrapped = unwrapListModel(pModel, modelMap);
|
|
869
|
+
if (unwrapped) {
|
|
870
|
+
paginatedItemRawName = unwrapped.name;
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
const paginatedItemType = paginatedItemRawName ? resolveInterfaceName(paginatedItemRawName, ctx) : responseModel;
|
|
875
|
+
if (paginatedItemType) {
|
|
876
|
+
const resolvedServiceNameForPaginated = resolveServiceName(service, ctx);
|
|
877
|
+
renderPaginatedMethod(
|
|
878
|
+
lines,
|
|
879
|
+
op,
|
|
880
|
+
plan,
|
|
881
|
+
method,
|
|
882
|
+
paginatedItemType,
|
|
883
|
+
pathStr,
|
|
884
|
+
resolvedServiceNameForPaginated,
|
|
885
|
+
specEnumNames,
|
|
199
886
|
);
|
|
200
|
-
return lines;
|
|
201
887
|
}
|
|
202
|
-
|
|
888
|
+
} else if (plan.isPaginated && plan.hasBody && responseModel) {
|
|
889
|
+
// Non-GET paginated operation (e.g., PUT with list response) — treat as body method
|
|
890
|
+
renderBodyMethod(lines, op, plan, method, responseModel, pathStr, ctx, specEnumNames);
|
|
891
|
+
} else if (plan.isDelete && plan.hasBody) {
|
|
892
|
+
renderDeleteWithBodyMethod(lines, op, plan, method, pathStr, ctx, specEnumNames);
|
|
203
893
|
} else if (plan.isDelete) {
|
|
204
|
-
renderDeleteMethod(lines, op, plan, method, pathStr);
|
|
894
|
+
renderDeleteMethod(lines, op, plan, method, pathStr, specEnumNames);
|
|
205
895
|
} else if (plan.hasBody && responseModel) {
|
|
206
|
-
renderBodyMethod(lines, op, plan, method, responseModel, pathStr, ctx);
|
|
896
|
+
renderBodyMethod(lines, op, plan, method, responseModel, pathStr, ctx, specEnumNames);
|
|
207
897
|
} else if (responseModel) {
|
|
208
|
-
renderGetMethod(lines, op, plan, method, responseModel, pathStr);
|
|
898
|
+
renderGetMethod(lines, op, plan, method, responseModel, pathStr, specEnumNames);
|
|
209
899
|
} else {
|
|
210
|
-
renderVoidMethod(lines, op, plan, method, pathStr);
|
|
900
|
+
renderVoidMethod(lines, op, plan, method, pathStr, ctx, specEnumNames);
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
// Defensive: if no render function produced a method body, emit a stub
|
|
904
|
+
if (lines.length === preDecisionCount) {
|
|
905
|
+
const params = buildPathParams(op, specEnumNames);
|
|
906
|
+
lines.push(` async ${method}(${params}): Promise<void> {`);
|
|
907
|
+
lines.push(
|
|
908
|
+
` await this.workos.${op.httpMethod}(${pathStr}${httpMethodNeedsBody(op.httpMethod) ? ', {}' : ''});`,
|
|
909
|
+
);
|
|
910
|
+
lines.push(' }');
|
|
211
911
|
}
|
|
212
912
|
|
|
213
913
|
return lines;
|
|
@@ -219,29 +919,20 @@ function renderPaginatedMethod(
|
|
|
219
919
|
plan: OperationPlan,
|
|
220
920
|
method: string,
|
|
221
921
|
itemType: string,
|
|
922
|
+
pathStr: string,
|
|
923
|
+
resolvedServiceName: string,
|
|
924
|
+
specEnumNames?: Set<string>,
|
|
222
925
|
): void {
|
|
223
|
-
const extraParams = op.queryParams.filter((p) => !
|
|
224
|
-
const optionsType = extraParams.length > 0 ?
|
|
926
|
+
const extraParams = op.queryParams.filter((p) => !PAGINATION_PARAM_NAMES.has(p.name));
|
|
927
|
+
const optionsType = extraParams.length > 0 ? paginatedOptionsName(method, resolvedServiceName) : 'PaginationOptions';
|
|
225
928
|
|
|
226
|
-
const
|
|
929
|
+
const pathParams = buildPathParams(op, specEnumNames);
|
|
930
|
+
const allParams = pathParams ? `${pathParams}, options?: ${optionsType}` : `options?: ${optionsType}`;
|
|
227
931
|
|
|
228
|
-
lines.push(` async ${method}(
|
|
229
|
-
lines.push(
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
lines.push(` ${pathStr},`);
|
|
233
|
-
lines.push(` deserialize${itemType},`);
|
|
234
|
-
lines.push(' options,');
|
|
235
|
-
lines.push(' ),');
|
|
236
|
-
lines.push(' (params) =>');
|
|
237
|
-
lines.push(` fetchAndDeserialize<${wireInterfaceName(itemType)}, ${itemType}>(`);
|
|
238
|
-
lines.push(' this.workos,');
|
|
239
|
-
lines.push(` ${pathStr},`);
|
|
240
|
-
lines.push(` deserialize${itemType},`);
|
|
241
|
-
lines.push(' params,');
|
|
242
|
-
lines.push(' ),');
|
|
243
|
-
lines.push(' options,');
|
|
244
|
-
lines.push(' );');
|
|
932
|
+
lines.push(` async ${method}(${allParams}): Promise<AutoPaginatable<${itemType}, ${optionsType}>> {`);
|
|
933
|
+
lines.push(
|
|
934
|
+
` return createPaginatedList<${wireInterfaceName(itemType)}, ${itemType}, ${optionsType}>(this.workos, ${pathStr}, deserialize${itemType}, options);`,
|
|
935
|
+
);
|
|
245
936
|
lines.push(' }');
|
|
246
937
|
}
|
|
247
938
|
|
|
@@ -251,13 +942,54 @@ function renderDeleteMethod(
|
|
|
251
942
|
plan: OperationPlan,
|
|
252
943
|
method: string,
|
|
253
944
|
pathStr: string,
|
|
945
|
+
specEnumNames?: Set<string>,
|
|
254
946
|
): void {
|
|
255
|
-
const params = buildPathParams(op);
|
|
947
|
+
const params = buildPathParams(op, specEnumNames);
|
|
256
948
|
lines.push(` async ${method}(${params}): Promise<void> {`);
|
|
257
949
|
lines.push(` await this.workos.delete(${pathStr});`);
|
|
258
950
|
lines.push(' }');
|
|
259
951
|
}
|
|
260
952
|
|
|
953
|
+
function renderDeleteWithBodyMethod(
|
|
954
|
+
lines: string[],
|
|
955
|
+
op: Operation,
|
|
956
|
+
plan: OperationPlan,
|
|
957
|
+
method: string,
|
|
958
|
+
pathStr: string,
|
|
959
|
+
ctx: EmitterContext,
|
|
960
|
+
specEnumNames?: Set<string>,
|
|
961
|
+
): void {
|
|
962
|
+
const bodyInfo = extractRequestBodyType(op, ctx);
|
|
963
|
+
let requestType: string;
|
|
964
|
+
let bodyExpr: string;
|
|
965
|
+
if (bodyInfo?.kind === 'model') {
|
|
966
|
+
requestType = resolveInterfaceName(bodyInfo.name, ctx);
|
|
967
|
+
bodyExpr = `serialize${requestType}(payload)`;
|
|
968
|
+
} else if (bodyInfo?.kind === 'union') {
|
|
969
|
+
requestType = bodyInfo.typeStr;
|
|
970
|
+
if (bodyInfo.discriminator) {
|
|
971
|
+
bodyExpr = renderUnionBodySerializer(bodyInfo.discriminator, ctx);
|
|
972
|
+
} else {
|
|
973
|
+
bodyExpr = renderNonDiscriminatedUnionBodySerializer(bodyInfo.modelNames, ctx);
|
|
974
|
+
}
|
|
975
|
+
} else {
|
|
976
|
+
requestType = 'Record<string, unknown>';
|
|
977
|
+
bodyExpr = 'payload';
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
const paramParts: string[] = [];
|
|
981
|
+
for (const param of op.pathParams) {
|
|
982
|
+
paramParts.push(
|
|
983
|
+
`${fieldName(param.name)}: ${specEnumNames ? mapParamType(param.type, specEnumNames) : mapTypeRef(param.type)}`,
|
|
984
|
+
);
|
|
985
|
+
}
|
|
986
|
+
paramParts.push(`payload: ${requestType}`);
|
|
987
|
+
|
|
988
|
+
lines.push(` async ${method}(${paramParts.join(', ')}): Promise<void> {`);
|
|
989
|
+
lines.push(` await this.workos.deleteWithBody(${pathStr}, ${bodyExpr});`);
|
|
990
|
+
lines.push(' }');
|
|
991
|
+
}
|
|
992
|
+
|
|
261
993
|
function renderBodyMethod(
|
|
262
994
|
lines: string[],
|
|
263
995
|
op: Operation,
|
|
@@ -266,15 +998,33 @@ function renderBodyMethod(
|
|
|
266
998
|
responseModel: string,
|
|
267
999
|
pathStr: string,
|
|
268
1000
|
ctx: EmitterContext,
|
|
1001
|
+
specEnumNames?: Set<string>,
|
|
269
1002
|
): void {
|
|
270
|
-
const
|
|
271
|
-
|
|
1003
|
+
const bodyInfo = extractRequestBodyType(op, ctx);
|
|
1004
|
+
let requestType: string;
|
|
1005
|
+
let bodyExpr: string;
|
|
1006
|
+
if (bodyInfo?.kind === 'model') {
|
|
1007
|
+
requestType = resolveInterfaceName(bodyInfo.name, ctx);
|
|
1008
|
+
bodyExpr = `serialize${requestType}(payload)`;
|
|
1009
|
+
} else if (bodyInfo?.kind === 'union') {
|
|
1010
|
+
requestType = bodyInfo.typeStr;
|
|
1011
|
+
if (bodyInfo.discriminator) {
|
|
1012
|
+
bodyExpr = renderUnionBodySerializer(bodyInfo.discriminator, ctx);
|
|
1013
|
+
} else {
|
|
1014
|
+
bodyExpr = renderNonDiscriminatedUnionBodySerializer(bodyInfo.modelNames, ctx);
|
|
1015
|
+
}
|
|
1016
|
+
} else {
|
|
1017
|
+
requestType = 'Record<string, unknown>';
|
|
1018
|
+
bodyExpr = 'payload';
|
|
1019
|
+
}
|
|
272
1020
|
|
|
273
1021
|
const paramParts: string[] = [];
|
|
274
1022
|
|
|
275
1023
|
// Always pass path params as individual parameters (matches existing SDK pattern)
|
|
276
1024
|
for (const param of op.pathParams) {
|
|
277
|
-
paramParts.push(
|
|
1025
|
+
paramParts.push(
|
|
1026
|
+
`${fieldName(param.name)}: ${specEnumNames ? mapParamType(param.type, specEnumNames) : mapTypeRef(param.type)}`,
|
|
1027
|
+
);
|
|
278
1028
|
}
|
|
279
1029
|
|
|
280
1030
|
paramParts.push(`payload: ${requestType}`);
|
|
@@ -284,20 +1034,40 @@ function renderBodyMethod(
|
|
|
284
1034
|
}
|
|
285
1035
|
|
|
286
1036
|
const paramsStr = paramParts.join(', ');
|
|
287
|
-
|
|
1037
|
+
|
|
1038
|
+
// Fix 2: Pass encoding option when requestBodyEncoding is non-json
|
|
1039
|
+
const encoding = op.requestBodyEncoding;
|
|
1040
|
+
const encodingOption = encoding && encoding !== 'json' ? `, encoding: '${encoding}' as const` : '';
|
|
1041
|
+
const hasCustomEncoding = encodingOption !== '';
|
|
288
1042
|
|
|
289
1043
|
lines.push(` async ${method}(${paramsStr}): Promise<${responseModel}> {`);
|
|
290
1044
|
if (plan.isIdempotentPost) {
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
1045
|
+
if (hasCustomEncoding) {
|
|
1046
|
+
lines.push(` const { data } = await this.workos.${op.httpMethod}<${wireInterfaceName(responseModel)}>(`);
|
|
1047
|
+
lines.push(` ${pathStr},`);
|
|
1048
|
+
lines.push(` ${bodyExpr},`);
|
|
1049
|
+
lines.push(` { ...requestOptions${encodingOption} },`);
|
|
1050
|
+
lines.push(' );');
|
|
1051
|
+
} else {
|
|
1052
|
+
lines.push(` const { data } = await this.workos.${op.httpMethod}<${wireInterfaceName(responseModel)}>(`);
|
|
1053
|
+
lines.push(` ${pathStr},`);
|
|
1054
|
+
lines.push(` ${bodyExpr},`);
|
|
1055
|
+
lines.push(' requestOptions,');
|
|
1056
|
+
lines.push(' );');
|
|
1057
|
+
}
|
|
296
1058
|
} else {
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
1059
|
+
if (hasCustomEncoding) {
|
|
1060
|
+
lines.push(` const { data } = await this.workos.${op.httpMethod}<${wireInterfaceName(responseModel)}>(`);
|
|
1061
|
+
lines.push(` ${pathStr},`);
|
|
1062
|
+
lines.push(` ${bodyExpr},`);
|
|
1063
|
+
lines.push(` { ${encodingOption.slice(2)} },`);
|
|
1064
|
+
lines.push(' );');
|
|
1065
|
+
} else {
|
|
1066
|
+
lines.push(` const { data } = await this.workos.${op.httpMethod}<${wireInterfaceName(responseModel)}>(`);
|
|
1067
|
+
lines.push(` ${pathStr},`);
|
|
1068
|
+
lines.push(` ${bodyExpr},`);
|
|
1069
|
+
lines.push(' );');
|
|
1070
|
+
}
|
|
301
1071
|
}
|
|
302
1072
|
lines.push(` return deserialize${responseModel}(data);`);
|
|
303
1073
|
lines.push(' }');
|
|
@@ -310,23 +1080,27 @@ function renderGetMethod(
|
|
|
310
1080
|
method: string,
|
|
311
1081
|
responseModel: string,
|
|
312
1082
|
pathStr: string,
|
|
1083
|
+
specEnumNames?: Set<string>,
|
|
313
1084
|
): void {
|
|
314
|
-
const params = buildPathParams(op);
|
|
1085
|
+
const params = buildPathParams(op, specEnumNames);
|
|
315
1086
|
const hasQuery = op.queryParams.length > 0 && !plan.isPaginated;
|
|
1087
|
+
const optionsType = hasQuery ? toPascalCase(method) + 'Options' : null;
|
|
316
1088
|
|
|
317
|
-
const allParams = hasQuery
|
|
318
|
-
? params
|
|
319
|
-
? `${params}, options?: Record<string, any>`
|
|
320
|
-
: 'options?: Record<string, any>'
|
|
321
|
-
: params;
|
|
1089
|
+
const allParams = hasQuery ? (params ? `${params}, options?: ${optionsType}` : `options?: ${optionsType}`) : params;
|
|
322
1090
|
|
|
323
1091
|
lines.push(` async ${method}(${allParams}): Promise<${responseModel}> {`);
|
|
324
1092
|
if (hasQuery) {
|
|
1093
|
+
const queryExpr = renderQueryExpr(op.queryParams);
|
|
325
1094
|
lines.push(
|
|
326
1095
|
` const { data } = await this.workos.${op.httpMethod}<${wireInterfaceName(responseModel)}>(${pathStr}, {`,
|
|
327
1096
|
);
|
|
328
|
-
lines.push(
|
|
1097
|
+
lines.push(` query: ${queryExpr},`);
|
|
329
1098
|
lines.push(' });');
|
|
1099
|
+
} else if (httpMethodNeedsBody(op.httpMethod)) {
|
|
1100
|
+
// PUT/PATCH/POST require a body argument even when the spec has no request body
|
|
1101
|
+
lines.push(
|
|
1102
|
+
` const { data } = await this.workos.${op.httpMethod}<${wireInterfaceName(responseModel)}>(${pathStr}, {});`,
|
|
1103
|
+
);
|
|
330
1104
|
} else {
|
|
331
1105
|
lines.push(
|
|
332
1106
|
` const { data } = await this.workos.${op.httpMethod}<${wireInterfaceName(responseModel)}>(${pathStr});`,
|
|
@@ -336,45 +1110,325 @@ function renderGetMethod(
|
|
|
336
1110
|
lines.push(' }');
|
|
337
1111
|
}
|
|
338
1112
|
|
|
339
|
-
function renderVoidMethod(
|
|
340
|
-
|
|
341
|
-
|
|
1113
|
+
function renderVoidMethod(
|
|
1114
|
+
lines: string[],
|
|
1115
|
+
op: Operation,
|
|
1116
|
+
plan: OperationPlan,
|
|
1117
|
+
method: string,
|
|
1118
|
+
pathStr: string,
|
|
1119
|
+
ctx: EmitterContext,
|
|
1120
|
+
specEnumNames?: Set<string>,
|
|
1121
|
+
): void {
|
|
1122
|
+
const params = buildPathParams(op, specEnumNames);
|
|
1123
|
+
const hasQuery = op.queryParams.length > 0 && !plan.hasBody;
|
|
1124
|
+
const optionsType = hasQuery ? toPascalCase(method) + 'Options' : null;
|
|
1125
|
+
|
|
1126
|
+
let bodyParam = '';
|
|
1127
|
+
let bodyExpr = 'payload';
|
|
1128
|
+
if (plan.hasBody) {
|
|
1129
|
+
const bodyInfo = extractRequestBodyType(op, ctx);
|
|
1130
|
+
if (bodyInfo?.kind === 'model') {
|
|
1131
|
+
const requestType = resolveInterfaceName(bodyInfo.name, ctx);
|
|
1132
|
+
bodyParam = `payload: ${requestType}`;
|
|
1133
|
+
bodyExpr = `serialize${requestType}(payload)`;
|
|
1134
|
+
} else if (bodyInfo?.kind === 'union') {
|
|
1135
|
+
bodyParam = `payload: ${bodyInfo.typeStr}`;
|
|
1136
|
+
if (bodyInfo.discriminator) {
|
|
1137
|
+
bodyExpr = renderUnionBodySerializer(bodyInfo.discriminator, ctx);
|
|
1138
|
+
} else {
|
|
1139
|
+
bodyExpr = renderNonDiscriminatedUnionBodySerializer(bodyInfo.modelNames, ctx);
|
|
1140
|
+
}
|
|
1141
|
+
} else {
|
|
1142
|
+
bodyParam = 'payload: Record<string, unknown>';
|
|
1143
|
+
bodyExpr = 'payload';
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
const paramParts: string[] = [];
|
|
1148
|
+
if (params) paramParts.push(params);
|
|
1149
|
+
if (bodyParam) paramParts.push(bodyParam);
|
|
1150
|
+
if (optionsType) paramParts.push(`options?: ${optionsType}`);
|
|
1151
|
+
const allParams = paramParts.join(', ');
|
|
342
1152
|
|
|
343
1153
|
lines.push(` async ${method}(${allParams}): Promise<void> {`);
|
|
344
1154
|
if (plan.hasBody) {
|
|
345
|
-
lines.push(` await this.workos.${op.httpMethod}(${pathStr},
|
|
1155
|
+
lines.push(` await this.workos.${op.httpMethod}(${pathStr}, ${bodyExpr});`);
|
|
1156
|
+
} else if (hasQuery) {
|
|
1157
|
+
const queryExpr = renderQueryExpr(op.queryParams);
|
|
1158
|
+
lines.push(` await this.workos.${op.httpMethod}(${pathStr}, {`);
|
|
1159
|
+
lines.push(` query: ${queryExpr},`);
|
|
1160
|
+
lines.push(' });');
|
|
1161
|
+
} else if (httpMethodNeedsBody(op.httpMethod)) {
|
|
1162
|
+
lines.push(` await this.workos.${op.httpMethod}(${pathStr}, {});`);
|
|
346
1163
|
} else {
|
|
347
1164
|
lines.push(` await this.workos.${op.httpMethod}(${pathStr});`);
|
|
348
1165
|
}
|
|
349
1166
|
lines.push(' }');
|
|
350
1167
|
}
|
|
351
1168
|
|
|
1169
|
+
/**
|
|
1170
|
+
* Generate an inline query serialization expression that maps camelCase option
|
|
1171
|
+
* keys to their snake_case wire equivalents. When all keys already match
|
|
1172
|
+
* (camel === snake), returns 'options' as-is for brevity.
|
|
1173
|
+
*/
|
|
1174
|
+
function renderQueryExpr(queryParams: { name: string; required: boolean }[]): string {
|
|
1175
|
+
// Check if any key actually needs conversion
|
|
1176
|
+
const needsConversion = queryParams.some((p) => fieldName(p.name) !== wireFieldName(p.name));
|
|
1177
|
+
if (!needsConversion) return 'options';
|
|
1178
|
+
|
|
1179
|
+
const parts: string[] = [];
|
|
1180
|
+
for (const param of queryParams) {
|
|
1181
|
+
const camel = fieldName(param.name);
|
|
1182
|
+
const snake = wireFieldName(param.name);
|
|
1183
|
+
if (param.required) {
|
|
1184
|
+
parts.push(`${snake}: options.${camel}`);
|
|
1185
|
+
} else {
|
|
1186
|
+
parts.push(`...(options.${camel} !== undefined && { ${snake}: options.${camel} })`);
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
return `options ? { ${parts.join(', ')} } : undefined`;
|
|
1190
|
+
}
|
|
1191
|
+
|
|
352
1192
|
function buildPathStr(op: Operation): string {
|
|
353
1193
|
const interpolated = op.path.replace(/\{(\w+)\}/g, (_, p) => `\${${fieldName(p)}}`);
|
|
354
1194
|
return interpolated.includes('${') ? `\`${interpolated}\`` : `'${op.path}'`;
|
|
355
1195
|
}
|
|
356
1196
|
|
|
357
|
-
function buildPathParams(op: Operation): string {
|
|
358
|
-
|
|
1197
|
+
function buildPathParams(op: Operation, specEnumNames?: Set<string>): string {
|
|
1198
|
+
// Start with declared path params
|
|
1199
|
+
const declaredNames = new Set(op.pathParams.map((p) => fieldName(p.name)));
|
|
1200
|
+
const params = op.pathParams.map((p) => {
|
|
1201
|
+
const type = specEnumNames ? mapParamType(p.type, specEnumNames) : mapTypeRef(p.type);
|
|
1202
|
+
return `${fieldName(p.name)}: ${type}`;
|
|
1203
|
+
});
|
|
1204
|
+
|
|
1205
|
+
// Detect path template variables not in declared pathParams and add them as string params.
|
|
1206
|
+
// This handles cases where the spec path has {param} but pathParams is incomplete.
|
|
1207
|
+
const templateVars = [...op.path.matchAll(/\{(\w+)\}/g)].map(([, name]) => fieldName(name));
|
|
1208
|
+
for (const varName of templateVars) {
|
|
1209
|
+
if (!declaredNames.has(varName)) {
|
|
1210
|
+
params.push(`${varName}: string`);
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
return params.join(', ');
|
|
359
1215
|
}
|
|
360
1216
|
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
1217
|
+
/**
|
|
1218
|
+
* Walk a parameter's type tree and collect enum/model names for imports.
|
|
1219
|
+
* Handles arrays and nullable wrappers that may contain nested enums/models.
|
|
1220
|
+
*/
|
|
1221
|
+
function collectParamTypeRefs(type: TypeRef, enums: Set<string>, models: Set<string>): void {
|
|
1222
|
+
switch (type.kind) {
|
|
1223
|
+
case 'enum':
|
|
1224
|
+
enums.add(type.name);
|
|
1225
|
+
break;
|
|
1226
|
+
case 'model':
|
|
1227
|
+
models.add(type.name);
|
|
1228
|
+
break;
|
|
1229
|
+
case 'array':
|
|
1230
|
+
collectParamTypeRefs(type.items, enums, models);
|
|
1231
|
+
break;
|
|
1232
|
+
case 'nullable':
|
|
1233
|
+
collectParamTypeRefs(type.inner, enums, models);
|
|
1234
|
+
break;
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
/**
|
|
1239
|
+
* Extract request body type info, supporting both single models and union types.
|
|
1240
|
+
* Returns structured info so callers can handle imports and serialization appropriately.
|
|
1241
|
+
*/
|
|
1242
|
+
/**
|
|
1243
|
+
* Generate an IIFE expression that dispatches to the correct serializer for a
|
|
1244
|
+
* discriminated union request body. Switches on the camelCase discriminator
|
|
1245
|
+
* property of the domain object and calls the appropriate serialize function
|
|
1246
|
+
* for each mapped model variant.
|
|
1247
|
+
*/
|
|
1248
|
+
function renderUnionBodySerializer(
|
|
1249
|
+
disc: { property: string; mapping: Record<string, string> },
|
|
1250
|
+
ctx: EmitterContext,
|
|
1251
|
+
): string {
|
|
1252
|
+
const prop = fieldName(disc.property);
|
|
1253
|
+
const cases: string[] = [];
|
|
1254
|
+
for (const [value, modelName] of Object.entries(disc.mapping)) {
|
|
1255
|
+
const resolved = resolveInterfaceName(modelName, ctx);
|
|
1256
|
+
cases.push(`case '${value}': return serialize${resolved}(payload as any)`);
|
|
1257
|
+
}
|
|
1258
|
+
return `(() => { switch ((payload as any).${prop}) { ${cases.join('; ')}; default: return payload } })()`;
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
/**
|
|
1262
|
+
* Generate an IIFE expression that dispatches to the correct serializer for a
|
|
1263
|
+
* non-discriminated union request body. Inspects model fields to find a
|
|
1264
|
+
* required field unique to each variant and uses `'field' in payload` guards.
|
|
1265
|
+
* Falls back to `payload` only when no variant can be distinguished.
|
|
1266
|
+
*/
|
|
1267
|
+
function renderNonDiscriminatedUnionBodySerializer(modelNames: string[], ctx: EmitterContext): string {
|
|
1268
|
+
const modelMap = new Map(ctx.spec.models.map((m) => [m.name, m]));
|
|
1269
|
+
|
|
1270
|
+
// Try to detect an implicit discriminator: a required field present in all
|
|
1271
|
+
// variants whose type is `kind: 'literal'` with a distinct value per variant.
|
|
1272
|
+
// This covers oneOf unions where each variant has e.g. `grant_type: 'authorization_code'`.
|
|
1273
|
+
const implicitDisc = detectImplicitDiscriminator(modelNames, modelMap);
|
|
1274
|
+
if (implicitDisc) {
|
|
1275
|
+
return renderUnionBodySerializer(implicitDisc, ctx);
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
// Collect required field names per model (using camelCase domain names).
|
|
1279
|
+
const requiredFieldsByModel = new Map<string, Set<string>>();
|
|
1280
|
+
for (const name of modelNames) {
|
|
1281
|
+
const model = modelMap.get(name);
|
|
1282
|
+
if (!model) return 'payload';
|
|
1283
|
+
requiredFieldsByModel.set(name, new Set(model.fields.filter((f) => f.required).map((f) => fieldName(f.name))));
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
// For each model, find a required field that no other model has.
|
|
1287
|
+
const guards: Array<{ modelName: string; field: string }> = [];
|
|
1288
|
+
let fallbackModel: string | undefined;
|
|
1289
|
+
|
|
1290
|
+
for (const name of modelNames) {
|
|
1291
|
+
const myFields = requiredFieldsByModel.get(name)!;
|
|
1292
|
+
let uniqueField: string | undefined;
|
|
1293
|
+
for (const field of myFields) {
|
|
1294
|
+
const isUnique = modelNames.every((other) => other === name || !requiredFieldsByModel.get(other)?.has(field));
|
|
1295
|
+
if (isUnique) {
|
|
1296
|
+
uniqueField = field;
|
|
1297
|
+
break;
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
if (uniqueField) {
|
|
1301
|
+
guards.push({ modelName: name, field: uniqueField });
|
|
1302
|
+
} else if (!fallbackModel) {
|
|
1303
|
+
fallbackModel = name;
|
|
1304
|
+
} else {
|
|
1305
|
+
// Multiple models with no unique field — can't dispatch
|
|
1306
|
+
return 'payload';
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
if (guards.length === 0) return 'payload';
|
|
1311
|
+
|
|
1312
|
+
const parts: string[] = [];
|
|
1313
|
+
for (const { modelName, field } of guards) {
|
|
1314
|
+
const resolved = resolveInterfaceName(modelName, ctx);
|
|
1315
|
+
parts.push(`if ('${field}' in payload) return serialize${resolved}(payload as any)`);
|
|
1316
|
+
}
|
|
1317
|
+
if (fallbackModel) {
|
|
1318
|
+
const resolved = resolveInterfaceName(fallbackModel, ctx);
|
|
1319
|
+
parts.push(`return serialize${resolved}(payload as any)`);
|
|
1320
|
+
} else {
|
|
1321
|
+
parts.push('return payload');
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
return `(() => { ${parts.join('; ')} })()`;
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
/**
|
|
1328
|
+
* Detect an implicit discriminator from literal-typed fields.
|
|
1329
|
+
* Returns a discriminator descriptor if all variants share a required field
|
|
1330
|
+
* whose type is `kind: 'literal'` with a distinct value per variant.
|
|
1331
|
+
*/
|
|
1332
|
+
function detectImplicitDiscriminator(
|
|
1333
|
+
modelNames: string[],
|
|
1334
|
+
modelMap: Map<string, Model>,
|
|
1335
|
+
): { property: string; mapping: Record<string, string> } | null {
|
|
1336
|
+
if (modelNames.length < 2) return null;
|
|
1337
|
+
|
|
1338
|
+
const firstModel = modelMap.get(modelNames[0]);
|
|
1339
|
+
if (!firstModel) return null;
|
|
1340
|
+
|
|
1341
|
+
// Candidate fields: required fields with literal type in the first model.
|
|
1342
|
+
const candidates = firstModel.fields.filter((f) => f.required && f.type.kind === 'literal');
|
|
1343
|
+
|
|
1344
|
+
for (const candidate of candidates) {
|
|
1345
|
+
const mapping: Record<string, string> = {};
|
|
1346
|
+
const values = new Set<string | number | boolean | null>();
|
|
1347
|
+
let valid = true;
|
|
1348
|
+
|
|
1349
|
+
for (const name of modelNames) {
|
|
1350
|
+
const model = modelMap.get(name);
|
|
1351
|
+
if (!model) {
|
|
1352
|
+
valid = false;
|
|
1353
|
+
break;
|
|
1354
|
+
}
|
|
1355
|
+
const field = model.fields.find((f) => f.name === candidate.name);
|
|
1356
|
+
if (!field || !field.required || field.type.kind !== 'literal') {
|
|
1357
|
+
valid = false;
|
|
1358
|
+
break;
|
|
1359
|
+
}
|
|
1360
|
+
const val = field.type.value;
|
|
1361
|
+
if (values.has(val)) {
|
|
1362
|
+
valid = false;
|
|
1363
|
+
break;
|
|
1364
|
+
} // duplicate value
|
|
1365
|
+
values.add(val);
|
|
1366
|
+
mapping[String(val)] = name;
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
if (valid && Object.keys(mapping).length === modelNames.length) {
|
|
1370
|
+
return { property: candidate.name, mapping };
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
|
|
364
1374
|
return null;
|
|
365
1375
|
}
|
|
366
1376
|
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
};
|
|
1377
|
+
/** Return type for extractRequestBodyType when the body is a union. */
|
|
1378
|
+
interface UnionBodyInfo {
|
|
1379
|
+
kind: 'union';
|
|
1380
|
+
typeStr: string;
|
|
1381
|
+
modelNames: string[];
|
|
1382
|
+
discriminator?: { property: string; mapping: Record<string, string> };
|
|
1383
|
+
}
|
|
375
1384
|
|
|
376
|
-
function
|
|
377
|
-
|
|
378
|
-
|
|
1385
|
+
function extractRequestBodyType(
|
|
1386
|
+
op: Operation,
|
|
1387
|
+
ctx: EmitterContext,
|
|
1388
|
+
): { kind: 'model'; name: string } | UnionBodyInfo | null {
|
|
1389
|
+
if (!op.requestBody) return null;
|
|
1390
|
+
if (op.requestBody.kind === 'model') return { kind: 'model', name: op.requestBody.name };
|
|
1391
|
+
if (op.requestBody.kind === 'union') {
|
|
1392
|
+
const modelNames: string[] = [];
|
|
1393
|
+
for (const variant of op.requestBody.variants) {
|
|
1394
|
+
if (variant.kind === 'model') modelNames.push(variant.name);
|
|
1395
|
+
}
|
|
1396
|
+
if (modelNames.length > 0) {
|
|
1397
|
+
const typeStr = modelNames.map((n) => resolveInterfaceName(n, ctx)).join(' | ');
|
|
1398
|
+
return {
|
|
1399
|
+
kind: 'union',
|
|
1400
|
+
typeStr,
|
|
1401
|
+
modelNames,
|
|
1402
|
+
discriminator: op.requestBody.discriminator,
|
|
1403
|
+
};
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
379
1406
|
return null;
|
|
380
1407
|
}
|
|
1408
|
+
|
|
1409
|
+
/**
|
|
1410
|
+
* Map a parameter type to a TypeScript type string, handling inline enums
|
|
1411
|
+
* that don't have corresponding global enum definitions. These would
|
|
1412
|
+
* otherwise emit bare names like `Type` or `Action` that are never imported.
|
|
1413
|
+
*
|
|
1414
|
+
* Recursively handles container types (arrays, nullable) so that inline
|
|
1415
|
+
* enums nested inside e.g. `array<enum>` are also inlined as string literal unions.
|
|
1416
|
+
*/
|
|
1417
|
+
function mapParamType(type: TypeRef, specEnumNames: Set<string>): string {
|
|
1418
|
+
if (type.kind === 'enum' && !specEnumNames.has(type.name)) {
|
|
1419
|
+
// Inline enum with no generated file — render values as string literal union
|
|
1420
|
+
if (type.values && type.values.length > 0) {
|
|
1421
|
+
return type.values.map((v: string | number) => (typeof v === 'string' ? `'${v}'` : String(v))).join(' | ');
|
|
1422
|
+
}
|
|
1423
|
+
return 'string';
|
|
1424
|
+
}
|
|
1425
|
+
if (type.kind === 'array') {
|
|
1426
|
+
const inner = mapParamType(type.items, specEnumNames);
|
|
1427
|
+
// Parenthesize union types when used as array element type
|
|
1428
|
+
return inner.includes(' | ') ? `(${inner})[]` : `${inner}[]`;
|
|
1429
|
+
}
|
|
1430
|
+
if (type.kind === 'nullable') {
|
|
1431
|
+
return `${mapParamType(type.inner, specEnumNames)} | null`;
|
|
1432
|
+
}
|
|
1433
|
+
return mapTypeRef(type);
|
|
1434
|
+
}
|