@workos/oagen-emitters 0.0.1 → 0.2.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/.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/.prettierignore +1 -0
- package/.release-please-manifest.json +3 -0
- package/.vscode/settings.json +3 -0
- package/CHANGELOG.md +54 -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 +3522 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +14 -18
- package/release-please-config.json +11 -0
- package/src/node/client.ts +437 -204
- package/src/node/common.ts +74 -4
- package/src/node/config.ts +1 -0
- package/src/node/enums.ts +50 -6
- package/src/node/errors.ts +78 -3
- package/src/node/fixtures.ts +84 -15
- package/src/node/index.ts +2 -2
- package/src/node/manifest.ts +4 -2
- package/src/node/models.ts +195 -79
- package/src/node/naming.ts +16 -1
- package/src/node/resources.ts +721 -106
- package/src/node/serializers.ts +510 -52
- package/src/node/tests.ts +621 -105
- package/src/node/type-map.ts +89 -11
- package/src/node/utils.ts +377 -114
- package/test/node/client.test.ts +979 -15
- package/test/node/enums.test.ts +0 -1
- package/test/node/errors.test.ts +4 -21
- package/test/node/models.test.ts +409 -2
- package/test/node/naming.test.ts +0 -3
- package/test/node/resources.test.ts +964 -7
- package/test/node/serializers.test.ts +212 -3
- 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,26 +1,123 @@
|
|
|
1
|
-
|
|
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';
|
|
2
4
|
import { planOperation, toPascalCase } 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
|
serviceDirName,
|
|
9
12
|
resolveMethodName,
|
|
10
13
|
resolveInterfaceName,
|
|
11
14
|
resolveServiceName,
|
|
12
|
-
buildServiceNameMap,
|
|
13
15
|
wireInterfaceName,
|
|
14
16
|
} from './naming.js';
|
|
15
|
-
import {
|
|
17
|
+
import { docComment, createServiceDirResolver, isServiceCoveredByExisting, uncoveredOperations } from './utils.js';
|
|
18
|
+
import { assignEnumsToServices } from './enums.js';
|
|
19
|
+
import { unwrapListModel } from './fixtures.js';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Check whether the baseline (hand-written) class has a constructor compatible
|
|
23
|
+
* with the generated pattern `constructor(private readonly workos: WorkOS)`.
|
|
24
|
+
* Returns true when no baseline exists (fresh generation) or when compatible.
|
|
25
|
+
*/
|
|
26
|
+
export function hasCompatibleConstructor(className: string, ctx: EmitterContext): boolean {
|
|
27
|
+
const baselineClass = ctx.apiSurface?.classes?.[className];
|
|
28
|
+
if (!baselineClass) return true; // No baseline — fresh generation
|
|
29
|
+
const params = baselineClass.constructorParams;
|
|
30
|
+
if (!params || params.length === 0) return true; // No-arg constructor is compatible
|
|
31
|
+
// Compatible if there is a single `workos` param whose type contains "WorkOS"
|
|
32
|
+
return params.some((p) => p.name === 'workos' && p.type.includes('WorkOS'));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Resolve the resource class name for a service, accounting for constructor
|
|
37
|
+
* compatibility with the baseline class.
|
|
38
|
+
*
|
|
39
|
+
* When the overlay-resolved class has an incompatible constructor (e.g., a
|
|
40
|
+
* hand-written `Webhooks` class that takes `CryptoProvider` instead of `WorkOS`),
|
|
41
|
+
* falls back to the IR name (`toPascalCase(service.name)`). If the IR name
|
|
42
|
+
* collides with the overlay name, appends an `Endpoints` suffix.
|
|
43
|
+
*/
|
|
44
|
+
export function resolveResourceClassName(service: Service, ctx: EmitterContext): string {
|
|
45
|
+
const overlayName = resolveServiceName(service, ctx);
|
|
46
|
+
if (hasCompatibleConstructor(overlayName, ctx)) {
|
|
47
|
+
return overlayName;
|
|
48
|
+
}
|
|
49
|
+
// Incompatible constructor — fall back to IR name
|
|
50
|
+
const irName = toPascalCase(service.name);
|
|
51
|
+
if (irName === overlayName) {
|
|
52
|
+
return irName + 'Endpoints';
|
|
53
|
+
}
|
|
54
|
+
return irName;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Standard pagination query params handled by PaginationOptions — not imported individually. */
|
|
58
|
+
const PAGINATION_PARAM_NAMES = new Set(['limit', 'before', 'after', 'order']);
|
|
59
|
+
|
|
60
|
+
/** Map HTTP status codes to their corresponding exception class names for @throws docs. */
|
|
61
|
+
const STATUS_TO_EXCEPTION_NAME: Record<number, string> = {
|
|
62
|
+
400: 'BadRequestException',
|
|
63
|
+
401: 'UnauthorizedException',
|
|
64
|
+
404: 'NotFoundException',
|
|
65
|
+
409: 'ConflictException',
|
|
66
|
+
422: 'UnprocessableEntityException',
|
|
67
|
+
429: 'RateLimitExceededException',
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Compute the options interface name for a paginated method.
|
|
72
|
+
* When the method name is simply "list", prefix with the service name to avoid
|
|
73
|
+
* naming collisions at barrel-export level (e.g. "ConnectionsListOptions"
|
|
74
|
+
* instead of the generic "ListOptions").
|
|
75
|
+
*/
|
|
76
|
+
function paginatedOptionsName(method: string, resolvedServiceName: string): string {
|
|
77
|
+
if (method === 'list') {
|
|
78
|
+
return `${toPascalCase(resolvedServiceName)}ListOptions`;
|
|
79
|
+
}
|
|
80
|
+
return toPascalCase(method) + 'Options';
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** HTTP methods that require a body argument even when the spec has no request body. */
|
|
84
|
+
function httpMethodNeedsBody(method: string): boolean {
|
|
85
|
+
return method === 'post' || method === 'put' || method === 'patch';
|
|
86
|
+
}
|
|
16
87
|
|
|
17
88
|
export function generateResources(services: Service[], ctx: EmitterContext): GeneratedFile[] {
|
|
18
89
|
if (services.length === 0) return [];
|
|
19
|
-
|
|
90
|
+
const files: GeneratedFile[] = [];
|
|
91
|
+
|
|
92
|
+
for (const service of services) {
|
|
93
|
+
if (isServiceCoveredByExisting(service, ctx)) {
|
|
94
|
+
// Fully covered — skip entirely
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Check for partial coverage: some operations covered, some not.
|
|
99
|
+
// Generate methods only for uncovered operations.
|
|
100
|
+
const ops = uncoveredOperations(service, ctx);
|
|
101
|
+
if (ops.length === 0) continue;
|
|
102
|
+
|
|
103
|
+
if (ops.length < service.operations.length) {
|
|
104
|
+
// Partial coverage: create a service with only uncovered operations.
|
|
105
|
+
// Remove skipIfExists so the merger can add these new methods to the
|
|
106
|
+
// existing class file (otherwise uncovered operations are silently lost).
|
|
107
|
+
const partialService = { ...service, operations: ops };
|
|
108
|
+
const file = generateResourceClass(partialService, ctx);
|
|
109
|
+
delete file.skipIfExists;
|
|
110
|
+
files.push(file);
|
|
111
|
+
} else {
|
|
112
|
+
files.push(generateResourceClass(service, ctx));
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return files;
|
|
20
117
|
}
|
|
21
118
|
|
|
22
119
|
function generateResourceClass(service: Service, ctx: EmitterContext): GeneratedFile {
|
|
23
|
-
const resolvedName =
|
|
120
|
+
const resolvedName = resolveResourceClassName(service, ctx);
|
|
24
121
|
const serviceDir = serviceDirName(resolvedName);
|
|
25
122
|
const serviceClass = resolvedName;
|
|
26
123
|
const resourcePath = `src/${serviceDir}/${fileName(resolvedName)}.ts`;
|
|
@@ -31,20 +128,89 @@ function generateResourceClass(service: Service, ctx: EmitterContext): Generated
|
|
|
31
128
|
method: resolveMethodName(op, service, ctx),
|
|
32
129
|
}));
|
|
33
130
|
|
|
131
|
+
// Sort plans to match the existing file's method order.
|
|
132
|
+
// When the merger integrates generated content with existing files, its
|
|
133
|
+
// URL-fingerprint fallback (pass 2) matches by position among methods that
|
|
134
|
+
// share the same endpoint path. If the spec lists POST before GET for a
|
|
135
|
+
// path (common in OpenAPI) but the existing class has them in a different
|
|
136
|
+
// order, JSDoc comments get attached to the wrong methods (list↔create,
|
|
137
|
+
// add↔set swaps). Sorting by the overlay's method order ensures the
|
|
138
|
+
// generated output matches the existing file's method order.
|
|
139
|
+
if (ctx.overlayLookup?.methodByOperation) {
|
|
140
|
+
const methodOrder = new Map<string, number>();
|
|
141
|
+
let pos = 0;
|
|
142
|
+
for (const [, info] of ctx.overlayLookup.methodByOperation) {
|
|
143
|
+
if (!methodOrder.has(info.methodName)) {
|
|
144
|
+
methodOrder.set(info.methodName, pos++);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (methodOrder.size > 0) {
|
|
148
|
+
plans.sort((a, b) => {
|
|
149
|
+
const aPos = methodOrder.get(a.method) ?? Number.MAX_SAFE_INTEGER;
|
|
150
|
+
const bPos = methodOrder.get(b.method) ?? Number.MAX_SAFE_INTEGER;
|
|
151
|
+
return aPos - bPos;
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
34
156
|
const hasPaginated = plans.some((p) => p.plan.isPaginated);
|
|
157
|
+
const modelMap = new Map(ctx.spec.models.map((m) => [m.name, m]));
|
|
35
158
|
|
|
36
|
-
// Collect models for imports
|
|
159
|
+
// Collect models for imports — only include models that are actually used
|
|
160
|
+
// in method signatures (not all union variants from the spec)
|
|
37
161
|
const responseModels = new Set<string>();
|
|
38
162
|
const requestModels = new Set<string>();
|
|
163
|
+
const paramEnums = new Set<string>();
|
|
164
|
+
const paramModels = new Set<string>();
|
|
39
165
|
for (const { op, plan } of plans) {
|
|
40
|
-
if (plan.
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
166
|
+
if (plan.isPaginated && op.pagination?.itemType.kind === 'model') {
|
|
167
|
+
// For paginated operations, import the item type (e.g., Connection)
|
|
168
|
+
// rather than the list wrapper type (e.g., ConnectionList).
|
|
169
|
+
// fetchAndDeserialize handles the list envelope internally.
|
|
170
|
+
// The IR's itemType may point to a list wrapper model — unwrap to the actual item.
|
|
171
|
+
let itemName = op.pagination.itemType.name;
|
|
172
|
+
const itemModel = modelMap.get(itemName);
|
|
173
|
+
if (itemModel) {
|
|
174
|
+
const unwrapped = unwrapListModel(itemModel, modelMap);
|
|
175
|
+
if (unwrapped) {
|
|
176
|
+
itemName = unwrapped.name;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
responseModels.add(itemName);
|
|
180
|
+
} else if (plan.responseModelName) {
|
|
181
|
+
responseModels.add(plan.responseModelName);
|
|
182
|
+
}
|
|
183
|
+
// Import request body model(s) — handles both single models and union variants.
|
|
184
|
+
const bodyInfo = extractRequestBodyType(op, ctx);
|
|
185
|
+
if (bodyInfo?.kind === 'model') {
|
|
186
|
+
requestModels.add(bodyInfo.name);
|
|
187
|
+
} else if (bodyInfo?.kind === 'union') {
|
|
188
|
+
if (bodyInfo.discriminator) {
|
|
189
|
+
// Discriminated union: import variant models with serializers so we can
|
|
190
|
+
// dispatch to the correct serializer at runtime based on the discriminator.
|
|
191
|
+
for (const name of bodyInfo.modelNames) {
|
|
192
|
+
requestModels.add(name);
|
|
193
|
+
}
|
|
194
|
+
} else {
|
|
195
|
+
// Non-discriminated union: import variant models as domain types only.
|
|
196
|
+
// Without a discriminator we can't statically dispatch serialization,
|
|
197
|
+
// so the payload is passed through as-is.
|
|
198
|
+
for (const name of bodyInfo.modelNames) {
|
|
199
|
+
paramModels.add(name);
|
|
200
|
+
}
|
|
44
201
|
}
|
|
45
202
|
}
|
|
203
|
+
// Collect types referenced in query and path parameters.
|
|
204
|
+
// For paginated operations, skip standard pagination params (limit, before, after, order)
|
|
205
|
+
// since they're handled by PaginationOptions and don't need explicit imports.
|
|
206
|
+
const queryParams = plan.isPaginated
|
|
207
|
+
? op.queryParams.filter((p) => !PAGINATION_PARAM_NAMES.has(p.name))
|
|
208
|
+
: op.queryParams;
|
|
209
|
+
for (const param of [...queryParams, ...op.pathParams]) {
|
|
210
|
+
collectParamTypeRefs(param.type, paramEnums, paramModels);
|
|
211
|
+
}
|
|
46
212
|
}
|
|
47
|
-
const allModels = new Set([...responseModels, ...requestModels]);
|
|
213
|
+
const allModels = new Set([...responseModels, ...requestModels, ...paramModels]);
|
|
48
214
|
|
|
49
215
|
const lines: string[] = [];
|
|
50
216
|
|
|
@@ -52,63 +218,126 @@ function generateResourceClass(service: Service, ctx: EmitterContext): Generated
|
|
|
52
218
|
lines.push("import type { WorkOS } from '../workos';");
|
|
53
219
|
if (hasPaginated) {
|
|
54
220
|
lines.push("import type { PaginationOptions } from '../common/interfaces/pagination-options.interface';");
|
|
55
|
-
lines.push("import { AutoPaginatable } from '../common/utils/pagination';");
|
|
56
|
-
lines.push("import {
|
|
221
|
+
lines.push("import type { AutoPaginatable } from '../common/utils/pagination';");
|
|
222
|
+
lines.push("import { createPaginatedList } from '../common/utils/fetch-and-deserialize';");
|
|
57
223
|
}
|
|
58
224
|
|
|
59
|
-
// Check if any operation
|
|
225
|
+
// Check if any operation needs PostOptions (idempotent POST or custom encoding)
|
|
60
226
|
const hasIdempotentPost = plans.some((p) => p.plan.isIdempotentPost);
|
|
61
|
-
|
|
227
|
+
const hasCustomEncoding = plans.some(
|
|
228
|
+
(p) => p.op.requestBodyEncoding && p.op.requestBodyEncoding !== 'json' && p.plan.hasBody,
|
|
229
|
+
);
|
|
230
|
+
if (hasIdempotentPost || hasCustomEncoding) {
|
|
62
231
|
lines.push("import type { PostOptions } from '../common/interfaces/post-options.interface';");
|
|
63
232
|
}
|
|
64
233
|
|
|
65
234
|
// Compute model-to-service mapping for correct cross-service import paths
|
|
66
|
-
const modelToService =
|
|
67
|
-
const serviceNameMap = buildServiceNameMap(ctx.spec.services, ctx);
|
|
68
|
-
const resolveDir = (irService: string | undefined) =>
|
|
69
|
-
irService ? serviceDirName(serviceNameMap.get(irService) ?? irService) : 'common';
|
|
235
|
+
const { modelToService, resolveDir } = createServiceDirResolver(ctx.spec.models, ctx.spec.services, ctx);
|
|
70
236
|
|
|
237
|
+
// Wire (Response) types are only needed for models used as response types in method signatures.
|
|
238
|
+
// Request models and param models only need the domain type.
|
|
239
|
+
const usedWireTypes = new Set<string>();
|
|
240
|
+
for (const name of responseModels) {
|
|
241
|
+
usedWireTypes.add(resolveInterfaceName(name, ctx));
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Track imported resolved names to prevent duplicate type name collisions
|
|
245
|
+
const importedTypeNames = new Set<string>();
|
|
71
246
|
for (const name of allModels) {
|
|
72
247
|
const resolved = resolveInterfaceName(name, ctx);
|
|
248
|
+
if (importedTypeNames.has(resolved)) continue; // Skip duplicate resolved names
|
|
249
|
+
importedTypeNames.add(resolved);
|
|
73
250
|
const modelDir = modelToService.get(name);
|
|
74
251
|
const modelServiceDir = resolveDir(modelDir);
|
|
75
252
|
const relPath =
|
|
76
253
|
modelServiceDir === serviceDir
|
|
77
254
|
? `./interfaces/${fileName(name)}.interface`
|
|
78
255
|
: `../${modelServiceDir}/interfaces/${fileName(name)}.interface`;
|
|
79
|
-
|
|
256
|
+
if (usedWireTypes.has(resolved)) {
|
|
257
|
+
lines.push(`import type { ${resolved}, ${wireInterfaceName(resolved)} } from '${relPath}';`);
|
|
258
|
+
} else {
|
|
259
|
+
lines.push(`import type { ${resolved} } from '${relPath}';`);
|
|
260
|
+
}
|
|
80
261
|
}
|
|
81
262
|
|
|
263
|
+
// Collect serializer imports by module path so we can merge deserialize and
|
|
264
|
+
// serialize imports from the same module into a single import statement.
|
|
265
|
+
const serializerImportsByPath = new Map<string, string[]>();
|
|
266
|
+
|
|
267
|
+
const importedDeserializers = new Set<string>();
|
|
82
268
|
for (const name of responseModels) {
|
|
83
269
|
const resolved = resolveInterfaceName(name, ctx);
|
|
270
|
+
if (importedDeserializers.has(resolved)) continue;
|
|
271
|
+
importedDeserializers.add(resolved);
|
|
84
272
|
const modelDir = modelToService.get(name);
|
|
85
273
|
const modelServiceDir = resolveDir(modelDir);
|
|
86
274
|
const relPath =
|
|
87
275
|
modelServiceDir === serviceDir
|
|
88
276
|
? `./serializers/${fileName(name)}.serializer`
|
|
89
277
|
: `../${modelServiceDir}/serializers/${fileName(name)}.serializer`;
|
|
90
|
-
|
|
278
|
+
const existing = serializerImportsByPath.get(relPath) ?? [];
|
|
279
|
+
existing.push(`deserialize${resolved}`);
|
|
280
|
+
serializerImportsByPath.set(relPath, existing);
|
|
91
281
|
}
|
|
92
282
|
|
|
283
|
+
const importedSerializers = new Set<string>();
|
|
93
284
|
for (const name of requestModels) {
|
|
94
285
|
const resolved = resolveInterfaceName(name, ctx);
|
|
286
|
+
if (importedSerializers.has(resolved)) continue;
|
|
287
|
+
importedSerializers.add(resolved);
|
|
95
288
|
const modelDir = modelToService.get(name);
|
|
96
289
|
const modelServiceDir = resolveDir(modelDir);
|
|
97
290
|
const relPath =
|
|
98
291
|
modelServiceDir === serviceDir
|
|
99
292
|
? `./serializers/${fileName(name)}.serializer`
|
|
100
293
|
: `../${modelServiceDir}/serializers/${fileName(name)}.serializer`;
|
|
101
|
-
|
|
294
|
+
const existing = serializerImportsByPath.get(relPath) ?? [];
|
|
295
|
+
existing.push(`serialize${resolved}`);
|
|
296
|
+
serializerImportsByPath.set(relPath, existing);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Emit merged serializer imports
|
|
300
|
+
for (const [relPath, specifiers] of serializerImportsByPath) {
|
|
301
|
+
lines.push(`import { ${specifiers.join(', ')} } from '${relPath}';`);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Build a set of global enum names — used to distinguish named enums (with files)
|
|
305
|
+
// from inline enums (no file, must be rendered as string literal unions).
|
|
306
|
+
const specEnumNames = new Set(ctx.spec.enums.map((e) => e.name));
|
|
307
|
+
|
|
308
|
+
// Import enum types referenced in query/path parameters.
|
|
309
|
+
// Only import enums that actually exist in the spec's global enums list —
|
|
310
|
+
// inline string unions may have kind 'enum' but no corresponding file.
|
|
311
|
+
if (paramEnums.size > 0) {
|
|
312
|
+
const enumToService = assignEnumsToServices(ctx.spec.enums, ctx.spec.services);
|
|
313
|
+
for (const name of paramEnums) {
|
|
314
|
+
if (allModels.has(name)) continue; // Already imported as a model
|
|
315
|
+
if (!specEnumNames.has(name)) continue; // No file generated for this enum
|
|
316
|
+
const enumDir = enumToService.get(name);
|
|
317
|
+
const enumServiceDir = resolveDir(enumDir);
|
|
318
|
+
const relPath =
|
|
319
|
+
enumServiceDir === serviceDir
|
|
320
|
+
? `./interfaces/${fileName(name)}.interface`
|
|
321
|
+
: `../${enumServiceDir}/interfaces/${fileName(name)}.interface`;
|
|
322
|
+
lines.push(`import type { ${name} } from '${relPath}';`);
|
|
323
|
+
}
|
|
102
324
|
}
|
|
103
325
|
|
|
104
326
|
lines.push('');
|
|
105
327
|
|
|
106
|
-
//
|
|
328
|
+
// Options interfaces for operations with query params.
|
|
329
|
+
// Paginated operations extend PaginationOptions; non-paginated operations get standalone interfaces.
|
|
107
330
|
for (const { op, plan, method } of plans) {
|
|
108
331
|
if (plan.isPaginated) {
|
|
109
|
-
const extraParams = op.queryParams.filter((p) => !
|
|
332
|
+
const extraParams = op.queryParams.filter((p) => !PAGINATION_PARAM_NAMES.has(p.name));
|
|
110
333
|
if (extraParams.length > 0) {
|
|
111
|
-
const optionsName =
|
|
334
|
+
const optionsName = paginatedOptionsName(method, resolvedName);
|
|
335
|
+
// Always generate the options interface locally in the resource file.
|
|
336
|
+
// Previously we skipped generation when a baseline interface with a matching
|
|
337
|
+
// name existed, but the baseline interface may live in a different module
|
|
338
|
+
// (e.g., `user-management/` vs `user-management-users/`) and would not be
|
|
339
|
+
// available without an import. Generating locally is safe and avoids
|
|
340
|
+
// cross-module import resolution issues.
|
|
112
341
|
lines.push(`export interface ${optionsName} extends PaginationOptions {`);
|
|
113
342
|
for (const param of extraParams) {
|
|
114
343
|
const opt = !param.required ? '?' : '';
|
|
@@ -118,11 +347,28 @@ function generateResourceClass(service: Service, ctx: EmitterContext): Generated
|
|
|
118
347
|
if (param.deprecated) parts.push('@deprecated');
|
|
119
348
|
lines.push(...docComment(parts.join('\n'), 2));
|
|
120
349
|
}
|
|
121
|
-
lines.push(` ${fieldName(param.name)}${opt}: ${
|
|
350
|
+
lines.push(` ${fieldName(param.name)}${opt}: ${mapParamType(param.type, specEnumNames)};`);
|
|
122
351
|
}
|
|
123
352
|
lines.push('}');
|
|
124
353
|
lines.push('');
|
|
125
354
|
}
|
|
355
|
+
} else if (!plan.isPaginated && !plan.hasBody && !plan.isDelete && op.queryParams.length > 0) {
|
|
356
|
+
// Non-paginated GET or void methods with query params get a typed options interface
|
|
357
|
+
// instead of falling back to Record<string, unknown>.
|
|
358
|
+
const optionsName = toPascalCase(method) + 'Options';
|
|
359
|
+
lines.push(`export interface ${optionsName} {`);
|
|
360
|
+
for (const param of op.queryParams) {
|
|
361
|
+
const opt = !param.required ? '?' : '';
|
|
362
|
+
if (param.description || param.deprecated) {
|
|
363
|
+
const parts: string[] = [];
|
|
364
|
+
if (param.description) parts.push(param.description);
|
|
365
|
+
if (param.deprecated) parts.push('@deprecated');
|
|
366
|
+
lines.push(...docComment(parts.join('\n'), 2));
|
|
367
|
+
}
|
|
368
|
+
lines.push(` ${fieldName(param.name)}${opt}: ${mapParamType(param.type, specEnumNames)};`);
|
|
369
|
+
}
|
|
370
|
+
lines.push('}');
|
|
371
|
+
lines.push('');
|
|
126
372
|
}
|
|
127
373
|
}
|
|
128
374
|
|
|
@@ -135,7 +381,7 @@ function generateResourceClass(service: Service, ctx: EmitterContext): Generated
|
|
|
135
381
|
|
|
136
382
|
for (const { op, plan, method } of plans) {
|
|
137
383
|
lines.push('');
|
|
138
|
-
lines.push(...renderMethod(op, plan, method, service, ctx));
|
|
384
|
+
lines.push(...renderMethod(op, plan, method, service, ctx, modelMap, specEnumNames));
|
|
139
385
|
}
|
|
140
386
|
|
|
141
387
|
lines.push('}');
|
|
@@ -149,29 +395,125 @@ function renderMethod(
|
|
|
149
395
|
method: string,
|
|
150
396
|
service: Service,
|
|
151
397
|
ctx: EmitterContext,
|
|
398
|
+
modelMap: Map<string, Model>,
|
|
399
|
+
specEnumNames?: Set<string>,
|
|
152
400
|
): string[] {
|
|
153
401
|
const lines: string[] = [];
|
|
154
402
|
const responseModel = plan.responseModelName ? resolveInterfaceName(plan.responseModelName, ctx) : null;
|
|
155
403
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
404
|
+
const pathStr = buildPathStr(op);
|
|
405
|
+
|
|
406
|
+
// Build set of valid param names to filter @param tags.
|
|
407
|
+
// Prefer the overlay (existing method signature) if available;
|
|
408
|
+
// otherwise compute from what the render path will actually include.
|
|
409
|
+
const httpKey = `${op.httpMethod.toUpperCase()} ${op.path}`;
|
|
410
|
+
const overlayMethod = ctx.overlayLookup?.methodByOperation?.get(httpKey);
|
|
411
|
+
let validParamNames: Set<string> | null = null;
|
|
412
|
+
if (overlayMethod) {
|
|
413
|
+
validParamNames = new Set(overlayMethod.params.map((p) => p.name));
|
|
414
|
+
} else {
|
|
415
|
+
// Compute actual params based on render path to avoid documenting params
|
|
416
|
+
// that won't appear in the method signature
|
|
417
|
+
const actualParams = new Set<string>();
|
|
418
|
+
for (const p of op.pathParams) actualParams.add(fieldName(p.name));
|
|
419
|
+
if (plan.hasBody) actualParams.add('payload');
|
|
420
|
+
if (plan.isPaginated) actualParams.add('options');
|
|
421
|
+
// renderGetMethod adds options when there are non-paginated query params
|
|
422
|
+
if (!plan.isPaginated && op.queryParams.length > 0 && !plan.isDelete && responseModel) {
|
|
423
|
+
actualParams.add('options');
|
|
424
|
+
}
|
|
425
|
+
validParamNames = actualParams;
|
|
426
|
+
}
|
|
160
427
|
|
|
161
428
|
const docParts: string[] = [];
|
|
162
429
|
if (op.description) docParts.push(op.description);
|
|
163
430
|
for (const param of op.pathParams) {
|
|
431
|
+
const paramName = fieldName(param.name);
|
|
432
|
+
if (validParamNames && !validParamNames.has(paramName)) continue;
|
|
433
|
+
const deprecatedPrefix = param.deprecated ? '(deprecated) ' : '';
|
|
164
434
|
if (param.description) {
|
|
165
|
-
docParts.push(`@param ${
|
|
435
|
+
docParts.push(`@param ${paramName} - ${deprecatedPrefix}${param.description}`);
|
|
436
|
+
} else if (param.deprecated) {
|
|
437
|
+
docParts.push(`@param ${paramName} - (deprecated)`);
|
|
166
438
|
}
|
|
439
|
+
if (param.default !== undefined) docParts.push(`@default ${JSON.stringify(param.default)}`);
|
|
440
|
+
if (param.example !== undefined) docParts.push(`@example ${JSON.stringify(param.example)}`);
|
|
167
441
|
}
|
|
168
|
-
|
|
442
|
+
// Document query params for non-paginated operations
|
|
443
|
+
if (!plan.isPaginated) {
|
|
444
|
+
// Only document query params if the method will have an options parameter
|
|
445
|
+
if (validParamNames && (validParamNames.has('options') || overlayMethod)) {
|
|
446
|
+
for (const param of op.queryParams) {
|
|
447
|
+
const paramName = `options.${fieldName(param.name)}`;
|
|
448
|
+
if (validParamNames && !validParamNames.has('options') && !validParamNames.has(fieldName(param.name))) continue;
|
|
449
|
+
const deprecatedPrefix = param.deprecated ? '(deprecated) ' : '';
|
|
450
|
+
if (param.description) {
|
|
451
|
+
docParts.push(`@param ${paramName} - ${deprecatedPrefix}${param.description}`);
|
|
452
|
+
} else if (param.deprecated) {
|
|
453
|
+
docParts.push(`@param ${paramName} - (deprecated)`);
|
|
454
|
+
}
|
|
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
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
// Skip header and cookie params in JSDoc — they are not exposed in the method signature.
|
|
461
|
+
// The SDK handles headers and cookies internally, so documenting them would be misleading.
|
|
462
|
+
// Document payload parameter when there is a request body
|
|
463
|
+
if (plan.hasBody) {
|
|
464
|
+
const bodyInfo = extractRequestBodyType(op, ctx);
|
|
465
|
+
if (bodyInfo?.kind === 'model') {
|
|
466
|
+
const bodyModel = ctx.spec.models.find((m) => m.name === bodyInfo.name);
|
|
467
|
+
let payloadDesc: string;
|
|
468
|
+
if (bodyModel?.description) {
|
|
469
|
+
payloadDesc = `@param payload - ${bodyModel.description}`;
|
|
470
|
+
} else if (bodyModel) {
|
|
471
|
+
// When the model lacks a description, list its required fields to help
|
|
472
|
+
// callers understand what must be provided.
|
|
473
|
+
const requiredFieldNames = bodyModel.fields.filter((f) => f.required).map((f) => fieldName(f.name));
|
|
474
|
+
payloadDesc =
|
|
475
|
+
requiredFieldNames.length > 0
|
|
476
|
+
? `@param payload - Object containing ${requiredFieldNames.join(', ')}.`
|
|
477
|
+
: '@param payload - The request body.';
|
|
478
|
+
} else {
|
|
479
|
+
payloadDesc = '@param payload - The request body.';
|
|
480
|
+
}
|
|
481
|
+
docParts.push(payloadDesc);
|
|
482
|
+
} else {
|
|
483
|
+
docParts.push('@param payload - The request body.');
|
|
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
|
|
169
510
|
for (const err of op.errors) {
|
|
170
|
-
const exceptionName =
|
|
511
|
+
const exceptionName = STATUS_TO_EXCEPTION_NAME[err.statusCode];
|
|
171
512
|
if (exceptionName) {
|
|
172
513
|
docParts.push(`@throws {${exceptionName}} ${err.statusCode}`);
|
|
173
514
|
}
|
|
174
515
|
}
|
|
516
|
+
if (op.deprecated) docParts.push('@deprecated');
|
|
175
517
|
|
|
176
518
|
if (docParts.length > 0) {
|
|
177
519
|
// Flatten all parts, splitting multiline descriptions into individual lines
|
|
@@ -192,22 +534,59 @@ function renderMethod(
|
|
|
192
534
|
}
|
|
193
535
|
}
|
|
194
536
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
537
|
+
const preDecisionCount = lines.length;
|
|
538
|
+
|
|
539
|
+
if (plan.isPaginated && op.pagination && op.httpMethod === 'get') {
|
|
540
|
+
// For paginated operations, use the item type from pagination metadata
|
|
541
|
+
// (e.g., Connection) rather than the list wrapper type (e.g., ConnectionList).
|
|
542
|
+
// Unwrap list wrapper models to get the actual item type name.
|
|
543
|
+
let paginatedItemRawName = op.pagination.itemType.kind === 'model' ? op.pagination.itemType.name : null;
|
|
544
|
+
if (paginatedItemRawName) {
|
|
545
|
+
const pModel = modelMap.get(paginatedItemRawName);
|
|
546
|
+
if (pModel) {
|
|
547
|
+
const unwrapped = unwrapListModel(pModel, modelMap);
|
|
548
|
+
if (unwrapped) {
|
|
549
|
+
paginatedItemRawName = unwrapped.name;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
const paginatedItemType = paginatedItemRawName ? resolveInterfaceName(paginatedItemRawName, ctx) : responseModel;
|
|
554
|
+
if (paginatedItemType) {
|
|
555
|
+
const resolvedServiceNameForPaginated = resolveServiceName(service, ctx);
|
|
556
|
+
renderPaginatedMethod(
|
|
557
|
+
lines,
|
|
558
|
+
op,
|
|
559
|
+
plan,
|
|
560
|
+
method,
|
|
561
|
+
paginatedItemType,
|
|
562
|
+
pathStr,
|
|
563
|
+
resolvedServiceNameForPaginated,
|
|
564
|
+
specEnumNames,
|
|
199
565
|
);
|
|
200
|
-
return lines;
|
|
201
566
|
}
|
|
202
|
-
|
|
567
|
+
} else if (plan.isPaginated && plan.hasBody && responseModel) {
|
|
568
|
+
// Non-GET paginated operation (e.g., PUT with list response) — treat as body method
|
|
569
|
+
renderBodyMethod(lines, op, plan, method, responseModel, pathStr, ctx, specEnumNames);
|
|
570
|
+
} else if (plan.isDelete && plan.hasBody) {
|
|
571
|
+
renderDeleteWithBodyMethod(lines, op, plan, method, pathStr, ctx, specEnumNames);
|
|
203
572
|
} else if (plan.isDelete) {
|
|
204
|
-
renderDeleteMethod(lines, op, plan, method, pathStr);
|
|
573
|
+
renderDeleteMethod(lines, op, plan, method, pathStr, specEnumNames);
|
|
205
574
|
} else if (plan.hasBody && responseModel) {
|
|
206
|
-
renderBodyMethod(lines, op, plan, method, responseModel, pathStr, ctx);
|
|
575
|
+
renderBodyMethod(lines, op, plan, method, responseModel, pathStr, ctx, specEnumNames);
|
|
207
576
|
} else if (responseModel) {
|
|
208
|
-
renderGetMethod(lines, op, plan, method, responseModel, pathStr);
|
|
577
|
+
renderGetMethod(lines, op, plan, method, responseModel, pathStr, specEnumNames);
|
|
209
578
|
} else {
|
|
210
|
-
renderVoidMethod(lines, op, plan, method, pathStr);
|
|
579
|
+
renderVoidMethod(lines, op, plan, method, pathStr, ctx, specEnumNames);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// Defensive: if no render function produced a method body, emit a stub
|
|
583
|
+
if (lines.length === preDecisionCount) {
|
|
584
|
+
const params = buildPathParams(op, specEnumNames);
|
|
585
|
+
lines.push(` async ${method}(${params}): Promise<void> {`);
|
|
586
|
+
lines.push(
|
|
587
|
+
` await this.workos.${op.httpMethod}(${pathStr}${httpMethodNeedsBody(op.httpMethod) ? ', {}' : ''});`,
|
|
588
|
+
);
|
|
589
|
+
lines.push(' }');
|
|
211
590
|
}
|
|
212
591
|
|
|
213
592
|
return lines;
|
|
@@ -219,29 +598,20 @@ function renderPaginatedMethod(
|
|
|
219
598
|
plan: OperationPlan,
|
|
220
599
|
method: string,
|
|
221
600
|
itemType: string,
|
|
601
|
+
pathStr: string,
|
|
602
|
+
resolvedServiceName: string,
|
|
603
|
+
specEnumNames?: Set<string>,
|
|
222
604
|
): void {
|
|
223
|
-
const extraParams = op.queryParams.filter((p) => !
|
|
224
|
-
const optionsType = extraParams.length > 0 ?
|
|
605
|
+
const extraParams = op.queryParams.filter((p) => !PAGINATION_PARAM_NAMES.has(p.name));
|
|
606
|
+
const optionsType = extraParams.length > 0 ? paginatedOptionsName(method, resolvedServiceName) : 'PaginationOptions';
|
|
225
607
|
|
|
226
|
-
const
|
|
608
|
+
const pathParams = buildPathParams(op, specEnumNames);
|
|
609
|
+
const allParams = pathParams ? `${pathParams}, options?: ${optionsType}` : `options?: ${optionsType}`;
|
|
227
610
|
|
|
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(' );');
|
|
611
|
+
lines.push(` async ${method}(${allParams}): Promise<AutoPaginatable<${itemType}, ${optionsType}>> {`);
|
|
612
|
+
lines.push(
|
|
613
|
+
` return createPaginatedList<${wireInterfaceName(itemType)}, ${itemType}, ${optionsType}>(this.workos, ${pathStr}, deserialize${itemType}, options);`,
|
|
614
|
+
);
|
|
245
615
|
lines.push(' }');
|
|
246
616
|
}
|
|
247
617
|
|
|
@@ -251,13 +621,54 @@ function renderDeleteMethod(
|
|
|
251
621
|
plan: OperationPlan,
|
|
252
622
|
method: string,
|
|
253
623
|
pathStr: string,
|
|
624
|
+
specEnumNames?: Set<string>,
|
|
254
625
|
): void {
|
|
255
|
-
const params = buildPathParams(op);
|
|
626
|
+
const params = buildPathParams(op, specEnumNames);
|
|
256
627
|
lines.push(` async ${method}(${params}): Promise<void> {`);
|
|
257
628
|
lines.push(` await this.workos.delete(${pathStr});`);
|
|
258
629
|
lines.push(' }');
|
|
259
630
|
}
|
|
260
631
|
|
|
632
|
+
function renderDeleteWithBodyMethod(
|
|
633
|
+
lines: string[],
|
|
634
|
+
op: Operation,
|
|
635
|
+
plan: OperationPlan,
|
|
636
|
+
method: string,
|
|
637
|
+
pathStr: string,
|
|
638
|
+
ctx: EmitterContext,
|
|
639
|
+
specEnumNames?: Set<string>,
|
|
640
|
+
): void {
|
|
641
|
+
const bodyInfo = extractRequestBodyType(op, ctx);
|
|
642
|
+
let requestType: string;
|
|
643
|
+
let bodyExpr: string;
|
|
644
|
+
if (bodyInfo?.kind === 'model') {
|
|
645
|
+
requestType = resolveInterfaceName(bodyInfo.name, ctx);
|
|
646
|
+
bodyExpr = `serialize${requestType}(payload)`;
|
|
647
|
+
} else if (bodyInfo?.kind === 'union') {
|
|
648
|
+
requestType = bodyInfo.typeStr;
|
|
649
|
+
if (bodyInfo.discriminator) {
|
|
650
|
+
bodyExpr = renderUnionBodySerializer(bodyInfo.discriminator, ctx);
|
|
651
|
+
} else {
|
|
652
|
+
bodyExpr = 'payload';
|
|
653
|
+
}
|
|
654
|
+
} else {
|
|
655
|
+
requestType = 'Record<string, unknown>';
|
|
656
|
+
bodyExpr = 'payload';
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
const paramParts: string[] = [];
|
|
660
|
+
for (const param of op.pathParams) {
|
|
661
|
+
paramParts.push(
|
|
662
|
+
`${fieldName(param.name)}: ${specEnumNames ? mapParamType(param.type, specEnumNames) : mapTypeRef(param.type)}`,
|
|
663
|
+
);
|
|
664
|
+
}
|
|
665
|
+
paramParts.push(`payload: ${requestType}`);
|
|
666
|
+
|
|
667
|
+
lines.push(` async ${method}(${paramParts.join(', ')}): Promise<void> {`);
|
|
668
|
+
lines.push(` await this.workos.deleteWithBody(${pathStr}, ${bodyExpr});`);
|
|
669
|
+
lines.push(' }');
|
|
670
|
+
}
|
|
671
|
+
|
|
261
672
|
function renderBodyMethod(
|
|
262
673
|
lines: string[],
|
|
263
674
|
op: Operation,
|
|
@@ -266,15 +677,36 @@ function renderBodyMethod(
|
|
|
266
677
|
responseModel: string,
|
|
267
678
|
pathStr: string,
|
|
268
679
|
ctx: EmitterContext,
|
|
680
|
+
specEnumNames?: Set<string>,
|
|
269
681
|
): void {
|
|
270
|
-
const
|
|
271
|
-
|
|
682
|
+
const bodyInfo = extractRequestBodyType(op, ctx);
|
|
683
|
+
let requestType: string;
|
|
684
|
+
let bodyExpr: string;
|
|
685
|
+
if (bodyInfo?.kind === 'model') {
|
|
686
|
+
requestType = resolveInterfaceName(bodyInfo.name, ctx);
|
|
687
|
+
bodyExpr = `serialize${requestType}(payload)`;
|
|
688
|
+
} else if (bodyInfo?.kind === 'union') {
|
|
689
|
+
requestType = bodyInfo.typeStr;
|
|
690
|
+
if (bodyInfo.discriminator) {
|
|
691
|
+
// Discriminated union: dispatch to the correct serializer at runtime.
|
|
692
|
+
bodyExpr = renderUnionBodySerializer(bodyInfo.discriminator, ctx);
|
|
693
|
+
} else {
|
|
694
|
+
// Non-discriminated union: cannot statically dispatch —
|
|
695
|
+
// pass the payload directly (caller provides the correct shape).
|
|
696
|
+
bodyExpr = 'payload';
|
|
697
|
+
}
|
|
698
|
+
} else {
|
|
699
|
+
requestType = 'Record<string, unknown>';
|
|
700
|
+
bodyExpr = 'payload';
|
|
701
|
+
}
|
|
272
702
|
|
|
273
703
|
const paramParts: string[] = [];
|
|
274
704
|
|
|
275
705
|
// Always pass path params as individual parameters (matches existing SDK pattern)
|
|
276
706
|
for (const param of op.pathParams) {
|
|
277
|
-
paramParts.push(
|
|
707
|
+
paramParts.push(
|
|
708
|
+
`${fieldName(param.name)}: ${specEnumNames ? mapParamType(param.type, specEnumNames) : mapTypeRef(param.type)}`,
|
|
709
|
+
);
|
|
278
710
|
}
|
|
279
711
|
|
|
280
712
|
paramParts.push(`payload: ${requestType}`);
|
|
@@ -284,20 +716,40 @@ function renderBodyMethod(
|
|
|
284
716
|
}
|
|
285
717
|
|
|
286
718
|
const paramsStr = paramParts.join(', ');
|
|
287
|
-
|
|
719
|
+
|
|
720
|
+
// Fix 2: Pass encoding option when requestBodyEncoding is non-json
|
|
721
|
+
const encoding = op.requestBodyEncoding;
|
|
722
|
+
const encodingOption = encoding && encoding !== 'json' ? `, encoding: '${encoding}' as const` : '';
|
|
723
|
+
const hasCustomEncoding = encodingOption !== '';
|
|
288
724
|
|
|
289
725
|
lines.push(` async ${method}(${paramsStr}): Promise<${responseModel}> {`);
|
|
290
726
|
if (plan.isIdempotentPost) {
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
727
|
+
if (hasCustomEncoding) {
|
|
728
|
+
lines.push(` const { data } = await this.workos.${op.httpMethod}<${wireInterfaceName(responseModel)}>(`);
|
|
729
|
+
lines.push(` ${pathStr},`);
|
|
730
|
+
lines.push(` ${bodyExpr},`);
|
|
731
|
+
lines.push(` { ...requestOptions${encodingOption} },`);
|
|
732
|
+
lines.push(' );');
|
|
733
|
+
} else {
|
|
734
|
+
lines.push(` const { data } = await this.workos.${op.httpMethod}<${wireInterfaceName(responseModel)}>(`);
|
|
735
|
+
lines.push(` ${pathStr},`);
|
|
736
|
+
lines.push(` ${bodyExpr},`);
|
|
737
|
+
lines.push(' requestOptions,');
|
|
738
|
+
lines.push(' );');
|
|
739
|
+
}
|
|
296
740
|
} else {
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
741
|
+
if (hasCustomEncoding) {
|
|
742
|
+
lines.push(` const { data } = await this.workos.${op.httpMethod}<${wireInterfaceName(responseModel)}>(`);
|
|
743
|
+
lines.push(` ${pathStr},`);
|
|
744
|
+
lines.push(` ${bodyExpr},`);
|
|
745
|
+
lines.push(` { ${encodingOption.slice(2)} },`);
|
|
746
|
+
lines.push(' );');
|
|
747
|
+
} else {
|
|
748
|
+
lines.push(` const { data } = await this.workos.${op.httpMethod}<${wireInterfaceName(responseModel)}>(`);
|
|
749
|
+
lines.push(` ${pathStr},`);
|
|
750
|
+
lines.push(` ${bodyExpr},`);
|
|
751
|
+
lines.push(' );');
|
|
752
|
+
}
|
|
301
753
|
}
|
|
302
754
|
lines.push(` return deserialize${responseModel}(data);`);
|
|
303
755
|
lines.push(' }');
|
|
@@ -310,23 +762,27 @@ function renderGetMethod(
|
|
|
310
762
|
method: string,
|
|
311
763
|
responseModel: string,
|
|
312
764
|
pathStr: string,
|
|
765
|
+
specEnumNames?: Set<string>,
|
|
313
766
|
): void {
|
|
314
|
-
const params = buildPathParams(op);
|
|
767
|
+
const params = buildPathParams(op, specEnumNames);
|
|
315
768
|
const hasQuery = op.queryParams.length > 0 && !plan.isPaginated;
|
|
769
|
+
const optionsType = hasQuery ? toPascalCase(method) + 'Options' : null;
|
|
316
770
|
|
|
317
|
-
const allParams = hasQuery
|
|
318
|
-
? params
|
|
319
|
-
? `${params}, options?: Record<string, any>`
|
|
320
|
-
: 'options?: Record<string, any>'
|
|
321
|
-
: params;
|
|
771
|
+
const allParams = hasQuery ? (params ? `${params}, options?: ${optionsType}` : `options?: ${optionsType}`) : params;
|
|
322
772
|
|
|
323
773
|
lines.push(` async ${method}(${allParams}): Promise<${responseModel}> {`);
|
|
324
774
|
if (hasQuery) {
|
|
775
|
+
const queryExpr = renderQueryExpr(op.queryParams);
|
|
325
776
|
lines.push(
|
|
326
777
|
` const { data } = await this.workos.${op.httpMethod}<${wireInterfaceName(responseModel)}>(${pathStr}, {`,
|
|
327
778
|
);
|
|
328
|
-
lines.push(
|
|
779
|
+
lines.push(` query: ${queryExpr},`);
|
|
329
780
|
lines.push(' });');
|
|
781
|
+
} else if (httpMethodNeedsBody(op.httpMethod)) {
|
|
782
|
+
// PUT/PATCH/POST require a body argument even when the spec has no request body
|
|
783
|
+
lines.push(
|
|
784
|
+
` const { data } = await this.workos.${op.httpMethod}<${wireInterfaceName(responseModel)}>(${pathStr}, {});`,
|
|
785
|
+
);
|
|
330
786
|
} else {
|
|
331
787
|
lines.push(
|
|
332
788
|
` const { data } = await this.workos.${op.httpMethod}<${wireInterfaceName(responseModel)}>(${pathStr});`,
|
|
@@ -336,45 +792,204 @@ function renderGetMethod(
|
|
|
336
792
|
lines.push(' }');
|
|
337
793
|
}
|
|
338
794
|
|
|
339
|
-
function renderVoidMethod(
|
|
340
|
-
|
|
341
|
-
|
|
795
|
+
function renderVoidMethod(
|
|
796
|
+
lines: string[],
|
|
797
|
+
op: Operation,
|
|
798
|
+
plan: OperationPlan,
|
|
799
|
+
method: string,
|
|
800
|
+
pathStr: string,
|
|
801
|
+
ctx: EmitterContext,
|
|
802
|
+
specEnumNames?: Set<string>,
|
|
803
|
+
): void {
|
|
804
|
+
const params = buildPathParams(op, specEnumNames);
|
|
805
|
+
const hasQuery = op.queryParams.length > 0 && !plan.hasBody;
|
|
806
|
+
const optionsType = hasQuery ? toPascalCase(method) + 'Options' : null;
|
|
807
|
+
|
|
808
|
+
let bodyParam = '';
|
|
809
|
+
let bodyExpr = 'payload';
|
|
810
|
+
if (plan.hasBody) {
|
|
811
|
+
const bodyInfo = extractRequestBodyType(op, ctx);
|
|
812
|
+
if (bodyInfo?.kind === 'model') {
|
|
813
|
+
const requestType = resolveInterfaceName(bodyInfo.name, ctx);
|
|
814
|
+
bodyParam = `payload: ${requestType}`;
|
|
815
|
+
bodyExpr = `serialize${requestType}(payload)`;
|
|
816
|
+
} else if (bodyInfo?.kind === 'union') {
|
|
817
|
+
bodyParam = `payload: ${bodyInfo.typeStr}`;
|
|
818
|
+
if (bodyInfo.discriminator) {
|
|
819
|
+
bodyExpr = renderUnionBodySerializer(bodyInfo.discriminator, ctx);
|
|
820
|
+
} else {
|
|
821
|
+
bodyExpr = 'payload';
|
|
822
|
+
}
|
|
823
|
+
} else {
|
|
824
|
+
bodyParam = 'payload: Record<string, unknown>';
|
|
825
|
+
bodyExpr = 'payload';
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
const paramParts: string[] = [];
|
|
830
|
+
if (params) paramParts.push(params);
|
|
831
|
+
if (bodyParam) paramParts.push(bodyParam);
|
|
832
|
+
if (optionsType) paramParts.push(`options?: ${optionsType}`);
|
|
833
|
+
const allParams = paramParts.join(', ');
|
|
342
834
|
|
|
343
835
|
lines.push(` async ${method}(${allParams}): Promise<void> {`);
|
|
344
836
|
if (plan.hasBody) {
|
|
345
|
-
lines.push(` await this.workos.${op.httpMethod}(${pathStr},
|
|
837
|
+
lines.push(` await this.workos.${op.httpMethod}(${pathStr}, ${bodyExpr});`);
|
|
838
|
+
} else if (hasQuery) {
|
|
839
|
+
const queryExpr = renderQueryExpr(op.queryParams);
|
|
840
|
+
lines.push(` await this.workos.${op.httpMethod}(${pathStr}, {`);
|
|
841
|
+
lines.push(` query: ${queryExpr},`);
|
|
842
|
+
lines.push(' });');
|
|
843
|
+
} else if (httpMethodNeedsBody(op.httpMethod)) {
|
|
844
|
+
lines.push(` await this.workos.${op.httpMethod}(${pathStr}, {});`);
|
|
346
845
|
} else {
|
|
347
846
|
lines.push(` await this.workos.${op.httpMethod}(${pathStr});`);
|
|
348
847
|
}
|
|
349
848
|
lines.push(' }');
|
|
350
849
|
}
|
|
351
850
|
|
|
851
|
+
/**
|
|
852
|
+
* Generate an inline query serialization expression that maps camelCase option
|
|
853
|
+
* keys to their snake_case wire equivalents. When all keys already match
|
|
854
|
+
* (camel === snake), returns 'options' as-is for brevity.
|
|
855
|
+
*/
|
|
856
|
+
function renderQueryExpr(queryParams: { name: string; required: boolean }[]): string {
|
|
857
|
+
// Check if any key actually needs conversion
|
|
858
|
+
const needsConversion = queryParams.some((p) => fieldName(p.name) !== wireFieldName(p.name));
|
|
859
|
+
if (!needsConversion) return 'options';
|
|
860
|
+
|
|
861
|
+
const parts: string[] = [];
|
|
862
|
+
for (const param of queryParams) {
|
|
863
|
+
const camel = fieldName(param.name);
|
|
864
|
+
const snake = wireFieldName(param.name);
|
|
865
|
+
if (param.required) {
|
|
866
|
+
parts.push(`${snake}: options.${camel}`);
|
|
867
|
+
} else {
|
|
868
|
+
parts.push(`...(options.${camel} !== undefined && { ${snake}: options.${camel} })`);
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
return `options ? { ${parts.join(', ')} } : undefined`;
|
|
872
|
+
}
|
|
873
|
+
|
|
352
874
|
function buildPathStr(op: Operation): string {
|
|
353
875
|
const interpolated = op.path.replace(/\{(\w+)\}/g, (_, p) => `\${${fieldName(p)}}`);
|
|
354
876
|
return interpolated.includes('${') ? `\`${interpolated}\`` : `'${op.path}'`;
|
|
355
877
|
}
|
|
356
878
|
|
|
357
|
-
function buildPathParams(op: Operation): string {
|
|
358
|
-
|
|
879
|
+
function buildPathParams(op: Operation, specEnumNames?: Set<string>): string {
|
|
880
|
+
// Start with declared path params
|
|
881
|
+
const declaredNames = new Set(op.pathParams.map((p) => fieldName(p.name)));
|
|
882
|
+
const params = op.pathParams.map((p) => {
|
|
883
|
+
const type = specEnumNames ? mapParamType(p.type, specEnumNames) : mapTypeRef(p.type);
|
|
884
|
+
return `${fieldName(p.name)}: ${type}`;
|
|
885
|
+
});
|
|
886
|
+
|
|
887
|
+
// Detect path template variables not in declared pathParams and add them as string params.
|
|
888
|
+
// This handles cases where the spec path has {param} but pathParams is incomplete.
|
|
889
|
+
const templateVars = [...op.path.matchAll(/\{(\w+)\}/g)].map(([, name]) => fieldName(name));
|
|
890
|
+
for (const varName of templateVars) {
|
|
891
|
+
if (!declaredNames.has(varName)) {
|
|
892
|
+
params.push(`${varName}: string`);
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
return params.join(', ');
|
|
359
897
|
}
|
|
360
898
|
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
899
|
+
/**
|
|
900
|
+
* Walk a parameter's type tree and collect enum/model names for imports.
|
|
901
|
+
* Handles arrays and nullable wrappers that may contain nested enums/models.
|
|
902
|
+
*/
|
|
903
|
+
function collectParamTypeRefs(type: TypeRef, enums: Set<string>, models: Set<string>): void {
|
|
904
|
+
switch (type.kind) {
|
|
905
|
+
case 'enum':
|
|
906
|
+
enums.add(type.name);
|
|
907
|
+
break;
|
|
908
|
+
case 'model':
|
|
909
|
+
models.add(type.name);
|
|
910
|
+
break;
|
|
911
|
+
case 'array':
|
|
912
|
+
collectParamTypeRefs(type.items, enums, models);
|
|
913
|
+
break;
|
|
914
|
+
case 'nullable':
|
|
915
|
+
collectParamTypeRefs(type.inner, enums, models);
|
|
916
|
+
break;
|
|
917
|
+
}
|
|
365
918
|
}
|
|
366
919
|
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
920
|
+
/**
|
|
921
|
+
* Extract request body type info, supporting both single models and union types.
|
|
922
|
+
* Returns structured info so callers can handle imports and serialization appropriately.
|
|
923
|
+
*/
|
|
924
|
+
/**
|
|
925
|
+
* Generate an IIFE expression that dispatches to the correct serializer for a
|
|
926
|
+
* discriminated union request body. Switches on the camelCase discriminator
|
|
927
|
+
* property of the domain object and calls the appropriate serialize function
|
|
928
|
+
* for each mapped model variant.
|
|
929
|
+
*/
|
|
930
|
+
function renderUnionBodySerializer(
|
|
931
|
+
disc: { property: string; mapping: Record<string, string> },
|
|
932
|
+
ctx: EmitterContext,
|
|
933
|
+
): string {
|
|
934
|
+
const prop = fieldName(disc.property);
|
|
935
|
+
const cases: string[] = [];
|
|
936
|
+
for (const [value, modelName] of Object.entries(disc.mapping)) {
|
|
937
|
+
const resolved = resolveInterfaceName(modelName, ctx);
|
|
938
|
+
cases.push(`case '${value}': return serialize${resolved}(payload as any)`);
|
|
939
|
+
}
|
|
940
|
+
return `(() => { switch ((payload as any).${prop}) { ${cases.join('; ')}; default: return payload } })()`;
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
/** Return type for extractRequestBodyType when the body is a union. */
|
|
944
|
+
interface UnionBodyInfo {
|
|
945
|
+
kind: 'union';
|
|
946
|
+
typeStr: string;
|
|
947
|
+
modelNames: string[];
|
|
948
|
+
discriminator?: { property: string; mapping: Record<string, string> };
|
|
949
|
+
}
|
|
375
950
|
|
|
376
|
-
function
|
|
377
|
-
|
|
378
|
-
|
|
951
|
+
function extractRequestBodyType(
|
|
952
|
+
op: Operation,
|
|
953
|
+
ctx: EmitterContext,
|
|
954
|
+
): { kind: 'model'; name: string } | UnionBodyInfo | null {
|
|
955
|
+
if (!op.requestBody) return null;
|
|
956
|
+
if (op.requestBody.kind === 'model') return { kind: 'model', name: op.requestBody.name };
|
|
957
|
+
if (op.requestBody.kind === 'union') {
|
|
958
|
+
const modelNames: string[] = [];
|
|
959
|
+
for (const variant of op.requestBody.variants) {
|
|
960
|
+
if (variant.kind === 'model') modelNames.push(variant.name);
|
|
961
|
+
}
|
|
962
|
+
if (modelNames.length > 0) {
|
|
963
|
+
const typeStr = modelNames.map((n) => resolveInterfaceName(n, ctx)).join(' | ');
|
|
964
|
+
return { kind: 'union', typeStr, modelNames, discriminator: op.requestBody.discriminator };
|
|
965
|
+
}
|
|
966
|
+
}
|
|
379
967
|
return null;
|
|
380
968
|
}
|
|
969
|
+
|
|
970
|
+
/**
|
|
971
|
+
* Map a parameter type to a TypeScript type string, handling inline enums
|
|
972
|
+
* that don't have corresponding global enum definitions. These would
|
|
973
|
+
* otherwise emit bare names like `Type` or `Action` that are never imported.
|
|
974
|
+
*
|
|
975
|
+
* Recursively handles container types (arrays, nullable) so that inline
|
|
976
|
+
* enums nested inside e.g. `array<enum>` are also inlined as string literal unions.
|
|
977
|
+
*/
|
|
978
|
+
function mapParamType(type: TypeRef, specEnumNames: Set<string>): string {
|
|
979
|
+
if (type.kind === 'enum' && !specEnumNames.has(type.name)) {
|
|
980
|
+
// Inline enum with no generated file — render values as string literal union
|
|
981
|
+
if (type.values && type.values.length > 0) {
|
|
982
|
+
return type.values.map((v: string | number) => (typeof v === 'string' ? `'${v}'` : String(v))).join(' | ');
|
|
983
|
+
}
|
|
984
|
+
return 'string';
|
|
985
|
+
}
|
|
986
|
+
if (type.kind === 'array') {
|
|
987
|
+
const inner = mapParamType(type.items, specEnumNames);
|
|
988
|
+
// Parenthesize union types when used as array element type
|
|
989
|
+
return inner.includes(' | ') ? `(${inner})[]` : `${inner}[]`;
|
|
990
|
+
}
|
|
991
|
+
if (type.kind === 'nullable') {
|
|
992
|
+
return `${mapParamType(type.inner, specEnumNames)} | null`;
|
|
993
|
+
}
|
|
994
|
+
return mapTypeRef(type);
|
|
995
|
+
}
|