@workos/oagen-emitters 0.2.1 → 0.4.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.
Files changed (136) hide show
  1. package/.husky/pre-commit +1 -0
  2. package/.release-please-manifest.json +1 -1
  3. package/CHANGELOG.md +15 -0
  4. package/README.md +129 -0
  5. package/dist/index.d.mts +13 -1
  6. package/dist/index.d.mts.map +1 -1
  7. package/dist/index.mjs +14549 -3385
  8. package/dist/index.mjs.map +1 -1
  9. package/docs/sdk-architecture/dotnet.md +336 -0
  10. package/docs/sdk-architecture/go.md +338 -0
  11. package/docs/sdk-architecture/php.md +315 -0
  12. package/docs/sdk-architecture/python.md +511 -0
  13. package/oagen.config.ts +328 -2
  14. package/package.json +9 -5
  15. package/scripts/generate-php.js +13 -0
  16. package/scripts/git-push-with-published-oagen.sh +21 -0
  17. package/smoke/sdk-dotnet.ts +45 -12
  18. package/smoke/sdk-go.ts +116 -42
  19. package/smoke/sdk-php.ts +28 -26
  20. package/smoke/sdk-python.ts +5 -2
  21. package/src/dotnet/client.ts +89 -0
  22. package/src/dotnet/enums.ts +323 -0
  23. package/src/dotnet/fixtures.ts +236 -0
  24. package/src/dotnet/index.ts +246 -0
  25. package/src/dotnet/manifest.ts +36 -0
  26. package/src/dotnet/models.ts +344 -0
  27. package/src/dotnet/naming.ts +330 -0
  28. package/src/dotnet/resources.ts +622 -0
  29. package/src/dotnet/tests.ts +693 -0
  30. package/src/dotnet/type-map.ts +201 -0
  31. package/src/dotnet/wrappers.ts +186 -0
  32. package/src/go/client.ts +141 -0
  33. package/src/go/enums.ts +196 -0
  34. package/src/go/fixtures.ts +212 -0
  35. package/src/go/index.ts +84 -0
  36. package/src/go/manifest.ts +36 -0
  37. package/src/go/models.ts +254 -0
  38. package/src/go/naming.ts +179 -0
  39. package/src/go/resources.ts +827 -0
  40. package/src/go/tests.ts +751 -0
  41. package/src/go/type-map.ts +82 -0
  42. package/src/go/wrappers.ts +261 -0
  43. package/src/index.ts +4 -0
  44. package/src/kotlin/client.ts +53 -0
  45. package/src/kotlin/enums.ts +162 -0
  46. package/src/kotlin/index.ts +92 -0
  47. package/src/kotlin/manifest.ts +55 -0
  48. package/src/kotlin/models.ts +395 -0
  49. package/src/kotlin/naming.ts +223 -0
  50. package/src/kotlin/overrides.ts +25 -0
  51. package/src/kotlin/resources.ts +667 -0
  52. package/src/kotlin/tests.ts +1019 -0
  53. package/src/kotlin/type-map.ts +123 -0
  54. package/src/kotlin/wrappers.ts +168 -0
  55. package/src/node/client.ts +128 -115
  56. package/src/node/enums.ts +9 -0
  57. package/src/node/errors.ts +37 -232
  58. package/src/node/field-plan.ts +726 -0
  59. package/src/node/fixtures.ts +9 -1
  60. package/src/node/index.ts +3 -9
  61. package/src/node/models.ts +178 -21
  62. package/src/node/naming.ts +49 -111
  63. package/src/node/resources.ts +527 -397
  64. package/src/node/sdk-errors.ts +41 -0
  65. package/src/node/tests.ts +69 -19
  66. package/src/node/type-map.ts +4 -2
  67. package/src/node/utils.ts +13 -71
  68. package/src/node/wrappers.ts +151 -0
  69. package/src/php/client.ts +179 -0
  70. package/src/php/enums.ts +67 -0
  71. package/src/php/errors.ts +9 -0
  72. package/src/php/fixtures.ts +181 -0
  73. package/src/php/index.ts +96 -0
  74. package/src/php/manifest.ts +36 -0
  75. package/src/php/models.ts +310 -0
  76. package/src/php/naming.ts +279 -0
  77. package/src/php/resources.ts +636 -0
  78. package/src/php/tests.ts +609 -0
  79. package/src/php/type-map.ts +90 -0
  80. package/src/php/utils.ts +18 -0
  81. package/src/php/wrappers.ts +152 -0
  82. package/src/python/client.ts +345 -0
  83. package/src/python/enums.ts +313 -0
  84. package/src/python/fixtures.ts +196 -0
  85. package/src/python/index.ts +95 -0
  86. package/src/python/manifest.ts +38 -0
  87. package/src/python/models.ts +688 -0
  88. package/src/python/naming.ts +189 -0
  89. package/src/python/resources.ts +1322 -0
  90. package/src/python/tests.ts +1335 -0
  91. package/src/python/type-map.ts +93 -0
  92. package/src/python/wrappers.ts +191 -0
  93. package/src/shared/model-utils.ts +472 -0
  94. package/src/shared/naming-utils.ts +154 -0
  95. package/src/shared/non-spec-services.ts +54 -0
  96. package/src/shared/resolved-ops.ts +109 -0
  97. package/src/shared/wrapper-utils.ts +70 -0
  98. package/test/dotnet/client.test.ts +121 -0
  99. package/test/dotnet/enums.test.ts +193 -0
  100. package/test/dotnet/errors.test.ts +9 -0
  101. package/test/dotnet/manifest.test.ts +82 -0
  102. package/test/dotnet/models.test.ts +260 -0
  103. package/test/dotnet/resources.test.ts +255 -0
  104. package/test/dotnet/tests.test.ts +202 -0
  105. package/test/go/client.test.ts +92 -0
  106. package/test/go/enums.test.ts +132 -0
  107. package/test/go/errors.test.ts +9 -0
  108. package/test/go/models.test.ts +265 -0
  109. package/test/go/resources.test.ts +408 -0
  110. package/test/go/tests.test.ts +143 -0
  111. package/test/kotlin/models.test.ts +135 -0
  112. package/test/kotlin/tests.test.ts +176 -0
  113. package/test/node/client.test.ts +92 -12
  114. package/test/node/enums.test.ts +2 -0
  115. package/test/node/errors.test.ts +2 -41
  116. package/test/node/models.test.ts +2 -0
  117. package/test/node/naming.test.ts +23 -0
  118. package/test/node/resources.test.ts +315 -84
  119. package/test/node/serializers.test.ts +3 -1
  120. package/test/node/type-map.test.ts +11 -0
  121. package/test/php/client.test.ts +95 -0
  122. package/test/php/enums.test.ts +173 -0
  123. package/test/php/errors.test.ts +9 -0
  124. package/test/php/models.test.ts +497 -0
  125. package/test/php/resources.test.ts +682 -0
  126. package/test/php/tests.test.ts +185 -0
  127. package/test/python/client.test.ts +200 -0
  128. package/test/python/enums.test.ts +228 -0
  129. package/test/python/errors.test.ts +16 -0
  130. package/test/python/manifest.test.ts +74 -0
  131. package/test/python/models.test.ts +716 -0
  132. package/test/python/resources.test.ts +617 -0
  133. package/test/python/tests.test.ts +202 -0
  134. package/src/node/common.ts +0 -273
  135. package/src/node/config.ts +0 -71
  136. package/src/node/serializers.ts +0 -746
@@ -0,0 +1,1322 @@
1
+ import type {
2
+ Service,
3
+ Operation,
4
+ OperationPlan,
5
+ EmitterContext,
6
+ GeneratedFile,
7
+ TypeRef,
8
+ ResolvedOperation,
9
+ Parameter,
10
+ } from '@workos/oagen';
11
+
12
+ /** Extend Parameter with `explode` until @workos/oagen publishes the field. */
13
+ type ParameterExt = Parameter & { explode?: boolean };
14
+ import {
15
+ planOperation,
16
+ toPascalCase,
17
+ toSnakeCase,
18
+ collectModelRefs,
19
+ collectEnumRefs,
20
+ assignModelsToServices,
21
+ } from '@workos/oagen';
22
+ import { mapTypeRefUnquoted } from './type-map.js';
23
+ import {
24
+ className,
25
+ fieldName,
26
+ fileName,
27
+ moduleName,
28
+ resolveClassName,
29
+ buildMountDirMap,
30
+ dirToModule,
31
+ relativeImportPrefix,
32
+ } from './naming.js';
33
+ import {
34
+ buildResolvedLookup,
35
+ lookupMethodName,
36
+ lookupResolved,
37
+ groupByMount,
38
+ getOpDefaults,
39
+ getOpInferFromClient,
40
+ buildHiddenParams as buildHiddenParamsShared,
41
+ } from '../shared/resolved-ops.js';
42
+ import {
43
+ generateSyncWrapperMethods,
44
+ generateAsyncWrapperMethods,
45
+ pythonLiteral,
46
+ clientFieldExpression,
47
+ } from './wrappers.js';
48
+
49
+ /**
50
+ * Compute the Python parameter name for a body field, prefixing with `body_` if it
51
+ * collides with a path parameter name.
52
+ */
53
+ export function bodyParamName(field: { name: string }, pathParamNames: Set<string>): string {
54
+ const name = fieldName(field.name);
55
+ return pathParamNames.has(name) ? `body_${name}` : name;
56
+ }
57
+
58
+ /**
59
+ * Resolve the resource class name for a service.
60
+ */
61
+ export function resolveResourceClassName(service: Service, ctx: EmitterContext): string {
62
+ return resolveClassName(service, ctx);
63
+ }
64
+
65
+ // buildHiddenParams is imported from ../shared/resolved-ops.js as buildHiddenParamsShared
66
+ const buildHiddenParams = buildHiddenParamsShared;
67
+
68
+ // ─── Shared method-emission helpers ──────────────────────────────────
69
+
70
+ /** Metadata returned by emitMethodSignature, consumed by docstring & body emitters. */
71
+ interface SignatureMetadata {
72
+ returnType: string;
73
+ pathParamNames: Set<string>;
74
+ isArrayResponse: boolean;
75
+ isRedirect: boolean;
76
+ hasBearerOverride: boolean;
77
+ }
78
+
79
+ function emitDocArg(lines: string[], name: string, desc?: string): void {
80
+ const fallback = `The ${name.replace(/_/g, ' ')}.`;
81
+ const description = desc ?? fallback;
82
+ const descLines = description
83
+ .split('\n')
84
+ .map((line) => line.trim())
85
+ .filter((line) => line.length > 0);
86
+
87
+ if (descLines.length === 0) {
88
+ lines.push(` ${name}: ${fallback}`);
89
+ return;
90
+ }
91
+
92
+ lines.push(` ${name}: ${descLines[0]}`);
93
+ for (const line of descLines.slice(1)) {
94
+ lines.push(` ${line}`);
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Emit a Python method signature (def / async def, parameters, return type).
100
+ */
101
+ function emitMethodSignature(
102
+ lines: string[],
103
+ op: Operation,
104
+ plan: OperationPlan,
105
+ method: string,
106
+ isAsync: boolean,
107
+ specEnumNames: Set<string>,
108
+ modelImports: Set<string>,
109
+ listWrapperNames: Set<string>,
110
+ ctx: EmitterContext,
111
+ resolvedOp?: ResolvedOperation,
112
+ ): SignatureMetadata {
113
+ const hiddenParams = buildHiddenParams(resolvedOp);
114
+ const isPaginated = plan.isPaginated;
115
+ const isDelete = plan.isDelete;
116
+ // Redirect endpoints never await, so emit as plain def even in async class
117
+ const isRedirectOp = isRedirectEndpoint(op);
118
+ const defKeyword = isAsync && !isRedirectOp ? 'async def' : 'def';
119
+ const usesClientCredentialDefaults = false;
120
+
121
+ lines.push(` ${defKeyword} ${method}(`);
122
+ lines.push(' self,');
123
+
124
+ // Path params as positional args
125
+ for (const param of op.pathParams) {
126
+ const paramName = fieldName(param.name);
127
+ const paramType = mapTypeRefUnquoted(param.type, specEnumNames, true);
128
+ lines.push(` ${paramName}: ${paramType},`);
129
+ }
130
+
131
+ lines.push(' *,');
132
+
133
+ const pathParamNames = new Set(op.pathParams.map((p) => fieldName(p.name)));
134
+
135
+ // Request body fields as keyword args (rename fields that clash with path params)
136
+ if (plan.hasBody && op.requestBody) {
137
+ const bodyModel = ctx.spec.models.find((m) => op.requestBody?.kind === 'model' && m.name === op.requestBody.name);
138
+ if (bodyModel) {
139
+ const reqFields = bodyModel.fields.filter((f) => f.required && !hiddenParams.has(f.name));
140
+ const optFields = bodyModel.fields.filter((f) => !f.required && !hiddenParams.has(f.name));
141
+ for (const f of reqFields) {
142
+ const fieldType = mapTypeRefUnquoted(f.type, specEnumNames, true);
143
+ if (usesClientCredentialDefaults && (f.name === 'client_id' || f.name === 'client_secret')) {
144
+ lines.push(` ${bodyParamName(f, pathParamNames)}: Optional[${fieldType}] = None,`);
145
+ } else {
146
+ lines.push(` ${bodyParamName(f, pathParamNames)}: ${fieldType},`);
147
+ }
148
+ }
149
+ for (const f of optFields) {
150
+ const innerType =
151
+ f.type.kind === 'nullable'
152
+ ? mapTypeRefUnquoted(f.type.inner, specEnumNames, true)
153
+ : mapTypeRefUnquoted(f.type, specEnumNames, true);
154
+ lines.push(` ${bodyParamName(f, pathParamNames)}: Optional[${innerType}] = None,`);
155
+ }
156
+ } else if (op.requestBody.kind === 'union') {
157
+ // Union body — accept any of the variant models or a plain dict
158
+ const variantModels = (op.requestBody.variants ?? [])
159
+ .filter((v: any) => v.kind === 'model')
160
+ .map((v: any) => className(v.name));
161
+ // Add variant models to imports
162
+ for (const vm of variantModels) {
163
+ modelImports.add(vm);
164
+ }
165
+ if (variantModels.length > 0) {
166
+ const unionType = `Union[${[...variantModels, 'Dict[str, Any]'].join(', ')}]`;
167
+ lines.push(` body: ${unionType},`);
168
+ } else {
169
+ lines.push(' body: Dict[str, Any],');
170
+ }
171
+ } else {
172
+ // Non-model body — use generic dict
173
+ lines.push(' body: Optional[Dict[str, Any]] = None,');
174
+ }
175
+ }
176
+
177
+ // Query params for non-paginated methods
178
+ if (plan.hasQueryParams && !isPaginated) {
179
+ for (const param of op.queryParams) {
180
+ if (hiddenParams.has(param.name)) continue;
181
+ const paramName = fieldName(param.name);
182
+ if (pathParamNames.has(paramName)) continue;
183
+ // Skip query params that collide with body field names (using possibly-renamed names)
184
+ if (plan.hasBody && op.requestBody?.kind === 'model') {
185
+ const bodyModel = ctx.spec.models.find(
186
+ (m) => op.requestBody?.kind === 'model' && m.name === op.requestBody.name,
187
+ );
188
+ if (bodyModel?.fields.some((f) => bodyParamName(f, pathParamNames) === paramName)) continue;
189
+ }
190
+ const paramType = mapTypeRefUnquoted(param.type, specEnumNames, true);
191
+ if (usesClientCredentialDefaults && (param.name === 'client_id' || param.name === 'client_secret')) {
192
+ lines.push(` ${paramName}: Optional[${paramType}] = None,`);
193
+ } else if (param.required) {
194
+ lines.push(` ${paramName}: ${paramType},`);
195
+ } else {
196
+ lines.push(` ${paramName}: Optional[${paramType}] = None,`);
197
+ }
198
+ }
199
+ }
200
+
201
+ // Pagination params
202
+ if (isPaginated) {
203
+ lines.push(' limit: Optional[int] = None,');
204
+ lines.push(' before: Optional[str] = None,');
205
+ lines.push(' after: Optional[str] = None,');
206
+ // Use typed enum for order param if the spec provides one, otherwise fall back to str
207
+ const orderParam = op.queryParams.find((p) => p.name === 'order');
208
+ const orderType =
209
+ orderParam && orderParam.type.kind === 'enum' ? mapTypeRefUnquoted(orderParam.type, specEnumNames, true) : 'str';
210
+ lines.push(` order: Optional[${orderType}] = None,`);
211
+ // Additional non-pagination query params
212
+ for (const param of op.queryParams) {
213
+ if (['limit', 'before', 'after', 'order'].includes(param.name)) continue;
214
+ if (hiddenParams.has(param.name)) continue;
215
+ const paramName = fieldName(param.name);
216
+ const paramType = mapTypeRefUnquoted(param.type, specEnumNames, true);
217
+ if (param.required) {
218
+ lines.push(` ${paramName}: ${paramType},`);
219
+ } else {
220
+ lines.push(` ${paramName}: Optional[${paramType}] = None,`);
221
+ }
222
+ }
223
+ }
224
+
225
+ // Idempotency key for idempotent POSTs
226
+ if (plan.isIdempotentPost) {
227
+ lines.push(' idempotency_key: Optional[str] = None,');
228
+ }
229
+
230
+ // Per-operation Bearer token auth (e.g., SSO.get_profile uses access_token instead of API key)
231
+ const hasBearerOverride = op.security?.some((s) => s.schemeName !== 'bearerAuth') ?? false;
232
+ if (hasBearerOverride) {
233
+ const tokenParamName = op.security!.find((s) => s.schemeName !== 'bearerAuth')!.schemeName;
234
+ lines.push(` ${fieldName(tokenParamName)}: str,`);
235
+ }
236
+
237
+ lines.push(' request_options: Optional[RequestOptions] = None,');
238
+
239
+ // Detect array response type
240
+ const isArrayResponse = op.response.kind === 'array' && op.response.items.kind === 'model';
241
+ const isRedirect = isRedirectEndpoint(op);
242
+
243
+ // Return type
244
+ const pageType = isAsync ? 'AsyncPage' : 'SyncPage';
245
+ let returnType: string;
246
+ if (isDelete) {
247
+ returnType = 'None';
248
+ } else if (isRedirect) {
249
+ returnType = 'str';
250
+ } else if (isPaginated) {
251
+ const resolvedItem = resolvePageItemName(op.pagination!.itemType, listWrapperNames, ctx);
252
+ returnType = `${pageType}[${className(resolvedItem)}]`;
253
+ } else if (isArrayResponse) {
254
+ returnType = `List[${className(plan.responseModelName!)}]`;
255
+ } else if (plan.responseModelName) {
256
+ returnType = className(plan.responseModelName);
257
+ } else {
258
+ returnType = 'None';
259
+ }
260
+
261
+ lines.push(` ) -> ${returnType}:`);
262
+
263
+ return { returnType, pathParamNames, isArrayResponse, isRedirect, hasBearerOverride };
264
+ }
265
+
266
+ /**
267
+ * Emit a Python method docstring (description, Args, Returns, Raises).
268
+ * Identical for sync and async — no isAsync parameter needed.
269
+ */
270
+ function emitMethodDocstring(
271
+ lines: string[],
272
+ op: Operation,
273
+ plan: OperationPlan,
274
+ method: string,
275
+ meta: SignatureMetadata,
276
+ specEnumNames: Set<string>,
277
+ ctx: EmitterContext,
278
+ resolvedOp?: ResolvedOperation,
279
+ ): void {
280
+ const { returnType, pathParamNames, hasBearerOverride } = meta;
281
+ const isPaginated = plan.isPaginated;
282
+ const hiddenParams = buildHiddenParams(resolvedOp);
283
+
284
+ // Description — indent continuation lines to align with the opening `"""`
285
+ if (op.description) {
286
+ const descLines = op.description.split('\n');
287
+ const indentedDesc = descLines
288
+ .map((line, i) => (i === 0 ? line : line.trim() === '' ? '' : ` ${line}`))
289
+ .join('\n');
290
+ lines.push(` """${indentedDesc}`);
291
+ } else {
292
+ lines.push(` """${toPascalCase(method.replace(/_/g, ' '))} operation.`);
293
+ }
294
+
295
+ // Args section
296
+ const allParams: { name: string; desc?: string }[] = op.pathParams.map((p) => ({
297
+ name: fieldName(p.name),
298
+ desc: p.deprecated ? (p.description ? `(deprecated) ${p.description}` : '(deprecated)') : p.description,
299
+ }));
300
+
301
+ // Add body model fields to docs
302
+ if (plan.hasBody && op.requestBody) {
303
+ if (op.requestBody.kind === 'model') {
304
+ const requestBodyName = op.requestBody.name;
305
+ const bodyModel = ctx.spec.models.find((m) => m.name === requestBodyName);
306
+ if (bodyModel) {
307
+ for (const f of bodyModel.fields) {
308
+ if (hiddenParams.has(f.name)) continue;
309
+ allParams.push({
310
+ name: bodyParamName(f, pathParamNames),
311
+ desc: f.deprecated ? (f.description ? `(deprecated) ${f.description}` : '(deprecated)') : f.description,
312
+ });
313
+ }
314
+ }
315
+ } else if (op.requestBody.kind === 'union') {
316
+ // Union body — document the body parameter with the accepted variant types
317
+ const variantModels = (op.requestBody.variants ?? [])
318
+ .filter((v: any) => v.kind === 'model')
319
+ .map((v: any) => className(v.name));
320
+ const desc =
321
+ variantModels.length > 0
322
+ ? `The request body. Accepts: ${variantModels.join(', ')}, or a plain dict.`
323
+ : 'The request body.';
324
+ allParams.push({ name: 'body', desc });
325
+ }
326
+ }
327
+
328
+ // Add query params for non-paginated methods
329
+ if (plan.hasQueryParams && !isPaginated) {
330
+ for (const param of op.queryParams) {
331
+ if (hiddenParams.has(param.name)) continue;
332
+ const pn = fieldName(param.name);
333
+ if (pathParamNames.has(pn)) continue;
334
+ // Skip params already documented from body fields
335
+ if (allParams.some((p) => p.name === pn)) continue;
336
+ let desc = param.deprecated
337
+ ? param.description
338
+ ? `(deprecated) ${param.description}`
339
+ : '(deprecated)'
340
+ : param.description;
341
+ if (param.default != null) {
342
+ const defaultStr = `Defaults to \`${param.default}\`.`;
343
+ desc = desc ? `${desc} ${defaultStr}` : defaultStr;
344
+ }
345
+ allParams.push({ name: pn, desc });
346
+ }
347
+ }
348
+
349
+ // Add extra non-standard pagination query params
350
+ if (isPaginated) {
351
+ for (const paramName of ['limit', 'before', 'after', 'order']) {
352
+ const param = op.queryParams.find((p) => p.name === paramName);
353
+ let desc = param?.description;
354
+ if (param?.default != null) {
355
+ const defaultStr = `Defaults to \`${param.default}\`.`;
356
+ desc = desc ? `${desc} ${defaultStr}` : defaultStr;
357
+ }
358
+ allParams.push({
359
+ name: fieldName(paramName),
360
+ desc,
361
+ });
362
+ }
363
+ for (const param of op.queryParams) {
364
+ if (['limit', 'before', 'after', 'order'].includes(param.name)) continue;
365
+ let desc = param.deprecated
366
+ ? param.description
367
+ ? `(deprecated) ${param.description}`
368
+ : '(deprecated)'
369
+ : param.description;
370
+ if (param.default != null) {
371
+ const defaultStr = `Defaults to \`${param.default}\`.`;
372
+ desc = desc ? `${desc} ${defaultStr}` : defaultStr;
373
+ }
374
+ allParams.push({ name: fieldName(param.name), desc });
375
+ }
376
+ }
377
+
378
+ // Add idempotency key parameter to docs
379
+ if (plan.isIdempotentPost) {
380
+ allParams.push({ name: 'idempotency_key', desc: 'Optional idempotency key for safe retries.' });
381
+ }
382
+
383
+ // Add bearer override parameter to docs (e.g., access_token for SSO)
384
+ if (hasBearerOverride) {
385
+ const tokenParamName = fieldName(op.security!.find((s) => s.schemeName !== 'bearerAuth')!.schemeName);
386
+ allParams.push({ name: tokenParamName, desc: 'The bearer token for authentication.' });
387
+ }
388
+
389
+ if (allParams.length > 0 || isPaginated) {
390
+ lines.push('');
391
+ lines.push(' Args:');
392
+ for (const p of allParams) {
393
+ emitDocArg(lines, p.name, p.desc);
394
+ }
395
+ lines.push(
396
+ ' request_options: Per-request options. Supports extra_headers, timeout, max_retries, and base_url override.',
397
+ );
398
+ }
399
+
400
+ if (returnType !== 'None') {
401
+ lines.push('');
402
+ lines.push(' Returns:');
403
+ lines.push(` ${returnType}`);
404
+ }
405
+
406
+ // Per-operation error documentation from spec error responses
407
+ const errorRaises = buildErrorRaisesBlock(op);
408
+ lines.push('');
409
+ lines.push(' Raises:');
410
+ for (const line of errorRaises) {
411
+ lines.push(` ${line}`);
412
+ }
413
+ if (op.deprecated) {
414
+ lines.push('');
415
+ lines.push(' .. deprecated::');
416
+ lines.push(' This operation is deprecated. See the migration guide for alternatives.');
417
+ }
418
+ lines.push(' """');
419
+ }
420
+
421
+ /**
422
+ * Emit the Python method body (auth override, path building, request call).
423
+ */
424
+ function emitMethodBody(
425
+ lines: string[],
426
+ op: Operation,
427
+ plan: OperationPlan,
428
+ meta: SignatureMetadata,
429
+ isAsync: boolean,
430
+ modelImports: Set<string>,
431
+ listWrapperNames: Set<string>,
432
+ ctx: EmitterContext,
433
+ resolvedOp?: ResolvedOperation,
434
+ ): void {
435
+ const { pathParamNames, isArrayResponse, isRedirect, hasBearerOverride } = meta;
436
+ const isPaginated = plan.isPaginated;
437
+ const awaitPrefix = isAsync ? 'await ' : '';
438
+ const usesClientCredentialDefaults = false;
439
+ const hiddenParams = buildHiddenParams(resolvedOp);
440
+ const opDefaults = getOpDefaults(resolvedOp);
441
+ const opInferFromClient = getOpInferFromClient(resolvedOp);
442
+
443
+ if (op.deprecated) {
444
+ const method = toSnakeCase(op.name);
445
+ lines.push(` warnings.warn("${method} is deprecated", DeprecationWarning, stacklevel=2)`);
446
+ }
447
+
448
+ // Method body — build path
449
+ const pathStr = buildPathString(op);
450
+ const httpMethod = op.httpMethod;
451
+
452
+ // Emit auth override for per-operation Bearer token security
453
+ if (hasBearerOverride) {
454
+ const tokenParamName = fieldName(op.security!.find((s) => s.schemeName !== 'bearerAuth')!.schemeName);
455
+ lines.push(` request_options = request_options or {}`);
456
+ lines.push(
457
+ ` request_options = {**request_options, "extra_headers": {**(request_options.get("extra_headers") or {}), "Authorization": f"Bearer {${tokenParamName}}"}}`,
458
+ );
459
+ }
460
+
461
+ if (isRedirect) {
462
+ // Redirect endpoint: construct URL client-side instead of making HTTP request
463
+ const bodyModel =
464
+ plan.hasBody && op.requestBody?.kind === 'model'
465
+ ? ctx.spec.models.find((m) => op.requestBody?.kind === 'model' && m.name === op.requestBody.name)
466
+ : undefined;
467
+ const redirectParamEntries: { key: string; varName: string }[] = [];
468
+ if (bodyModel) {
469
+ for (const f of bodyModel.fields) {
470
+ if (hiddenParams.has(f.name)) continue;
471
+ redirectParamEntries.push({ key: f.name, varName: bodyParamName(f, pathParamNames) });
472
+ }
473
+ }
474
+ for (const param of op.queryParams) {
475
+ if (hiddenParams.has(param.name)) continue;
476
+ const pn = fieldName(param.name);
477
+ if (!redirectParamEntries.some((e) => e.varName === pn)) {
478
+ redirectParamEntries.push({ key: param.name, varName: pn });
479
+ }
480
+ }
481
+ const hasHiddenInjections =
482
+ (opDefaults && Object.keys(opDefaults).length > 0) || (opInferFromClient && opInferFromClient.length > 0);
483
+ if (redirectParamEntries.length > 0 || hasHiddenInjections) {
484
+ lines.push(' params = {k: v for k, v in {');
485
+ for (const entry of redirectParamEntries) {
486
+ const param = op.queryParams.find((p) => p.name === entry.key);
487
+ const value = param
488
+ ? serializeParameterValue(param.type, entry.varName, false, (param as ParameterExt).explode)
489
+ : entry.varName;
490
+ lines.push(` "${entry.key}": ${value},`);
491
+ }
492
+ lines.push(' }.items() if v is not None}');
493
+ // Inject constant defaults
494
+ if (Object.keys(opDefaults).length > 0) {
495
+ for (const [key, value] of Object.entries(opDefaults)) {
496
+ lines.push(` params["${key}"] = ${pythonLiteral(value)}`);
497
+ }
498
+ }
499
+ // Inject fields from client config
500
+ if (opInferFromClient.length > 0) {
501
+ for (const field of opInferFromClient) {
502
+ const expr = clientFieldExpression(field);
503
+ lines.push(` if ${expr} is not None:`);
504
+ lines.push(` params["${field}"] = ${expr}`);
505
+ }
506
+ }
507
+ if (usesClientCredentialDefaults) {
508
+ if (op.queryParams.some((param) => param.name === 'client_id')) {
509
+ lines.push(' params["client_id"] = params.get("client_id") or self._client._require_client_id()');
510
+ }
511
+ if (op.queryParams.some((param) => param.name === 'client_secret')) {
512
+ lines.push(
513
+ ' params["client_secret"] = params.get("client_secret") or self._client._require_api_key()',
514
+ );
515
+ }
516
+ }
517
+ lines.push(` return self._client.build_url(${pathStr}, params)`);
518
+ } else {
519
+ lines.push(` return self._client.build_url(${pathStr})`);
520
+ }
521
+ } else if (isPaginated) {
522
+ const resolvedItemName = resolvePageItemName(op.pagination!.itemType, listWrapperNames, ctx);
523
+ const itemTypeClass = className(resolvedItemName);
524
+ const orderParam = op.queryParams.find((p) => p.name === 'order');
525
+ // Build query params dict
526
+ lines.push(' params = {k: v for k, v in {');
527
+ lines.push(' "limit": limit,');
528
+ lines.push(' "before": before,');
529
+ lines.push(' "after": after,');
530
+ lines.push(
531
+ ` "order": ${serializeParameterValue(orderParam?.type, 'order', false, (orderParam as ParameterExt | undefined)?.explode)},`,
532
+ );
533
+ for (const param of op.queryParams) {
534
+ if (['limit', 'before', 'after', 'order'].includes(param.name)) continue;
535
+ if (hiddenParams.has(param.name)) continue;
536
+ const pn = fieldName(param.name);
537
+ const value = serializeParameterValue(param.type, pn, param.required, (param as ParameterExt).explode);
538
+ lines.push(` "${param.name}": ${value},`);
539
+ }
540
+ lines.push(' }.items() if v is not None}');
541
+ // Inject constant defaults
542
+ if (Object.keys(opDefaults).length > 0) {
543
+ for (const [key, value] of Object.entries(opDefaults)) {
544
+ lines.push(` params["${key}"] = ${pythonLiteral(value)}`);
545
+ }
546
+ }
547
+ // Inject fields from client config
548
+ if (opInferFromClient.length > 0) {
549
+ for (const field of opInferFromClient) {
550
+ const expr = clientFieldExpression(field);
551
+ lines.push(` if ${expr} is not None:`);
552
+ lines.push(` params["${field}"] = ${expr}`);
553
+ }
554
+ }
555
+ lines.push(` return ${awaitPrefix}self._client.request_page(`);
556
+ lines.push(` method="${httpMethod}",`);
557
+ lines.push(` path=${pathStr},`);
558
+ lines.push(` model=${itemTypeClass},`);
559
+ lines.push(' params=params,');
560
+ lines.push(' request_options=request_options,');
561
+ lines.push(' )');
562
+ } else if (plan.isDelete) {
563
+ // Build body dict if the DELETE has a request body
564
+ const deleteBodyFieldNames = new Set<string>();
565
+ if (plan.hasBody && op.requestBody) {
566
+ const bodyModel = ctx.spec.models.find((m) => op.requestBody?.kind === 'model' && m.name === op.requestBody.name);
567
+ if (bodyModel) {
568
+ const bodyFields = bodyModel.fields.filter((f) => !hiddenParams.has(f.name));
569
+ for (const f of bodyFields) deleteBodyFieldNames.add(bodyParamName(f, pathParamNames));
570
+ const hasOptionalBodyFields = bodyFields.some((f) => !f.required);
571
+ if (bodyFields.length > 0 && hasOptionalBodyFields) {
572
+ lines.push(' body: Dict[str, Any] = {k: v for k, v in {');
573
+ for (const f of bodyFields) {
574
+ lines.push(
575
+ ` "${f.name}": ${serializeBodyFieldValue(f.type, bodyParamName(f, pathParamNames), f.required)},`,
576
+ );
577
+ }
578
+ lines.push(' }.items() if v is not None}');
579
+ } else if (bodyFields.length > 0) {
580
+ lines.push(' body: Dict[str, Any] = {');
581
+ for (const f of bodyFields) {
582
+ lines.push(
583
+ ` "${f.name}": ${serializeBodyFieldValue(f.type, bodyParamName(f, pathParamNames), f.required)},`,
584
+ );
585
+ }
586
+ lines.push(' }');
587
+ }
588
+ // Inject constant defaults into body
589
+ if (Object.keys(opDefaults).length > 0) {
590
+ for (const [key, value] of Object.entries(opDefaults)) {
591
+ lines.push(` body["${key}"] = ${pythonLiteral(value)}`);
592
+ }
593
+ }
594
+ // Inject fields from client config into body
595
+ if (opInferFromClient.length > 0) {
596
+ for (const field of opInferFromClient) {
597
+ const expr = clientFieldExpression(field);
598
+ lines.push(` if ${expr} is not None:`);
599
+ lines.push(` body["${field}"] = ${expr}`);
600
+ }
601
+ }
602
+ }
603
+ }
604
+ // Build query params dict if any exist alongside the body/path
605
+ const deleteHasParams =
606
+ plan.hasQueryParams && emitQueryParamsDict(lines, op, pathParamNames, deleteBodyFieldNames, hiddenParams);
607
+ lines.push(` ${awaitPrefix}self._client.request(`);
608
+ lines.push(` method="${httpMethod}",`);
609
+ lines.push(` path=${pathStr},`);
610
+ if (plan.hasBody && op.requestBody) {
611
+ lines.push(' body=body,');
612
+ }
613
+ if (deleteHasParams) {
614
+ lines.push(' params=params,');
615
+ }
616
+ lines.push(' request_options=request_options,');
617
+ lines.push(' )');
618
+ } else if (plan.hasBody && op.requestBody) {
619
+ const responseModel = plan.responseModelName ? className(plan.responseModelName) : 'None';
620
+ // Build body dict
621
+ const bodyModel = ctx.spec.models.find((m) => op.requestBody?.kind === 'model' && m.name === op.requestBody.name);
622
+ const bodyFieldNamesSet = new Set<string>();
623
+ if (bodyModel) {
624
+ const bodyFields = bodyModel.fields.filter((f) => !hiddenParams.has(f.name));
625
+ for (const f of bodyFields) bodyFieldNamesSet.add(bodyParamName(f, pathParamNames));
626
+ const hasOptionalBodyFields = bodyFields.some((f) => !f.required);
627
+ if (bodyFields.length > 0 && hasOptionalBodyFields) {
628
+ lines.push(' body: Dict[str, Any] = {k: v for k, v in {');
629
+ for (const f of bodyFields) {
630
+ lines.push(
631
+ ` "${f.name}": ${serializeBodyFieldValue(f.type, bodyParamName(f, pathParamNames), f.required)},`,
632
+ );
633
+ }
634
+ lines.push(' }.items() if v is not None}');
635
+ } else if (bodyFields.length > 0) {
636
+ lines.push(' body: Dict[str, Any] = {');
637
+ for (const f of bodyFields) {
638
+ lines.push(
639
+ ` "${f.name}": ${serializeBodyFieldValue(f.type, bodyParamName(f, pathParamNames), f.required)},`,
640
+ );
641
+ }
642
+ lines.push(' }');
643
+ } else {
644
+ lines.push(' body: Dict[str, Any] = {}');
645
+ }
646
+ // Inject constant defaults into body
647
+ if (Object.keys(opDefaults).length > 0) {
648
+ for (const [key, value] of Object.entries(opDefaults)) {
649
+ lines.push(` body["${key}"] = ${pythonLiteral(value)}`);
650
+ }
651
+ }
652
+ // Inject fields from client config into body
653
+ if (opInferFromClient.length > 0) {
654
+ for (const field of opInferFromClient) {
655
+ const expr = clientFieldExpression(field);
656
+ lines.push(` if ${expr} is not None:`);
657
+ lines.push(` body["${field}"] = ${expr}`);
658
+ }
659
+ }
660
+ } else {
661
+ // Union or non-model body — convert model instances to dicts
662
+ lines.push(' _body: Dict[str, Any] = body if isinstance(body, dict) else body.to_dict()');
663
+ }
664
+ // Build query params dict if any exist alongside the body
665
+ const bodyHasParams =
666
+ plan.hasQueryParams && emitQueryParamsDict(lines, op, pathParamNames, bodyFieldNamesSet, hiddenParams);
667
+ const bodyVarName = bodyModel ? 'body' : '_body';
668
+ if (bodyModel && usesClientCredentialDefaults) {
669
+ if (bodyModel.fields.some((f) => f.name === 'client_id')) {
670
+ lines.push(' body["client_id"] = body.get("client_id") or self._client._require_client_id()');
671
+ }
672
+ if (bodyModel.fields.some((f) => f.name === 'client_secret')) {
673
+ lines.push(' body["client_secret"] = body.get("client_secret") or self._client._require_api_key()');
674
+ }
675
+ }
676
+ if (isArrayResponse) {
677
+ // Array response with body: request_list returns List[Dict], then deserialize each item
678
+ const itemModel = className(plan.responseModelName!);
679
+ lines.push(` raw = ${awaitPrefix}self._client.request_list(`);
680
+ lines.push(` method="${httpMethod}",`);
681
+ lines.push(` path=${pathStr},`);
682
+ lines.push(` body=${bodyVarName},`);
683
+ if (bodyHasParams) {
684
+ lines.push(' params=params,');
685
+ }
686
+ if (plan.isIdempotentPost) {
687
+ lines.push(' idempotency_key=idempotency_key,');
688
+ }
689
+ lines.push(' request_options=request_options,');
690
+ lines.push(' )');
691
+ lines.push(` return [${itemModel}.from_dict(cast(Dict[str, Any], item)) for item in raw]`);
692
+ } else {
693
+ const bodyReturnPrefix = responseModel !== 'None' ? 'return ' : '';
694
+ lines.push(` ${bodyReturnPrefix}${awaitPrefix}self._client.request(`);
695
+ lines.push(` method="${httpMethod}",`);
696
+ lines.push(` path=${pathStr},`);
697
+ lines.push(` body=${bodyVarName},`);
698
+ if (bodyHasParams) {
699
+ lines.push(' params=params,');
700
+ }
701
+ if (responseModel !== 'None') {
702
+ lines.push(` model=${responseModel},`);
703
+ }
704
+ if (plan.isIdempotentPost) {
705
+ lines.push(' idempotency_key=idempotency_key,');
706
+ }
707
+ lines.push(' request_options=request_options,');
708
+ lines.push(' )');
709
+ }
710
+ } else {
711
+ // GET or similar with query params
712
+ const responseModel = plan.responseModelName ? className(plan.responseModelName) : 'None';
713
+ const visibleQueryParams = op.queryParams.filter((p) => !hiddenParams.has(p.name));
714
+ const hasVisibleQueryParams = plan.hasQueryParams && visibleQueryParams.length > 0;
715
+ const hasInjections =
716
+ (opDefaults && Object.keys(opDefaults).length > 0) || (opInferFromClient && opInferFromClient.length > 0);
717
+ if (hasVisibleQueryParams || hasInjections) {
718
+ const hasOptionalQueryParams = visibleQueryParams.some((p) => !p.required);
719
+ if (hasOptionalQueryParams) {
720
+ lines.push(' params: Dict[str, Any] = {k: v for k, v in {');
721
+ for (const param of visibleQueryParams) {
722
+ const pn = fieldName(param.name);
723
+ const value = serializeParameterValue(param.type, pn, param.required, (param as ParameterExt).explode);
724
+ lines.push(` "${param.name}": ${value},`);
725
+ }
726
+ lines.push(' }.items() if v is not None}');
727
+ } else if (visibleQueryParams.length > 0) {
728
+ lines.push(' params: Dict[str, Any] = {');
729
+ for (const param of visibleQueryParams) {
730
+ const pn = fieldName(param.name);
731
+ const value = serializeParameterValue(param.type, pn, param.required, (param as ParameterExt).explode);
732
+ lines.push(` "${param.name}": ${value},`);
733
+ }
734
+ lines.push(' }');
735
+ } else {
736
+ // No visible query params but we have injections — start with empty dict
737
+ lines.push(' params: Dict[str, Any] = {}');
738
+ }
739
+ // Inject constant defaults
740
+ if (Object.keys(opDefaults).length > 0) {
741
+ for (const [key, value] of Object.entries(opDefaults)) {
742
+ lines.push(` params["${key}"] = ${pythonLiteral(value)}`);
743
+ }
744
+ }
745
+ // Inject fields from client config
746
+ if (opInferFromClient.length > 0) {
747
+ for (const field of opInferFromClient) {
748
+ const expr = clientFieldExpression(field);
749
+ lines.push(` if ${expr} is not None:`);
750
+ lines.push(` params["${field}"] = ${expr}`);
751
+ }
752
+ }
753
+ if (usesClientCredentialDefaults) {
754
+ if (op.queryParams.some((param) => param.name === 'client_id')) {
755
+ lines.push(' params["client_id"] = params.get("client_id") or self._client._require_client_id()');
756
+ }
757
+ if (op.queryParams.some((param) => param.name === 'client_secret')) {
758
+ lines.push(
759
+ ' params["client_secret"] = params.get("client_secret") or self._client._require_api_key()',
760
+ );
761
+ }
762
+ }
763
+ }
764
+ const emittedParams = hasVisibleQueryParams || hasInjections;
765
+ if (isArrayResponse) {
766
+ // Array response: request_list returns List[Dict], then deserialize each item
767
+ const itemModel = className(plan.responseModelName!);
768
+ lines.push(` raw = ${awaitPrefix}self._client.request_list(`);
769
+ lines.push(` method="${httpMethod}",`);
770
+ lines.push(` path=${pathStr},`);
771
+ if (emittedParams) {
772
+ lines.push(' params=params,');
773
+ }
774
+ lines.push(' request_options=request_options,');
775
+ lines.push(' )');
776
+ lines.push(` return [${itemModel}.from_dict(cast(Dict[str, Any], item)) for item in raw]`);
777
+ } else {
778
+ const returnPrefix = responseModel !== 'None' ? 'return ' : '';
779
+ lines.push(` ${returnPrefix}${awaitPrefix}self._client.request(`);
780
+ lines.push(` method="${httpMethod}",`);
781
+ lines.push(` path=${pathStr},`);
782
+ if (emittedParams) {
783
+ lines.push(' params=params,');
784
+ }
785
+ if (responseModel !== 'None') {
786
+ lines.push(` model=${responseModel},`);
787
+ }
788
+ lines.push(' request_options=request_options,');
789
+ lines.push(' )');
790
+ }
791
+ }
792
+ }
793
+
794
+ // ─── Main generator ──────────────────────────────────────────────────
795
+
796
+ /**
797
+ * Generate Python resource class files from IR Service definitions.
798
+ * Uses mount-based grouping: one resource file per mount target with all
799
+ * co-mounted operations merged (flat pattern matching PHP).
800
+ */
801
+ export function generateResources(services: Service[], ctx: EmitterContext): GeneratedFile[] {
802
+ if (services.length === 0) return [];
803
+
804
+ const resolvedLookup = buildResolvedLookup(ctx);
805
+ const files: GeneratedFile[] = [];
806
+ const mountDirMap = buildMountDirMap(ctx);
807
+ const mountGroups = groupByMount(ctx);
808
+
809
+ // Build mount group entries. When resolved operations are available, group by
810
+ // mount target. Otherwise fall back to one group per service (for tests).
811
+ const entries: Array<{ name: string; operations: Operation[] }> =
812
+ mountGroups.size > 0
813
+ ? [...mountGroups].map(([name, group]) => ({ name, operations: group.operations }))
814
+ : services.map((s) => ({ name: resolveClassName(s, ctx), operations: s.operations }));
815
+
816
+ for (const { name: mountName, operations: allOperations } of entries) {
817
+ if (allOperations.length === 0) continue;
818
+ const dirName = moduleName(mountName);
819
+ const resourceClassName = className(mountName);
820
+ const importPrefix = relativeImportPrefix(dirName);
821
+
822
+ const lines: string[] = [];
823
+ lines.push('from __future__ import annotations');
824
+ lines.push('');
825
+ lines.push('from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Type, Union, cast');
826
+ lines.push('');
827
+ lines.push('if TYPE_CHECKING:');
828
+ lines.push(` from ${importPrefix}_client import AsyncWorkOSClient, WorkOSClient`);
829
+ lines.push('');
830
+
831
+ const hasDeprecatedOps = allOperations.some((op) => op.deprecated);
832
+ if (hasDeprecatedOps) {
833
+ lines.push('import warnings');
834
+ lines.push('');
835
+ }
836
+
837
+ // Collect all model and enum imports needed
838
+ const modelImports = new Set<string>();
839
+ const enumImports = new Set<string>();
840
+
841
+ // Build a set of list wrapper model names to skip
842
+ const listWrapperNames = new Set<string>();
843
+ for (const m of ctx.spec.models) {
844
+ const dataField = m.fields.find((f) => f.name === 'data');
845
+ const hasListMeta = m.fields.some((f) => f.name === 'list_metadata' || f.name === 'listMetadata');
846
+ if (dataField && hasListMeta && dataField.type.kind === 'array') {
847
+ listWrapperNames.add(m.name);
848
+ }
849
+ }
850
+
851
+ for (const op of allOperations) {
852
+ const plan = planOperation(op);
853
+ if (plan.responseModelName && !listWrapperNames.has(plan.responseModelName)) {
854
+ modelImports.add(plan.responseModelName);
855
+ }
856
+ if (op.requestBody?.kind === 'model') {
857
+ const requestBodyRef = op.requestBody;
858
+ modelImports.add(requestBodyRef.name);
859
+ // Also collect types from body model fields (expanded as keyword params)
860
+ const bodyModel = ctx.spec.models.find((m) => m.name === requestBodyRef.name);
861
+ if (bodyModel) {
862
+ for (const f of bodyModel.fields) {
863
+ for (const ref of collectModelRefs(f.type)) modelImports.add(ref);
864
+ for (const ref of collectEnumRefs(f.type)) enumImports.add(ref);
865
+ }
866
+ }
867
+ }
868
+ // Collect from params
869
+ for (const p of [...op.pathParams, ...op.queryParams]) {
870
+ for (const ref of collectEnumRefs(p.type)) {
871
+ enumImports.add(ref);
872
+ }
873
+ }
874
+ if (op.requestBody) {
875
+ for (const ref of collectModelRefs(op.requestBody)) {
876
+ modelImports.add(ref);
877
+ }
878
+ for (const ref of collectEnumRefs(op.requestBody)) {
879
+ enumImports.add(ref);
880
+ }
881
+ }
882
+ if (op.pagination?.itemType.kind === 'model') {
883
+ let paginationItemName = op.pagination.itemType.name;
884
+ // Unwrap list wrapper models to their inner item type for imports
885
+ if (listWrapperNames.has(paginationItemName)) {
886
+ const wrapperModel = ctx.spec.models.find((m) => m.name === paginationItemName);
887
+ const dataField = wrapperModel?.fields.find((f) => f.name === 'data');
888
+ if (dataField && dataField.type.kind === 'array' && dataField.type.items.kind === 'model') {
889
+ paginationItemName = dataField.type.items.name;
890
+ }
891
+ }
892
+ modelImports.add(paginationItemName);
893
+ }
894
+ // Collect model imports for union split wrapper response types
895
+ const resolved = lookupResolved(op, resolvedLookup);
896
+ if (resolved?.wrappers) {
897
+ for (const w of resolved.wrappers) {
898
+ if (w.responseModelName) modelImports.add(w.responseModelName);
899
+ }
900
+ }
901
+ }
902
+
903
+ // Filter enum imports to only those that actually exist in the spec
904
+ const specEnumNames = new Set(ctx.spec.enums.map((e) => e.name));
905
+ for (const name of enumImports) {
906
+ if (!specEnumNames.has(name)) enumImports.delete(name);
907
+ }
908
+
909
+ if (enumImports.size > 0) {
910
+ lines.push(`from ${importPrefix}_types import RequestOptions, enum_value`);
911
+ } else {
912
+ lines.push(`from ${importPrefix}_types import RequestOptions`);
913
+ }
914
+ const actualModelImports = [...modelImports];
915
+
916
+ // Split imports into same-service and cross-service (using mount-based dirs)
917
+ const modelToServiceMap = assignModelsToServices(ctx.spec.models, ctx.spec.services);
918
+ const resolveModelDir = (modelName: string) => {
919
+ const svc = modelToServiceMap.get(modelName);
920
+ return svc ? (mountDirMap.get(svc) ?? 'common') : 'common';
921
+ };
922
+
923
+ const localModels: string[] = [];
924
+ const crossServiceModels = new Map<string, string[]>(); // dir -> names
925
+
926
+ for (const name of actualModelImports.sort()) {
927
+ const modelDir = resolveModelDir(name);
928
+ if (modelDir === dirName) {
929
+ localModels.push(name);
930
+ } else {
931
+ if (!crossServiceModels.has(modelDir)) crossServiceModels.set(modelDir, []);
932
+ crossServiceModels.get(modelDir)!.push(name);
933
+ }
934
+ }
935
+
936
+ // Deduplicate: skip cross-service imports for models already available locally
937
+ const localSet = new Set(localModels);
938
+
939
+ if (localModels.length > 0) {
940
+ lines.push(`from .models import ${localModels.map((n) => className(n)).join(', ')}`);
941
+ }
942
+ for (const [csDir, names] of [...crossServiceModels].sort()) {
943
+ const unique = names.filter((n) => !localSet.has(n));
944
+ for (const n of unique) {
945
+ lines.push(`from ${ctx.namespace}.${dirToModule(csDir)}.models.${fileName(n)} import ${className(n)}`);
946
+ }
947
+ }
948
+
949
+ // Enum imports — same-service vs cross-service
950
+ const enumToServiceMap = new Map<string, string>();
951
+ for (const e of ctx.spec.enums) {
952
+ // Find which service uses this enum by walking full type trees
953
+ for (const svc of ctx.spec.services) {
954
+ for (const op of svc.operations) {
955
+ const refs = new Set<string>();
956
+ // Walk all type refs (including nested nullable/array/union) to find enums
957
+ const allTypeRefs = [
958
+ op.response,
959
+ ...(op.requestBody ? [op.requestBody] : []),
960
+ ...op.pathParams.map((p) => p.type),
961
+ ...op.queryParams.map((p) => p.type),
962
+ ...op.headerParams.map((p) => p.type),
963
+ ];
964
+ for (const typeRef of allTypeRefs) {
965
+ for (const ref of collectEnumRefs(typeRef)) refs.add(ref);
966
+ }
967
+ if (refs.has(e.name) && !enumToServiceMap.has(e.name)) {
968
+ enumToServiceMap.set(e.name, svc.name);
969
+ }
970
+ }
971
+ }
972
+ }
973
+
974
+ const localEnums: string[] = [];
975
+ const crossServiceEnums = new Map<string, string[]>();
976
+ for (const name of [...enumImports].sort()) {
977
+ const enumSvc = enumToServiceMap.get(name);
978
+ const enumDir = enumSvc ? (mountDirMap.get(enumSvc) ?? 'common') : 'common';
979
+ if (enumDir === dirName) {
980
+ localEnums.push(name);
981
+ } else {
982
+ if (!crossServiceEnums.has(enumDir)) crossServiceEnums.set(enumDir, []);
983
+ crossServiceEnums.get(enumDir)!.push(name);
984
+ }
985
+ }
986
+
987
+ if (localEnums.length > 0) {
988
+ lines.push(`from .models import ${localEnums.map((n) => className(n)).join(', ')}`);
989
+ }
990
+ for (const [csDir, names] of [...crossServiceEnums].sort()) {
991
+ for (const n of names) {
992
+ lines.push(`from ${ctx.namespace}.${dirToModule(csDir)}.models.${fileName(n)} import ${className(n)}`);
993
+ }
994
+ }
995
+
996
+ const hasPaginated = allOperations.some((op) => op.pagination);
997
+ if (hasPaginated) {
998
+ lines.push(`from ${importPrefix}_pagination import AsyncPage, SyncPage`);
999
+ }
1000
+ // --- Generate sync class ---
1001
+ lines.push('');
1002
+ lines.push(`class ${resourceClassName}:`);
1003
+ {
1004
+ let readable = resourceClassName.replace(/([a-z])([A-Z])/g, '$1 $2');
1005
+ readable = readable.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2');
1006
+ lines.push(` """${readable} API resources."""`);
1007
+ }
1008
+ lines.push('');
1009
+ lines.push(' def __init__(self, client: "WorkOSClient") -> None:');
1010
+ lines.push(' self._client = client');
1011
+
1012
+ const emittedMethods = new Set<string>();
1013
+ for (const op of allOperations) {
1014
+ const plan = planOperation(op);
1015
+ let method = lookupMethodName(op, resolvedLookup) ?? toSnakeCase(op.name);
1016
+ // On name collision, fall back to the full snake_case operation name
1017
+ if (emittedMethods.has(method)) {
1018
+ const fallback = toSnakeCase(op.name);
1019
+ if (fallback !== method && !emittedMethods.has(fallback)) {
1020
+ method = fallback;
1021
+ } else {
1022
+ continue;
1023
+ }
1024
+ }
1025
+ emittedMethods.add(method);
1026
+
1027
+ // Look up the resolved operation for defaults/inferFromClient support
1028
+ const resolvedSync = lookupResolved(op, resolvedLookup);
1029
+
1030
+ lines.push('');
1031
+ const meta = emitMethodSignature(
1032
+ lines,
1033
+ op,
1034
+ plan,
1035
+ method,
1036
+ false,
1037
+ specEnumNames,
1038
+ modelImports,
1039
+ listWrapperNames,
1040
+ ctx,
1041
+ resolvedSync,
1042
+ );
1043
+ emitMethodDocstring(lines, op, plan, method, meta, specEnumNames, ctx, resolvedSync);
1044
+ emitMethodBody(lines, op, plan, meta, false, modelImports, listWrapperNames, ctx, resolvedSync);
1045
+
1046
+ // Emit union split wrapper methods (e.g., authenticate_with_password)
1047
+ if (resolvedSync?.wrappers && resolvedSync.wrappers.length > 0) {
1048
+ lines.push(...generateSyncWrapperMethods(resolvedSync, ctx));
1049
+ for (const w of resolvedSync.wrappers) emittedMethods.add(w.name);
1050
+ }
1051
+ }
1052
+
1053
+ // --- Generate async class ---
1054
+ const asyncClassName = `Async${resourceClassName}`;
1055
+ lines.push('');
1056
+ lines.push('');
1057
+ lines.push(`class ${asyncClassName}:`);
1058
+ {
1059
+ let readable = resourceClassName.replace(/([a-z])([A-Z])/g, '$1 $2');
1060
+ readable = readable.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2');
1061
+ lines.push(` """${readable} API resources (async)."""`);
1062
+ }
1063
+ lines.push('');
1064
+ lines.push(' def __init__(self, client: "AsyncWorkOSClient") -> None:');
1065
+ lines.push(' self._client = client');
1066
+
1067
+ const asyncEmittedMethods = new Set<string>();
1068
+ for (const op of allOperations) {
1069
+ const plan = planOperation(op);
1070
+ let method = lookupMethodName(op, resolvedLookup) ?? toSnakeCase(op.name);
1071
+ if (asyncEmittedMethods.has(method)) {
1072
+ const fallback = toSnakeCase(op.name);
1073
+ if (fallback !== method && !asyncEmittedMethods.has(fallback)) {
1074
+ method = fallback;
1075
+ } else {
1076
+ continue;
1077
+ }
1078
+ }
1079
+ asyncEmittedMethods.add(method);
1080
+
1081
+ // Look up the resolved operation for defaults/inferFromClient support
1082
+ const resolvedAsync = lookupResolved(op, resolvedLookup);
1083
+
1084
+ lines.push('');
1085
+ const meta = emitMethodSignature(
1086
+ lines,
1087
+ op,
1088
+ plan,
1089
+ method,
1090
+ true,
1091
+ specEnumNames,
1092
+ modelImports,
1093
+ listWrapperNames,
1094
+ ctx,
1095
+ resolvedAsync,
1096
+ );
1097
+ emitMethodDocstring(lines, op, plan, method, meta, specEnumNames, ctx, resolvedAsync);
1098
+ emitMethodBody(lines, op, plan, meta, true, modelImports, listWrapperNames, ctx, resolvedAsync);
1099
+
1100
+ // Emit union split wrapper methods (e.g., authenticate_with_password)
1101
+ if (resolvedAsync?.wrappers && resolvedAsync.wrappers.length > 0) {
1102
+ lines.push(...generateAsyncWrapperMethods(resolvedAsync, ctx));
1103
+ for (const w of resolvedAsync.wrappers) asyncEmittedMethods.add(w.name);
1104
+ }
1105
+ }
1106
+
1107
+ files.push({
1108
+ path: `src/${ctx.namespace}/${dirName}/_resource.py`,
1109
+ content: lines.join('\n'),
1110
+ integrateTarget: true,
1111
+ overwriteExisting: true,
1112
+ });
1113
+ }
1114
+
1115
+ return files;
1116
+ }
1117
+
1118
+ // ─── Existing shared helpers ─────────────────────────────────────────
1119
+
1120
+ /**
1121
+ * Emit a `params` dict from query params (for methods that also have a body or DELETE).
1122
+ * Returns true if params were emitted, false if no query params exist.
1123
+ */
1124
+ function emitQueryParamsDict(
1125
+ lines: string[],
1126
+ op: Operation,
1127
+ pathParamNames: Set<string>,
1128
+ bodyFieldNames: Set<string>,
1129
+ hiddenParams?: Set<string>,
1130
+ ): boolean {
1131
+ // Filter to query params that aren't already path params, body fields, or hidden
1132
+ const queryParams = op.queryParams.filter((p) => {
1133
+ if (hiddenParams?.has(p.name)) return false;
1134
+ const pn = fieldName(p.name);
1135
+ return !pathParamNames.has(pn) && !bodyFieldNames.has(pn);
1136
+ });
1137
+ if (queryParams.length === 0) return false;
1138
+
1139
+ const hasOptional = queryParams.some((p) => !p.required);
1140
+ if (hasOptional) {
1141
+ lines.push(' params: Dict[str, Any] = {k: v for k, v in {');
1142
+ for (const param of queryParams) {
1143
+ lines.push(
1144
+ ` "${param.name}": ${serializeParameterValue(param.type, fieldName(param.name), param.required, (param as ParameterExt).explode)},`,
1145
+ );
1146
+ }
1147
+ lines.push(' }.items() if v is not None}');
1148
+ } else {
1149
+ lines.push(' params: Dict[str, Any] = {');
1150
+ for (const param of queryParams) {
1151
+ lines.push(
1152
+ ` "${param.name}": ${serializeParameterValue(param.type, fieldName(param.name), param.required, (param as ParameterExt).explode)},`,
1153
+ );
1154
+ }
1155
+ lines.push(' }');
1156
+ }
1157
+ return true;
1158
+ }
1159
+
1160
+ /**
1161
+ * Serialize a body field value for inclusion in a request body dict.
1162
+ * Calls .to_dict() directly on model fields since types are known at generation time.
1163
+ * For arrays of models, maps each item through .to_dict().
1164
+ */
1165
+ function serializeBodyFieldValue(fieldType: any, varName: string, isRequired: boolean): string {
1166
+ const effectiveType = fieldType.kind === 'nullable' ? fieldType.inner : fieldType;
1167
+ if (effectiveType.kind === 'enum') {
1168
+ return serializeParameterValue(effectiveType, varName, isRequired);
1169
+ }
1170
+ if (effectiveType.kind === 'model') {
1171
+ if (!isRequired) {
1172
+ return `${varName}.to_dict() if ${varName} is not None else None`;
1173
+ }
1174
+ return `${varName}.to_dict()`;
1175
+ }
1176
+ if (effectiveType.kind === 'array' && effectiveType.items?.kind === 'model') {
1177
+ if (!isRequired) {
1178
+ return `[item.to_dict() for item in ${varName}] if ${varName} is not None else None`;
1179
+ }
1180
+ return `[item.to_dict() for item in ${varName}]`;
1181
+ }
1182
+ return varName;
1183
+ }
1184
+
1185
+ function serializeParameterValue(
1186
+ type: TypeRef | undefined,
1187
+ varName: string,
1188
+ isRequired: boolean,
1189
+ explode?: boolean,
1190
+ ): string {
1191
+ if (type?.kind === 'nullable') {
1192
+ return serializeParameterValue(type.inner, varName, false, explode);
1193
+ }
1194
+ if (type?.kind === 'enum') {
1195
+ const expr = `enum_value(${varName})`;
1196
+ return isRequired ? expr : `${expr} if ${varName} is not None else None`;
1197
+ }
1198
+ // For explode=false array params, emit comma-joined string
1199
+ if (explode === false && type?.kind === 'array') {
1200
+ const joinExpr = `",".join(str(v) for v in ${varName})`;
1201
+ return isRequired ? joinExpr : `${joinExpr} if ${varName} is not None else None`;
1202
+ }
1203
+ return varName;
1204
+ }
1205
+
1206
+ /**
1207
+ * Resolve the item type name for a paginated operation, unwrapping list wrappers.
1208
+ */
1209
+ export function resolvePageItemName(itemType: TypeRef, listWrapperNames: Set<string>, ctx: EmitterContext): string {
1210
+ if (itemType.kind === 'model') {
1211
+ if (listWrapperNames.has(itemType.name)) {
1212
+ const wrapperModel = ctx.spec.models.find((m) => m.name === itemType.name);
1213
+ const dataField = wrapperModel?.fields.find((f) => f.name === 'data');
1214
+ if (dataField && dataField.type.kind === 'array' && dataField.type.items.kind === 'model') {
1215
+ return dataField.type.items.name;
1216
+ }
1217
+ }
1218
+ return itemType.name;
1219
+ }
1220
+ return 'dict';
1221
+ }
1222
+
1223
+ /**
1224
+ * Check if an operation is a redirect endpoint that should construct a URL
1225
+ * instead of making an HTTP request.
1226
+ *
1227
+ * Detection: GET endpoints with no response body (primitive unknown) are redirect
1228
+ * endpoints — e.g., SSO/OAuth authorize and logout flows that redirect the browser.
1229
+ * Also catches endpoints with 302 success responses when the parser includes them.
1230
+ */
1231
+ function isRedirectEndpoint(op: Operation): boolean {
1232
+ // Explicit 302 in success responses
1233
+ if (op.successResponses?.some((r) => r.statusCode >= 300 && r.statusCode < 400)) {
1234
+ return true;
1235
+ }
1236
+ // GET with no response body (primitive unknown) = browser redirect endpoint
1237
+ if (
1238
+ op.httpMethod === 'get' &&
1239
+ op.response.kind === 'primitive' &&
1240
+ op.response.type === 'unknown' &&
1241
+ op.queryParams.length > 0
1242
+ ) {
1243
+ return true;
1244
+ }
1245
+ return false;
1246
+ }
1247
+
1248
+ /**
1249
+ * Map HTTP status codes to Python error class names for per-operation Raises: documentation.
1250
+ * Falls back to a baseline set (401, 429, 5xx) when the operation has no explicit errors.
1251
+ */
1252
+ const STATUS_TO_ERROR: Record<number, string> = {
1253
+ 400: 'BadRequestError',
1254
+ 401: 'AuthenticationError',
1255
+ 403: 'AuthorizationError',
1256
+ 404: 'NotFoundError',
1257
+ 409: 'ConflictError',
1258
+ 422: 'UnprocessableEntityError',
1259
+ 429: 'RateLimitExceededError',
1260
+ };
1261
+
1262
+ const STATUS_TO_DESC: Record<number, string> = {
1263
+ 400: 'If the request is malformed (400).',
1264
+ 401: 'If the API key is invalid (401).',
1265
+ 403: 'If the request is forbidden (403).',
1266
+ 404: 'If the resource is not found (404).',
1267
+ 409: 'If a conflict occurs (409).',
1268
+ 422: 'If the request data is unprocessable (422).',
1269
+ 429: 'If rate limited (429).',
1270
+ };
1271
+
1272
+ function buildErrorRaisesBlock(op: Operation): string[] {
1273
+ const lines: string[] = [];
1274
+ const emittedCodes = new Set<number>();
1275
+
1276
+ if (op.errors.length > 0) {
1277
+ // Use per-operation error responses from the spec
1278
+ for (const err of op.errors) {
1279
+ const errorClass = STATUS_TO_ERROR[err.statusCode];
1280
+ const desc = STATUS_TO_DESC[err.statusCode];
1281
+ if (errorClass && !emittedCodes.has(err.statusCode)) {
1282
+ lines.push(`${errorClass}: ${desc}`);
1283
+ emittedCodes.add(err.statusCode);
1284
+ }
1285
+ }
1286
+ }
1287
+
1288
+ // Always include baseline errors for authenticated endpoints (401, 429, 5xx)
1289
+ if (!emittedCodes.has(401)) {
1290
+ lines.push('AuthenticationError: If the API key is invalid (401).');
1291
+ }
1292
+ if (!emittedCodes.has(429)) {
1293
+ lines.push('RateLimitExceededError: If rate limited (429).');
1294
+ }
1295
+ if (!emittedCodes.has(500)) {
1296
+ lines.push('ServerError: If the server returns a 5xx error.');
1297
+ }
1298
+
1299
+ return lines;
1300
+ }
1301
+
1302
+ /**
1303
+ * Build a Python f-string path expression from an operation path.
1304
+ * E.g., "/organizations/{id}" -> f"organizations/{id}"
1305
+ */
1306
+ function buildPathString(op: Operation): string {
1307
+ // Strip leading slash and convert {param} to Python f-string interpolation
1308
+ const path = op.path.replace(/^\//, '');
1309
+ if (op.pathParams.length === 0) {
1310
+ return `"${path}"`;
1311
+ }
1312
+ // Convert {paramName} to {fieldName(paramName)}
1313
+ let fPath = path;
1314
+ for (const param of op.pathParams) {
1315
+ if (param.type.kind === 'enum' || (param.type.kind === 'nullable' && (param.type as any).inner?.kind === 'enum')) {
1316
+ fPath = fPath.replace(`{${param.name}}`, `{enum_value(${fieldName(param.name)})}`);
1317
+ } else {
1318
+ fPath = fPath.replace(`{${param.name}}`, `{${fieldName(param.name)}}`);
1319
+ }
1320
+ }
1321
+ return `f"${fPath}"`;
1322
+ }