@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.
- package/.husky/pre-commit +1 -0
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +15 -0
- package/README.md +129 -0
- package/dist/index.d.mts +13 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +14549 -3385
- package/dist/index.mjs.map +1 -1
- package/docs/sdk-architecture/dotnet.md +336 -0
- package/docs/sdk-architecture/go.md +338 -0
- package/docs/sdk-architecture/php.md +315 -0
- package/docs/sdk-architecture/python.md +511 -0
- package/oagen.config.ts +328 -2
- package/package.json +9 -5
- package/scripts/generate-php.js +13 -0
- package/scripts/git-push-with-published-oagen.sh +21 -0
- package/smoke/sdk-dotnet.ts +45 -12
- package/smoke/sdk-go.ts +116 -42
- package/smoke/sdk-php.ts +28 -26
- package/smoke/sdk-python.ts +5 -2
- package/src/dotnet/client.ts +89 -0
- package/src/dotnet/enums.ts +323 -0
- package/src/dotnet/fixtures.ts +236 -0
- package/src/dotnet/index.ts +246 -0
- package/src/dotnet/manifest.ts +36 -0
- package/src/dotnet/models.ts +344 -0
- package/src/dotnet/naming.ts +330 -0
- package/src/dotnet/resources.ts +622 -0
- package/src/dotnet/tests.ts +693 -0
- package/src/dotnet/type-map.ts +201 -0
- package/src/dotnet/wrappers.ts +186 -0
- package/src/go/client.ts +141 -0
- package/src/go/enums.ts +196 -0
- package/src/go/fixtures.ts +212 -0
- package/src/go/index.ts +84 -0
- package/src/go/manifest.ts +36 -0
- package/src/go/models.ts +254 -0
- package/src/go/naming.ts +179 -0
- package/src/go/resources.ts +827 -0
- package/src/go/tests.ts +751 -0
- package/src/go/type-map.ts +82 -0
- package/src/go/wrappers.ts +261 -0
- package/src/index.ts +4 -0
- package/src/kotlin/client.ts +53 -0
- package/src/kotlin/enums.ts +162 -0
- package/src/kotlin/index.ts +92 -0
- package/src/kotlin/manifest.ts +55 -0
- package/src/kotlin/models.ts +395 -0
- package/src/kotlin/naming.ts +223 -0
- package/src/kotlin/overrides.ts +25 -0
- package/src/kotlin/resources.ts +667 -0
- package/src/kotlin/tests.ts +1019 -0
- package/src/kotlin/type-map.ts +123 -0
- package/src/kotlin/wrappers.ts +168 -0
- package/src/node/client.ts +128 -115
- package/src/node/enums.ts +9 -0
- package/src/node/errors.ts +37 -232
- package/src/node/field-plan.ts +726 -0
- package/src/node/fixtures.ts +9 -1
- package/src/node/index.ts +3 -9
- package/src/node/models.ts +178 -21
- package/src/node/naming.ts +49 -111
- package/src/node/resources.ts +527 -397
- package/src/node/sdk-errors.ts +41 -0
- package/src/node/tests.ts +69 -19
- package/src/node/type-map.ts +4 -2
- package/src/node/utils.ts +13 -71
- package/src/node/wrappers.ts +151 -0
- package/src/php/client.ts +179 -0
- package/src/php/enums.ts +67 -0
- package/src/php/errors.ts +9 -0
- package/src/php/fixtures.ts +181 -0
- package/src/php/index.ts +96 -0
- package/src/php/manifest.ts +36 -0
- package/src/php/models.ts +310 -0
- package/src/php/naming.ts +279 -0
- package/src/php/resources.ts +636 -0
- package/src/php/tests.ts +609 -0
- package/src/php/type-map.ts +90 -0
- package/src/php/utils.ts +18 -0
- package/src/php/wrappers.ts +152 -0
- package/src/python/client.ts +345 -0
- package/src/python/enums.ts +313 -0
- package/src/python/fixtures.ts +196 -0
- package/src/python/index.ts +95 -0
- package/src/python/manifest.ts +38 -0
- package/src/python/models.ts +688 -0
- package/src/python/naming.ts +189 -0
- package/src/python/resources.ts +1322 -0
- package/src/python/tests.ts +1335 -0
- package/src/python/type-map.ts +93 -0
- package/src/python/wrappers.ts +191 -0
- package/src/shared/model-utils.ts +472 -0
- package/src/shared/naming-utils.ts +154 -0
- package/src/shared/non-spec-services.ts +54 -0
- package/src/shared/resolved-ops.ts +109 -0
- package/src/shared/wrapper-utils.ts +70 -0
- package/test/dotnet/client.test.ts +121 -0
- package/test/dotnet/enums.test.ts +193 -0
- package/test/dotnet/errors.test.ts +9 -0
- package/test/dotnet/manifest.test.ts +82 -0
- package/test/dotnet/models.test.ts +260 -0
- package/test/dotnet/resources.test.ts +255 -0
- package/test/dotnet/tests.test.ts +202 -0
- package/test/go/client.test.ts +92 -0
- package/test/go/enums.test.ts +132 -0
- package/test/go/errors.test.ts +9 -0
- package/test/go/models.test.ts +265 -0
- package/test/go/resources.test.ts +408 -0
- package/test/go/tests.test.ts +143 -0
- package/test/kotlin/models.test.ts +135 -0
- package/test/kotlin/tests.test.ts +176 -0
- package/test/node/client.test.ts +92 -12
- package/test/node/enums.test.ts +2 -0
- package/test/node/errors.test.ts +2 -41
- package/test/node/models.test.ts +2 -0
- package/test/node/naming.test.ts +23 -0
- package/test/node/resources.test.ts +315 -84
- package/test/node/serializers.test.ts +3 -1
- package/test/node/type-map.test.ts +11 -0
- package/test/php/client.test.ts +95 -0
- package/test/php/enums.test.ts +173 -0
- package/test/php/errors.test.ts +9 -0
- package/test/php/models.test.ts +497 -0
- package/test/php/resources.test.ts +682 -0
- package/test/php/tests.test.ts +185 -0
- package/test/python/client.test.ts +200 -0
- package/test/python/enums.test.ts +228 -0
- package/test/python/errors.test.ts +16 -0
- package/test/python/manifest.test.ts +74 -0
- package/test/python/models.test.ts +716 -0
- package/test/python/resources.test.ts +617 -0
- package/test/python/tests.test.ts +202 -0
- package/src/node/common.ts +0 -273
- package/src/node/config.ts +0 -71
- package/src/node/serializers.ts +0 -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
|
+
}
|