@workos/oagen-emitters 0.2.0 → 0.3.0
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/.husky/pre-commit +1 -0
- package/.oxfmtrc.json +8 -1
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +15 -0
- package/README.md +129 -0
- package/dist/index.d.mts +10 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +11943 -2728
- package/dist/index.mjs.map +1 -1
- package/docs/sdk-architecture/go.md +338 -0
- package/docs/sdk-architecture/php.md +315 -0
- package/docs/sdk-architecture/python.md +511 -0
- package/oagen.config.ts +298 -2
- package/package.json +9 -5
- package/scripts/generate-php.js +13 -0
- package/scripts/git-push-with-published-oagen.sh +21 -0
- package/smoke/sdk-dotnet.ts +17 -3
- package/smoke/sdk-elixir.ts +17 -3
- package/smoke/sdk-go.ts +137 -46
- package/smoke/sdk-kotlin.ts +23 -4
- package/smoke/sdk-node.ts +15 -3
- package/smoke/sdk-php.ts +28 -26
- package/smoke/sdk-python.ts +5 -2
- package/smoke/sdk-ruby.ts +17 -3
- package/smoke/sdk-rust.ts +16 -3
- package/src/go/client.ts +141 -0
- package/src/go/enums.ts +196 -0
- package/src/go/fixtures.ts +212 -0
- package/src/go/index.ts +81 -0
- package/src/go/manifest.ts +36 -0
- package/src/go/models.ts +254 -0
- package/src/go/naming.ts +191 -0
- package/src/go/resources.ts +827 -0
- package/src/go/tests.ts +751 -0
- package/src/go/type-map.ts +82 -0
- package/src/go/wrappers.ts +261 -0
- package/src/index.ts +3 -0
- package/src/node/client.ts +167 -122
- package/src/node/enums.ts +13 -4
- package/src/node/errors.ts +42 -233
- package/src/node/field-plan.ts +726 -0
- package/src/node/fixtures.ts +15 -5
- package/src/node/index.ts +65 -16
- package/src/node/models.ts +264 -96
- package/src/node/naming.ts +52 -25
- package/src/node/resources.ts +621 -172
- package/src/node/sdk-errors.ts +41 -0
- package/src/node/tests.ts +71 -27
- package/src/node/type-map.ts +4 -2
- package/src/node/utils.ts +56 -64
- package/src/node/wrappers.ts +151 -0
- package/src/php/client.ts +171 -0
- package/src/php/enums.ts +67 -0
- package/src/php/errors.ts +9 -0
- package/src/php/fixtures.ts +181 -0
- package/src/php/index.ts +96 -0
- package/src/php/manifest.ts +36 -0
- package/src/php/models.ts +310 -0
- package/src/php/naming.ts +298 -0
- package/src/php/resources.ts +561 -0
- package/src/php/tests.ts +533 -0
- package/src/php/type-map.ts +90 -0
- package/src/php/utils.ts +18 -0
- package/src/php/wrappers.ts +151 -0
- package/src/python/client.ts +337 -0
- package/src/python/enums.ts +313 -0
- package/src/python/fixtures.ts +196 -0
- package/src/python/index.ts +95 -0
- package/src/python/manifest.ts +38 -0
- package/src/python/models.ts +688 -0
- package/src/python/naming.ts +209 -0
- package/src/python/resources.ts +1322 -0
- package/src/python/tests.ts +1335 -0
- package/src/python/type-map.ts +93 -0
- package/src/python/wrappers.ts +191 -0
- package/src/shared/model-utils.ts +255 -0
- package/src/shared/naming-utils.ts +107 -0
- package/src/shared/non-spec-services.ts +54 -0
- package/src/shared/resolved-ops.ts +109 -0
- package/src/shared/wrapper-utils.ts +59 -0
- package/test/go/client.test.ts +92 -0
- package/test/go/enums.test.ts +132 -0
- package/test/go/errors.test.ts +9 -0
- package/test/go/models.test.ts +265 -0
- package/test/go/resources.test.ts +408 -0
- package/test/go/tests.test.ts +143 -0
- package/test/node/client.test.ts +199 -94
- package/test/node/enums.test.ts +75 -3
- package/test/node/errors.test.ts +2 -41
- package/test/node/models.test.ts +109 -20
- package/test/node/naming.test.ts +37 -4
- package/test/node/resources.test.ts +662 -30
- package/test/node/serializers.test.ts +36 -7
- package/test/node/type-map.test.ts +11 -0
- package/test/php/client.test.ts +94 -0
- package/test/php/enums.test.ts +173 -0
- package/test/php/errors.test.ts +9 -0
- package/test/php/models.test.ts +497 -0
- package/test/php/resources.test.ts +644 -0
- package/test/php/tests.test.ts +118 -0
- package/test/python/client.test.ts +200 -0
- package/test/python/enums.test.ts +228 -0
- package/test/python/errors.test.ts +16 -0
- package/test/python/manifest.test.ts +74 -0
- package/test/python/models.test.ts +716 -0
- package/test/python/resources.test.ts +617 -0
- package/test/python/tests.test.ts +202 -0
- package/src/node/common.ts +0 -273
- package/src/node/config.ts +0 -71
- package/src/node/serializers.ts +0 -744
package/src/node/resources.ts
CHANGED
|
@@ -1,22 +1,45 @@
|
|
|
1
1
|
// @oagen-ignore: Operation.async — all TypeScript SDK methods are async by nature
|
|
2
2
|
|
|
3
|
-
import type {
|
|
4
|
-
|
|
3
|
+
import type {
|
|
4
|
+
Service,
|
|
5
|
+
Operation,
|
|
6
|
+
EmitterContext,
|
|
7
|
+
GeneratedFile,
|
|
8
|
+
TypeRef,
|
|
9
|
+
Model,
|
|
10
|
+
ResolvedOperation,
|
|
11
|
+
} from '@workos/oagen';
|
|
12
|
+
import { planOperation, toPascalCase, toCamelCase } from '@workos/oagen';
|
|
5
13
|
import type { OperationPlan } from '@workos/oagen';
|
|
6
14
|
import { mapTypeRef } from './type-map.js';
|
|
7
15
|
import {
|
|
8
16
|
fieldName,
|
|
9
17
|
wireFieldName,
|
|
10
18
|
fileName,
|
|
11
|
-
|
|
19
|
+
resolveServiceDir,
|
|
12
20
|
resolveMethodName,
|
|
13
21
|
resolveInterfaceName,
|
|
14
22
|
resolveServiceName,
|
|
15
23
|
wireInterfaceName,
|
|
16
24
|
} from './naming.js';
|
|
17
|
-
import {
|
|
25
|
+
import {
|
|
26
|
+
docComment,
|
|
27
|
+
createServiceDirResolver,
|
|
28
|
+
isServiceCoveredByExisting,
|
|
29
|
+
hasMethodsAbsentFromBaseline,
|
|
30
|
+
uncoveredOperations,
|
|
31
|
+
} from './utils.js';
|
|
18
32
|
import { assignEnumsToServices } from './enums.js';
|
|
19
33
|
import { unwrapListModel } from './fixtures.js';
|
|
34
|
+
import { buildNodeStatusExceptions } from './sdk-errors.js';
|
|
35
|
+
import {
|
|
36
|
+
buildResolvedLookup,
|
|
37
|
+
lookupResolved,
|
|
38
|
+
groupByMount,
|
|
39
|
+
getOpDefaults,
|
|
40
|
+
getOpInferFromClient,
|
|
41
|
+
} from '../shared/resolved-ops.js';
|
|
42
|
+
import { generateWrapperMethods, collectWrapperResponseModels } from './wrappers.js';
|
|
20
43
|
|
|
21
44
|
/**
|
|
22
45
|
* Check whether the baseline (hand-written) class has a constructor compatible
|
|
@@ -57,15 +80,9 @@ export function resolveResourceClassName(service: Service, ctx: EmitterContext):
|
|
|
57
80
|
/** Standard pagination query params handled by PaginationOptions — not imported individually. */
|
|
58
81
|
const PAGINATION_PARAM_NAMES = new Set(['limit', 'before', 'after', 'order']);
|
|
59
82
|
|
|
60
|
-
/** Map HTTP status codes to their corresponding exception class names for @throws docs.
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
401: 'UnauthorizedException',
|
|
64
|
-
404: 'NotFoundException',
|
|
65
|
-
409: 'ConflictException',
|
|
66
|
-
422: 'UnprocessableEntityException',
|
|
67
|
-
429: 'RateLimitExceededException',
|
|
68
|
-
};
|
|
83
|
+
/** Map HTTP status codes to their corresponding exception class names for @throws docs.
|
|
84
|
+
* Built from sdk.errors.statusCodeMap with Node-specific naming overrides. */
|
|
85
|
+
const STATUS_TO_EXCEPTION_NAME: Record<number, string> = buildNodeStatusExceptions();
|
|
69
86
|
|
|
70
87
|
/**
|
|
71
88
|
* Compute the options interface name for a paginated method.
|
|
@@ -85,28 +102,135 @@ function httpMethodNeedsBody(method: string): boolean {
|
|
|
85
102
|
return method === 'post' || method === 'put' || method === 'patch';
|
|
86
103
|
}
|
|
87
104
|
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
// Method-name deduplication helpers
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
/** Split a camelCase/PascalCase name into lowercase word parts. */
|
|
110
|
+
function splitCamelWords(name: string): string[] {
|
|
111
|
+
const parts: string[] = [];
|
|
112
|
+
let start = 0;
|
|
113
|
+
for (let i = 1; i < name.length; i++) {
|
|
114
|
+
if (name[i] >= 'A' && name[i] <= 'Z') {
|
|
115
|
+
parts.push(name.slice(start, i).toLowerCase());
|
|
116
|
+
start = i;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
parts.push(name.slice(start).toLowerCase());
|
|
120
|
+
return parts;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Naive singularize: strip trailing 's' unless it ends in 'ss'. */
|
|
124
|
+
function singularize(word: string): string {
|
|
125
|
+
return word.endsWith('s') && !word.endsWith('ss') ? word.slice(0, -1) : word;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Deduplicate method names within the plans array.
|
|
130
|
+
*
|
|
131
|
+
* When `disambiguateOperationNames()` in `@workos/oagen` fails (e.g., for
|
|
132
|
+
* single-segment paths like `/organizations`), two operations can resolve to
|
|
133
|
+
* the same method name. Disambiguate by appending a path-derived suffix.
|
|
134
|
+
*/
|
|
135
|
+
function deduplicateMethodNames(
|
|
136
|
+
plans: Array<{ op: Operation; plan: OperationPlan; method: string }>,
|
|
137
|
+
_ctx: EmitterContext,
|
|
138
|
+
): void {
|
|
139
|
+
const nameCount = new Map<string, number>();
|
|
140
|
+
for (const p of plans) {
|
|
141
|
+
nameCount.set(p.method, (nameCount.get(p.method) ?? 0) + 1);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
for (const [name, count] of nameCount) {
|
|
145
|
+
if (count <= 1) continue;
|
|
146
|
+
const dupes = plans.filter((p) => p.method === name);
|
|
147
|
+
|
|
148
|
+
// If all duplicates are on the SAME base path (different HTTP methods),
|
|
149
|
+
// trust the names — they represent the same resource.
|
|
150
|
+
const basePaths = new Set(dupes.map((d) => d.op.path.replace(/\/\{[^}]+\}$/, '')));
|
|
151
|
+
if (basePaths.size <= 1) continue;
|
|
152
|
+
|
|
153
|
+
// Disambiguate: keep the name for the plan whose path best matches,
|
|
154
|
+
// append path suffix for the others.
|
|
155
|
+
// Score: how many words in the method name appear in the path segments
|
|
156
|
+
const nameWords = new Set(splitCamelWords(name).map(singularize));
|
|
157
|
+
const scored = dupes.map((d) => {
|
|
158
|
+
const pathWords = d.op.path
|
|
159
|
+
.split('/')
|
|
160
|
+
.filter((s) => s && !s.startsWith('{'))
|
|
161
|
+
.flatMap((s) => s.split('_'))
|
|
162
|
+
.map(singularize);
|
|
163
|
+
const overlap = pathWords.filter((w) => nameWords.has(w)).length;
|
|
164
|
+
return { plan: d, score: overlap };
|
|
165
|
+
});
|
|
166
|
+
scored.sort((a, b) => b.score - a.score);
|
|
167
|
+
|
|
168
|
+
// The best-scoring plan keeps the name; others get disambiguated
|
|
169
|
+
for (let i = 1; i < scored.length; i++) {
|
|
170
|
+
const dupe = scored[i].plan;
|
|
171
|
+
const segments = dupe.op.path.split('/').filter((s) => s && !s.startsWith('{'));
|
|
172
|
+
// Use first segment as suffix (the resource collection name)
|
|
173
|
+
const suffix = segments[0] ?? '';
|
|
174
|
+
if (suffix) {
|
|
175
|
+
dupe.method = toCamelCase(`${name}_${suffix}`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// If still colliding after suffix, append index
|
|
180
|
+
const stillDuped = new Map<string, typeof dupes>();
|
|
181
|
+
for (const dupe of dupes) {
|
|
182
|
+
const group = stillDuped.get(dupe.method) ?? [];
|
|
183
|
+
group.push(dupe);
|
|
184
|
+
stillDuped.set(dupe.method, group);
|
|
185
|
+
}
|
|
186
|
+
for (const [, group] of stillDuped) {
|
|
187
|
+
if (group.length <= 1) continue;
|
|
188
|
+
for (let i = 1; i < group.length; i++) {
|
|
189
|
+
group[i].method = `${group[i].method}${i + 1}`;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
88
195
|
export function generateResources(services: Service[], ctx: EmitterContext): GeneratedFile[] {
|
|
89
196
|
if (services.length === 0) return [];
|
|
90
197
|
const files: GeneratedFile[] = [];
|
|
91
198
|
|
|
92
|
-
|
|
199
|
+
// Group services by mount target to avoid file path collisions when
|
|
200
|
+
// multiple IR services mount to the same resource class.
|
|
201
|
+
const mountGroups = groupByMount(ctx);
|
|
202
|
+
const mergedServices: Service[] =
|
|
203
|
+
mountGroups.size > 0 ? [...mountGroups].map(([name, group]) => ({ name, operations: group.operations })) : services;
|
|
204
|
+
|
|
205
|
+
for (const service of mergedServices) {
|
|
93
206
|
if (isServiceCoveredByExisting(service, ctx)) {
|
|
94
|
-
// Fully covered
|
|
207
|
+
// Fully covered: generate with ALL operations so the merger's docstring
|
|
208
|
+
// refresh pass can update JSDoc on existing methods.
|
|
209
|
+
const file = generateResourceClass(service, ctx);
|
|
210
|
+
// When the baseline class is missing methods for some operations,
|
|
211
|
+
// remove skipIfExists so the merger adds the new methods.
|
|
212
|
+
if (hasMethodsAbsentFromBaseline(service, ctx)) {
|
|
213
|
+
delete file.skipIfExists;
|
|
214
|
+
// Suppress auto-generated header — the file is a merge target
|
|
215
|
+
// containing hand-written code, not a fully generated file.
|
|
216
|
+
file.headerPlacement = 'skip';
|
|
217
|
+
}
|
|
218
|
+
files.push(file);
|
|
95
219
|
continue;
|
|
96
220
|
}
|
|
97
221
|
|
|
98
|
-
// Check for partial coverage: some operations covered, some not.
|
|
99
|
-
// Generate methods only for uncovered operations.
|
|
100
222
|
const ops = uncoveredOperations(service, ctx);
|
|
101
223
|
if (ops.length === 0) continue;
|
|
102
224
|
|
|
103
225
|
if (ops.length < service.operations.length) {
|
|
104
|
-
// Partial coverage:
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
const
|
|
108
|
-
const file = generateResourceClass(partialService, ctx);
|
|
226
|
+
// Partial coverage: generate with ALL operations so JSDoc is available
|
|
227
|
+
// for both covered and uncovered methods. Remove skipIfExists so the
|
|
228
|
+
// merger adds new methods AND refreshes existing JSDoc.
|
|
229
|
+
const file = generateResourceClass(service, ctx);
|
|
109
230
|
delete file.skipIfExists;
|
|
231
|
+
// Suppress auto-generated header — the file is a merge target
|
|
232
|
+
// containing hand-written code, not a fully generated file.
|
|
233
|
+
file.headerPlacement = 'skip';
|
|
110
234
|
files.push(file);
|
|
111
235
|
} else {
|
|
112
236
|
files.push(generateResourceClass(service, ctx));
|
|
@@ -118,7 +242,7 @@ export function generateResources(services: Service[], ctx: EmitterContext): Gen
|
|
|
118
242
|
|
|
119
243
|
function generateResourceClass(service: Service, ctx: EmitterContext): GeneratedFile {
|
|
120
244
|
const resolvedName = resolveResourceClassName(service, ctx);
|
|
121
|
-
const serviceDir =
|
|
245
|
+
const serviceDir = resolveServiceDir(resolvedName);
|
|
122
246
|
const serviceClass = resolvedName;
|
|
123
247
|
const resourcePath = `src/${serviceDir}/${fileName(resolvedName)}.ts`;
|
|
124
248
|
|
|
@@ -128,6 +252,13 @@ function generateResourceClass(service: Service, ctx: EmitterContext): Generated
|
|
|
128
252
|
method: resolveMethodName(op, service, ctx),
|
|
129
253
|
}));
|
|
130
254
|
|
|
255
|
+
// Resolved operations already produce correct method names via the
|
|
256
|
+
// centralized hint map — no per-emitter reconciliation needed.
|
|
257
|
+
|
|
258
|
+
// Deduplicate method names within the class (e.g., two operations both
|
|
259
|
+
// resolving to "create" for different paths).
|
|
260
|
+
deduplicateMethodNames(plans, ctx);
|
|
261
|
+
|
|
131
262
|
// Sort plans to match the existing file's method order.
|
|
132
263
|
// When the merger integrates generated content with existing files, its
|
|
133
264
|
// URL-fingerprint fallback (pass 2) matches by position among methods that
|
|
@@ -136,18 +267,23 @@ function generateResourceClass(service: Service, ctx: EmitterContext): Generated
|
|
|
136
267
|
// order, JSDoc comments get attached to the wrong methods (list↔create,
|
|
137
268
|
// add↔set swaps). Sorting by the overlay's method order ensures the
|
|
138
269
|
// generated output matches the existing file's method order.
|
|
270
|
+
//
|
|
271
|
+
// We build the order from HTTP operation keys (e.g., "GET /organizations")
|
|
272
|
+
// rather than method names, because resolveMethodName may return a different
|
|
273
|
+
// name than the overlay's methodName (e.g., when the hint map overrides it),
|
|
274
|
+
// causing the lookup to fail and the sort to produce wrong order.
|
|
139
275
|
if (ctx.overlayLookup?.methodByOperation) {
|
|
140
|
-
const
|
|
276
|
+
const httpKeyOrder = new Map<string, number>();
|
|
141
277
|
let pos = 0;
|
|
142
|
-
for (const [
|
|
143
|
-
|
|
144
|
-
methodOrder.set(info.methodName, pos++);
|
|
145
|
-
}
|
|
278
|
+
for (const [httpKey] of ctx.overlayLookup.methodByOperation) {
|
|
279
|
+
httpKeyOrder.set(httpKey, pos++);
|
|
146
280
|
}
|
|
147
|
-
if (
|
|
281
|
+
if (httpKeyOrder.size > 0) {
|
|
148
282
|
plans.sort((a, b) => {
|
|
149
|
-
const
|
|
150
|
-
const
|
|
283
|
+
const aKey = `${a.op.httpMethod.toUpperCase()} ${a.op.path}`;
|
|
284
|
+
const bKey = `${b.op.httpMethod.toUpperCase()} ${b.op.path}`;
|
|
285
|
+
const aPos = httpKeyOrder.get(aKey) ?? Number.MAX_SAFE_INTEGER;
|
|
286
|
+
const bPos = httpKeyOrder.get(bKey) ?? Number.MAX_SAFE_INTEGER;
|
|
151
287
|
return aPos - bPos;
|
|
152
288
|
});
|
|
153
289
|
}
|
|
@@ -156,13 +292,32 @@ function generateResourceClass(service: Service, ctx: EmitterContext): Generated
|
|
|
156
292
|
const hasPaginated = plans.some((p) => p.plan.isPaginated);
|
|
157
293
|
const modelMap = new Map(ctx.spec.models.map((m) => [m.name, m]));
|
|
158
294
|
|
|
295
|
+
// When merging into an existing class, the merger keeps baseline method
|
|
296
|
+
// bodies but may add imports from the generated code. To avoid orphaned
|
|
297
|
+
// imports for types used only by baseline methods (whose bodies are kept
|
|
298
|
+
// intact), skip model collection for methods that already exist.
|
|
299
|
+
const baselineMethodSet = new Set<string>();
|
|
300
|
+
const baselineClass = ctx.apiSurface?.classes?.[serviceClass];
|
|
301
|
+
if (baselineClass?.methods) {
|
|
302
|
+
for (const name of Object.keys(baselineClass.methods)) {
|
|
303
|
+
baselineMethodSet.add(name);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
159
307
|
// Collect models for imports — only include models that are actually used
|
|
160
308
|
// in method signatures (not all union variants from the spec)
|
|
161
309
|
const responseModels = new Set<string>();
|
|
162
310
|
const requestModels = new Set<string>();
|
|
163
311
|
const paramEnums = new Set<string>();
|
|
164
312
|
const paramModels = new Set<string>();
|
|
165
|
-
for (const { op, plan } of plans) {
|
|
313
|
+
for (const { op, plan, method } of plans) {
|
|
314
|
+
// Skip imports for methods that already exist in the baseline class.
|
|
315
|
+
// The merger keeps baseline method bodies, so their imports are already
|
|
316
|
+
// present in the existing file. Including them here would create
|
|
317
|
+
// orphaned imports when the generated return type differs from the
|
|
318
|
+
// baseline's (e.g., generated `List` vs baseline `RoleList`).
|
|
319
|
+
if (baselineMethodSet.has(method)) continue;
|
|
320
|
+
|
|
166
321
|
if (plan.isPaginated && op.pagination?.itemType.kind === 'model') {
|
|
167
322
|
// For paginated operations, import the item type (e.g., Connection)
|
|
168
323
|
// rather than the list wrapper type (e.g., ConnectionList).
|
|
@@ -192,11 +347,10 @@ function generateResourceClass(service: Service, ctx: EmitterContext): Generated
|
|
|
192
347
|
requestModels.add(name);
|
|
193
348
|
}
|
|
194
349
|
} else {
|
|
195
|
-
// Non-discriminated union: import variant models
|
|
196
|
-
//
|
|
197
|
-
// so the payload is passed through as-is.
|
|
350
|
+
// Non-discriminated union: import variant models with serializers so we
|
|
351
|
+
// can dispatch to the correct serializer at runtime via field guards.
|
|
198
352
|
for (const name of bodyInfo.modelNames) {
|
|
199
|
-
|
|
353
|
+
requestModels.add(name);
|
|
200
354
|
}
|
|
201
355
|
}
|
|
202
356
|
}
|
|
@@ -210,6 +364,20 @@ function generateResourceClass(service: Service, ctx: EmitterContext): Generated
|
|
|
210
364
|
collectParamTypeRefs(param.type, paramEnums, paramModels);
|
|
211
365
|
}
|
|
212
366
|
}
|
|
367
|
+
|
|
368
|
+
// Collect response models from union split wrappers so their types and
|
|
369
|
+
// deserializers are imported alongside the primary operation models.
|
|
370
|
+
const resolvedLookup = buildResolvedLookup(ctx);
|
|
371
|
+
for (const { op, method } of plans) {
|
|
372
|
+
if (baselineMethodSet.has(method)) continue;
|
|
373
|
+
const resolved = lookupResolved(op, resolvedLookup);
|
|
374
|
+
if (resolved) {
|
|
375
|
+
for (const name of collectWrapperResponseModels(resolved)) {
|
|
376
|
+
responseModels.add(name);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
213
381
|
const allModels = new Set([...responseModels, ...requestModels, ...paramModels]);
|
|
214
382
|
|
|
215
383
|
const lines: string[] = [];
|
|
@@ -355,20 +523,29 @@ function generateResourceClass(service: Service, ctx: EmitterContext): Generated
|
|
|
355
523
|
} else if (!plan.isPaginated && !plan.hasBody && !plan.isDelete && op.queryParams.length > 0) {
|
|
356
524
|
// Non-paginated GET or void methods with query params get a typed options interface
|
|
357
525
|
// instead of falling back to Record<string, unknown>.
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
526
|
+
// Filter out hidden params (defaults and inferFromClient fields)
|
|
527
|
+
const resolved = lookupResolved(op, resolvedLookup);
|
|
528
|
+
const opHiddenParams = new Set<string>([
|
|
529
|
+
...Object.keys(getOpDefaults(resolved)),
|
|
530
|
+
...getOpInferFromClient(resolved),
|
|
531
|
+
]);
|
|
532
|
+
const visibleParams = op.queryParams.filter((p) => !opHiddenParams.has(p.name));
|
|
533
|
+
if (visibleParams.length > 0) {
|
|
534
|
+
const optionsName = toPascalCase(method) + 'Options';
|
|
535
|
+
lines.push(`export interface ${optionsName} {`);
|
|
536
|
+
for (const param of visibleParams) {
|
|
537
|
+
const opt = !param.required ? '?' : '';
|
|
538
|
+
if (param.description || param.deprecated) {
|
|
539
|
+
const parts: string[] = [];
|
|
540
|
+
if (param.description) parts.push(param.description);
|
|
541
|
+
if (param.deprecated) parts.push('@deprecated');
|
|
542
|
+
lines.push(...docComment(parts.join('\n'), 2));
|
|
543
|
+
}
|
|
544
|
+
lines.push(` ${fieldName(param.name)}${opt}: ${mapParamType(param.type, specEnumNames)};`);
|
|
367
545
|
}
|
|
368
|
-
lines.push(
|
|
546
|
+
lines.push('}');
|
|
547
|
+
lines.push('');
|
|
369
548
|
}
|
|
370
|
-
lines.push('}');
|
|
371
|
-
lines.push('');
|
|
372
549
|
}
|
|
373
550
|
}
|
|
374
551
|
|
|
@@ -381,7 +558,13 @@ function generateResourceClass(service: Service, ctx: EmitterContext): Generated
|
|
|
381
558
|
|
|
382
559
|
for (const { op, plan, method } of plans) {
|
|
383
560
|
lines.push('');
|
|
384
|
-
|
|
561
|
+
const resolved = lookupResolved(op, resolvedLookup);
|
|
562
|
+
lines.push(...renderMethod(op, plan, method, service, ctx, modelMap, specEnumNames, resolved));
|
|
563
|
+
|
|
564
|
+
// Emit union split wrapper methods (typed convenience methods for each variant)
|
|
565
|
+
if (resolved?.wrappers && resolved.wrappers.length > 0) {
|
|
566
|
+
lines.push(...generateWrapperMethods(resolved, ctx));
|
|
567
|
+
}
|
|
385
568
|
}
|
|
386
569
|
|
|
387
570
|
lines.push('}');
|
|
@@ -397,12 +580,20 @@ function renderMethod(
|
|
|
397
580
|
ctx: EmitterContext,
|
|
398
581
|
modelMap: Map<string, Model>,
|
|
399
582
|
specEnumNames?: Set<string>,
|
|
583
|
+
resolvedOp?: ResolvedOperation,
|
|
400
584
|
): string[] {
|
|
401
585
|
const lines: string[] = [];
|
|
402
586
|
const responseModel = plan.responseModelName ? resolveInterfaceName(plan.responseModelName, ctx) : null;
|
|
403
587
|
|
|
404
588
|
const pathStr = buildPathStr(op);
|
|
405
589
|
|
|
590
|
+
// Build the set of params hidden from the method signature
|
|
591
|
+
// (injected from client config or as constant defaults)
|
|
592
|
+
const hiddenParams = new Set<string>([
|
|
593
|
+
...Object.keys(getOpDefaults(resolvedOp)),
|
|
594
|
+
...getOpInferFromClient(resolvedOp),
|
|
595
|
+
]);
|
|
596
|
+
|
|
406
597
|
// Build set of valid param names to filter @param tags.
|
|
407
598
|
// Prefer the overlay (existing method signature) if available;
|
|
408
599
|
// otherwise compute from what the render path will actually include.
|
|
@@ -418,119 +609,135 @@ function renderMethod(
|
|
|
418
609
|
for (const p of op.pathParams) actualParams.add(fieldName(p.name));
|
|
419
610
|
if (plan.hasBody) actualParams.add('payload');
|
|
420
611
|
if (plan.isPaginated) actualParams.add('options');
|
|
421
|
-
// renderGetMethod
|
|
422
|
-
|
|
612
|
+
// renderGetMethod/renderVoidMethod add options when there are visible non-paginated query params
|
|
613
|
+
const visibleQueryCount = op.queryParams.filter((q) => !hiddenParams.has(q.name)).length;
|
|
614
|
+
if (!plan.isPaginated && visibleQueryCount > 0 && !plan.isDelete) {
|
|
423
615
|
actualParams.add('options');
|
|
424
616
|
}
|
|
425
617
|
validParamNames = actualParams;
|
|
426
618
|
}
|
|
427
619
|
|
|
428
|
-
|
|
429
|
-
if
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
620
|
+
// Always generate JSDoc for all methods (both existing and new).
|
|
621
|
+
// The merger matches docstrings by member name — if we skip JSDoc for
|
|
622
|
+
// existing methods, previously misplaced docstrings can never be corrected.
|
|
623
|
+
// Hand-written docs (e.g., @deprecated, PKCE flow descriptions) are
|
|
624
|
+
// preserved by the merger's @deprecated-preservation and @oagen-ignore
|
|
625
|
+
// mechanisms instead.
|
|
626
|
+
{
|
|
627
|
+
const docParts: string[] = [];
|
|
628
|
+
if (op.description) docParts.push(op.description);
|
|
629
|
+
for (const param of op.pathParams) {
|
|
630
|
+
const paramName = fieldName(param.name);
|
|
631
|
+
if (validParamNames && !validParamNames.has(paramName)) continue;
|
|
632
|
+
const deprecatedPrefix = param.deprecated ? '(deprecated) ' : '';
|
|
633
|
+
if (param.description) {
|
|
634
|
+
docParts.push(`@param ${paramName} - ${deprecatedPrefix}${param.description}`);
|
|
635
|
+
} else if (param.deprecated) {
|
|
636
|
+
docParts.push(`@param ${paramName} - (deprecated)`);
|
|
637
|
+
}
|
|
638
|
+
if (param.default !== undefined) docParts.push(`@default ${JSON.stringify(param.default)}`);
|
|
639
|
+
if (param.example !== undefined) docParts.push(`@example ${JSON.stringify(param.example)}`);
|
|
640
|
+
}
|
|
641
|
+
// Document query params for non-paginated operations
|
|
642
|
+
if (!plan.isPaginated) {
|
|
643
|
+
// Only document query params if the method will have an options parameter
|
|
644
|
+
if (validParamNames && (validParamNames.has('options') || overlayMethod)) {
|
|
645
|
+
for (const param of op.queryParams) {
|
|
646
|
+
if (hiddenParams.has(param.name)) continue;
|
|
647
|
+
const paramName = `options.${fieldName(param.name)}`;
|
|
648
|
+
if (validParamNames && !validParamNames.has('options') && !validParamNames.has(fieldName(param.name)))
|
|
649
|
+
continue;
|
|
650
|
+
const deprecatedPrefix = param.deprecated ? '(deprecated) ' : '';
|
|
651
|
+
if (param.description) {
|
|
652
|
+
docParts.push(`@param ${paramName} - ${deprecatedPrefix}${param.description}`);
|
|
653
|
+
} else if (param.deprecated) {
|
|
654
|
+
docParts.push(`@param ${paramName} - (deprecated)`);
|
|
655
|
+
}
|
|
656
|
+
if (param.default !== undefined) docParts.push(`@default ${JSON.stringify(param.default)}`);
|
|
657
|
+
if (param.example !== undefined) docParts.push(`@example ${JSON.stringify(param.example)}`);
|
|
454
658
|
}
|
|
455
|
-
if (param.default !== undefined) docParts.push(`@default ${JSON.stringify(param.default)}`);
|
|
456
|
-
if (param.example !== undefined) docParts.push(`@example ${JSON.stringify(param.example)}`);
|
|
457
659
|
}
|
|
458
660
|
}
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
661
|
+
// Skip header and cookie params in JSDoc — they are not exposed in the method signature.
|
|
662
|
+
// The SDK handles headers and cookies internally, so documenting them would be misleading.
|
|
663
|
+
// Document payload parameter when there is a request body
|
|
664
|
+
if (plan.hasBody) {
|
|
665
|
+
const bodyInfo = extractRequestBodyType(op, ctx);
|
|
666
|
+
if (bodyInfo?.kind === 'model') {
|
|
667
|
+
const bodyModel = ctx.spec.models.find((m) => m.name === bodyInfo.name);
|
|
668
|
+
let payloadDesc: string;
|
|
669
|
+
if (bodyModel?.description) {
|
|
670
|
+
payloadDesc = `@param payload - ${bodyModel.description}`;
|
|
671
|
+
} else if (bodyModel) {
|
|
672
|
+
// When the model lacks a description, list its required fields to help
|
|
673
|
+
// callers understand what must be provided.
|
|
674
|
+
const requiredFieldNames = bodyModel.fields.filter((f) => f.required).map((f) => fieldName(f.name));
|
|
675
|
+
payloadDesc =
|
|
676
|
+
requiredFieldNames.length > 0
|
|
677
|
+
? `@param payload - Object containing ${requiredFieldNames.join(', ')}.`
|
|
678
|
+
: '@param payload - The request body.';
|
|
679
|
+
} else {
|
|
680
|
+
payloadDesc = '@param payload - The request body.';
|
|
681
|
+
}
|
|
682
|
+
docParts.push(payloadDesc);
|
|
478
683
|
} else {
|
|
479
|
-
|
|
684
|
+
docParts.push('@param payload - The request body.');
|
|
480
685
|
}
|
|
481
|
-
|
|
686
|
+
}
|
|
687
|
+
// Document options parameter for paginated operations
|
|
688
|
+
if (plan.isPaginated) {
|
|
689
|
+
docParts.push('@param options - Pagination and filter options.');
|
|
690
|
+
} else if (op.queryParams.filter((q) => !hiddenParams.has(q.name)).length > 0) {
|
|
691
|
+
docParts.push('@param options - Additional query options.');
|
|
692
|
+
}
|
|
693
|
+
// @returns for the primary response model.
|
|
694
|
+
// When an overlay method exists, prefer its return type so the JSDoc
|
|
695
|
+
// matches the actual TypeScript signature (the overlay may use a
|
|
696
|
+
// different model name than the OpenAPI schema).
|
|
697
|
+
if (overlayMethod?.returnType) {
|
|
698
|
+
docParts.push(`@returns {${overlayMethod.returnType}}`);
|
|
699
|
+
} else if (plan.isPaginated && op.pagination?.itemType.kind === 'model') {
|
|
700
|
+
// Unwrap list wrapper models to match the actual return type — the method returns
|
|
701
|
+
// AutoPaginatable<ItemType>, not the list wrapper.
|
|
702
|
+
let itemRawName = op.pagination.itemType.name;
|
|
703
|
+
const pModel = modelMap.get(itemRawName);
|
|
704
|
+
if (pModel) {
|
|
705
|
+
const unwrapped = unwrapListModel(pModel, modelMap);
|
|
706
|
+
if (unwrapped) itemRawName = unwrapped.name;
|
|
707
|
+
}
|
|
708
|
+
const itemTypeName = resolveInterfaceName(itemRawName, ctx);
|
|
709
|
+
docParts.push(`@returns {Promise<AutoPaginatable<${itemTypeName}>>}`);
|
|
710
|
+
} else if (responseModel) {
|
|
711
|
+
docParts.push(`@returns {Promise<${responseModel}>}`);
|
|
482
712
|
} else {
|
|
483
|
-
docParts.push('@
|
|
484
|
-
}
|
|
485
|
-
}
|
|
486
|
-
// Document options parameter for paginated operations
|
|
487
|
-
if (plan.isPaginated) {
|
|
488
|
-
docParts.push('@param options - Pagination and filter options.');
|
|
489
|
-
} else if (op.queryParams.length > 0) {
|
|
490
|
-
docParts.push('@param options - Additional query options.');
|
|
491
|
-
}
|
|
492
|
-
// @returns for the primary response model (use item type for paginated operations).
|
|
493
|
-
// Unwrap list wrapper models to match the actual return type — the method returns
|
|
494
|
-
// AutoPaginatable<ItemType>, not the list wrapper.
|
|
495
|
-
if (plan.isPaginated && op.pagination?.itemType.kind === 'model') {
|
|
496
|
-
let itemRawName = op.pagination.itemType.name;
|
|
497
|
-
const pModel = modelMap.get(itemRawName);
|
|
498
|
-
if (pModel) {
|
|
499
|
-
const unwrapped = unwrapListModel(pModel, modelMap);
|
|
500
|
-
if (unwrapped) itemRawName = unwrapped.name;
|
|
501
|
-
}
|
|
502
|
-
const itemTypeName = resolveInterfaceName(itemRawName, ctx);
|
|
503
|
-
docParts.push(`@returns {AutoPaginatable<${itemTypeName}>}`);
|
|
504
|
-
} else if (responseModel) {
|
|
505
|
-
docParts.push(`@returns {${responseModel}}`);
|
|
506
|
-
} else {
|
|
507
|
-
docParts.push('@returns {void}');
|
|
508
|
-
}
|
|
509
|
-
// @throws for error responses
|
|
510
|
-
for (const err of op.errors) {
|
|
511
|
-
const exceptionName = STATUS_TO_EXCEPTION_NAME[err.statusCode];
|
|
512
|
-
if (exceptionName) {
|
|
513
|
-
docParts.push(`@throws {${exceptionName}} ${err.statusCode}`);
|
|
713
|
+
docParts.push('@returns {Promise<void>}');
|
|
514
714
|
}
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
const allLines: string[] = [];
|
|
521
|
-
for (const part of docParts) {
|
|
522
|
-
for (const line of part.split('\n')) {
|
|
523
|
-
allLines.push(line);
|
|
715
|
+
// @throws for error responses
|
|
716
|
+
for (const err of op.errors) {
|
|
717
|
+
const exceptionName = STATUS_TO_EXCEPTION_NAME[err.statusCode];
|
|
718
|
+
if (exceptionName) {
|
|
719
|
+
docParts.push(`@throws {${exceptionName}} ${err.statusCode}`);
|
|
524
720
|
}
|
|
525
721
|
}
|
|
526
|
-
if (
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
lines
|
|
530
|
-
|
|
531
|
-
|
|
722
|
+
if (op.deprecated) docParts.push('@deprecated');
|
|
723
|
+
|
|
724
|
+
if (docParts.length > 0) {
|
|
725
|
+
// Flatten all parts, splitting multiline descriptions into individual lines
|
|
726
|
+
const allLines: string[] = [];
|
|
727
|
+
for (const part of docParts) {
|
|
728
|
+
for (const line of part.split('\n')) {
|
|
729
|
+
allLines.push(line);
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
if (allLines.length === 1) {
|
|
733
|
+
lines.push(` /** ${allLines[0]} */`);
|
|
734
|
+
} else {
|
|
735
|
+
lines.push(' /**');
|
|
736
|
+
for (const line of allLines) {
|
|
737
|
+
lines.push(line === '' ? ' *' : ` * ${line}`);
|
|
738
|
+
}
|
|
739
|
+
lines.push(' */');
|
|
532
740
|
}
|
|
533
|
-
lines.push(' */');
|
|
534
741
|
}
|
|
535
742
|
}
|
|
536
743
|
|
|
@@ -574,9 +781,9 @@ function renderMethod(
|
|
|
574
781
|
} else if (plan.hasBody && responseModel) {
|
|
575
782
|
renderBodyMethod(lines, op, plan, method, responseModel, pathStr, ctx, specEnumNames);
|
|
576
783
|
} else if (responseModel) {
|
|
577
|
-
renderGetMethod(lines, op, plan, method, responseModel, pathStr, specEnumNames);
|
|
784
|
+
renderGetMethod(lines, op, plan, method, responseModel, pathStr, specEnumNames, resolvedOp);
|
|
578
785
|
} else {
|
|
579
|
-
renderVoidMethod(lines, op, plan, method, pathStr, ctx, specEnumNames);
|
|
786
|
+
renderVoidMethod(lines, op, plan, method, pathStr, ctx, specEnumNames, resolvedOp);
|
|
580
787
|
}
|
|
581
788
|
|
|
582
789
|
// Defensive: if no render function produced a method body, emit a stub
|
|
@@ -649,7 +856,7 @@ function renderDeleteWithBodyMethod(
|
|
|
649
856
|
if (bodyInfo.discriminator) {
|
|
650
857
|
bodyExpr = renderUnionBodySerializer(bodyInfo.discriminator, ctx);
|
|
651
858
|
} else {
|
|
652
|
-
bodyExpr =
|
|
859
|
+
bodyExpr = renderNonDiscriminatedUnionBodySerializer(bodyInfo.modelNames, ctx);
|
|
653
860
|
}
|
|
654
861
|
} else {
|
|
655
862
|
requestType = 'Record<string, unknown>';
|
|
@@ -688,12 +895,9 @@ function renderBodyMethod(
|
|
|
688
895
|
} else if (bodyInfo?.kind === 'union') {
|
|
689
896
|
requestType = bodyInfo.typeStr;
|
|
690
897
|
if (bodyInfo.discriminator) {
|
|
691
|
-
// Discriminated union: dispatch to the correct serializer at runtime.
|
|
692
898
|
bodyExpr = renderUnionBodySerializer(bodyInfo.discriminator, ctx);
|
|
693
899
|
} else {
|
|
694
|
-
|
|
695
|
-
// pass the payload directly (caller provides the correct shape).
|
|
696
|
-
bodyExpr = 'payload';
|
|
900
|
+
bodyExpr = renderNonDiscriminatedUnionBodySerializer(bodyInfo.modelNames, ctx);
|
|
697
901
|
}
|
|
698
902
|
} else {
|
|
699
903
|
requestType = 'Record<string, unknown>';
|
|
@@ -763,21 +967,78 @@ function renderGetMethod(
|
|
|
763
967
|
responseModel: string,
|
|
764
968
|
pathStr: string,
|
|
765
969
|
specEnumNames?: Set<string>,
|
|
970
|
+
resolvedOp?: ResolvedOperation,
|
|
766
971
|
): void {
|
|
767
|
-
const
|
|
768
|
-
|
|
769
|
-
|
|
972
|
+
const hiddenParams = new Set<string>([
|
|
973
|
+
...Object.keys(getOpDefaults(resolvedOp)),
|
|
974
|
+
...getOpInferFromClient(resolvedOp),
|
|
975
|
+
]);
|
|
770
976
|
|
|
771
|
-
const
|
|
977
|
+
const params = buildPathParams(op, specEnumNames);
|
|
978
|
+
const visibleQueryParams = op.queryParams.filter((p) => !hiddenParams.has(p.name));
|
|
979
|
+
const hasVisibleQuery = visibleQueryParams.length > 0 && !plan.isPaginated;
|
|
980
|
+
const hasDefaults = Object.keys(getOpDefaults(resolvedOp)).length > 0;
|
|
981
|
+
const hasInferred = getOpInferFromClient(resolvedOp).length > 0;
|
|
982
|
+
const hasInjected = hasDefaults || hasInferred;
|
|
983
|
+
const hasQuery = (op.queryParams.length > 0 && !plan.isPaginated) || hasInjected;
|
|
984
|
+
const optionsType = hasVisibleQuery ? toPascalCase(method) + 'Options' : null;
|
|
985
|
+
|
|
986
|
+
const allParams = optionsType
|
|
987
|
+
? params
|
|
988
|
+
? `${params}, options?: ${optionsType}`
|
|
989
|
+
: `options?: ${optionsType}`
|
|
990
|
+
: params;
|
|
772
991
|
|
|
773
992
|
lines.push(` async ${method}(${allParams}): Promise<${responseModel}> {`);
|
|
774
993
|
if (hasQuery) {
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
994
|
+
if (hasInjected) {
|
|
995
|
+
// Build the query object with visible params, defaults, and inferred fields
|
|
996
|
+
const queryParts: string[] = [];
|
|
997
|
+
|
|
998
|
+
// Regular visible query params (from options)
|
|
999
|
+
if (hasVisibleQuery) {
|
|
1000
|
+
for (const param of visibleQueryParams) {
|
|
1001
|
+
const camel = fieldName(param.name);
|
|
1002
|
+
const snake = wireFieldName(param.name);
|
|
1003
|
+
if (camel === snake) {
|
|
1004
|
+
if (param.required) {
|
|
1005
|
+
queryParts.push(`${camel}: options.${camel}`);
|
|
1006
|
+
} else {
|
|
1007
|
+
queryParts.push(`...(options?.${camel} !== undefined && { ${camel}: options.${camel} })`);
|
|
1008
|
+
}
|
|
1009
|
+
} else {
|
|
1010
|
+
if (param.required) {
|
|
1011
|
+
queryParts.push(`${snake}: options.${camel}`);
|
|
1012
|
+
} else {
|
|
1013
|
+
queryParts.push(`...(options?.${camel} !== undefined && { ${snake}: options.${camel} })`);
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
// Constant defaults (e.g., response_type: 'code')
|
|
1020
|
+
for (const [key, value] of Object.entries(getOpDefaults(resolvedOp))) {
|
|
1021
|
+
queryParts.push(`${key}: ${tsLiteral(value)}`);
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
// Inferred fields from client config (e.g., client_id from this.workos.options.clientId)
|
|
1025
|
+
for (const field of getOpInferFromClient(resolvedOp)) {
|
|
1026
|
+
queryParts.push(`${field}: ${clientFieldExpression(field)}`);
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
lines.push(
|
|
1030
|
+
` const { data } = await this.workos.${op.httpMethod}<${wireInterfaceName(responseModel)}>(${pathStr}, {`,
|
|
1031
|
+
);
|
|
1032
|
+
lines.push(` query: { ${queryParts.join(', ')} },`);
|
|
1033
|
+
lines.push(' });');
|
|
1034
|
+
} else {
|
|
1035
|
+
const queryExpr = renderQueryExpr(visibleQueryParams);
|
|
1036
|
+
lines.push(
|
|
1037
|
+
` const { data } = await this.workos.${op.httpMethod}<${wireInterfaceName(responseModel)}>(${pathStr}, {`,
|
|
1038
|
+
);
|
|
1039
|
+
lines.push(` query: ${queryExpr},`);
|
|
1040
|
+
lines.push(' });');
|
|
1041
|
+
}
|
|
781
1042
|
} else if (httpMethodNeedsBody(op.httpMethod)) {
|
|
782
1043
|
// PUT/PATCH/POST require a body argument even when the spec has no request body
|
|
783
1044
|
lines.push(
|
|
@@ -792,6 +1053,25 @@ function renderGetMethod(
|
|
|
792
1053
|
lines.push(' }');
|
|
793
1054
|
}
|
|
794
1055
|
|
|
1056
|
+
/** Convert a JS value to a TypeScript literal. */
|
|
1057
|
+
function tsLiteral(value: string | number | boolean): string {
|
|
1058
|
+
if (typeof value === 'string') return `'${value.replace(/'/g, "\\'")}'`;
|
|
1059
|
+
if (typeof value === 'boolean') return value ? 'true' : 'false';
|
|
1060
|
+
return String(value);
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
/** Get the TypeScript expression for reading a client config field. */
|
|
1064
|
+
function clientFieldExpression(field: string): string {
|
|
1065
|
+
switch (field) {
|
|
1066
|
+
case 'client_id':
|
|
1067
|
+
return 'this.workos.options.clientId';
|
|
1068
|
+
case 'client_secret':
|
|
1069
|
+
return 'this.workos.key';
|
|
1070
|
+
default:
|
|
1071
|
+
return `this.workos.${toCamelCase(field)}`;
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
|
|
795
1075
|
function renderVoidMethod(
|
|
796
1076
|
lines: string[],
|
|
797
1077
|
op: Operation,
|
|
@@ -800,10 +1080,21 @@ function renderVoidMethod(
|
|
|
800
1080
|
pathStr: string,
|
|
801
1081
|
ctx: EmitterContext,
|
|
802
1082
|
specEnumNames?: Set<string>,
|
|
1083
|
+
resolvedOp?: ResolvedOperation,
|
|
803
1084
|
): void {
|
|
1085
|
+
const hiddenParams = new Set<string>([
|
|
1086
|
+
...Object.keys(getOpDefaults(resolvedOp)),
|
|
1087
|
+
...getOpInferFromClient(resolvedOp),
|
|
1088
|
+
]);
|
|
1089
|
+
|
|
804
1090
|
const params = buildPathParams(op, specEnumNames);
|
|
805
|
-
const
|
|
806
|
-
const
|
|
1091
|
+
const visibleQueryParams = op.queryParams.filter((p) => !hiddenParams.has(p.name));
|
|
1092
|
+
const hasVisibleQuery = visibleQueryParams.length > 0 && !plan.hasBody;
|
|
1093
|
+
const hasDefaults = Object.keys(getOpDefaults(resolvedOp)).length > 0;
|
|
1094
|
+
const hasInferred = getOpInferFromClient(resolvedOp).length > 0;
|
|
1095
|
+
const hasInjected = hasDefaults || hasInferred;
|
|
1096
|
+
const hasQuery = hasVisibleQuery || (hasInjected && !plan.hasBody);
|
|
1097
|
+
const optionsType = hasVisibleQuery ? toPascalCase(method) + 'Options' : null;
|
|
807
1098
|
|
|
808
1099
|
let bodyParam = '';
|
|
809
1100
|
let bodyExpr = 'payload';
|
|
@@ -818,7 +1109,7 @@ function renderVoidMethod(
|
|
|
818
1109
|
if (bodyInfo.discriminator) {
|
|
819
1110
|
bodyExpr = renderUnionBodySerializer(bodyInfo.discriminator, ctx);
|
|
820
1111
|
} else {
|
|
821
|
-
bodyExpr =
|
|
1112
|
+
bodyExpr = renderNonDiscriminatedUnionBodySerializer(bodyInfo.modelNames, ctx);
|
|
822
1113
|
}
|
|
823
1114
|
} else {
|
|
824
1115
|
bodyParam = 'payload: Record<string, unknown>';
|
|
@@ -836,10 +1127,47 @@ function renderVoidMethod(
|
|
|
836
1127
|
if (plan.hasBody) {
|
|
837
1128
|
lines.push(` await this.workos.${op.httpMethod}(${pathStr}, ${bodyExpr});`);
|
|
838
1129
|
} else if (hasQuery) {
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
1130
|
+
if (hasInjected) {
|
|
1131
|
+
// Build query object with visible params, defaults, and inferred fields
|
|
1132
|
+
const queryParts: string[] = [];
|
|
1133
|
+
|
|
1134
|
+
if (hasVisibleQuery) {
|
|
1135
|
+
for (const param of visibleQueryParams) {
|
|
1136
|
+
const camel = fieldName(param.name);
|
|
1137
|
+
const snake = wireFieldName(param.name);
|
|
1138
|
+
if (camel === snake) {
|
|
1139
|
+
if (param.required) {
|
|
1140
|
+
queryParts.push(`${camel}: options.${camel}`);
|
|
1141
|
+
} else {
|
|
1142
|
+
queryParts.push(`...(options?.${camel} !== undefined && { ${camel}: options.${camel} })`);
|
|
1143
|
+
}
|
|
1144
|
+
} else {
|
|
1145
|
+
if (param.required) {
|
|
1146
|
+
queryParts.push(`${snake}: options.${camel}`);
|
|
1147
|
+
} else {
|
|
1148
|
+
queryParts.push(`...(options?.${camel} !== undefined && { ${snake}: options.${camel} })`);
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
for (const [key, value] of Object.entries(getOpDefaults(resolvedOp))) {
|
|
1155
|
+
queryParts.push(`${key}: ${tsLiteral(value)}`);
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
for (const field of getOpInferFromClient(resolvedOp)) {
|
|
1159
|
+
queryParts.push(`${field}: ${clientFieldExpression(field)}`);
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
lines.push(` await this.workos.${op.httpMethod}(${pathStr}, {`);
|
|
1163
|
+
lines.push(` query: { ${queryParts.join(', ')} },`);
|
|
1164
|
+
lines.push(' });');
|
|
1165
|
+
} else {
|
|
1166
|
+
const queryExpr = renderQueryExpr(visibleQueryParams);
|
|
1167
|
+
lines.push(` await this.workos.${op.httpMethod}(${pathStr}, {`);
|
|
1168
|
+
lines.push(` query: ${queryExpr},`);
|
|
1169
|
+
lines.push(' });');
|
|
1170
|
+
}
|
|
843
1171
|
} else if (httpMethodNeedsBody(op.httpMethod)) {
|
|
844
1172
|
lines.push(` await this.workos.${op.httpMethod}(${pathStr}, {});`);
|
|
845
1173
|
} else {
|
|
@@ -940,6 +1268,122 @@ function renderUnionBodySerializer(
|
|
|
940
1268
|
return `(() => { switch ((payload as any).${prop}) { ${cases.join('; ')}; default: return payload } })()`;
|
|
941
1269
|
}
|
|
942
1270
|
|
|
1271
|
+
/**
|
|
1272
|
+
* Generate an IIFE expression that dispatches to the correct serializer for a
|
|
1273
|
+
* non-discriminated union request body. Inspects model fields to find a
|
|
1274
|
+
* required field unique to each variant and uses `'field' in payload` guards.
|
|
1275
|
+
* Falls back to `payload` only when no variant can be distinguished.
|
|
1276
|
+
*/
|
|
1277
|
+
function renderNonDiscriminatedUnionBodySerializer(modelNames: string[], ctx: EmitterContext): string {
|
|
1278
|
+
const modelMap = new Map(ctx.spec.models.map((m) => [m.name, m]));
|
|
1279
|
+
|
|
1280
|
+
// Try to detect an implicit discriminator: a required field present in all
|
|
1281
|
+
// variants whose type is `kind: 'literal'` with a distinct value per variant.
|
|
1282
|
+
// This covers oneOf unions where each variant has e.g. `grant_type: 'authorization_code'`.
|
|
1283
|
+
const implicitDisc = detectImplicitDiscriminator(modelNames, modelMap);
|
|
1284
|
+
if (implicitDisc) {
|
|
1285
|
+
return renderUnionBodySerializer(implicitDisc, ctx);
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
// Collect required field names per model (using camelCase domain names).
|
|
1289
|
+
const requiredFieldsByModel = new Map<string, Set<string>>();
|
|
1290
|
+
for (const name of modelNames) {
|
|
1291
|
+
const model = modelMap.get(name);
|
|
1292
|
+
if (!model) return 'payload';
|
|
1293
|
+
requiredFieldsByModel.set(name, new Set(model.fields.filter((f) => f.required).map((f) => fieldName(f.name))));
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
// For each model, find a required field that no other model has.
|
|
1297
|
+
const guards: Array<{ modelName: string; field: string }> = [];
|
|
1298
|
+
let fallbackModel: string | undefined;
|
|
1299
|
+
|
|
1300
|
+
for (const name of modelNames) {
|
|
1301
|
+
const myFields = requiredFieldsByModel.get(name)!;
|
|
1302
|
+
let uniqueField: string | undefined;
|
|
1303
|
+
for (const field of myFields) {
|
|
1304
|
+
const isUnique = modelNames.every((other) => other === name || !requiredFieldsByModel.get(other)?.has(field));
|
|
1305
|
+
if (isUnique) {
|
|
1306
|
+
uniqueField = field;
|
|
1307
|
+
break;
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
if (uniqueField) {
|
|
1311
|
+
guards.push({ modelName: name, field: uniqueField });
|
|
1312
|
+
} else if (!fallbackModel) {
|
|
1313
|
+
fallbackModel = name;
|
|
1314
|
+
} else {
|
|
1315
|
+
// Multiple models with no unique field — can't dispatch
|
|
1316
|
+
return 'payload';
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
if (guards.length === 0) return 'payload';
|
|
1321
|
+
|
|
1322
|
+
const parts: string[] = [];
|
|
1323
|
+
for (const { modelName, field } of guards) {
|
|
1324
|
+
const resolved = resolveInterfaceName(modelName, ctx);
|
|
1325
|
+
parts.push(`if ('${field}' in payload) return serialize${resolved}(payload as any)`);
|
|
1326
|
+
}
|
|
1327
|
+
if (fallbackModel) {
|
|
1328
|
+
const resolved = resolveInterfaceName(fallbackModel, ctx);
|
|
1329
|
+
parts.push(`return serialize${resolved}(payload as any)`);
|
|
1330
|
+
} else {
|
|
1331
|
+
parts.push('return payload');
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
return `(() => { ${parts.join('; ')} })()`;
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
/**
|
|
1338
|
+
* Detect an implicit discriminator from literal-typed fields.
|
|
1339
|
+
* Returns a discriminator descriptor if all variants share a required field
|
|
1340
|
+
* whose type is `kind: 'literal'` with a distinct value per variant.
|
|
1341
|
+
*/
|
|
1342
|
+
function detectImplicitDiscriminator(
|
|
1343
|
+
modelNames: string[],
|
|
1344
|
+
modelMap: Map<string, Model>,
|
|
1345
|
+
): { property: string; mapping: Record<string, string> } | null {
|
|
1346
|
+
if (modelNames.length < 2) return null;
|
|
1347
|
+
|
|
1348
|
+
const firstModel = modelMap.get(modelNames[0]);
|
|
1349
|
+
if (!firstModel) return null;
|
|
1350
|
+
|
|
1351
|
+
// Candidate fields: required fields with literal type in the first model.
|
|
1352
|
+
const candidates = firstModel.fields.filter((f) => f.required && f.type.kind === 'literal');
|
|
1353
|
+
|
|
1354
|
+
for (const candidate of candidates) {
|
|
1355
|
+
const mapping: Record<string, string> = {};
|
|
1356
|
+
const values = new Set<string | number | boolean | null>();
|
|
1357
|
+
let valid = true;
|
|
1358
|
+
|
|
1359
|
+
for (const name of modelNames) {
|
|
1360
|
+
const model = modelMap.get(name);
|
|
1361
|
+
if (!model) {
|
|
1362
|
+
valid = false;
|
|
1363
|
+
break;
|
|
1364
|
+
}
|
|
1365
|
+
const field = model.fields.find((f) => f.name === candidate.name);
|
|
1366
|
+
if (!field || !field.required || field.type.kind !== 'literal') {
|
|
1367
|
+
valid = false;
|
|
1368
|
+
break;
|
|
1369
|
+
}
|
|
1370
|
+
const val = field.type.value;
|
|
1371
|
+
if (values.has(val)) {
|
|
1372
|
+
valid = false;
|
|
1373
|
+
break;
|
|
1374
|
+
} // duplicate value
|
|
1375
|
+
values.add(val);
|
|
1376
|
+
mapping[String(val)] = name;
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
if (valid && Object.keys(mapping).length === modelNames.length) {
|
|
1380
|
+
return { property: candidate.name, mapping };
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
return null;
|
|
1385
|
+
}
|
|
1386
|
+
|
|
943
1387
|
/** Return type for extractRequestBodyType when the body is a union. */
|
|
944
1388
|
interface UnionBodyInfo {
|
|
945
1389
|
kind: 'union';
|
|
@@ -961,7 +1405,12 @@ function extractRequestBodyType(
|
|
|
961
1405
|
}
|
|
962
1406
|
if (modelNames.length > 0) {
|
|
963
1407
|
const typeStr = modelNames.map((n) => resolveInterfaceName(n, ctx)).join(' | ');
|
|
964
|
-
return {
|
|
1408
|
+
return {
|
|
1409
|
+
kind: 'union',
|
|
1410
|
+
typeStr,
|
|
1411
|
+
modelNames,
|
|
1412
|
+
discriminator: op.requestBody.discriminator,
|
|
1413
|
+
};
|
|
965
1414
|
}
|
|
966
1415
|
}
|
|
967
1416
|
return null;
|