@workos/oagen-emitters 0.2.0 → 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/.oxfmtrc.json +8 -1
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +7 -0
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +633 -85
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- 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 +94 -12
- package/src/node/common.ts +1 -1
- package/src/node/enums.ts +4 -4
- package/src/node/errors.ts +5 -1
- package/src/node/fixtures.ts +6 -4
- package/src/node/index.ts +65 -9
- package/src/node/models.ts +86 -75
- package/src/node/naming.ts +91 -2
- package/src/node/resources.ts +462 -23
- package/src/node/serializers.ts +3 -1
- package/src/node/tests.ts +39 -15
- package/src/node/utils.ts +52 -2
- package/test/node/client.test.ts +181 -82
- package/test/node/enums.test.ts +73 -3
- package/test/node/models.test.ts +107 -20
- package/test/node/naming.test.ts +14 -4
- package/test/node/resources.test.ts +627 -25
- package/test/node/serializers.test.ts +33 -6
package/src/node/resources.ts
CHANGED
|
@@ -1,20 +1,26 @@
|
|
|
1
1
|
// @oagen-ignore: Operation.async — all TypeScript SDK methods are async by nature
|
|
2
2
|
|
|
3
3
|
import type { Service, Operation, EmitterContext, GeneratedFile, TypeRef, Model } from '@workos/oagen';
|
|
4
|
-
import { planOperation, toPascalCase } from '@workos/oagen';
|
|
4
|
+
import { planOperation, toPascalCase, toCamelCase } from '@workos/oagen';
|
|
5
5
|
import type { OperationPlan } from '@workos/oagen';
|
|
6
6
|
import { mapTypeRef } from './type-map.js';
|
|
7
7
|
import {
|
|
8
8
|
fieldName,
|
|
9
9
|
wireFieldName,
|
|
10
10
|
fileName,
|
|
11
|
-
|
|
11
|
+
resolveServiceDir,
|
|
12
12
|
resolveMethodName,
|
|
13
13
|
resolveInterfaceName,
|
|
14
14
|
resolveServiceName,
|
|
15
15
|
wireInterfaceName,
|
|
16
16
|
} from './naming.js';
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
docComment,
|
|
19
|
+
createServiceDirResolver,
|
|
20
|
+
isServiceCoveredByExisting,
|
|
21
|
+
hasMethodsAbsentFromBaseline,
|
|
22
|
+
uncoveredOperations,
|
|
23
|
+
} from './utils.js';
|
|
18
24
|
import { assignEnumsToServices } from './enums.js';
|
|
19
25
|
import { unwrapListModel } from './fixtures.js';
|
|
20
26
|
|
|
@@ -85,27 +91,334 @@ function httpMethodNeedsBody(method: string): boolean {
|
|
|
85
91
|
return method === 'post' || method === 'put' || method === 'patch';
|
|
86
92
|
}
|
|
87
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
|
+
}
|
|
395
|
+
|
|
88
396
|
export function generateResources(services: Service[], ctx: EmitterContext): GeneratedFile[] {
|
|
89
397
|
if (services.length === 0) return [];
|
|
90
398
|
const files: GeneratedFile[] = [];
|
|
91
399
|
|
|
92
400
|
for (const service of services) {
|
|
93
401
|
if (isServiceCoveredByExisting(service, ctx)) {
|
|
94
|
-
// Fully covered
|
|
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);
|
|
95
411
|
continue;
|
|
96
412
|
}
|
|
97
413
|
|
|
98
|
-
// Check for partial coverage: some operations covered, some not.
|
|
99
|
-
// Generate methods only for uncovered operations.
|
|
100
414
|
const ops = uncoveredOperations(service, ctx);
|
|
101
415
|
if (ops.length === 0) continue;
|
|
102
416
|
|
|
103
417
|
if (ops.length < service.operations.length) {
|
|
104
|
-
// Partial coverage:
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
const
|
|
108
|
-
const file = generateResourceClass(partialService, ctx);
|
|
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);
|
|
109
422
|
delete file.skipIfExists;
|
|
110
423
|
files.push(file);
|
|
111
424
|
} else {
|
|
@@ -118,7 +431,7 @@ export function generateResources(services: Service[], ctx: EmitterContext): Gen
|
|
|
118
431
|
|
|
119
432
|
function generateResourceClass(service: Service, ctx: EmitterContext): GeneratedFile {
|
|
120
433
|
const resolvedName = resolveResourceClassName(service, ctx);
|
|
121
|
-
const serviceDir =
|
|
434
|
+
const serviceDir = resolveServiceDir(resolvedName);
|
|
122
435
|
const serviceClass = resolvedName;
|
|
123
436
|
const resourcePath = `src/${serviceDir}/${fileName(resolvedName)}.ts`;
|
|
124
437
|
|
|
@@ -128,6 +441,15 @@ function generateResourceClass(service: Service, ctx: EmitterContext): Generated
|
|
|
128
441
|
method: resolveMethodName(op, service, ctx),
|
|
129
442
|
}));
|
|
130
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
|
+
|
|
131
453
|
// Sort plans to match the existing file's method order.
|
|
132
454
|
// When the merger integrates generated content with existing files, its
|
|
133
455
|
// URL-fingerprint fallback (pass 2) matches by position among methods that
|
|
@@ -192,11 +514,10 @@ function generateResourceClass(service: Service, ctx: EmitterContext): Generated
|
|
|
192
514
|
requestModels.add(name);
|
|
193
515
|
}
|
|
194
516
|
} else {
|
|
195
|
-
// Non-discriminated union: import variant models
|
|
196
|
-
//
|
|
197
|
-
// so the payload is passed through as-is.
|
|
517
|
+
// Non-discriminated union: import variant models with serializers so we
|
|
518
|
+
// can dispatch to the correct serializer at runtime via field guards.
|
|
198
519
|
for (const name of bodyInfo.modelNames) {
|
|
199
|
-
|
|
520
|
+
requestModels.add(name);
|
|
200
521
|
}
|
|
201
522
|
}
|
|
202
523
|
}
|
|
@@ -649,7 +970,7 @@ function renderDeleteWithBodyMethod(
|
|
|
649
970
|
if (bodyInfo.discriminator) {
|
|
650
971
|
bodyExpr = renderUnionBodySerializer(bodyInfo.discriminator, ctx);
|
|
651
972
|
} else {
|
|
652
|
-
bodyExpr =
|
|
973
|
+
bodyExpr = renderNonDiscriminatedUnionBodySerializer(bodyInfo.modelNames, ctx);
|
|
653
974
|
}
|
|
654
975
|
} else {
|
|
655
976
|
requestType = 'Record<string, unknown>';
|
|
@@ -688,12 +1009,9 @@ function renderBodyMethod(
|
|
|
688
1009
|
} else if (bodyInfo?.kind === 'union') {
|
|
689
1010
|
requestType = bodyInfo.typeStr;
|
|
690
1011
|
if (bodyInfo.discriminator) {
|
|
691
|
-
// Discriminated union: dispatch to the correct serializer at runtime.
|
|
692
1012
|
bodyExpr = renderUnionBodySerializer(bodyInfo.discriminator, ctx);
|
|
693
1013
|
} else {
|
|
694
|
-
|
|
695
|
-
// pass the payload directly (caller provides the correct shape).
|
|
696
|
-
bodyExpr = 'payload';
|
|
1014
|
+
bodyExpr = renderNonDiscriminatedUnionBodySerializer(bodyInfo.modelNames, ctx);
|
|
697
1015
|
}
|
|
698
1016
|
} else {
|
|
699
1017
|
requestType = 'Record<string, unknown>';
|
|
@@ -818,7 +1136,7 @@ function renderVoidMethod(
|
|
|
818
1136
|
if (bodyInfo.discriminator) {
|
|
819
1137
|
bodyExpr = renderUnionBodySerializer(bodyInfo.discriminator, ctx);
|
|
820
1138
|
} else {
|
|
821
|
-
bodyExpr =
|
|
1139
|
+
bodyExpr = renderNonDiscriminatedUnionBodySerializer(bodyInfo.modelNames, ctx);
|
|
822
1140
|
}
|
|
823
1141
|
} else {
|
|
824
1142
|
bodyParam = 'payload: Record<string, unknown>';
|
|
@@ -940,6 +1258,122 @@ function renderUnionBodySerializer(
|
|
|
940
1258
|
return `(() => { switch ((payload as any).${prop}) { ${cases.join('; ')}; default: return payload } })()`;
|
|
941
1259
|
}
|
|
942
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
|
+
|
|
1374
|
+
return null;
|
|
1375
|
+
}
|
|
1376
|
+
|
|
943
1377
|
/** Return type for extractRequestBodyType when the body is a union. */
|
|
944
1378
|
interface UnionBodyInfo {
|
|
945
1379
|
kind: 'union';
|
|
@@ -961,7 +1395,12 @@ function extractRequestBodyType(
|
|
|
961
1395
|
}
|
|
962
1396
|
if (modelNames.length > 0) {
|
|
963
1397
|
const typeStr = modelNames.map((n) => resolveInterfaceName(n, ctx)).join(' | ');
|
|
964
|
-
return {
|
|
1398
|
+
return {
|
|
1399
|
+
kind: 'union',
|
|
1400
|
+
typeStr,
|
|
1401
|
+
modelNames,
|
|
1402
|
+
discriminator: op.requestBody.discriminator,
|
|
1403
|
+
};
|
|
965
1404
|
}
|
|
966
1405
|
}
|
|
967
1406
|
return null;
|
package/src/node/serializers.ts
CHANGED
|
@@ -641,7 +641,9 @@ function defaultForType(ref: TypeRef): string | null {
|
|
|
641
641
|
function serializerHasBaselineIncompatibility(
|
|
642
642
|
model: Model,
|
|
643
643
|
baselineResponse: { fields?: Record<string, { type: string; optional: boolean }> } | undefined,
|
|
644
|
-
baselineDomain?: {
|
|
644
|
+
baselineDomain?: {
|
|
645
|
+
fields?: Record<string, { type: string; optional: boolean }>;
|
|
646
|
+
},
|
|
645
647
|
ctx?: EmitterContext,
|
|
646
648
|
): boolean {
|
|
647
649
|
if (!baselineResponse?.fields) return false;
|