@workos/oagen-emitters 0.3.0 → 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/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +7 -0
- package/dist/index.d.mts +4 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +3288 -791
- package/dist/index.mjs.map +1 -1
- package/docs/sdk-architecture/dotnet.md +336 -0
- package/oagen.config.ts +42 -12
- package/package.json +2 -2
- package/smoke/sdk-dotnet.ts +45 -12
- 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/index.ts +5 -2
- package/src/go/naming.ts +5 -17
- package/src/index.ts +1 -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 +50 -0
- package/src/node/index.ts +1 -0
- package/src/node/resources.ts +164 -44
- package/src/node/tests.ts +37 -7
- package/src/php/client.ts +11 -3
- package/src/php/naming.ts +2 -21
- package/src/php/resources.ts +81 -6
- package/src/php/tests.ts +93 -17
- package/src/php/wrappers.ts +1 -0
- package/src/python/client.ts +37 -29
- package/src/python/enums.ts +7 -7
- package/src/python/models.ts +1 -1
- package/src/python/naming.ts +2 -22
- package/src/shared/model-utils.ts +232 -15
- package/src/shared/naming-utils.ts +47 -0
- package/src/shared/wrapper-utils.ts +12 -1
- 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/kotlin/models.test.ts +135 -0
- package/test/kotlin/tests.test.ts +176 -0
- package/test/node/client.test.ts +74 -0
- package/test/node/resources.test.ts +216 -15
- package/test/php/client.test.ts +2 -1
- package/test/php/resources.test.ts +38 -0
- package/test/php/tests.test.ts +67 -0
package/src/php/resources.ts
CHANGED
|
@@ -50,8 +50,11 @@ export function generateResources(services: Service[], ctx: EmitterContext): Gen
|
|
|
50
50
|
lines.push(`namespace ${ctx.namespacePascal}\\Service;`);
|
|
51
51
|
lines.push('');
|
|
52
52
|
|
|
53
|
+
// Build resolved lookup early — used by both imports and method generation
|
|
54
|
+
const resolvedLookup = buildResolvedLookup(ctx);
|
|
55
|
+
|
|
53
56
|
// Collect imports
|
|
54
|
-
const imports = collectImports(mergedService, ctx);
|
|
57
|
+
const imports = collectImports(mergedService, ctx, resolvedLookup);
|
|
55
58
|
for (const imp of imports) {
|
|
56
59
|
lines.push(`use ${imp};`);
|
|
57
60
|
}
|
|
@@ -66,7 +69,6 @@ export function generateResources(services: Service[], ctx: EmitterContext): Gen
|
|
|
66
69
|
|
|
67
70
|
// Track emitted method names to avoid duplicates
|
|
68
71
|
const emittedMethods = new Set<string>();
|
|
69
|
-
const resolvedLookup = buildResolvedLookup(ctx);
|
|
70
72
|
for (const op of operations) {
|
|
71
73
|
const method = resolveMethodName(op, mergedService, ctx);
|
|
72
74
|
if (emittedMethods.has(method)) continue;
|
|
@@ -94,6 +96,29 @@ export function generateResources(services: Service[], ctx: EmitterContext): Gen
|
|
|
94
96
|
return files;
|
|
95
97
|
}
|
|
96
98
|
|
|
99
|
+
/**
|
|
100
|
+
* Check if an operation is a redirect endpoint that should construct a URL
|
|
101
|
+
* instead of making an HTTP request.
|
|
102
|
+
*
|
|
103
|
+
* Detection: GET endpoints with no response body (primitive unknown) and query
|
|
104
|
+
* params are redirect endpoints (e.g., SSO/OAuth authorize and logout flows).
|
|
105
|
+
* Also respects an explicit urlBuilder flag on the resolved operation and
|
|
106
|
+
* catches endpoints with 302 success responses.
|
|
107
|
+
*/
|
|
108
|
+
export function isRedirectEndpoint(op: Operation, resolvedOp?: ResolvedOperation): boolean {
|
|
109
|
+
if ((resolvedOp as any)?.urlBuilder) return true;
|
|
110
|
+
if ((op as any).successResponses?.some((r: any) => r.statusCode >= 300 && r.statusCode < 400)) return true;
|
|
111
|
+
if (
|
|
112
|
+
op.httpMethod === 'get' &&
|
|
113
|
+
op.response.kind === 'primitive' &&
|
|
114
|
+
(op.response as any).type === 'unknown' &&
|
|
115
|
+
op.queryParams.length > 0
|
|
116
|
+
) {
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
|
|
97
122
|
function generateMethod(
|
|
98
123
|
lines: string[],
|
|
99
124
|
op: Operation,
|
|
@@ -112,8 +137,9 @@ function generateMethod(
|
|
|
112
137
|
...getOpInferFromClient(resolvedOp),
|
|
113
138
|
]);
|
|
114
139
|
|
|
140
|
+
const isRedirect = isRedirectEndpoint(op, resolvedOp);
|
|
115
141
|
const params = buildMethodParams(op, plan, modelMap, ctx, hiddenParams);
|
|
116
|
-
const returnType = getReturnType(plan, ctx);
|
|
142
|
+
const returnType = isRedirect ? 'string' : getReturnType(plan, ctx);
|
|
117
143
|
|
|
118
144
|
// PHPDoc block
|
|
119
145
|
const docParts: string[] = [];
|
|
@@ -183,6 +209,16 @@ function generateMethod(
|
|
|
183
209
|
docParts.push(`@return ${returnType}`);
|
|
184
210
|
}
|
|
185
211
|
|
|
212
|
+
// @throws — scope to what the method actually calls
|
|
213
|
+
if (!isRedirect) {
|
|
214
|
+
// HTTP methods can throw any WorkOSException (config, transport, API response)
|
|
215
|
+
docParts.push(`@throws \\${ctx.namespacePascal}\\Exception\\WorkOSException`);
|
|
216
|
+
} else if (getOpInferFromClient(resolvedOp).length > 0) {
|
|
217
|
+
// Redirect endpoints that inject client fields can throw ConfigurationException
|
|
218
|
+
docParts.push(`@throws \\${ctx.namespacePascal}\\Exception\\ConfigurationException`);
|
|
219
|
+
}
|
|
220
|
+
// Redirect endpoints with no inferFromClient: buildUrl() is pure, no @throws
|
|
221
|
+
|
|
186
222
|
if (op.deprecated) docParts.push('@deprecated');
|
|
187
223
|
lines.push(...phpDocComment(docParts.join('\n'), 4));
|
|
188
224
|
|
|
@@ -198,7 +234,41 @@ function generateMethod(
|
|
|
198
234
|
const httpMethod = op.httpMethod.toUpperCase();
|
|
199
235
|
const path = buildPathString(op);
|
|
200
236
|
|
|
201
|
-
if (
|
|
237
|
+
if (isRedirect) {
|
|
238
|
+
// Redirect endpoint: construct URL client-side instead of making HTTP request
|
|
239
|
+
const queryLines = buildQueryArray(op, hiddenParams);
|
|
240
|
+
const hasDefaults = Object.keys(getOpDefaults(resolvedOp)).length > 0;
|
|
241
|
+
const hasInferred = getOpInferFromClient(resolvedOp).length > 0;
|
|
242
|
+
const needsQuery = queryLines.length > 0 || hasDefaults || hasInferred;
|
|
243
|
+
|
|
244
|
+
if (needsQuery) {
|
|
245
|
+
const hasOptionalQuery = op.queryParams.some((q) => !q.required && !hiddenParams.has(q.name));
|
|
246
|
+
if (hasOptionalQuery) {
|
|
247
|
+
lines.push(' $query = array_filter([');
|
|
248
|
+
} else {
|
|
249
|
+
lines.push(' $query = [');
|
|
250
|
+
}
|
|
251
|
+
for (const q of queryLines) {
|
|
252
|
+
lines.push(` ${q}`);
|
|
253
|
+
}
|
|
254
|
+
// Inject constant defaults
|
|
255
|
+
for (const [key, value] of Object.entries(getOpDefaults(resolvedOp))) {
|
|
256
|
+
lines.push(` '${key}' => ${phpLiteral(value)},`);
|
|
257
|
+
}
|
|
258
|
+
if (hasOptionalQuery) {
|
|
259
|
+
lines.push(' ], fn ($v) => $v !== null);');
|
|
260
|
+
} else {
|
|
261
|
+
lines.push(' ];');
|
|
262
|
+
}
|
|
263
|
+
// Inject fields from client config
|
|
264
|
+
for (const clientField of getOpInferFromClient(resolvedOp)) {
|
|
265
|
+
lines.push(` $query['${clientField}'] = ${clientFieldExpression(clientField)};`);
|
|
266
|
+
}
|
|
267
|
+
lines.push(` return $this->client->buildUrl(${path}, $query, $options);`);
|
|
268
|
+
} else {
|
|
269
|
+
lines.push(` return $this->client->buildUrl(${path}, [], $options);`);
|
|
270
|
+
}
|
|
271
|
+
} else if (plan.isPaginated) {
|
|
202
272
|
const queryLines = buildQueryArray(op);
|
|
203
273
|
if (queryLines.length > 0) {
|
|
204
274
|
lines.push(' $query = array_filter([');
|
|
@@ -534,13 +604,18 @@ function clientFieldExpression(field: string): string {
|
|
|
534
604
|
}
|
|
535
605
|
}
|
|
536
606
|
|
|
537
|
-
function collectImports(
|
|
607
|
+
function collectImports(
|
|
608
|
+
service: Service,
|
|
609
|
+
ctx: EmitterContext,
|
|
610
|
+
resolvedLookup?: Map<string, ResolvedOperation>,
|
|
611
|
+
): string[] {
|
|
538
612
|
const imports = new Set<string>();
|
|
539
613
|
const ns = ctx.namespacePascal;
|
|
540
614
|
|
|
541
615
|
for (const op of service.operations) {
|
|
542
616
|
const plan = planOperation(op);
|
|
543
|
-
|
|
617
|
+
const resolved = resolvedLookup ? lookupResolved(op, resolvedLookup) : undefined;
|
|
618
|
+
if (plan.responseModelName && !plan.isPaginated && !isRedirectEndpoint(op, resolved)) {
|
|
544
619
|
imports.add(`${ns}\\Resource\\${className(plan.responseModelName)}`);
|
|
545
620
|
}
|
|
546
621
|
if (op.pagination?.itemType.kind === 'model') {
|
package/src/php/tests.ts
CHANGED
|
@@ -7,12 +7,19 @@ import type {
|
|
|
7
7
|
Model,
|
|
8
8
|
ResolvedOperation,
|
|
9
9
|
} from '@workos/oagen';
|
|
10
|
-
import { planOperation, toCamelCase } from '@workos/oagen';
|
|
10
|
+
import { planOperation, toCamelCase, toPascalCase } from '@workos/oagen';
|
|
11
11
|
import { className, enumClassName, resolveMethodName, snakeName, servicePropertyName } from './naming.js';
|
|
12
12
|
import { isListWrapperModel } from './models.js';
|
|
13
13
|
import { generateFixtures } from './fixtures.js';
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
getMountTarget,
|
|
16
|
+
groupByMount,
|
|
17
|
+
buildHiddenParams,
|
|
18
|
+
getOpDefaults,
|
|
19
|
+
getOpInferFromClient,
|
|
20
|
+
} from '../shared/resolved-ops.js';
|
|
15
21
|
import { resolveWrapperParams } from '../shared/wrapper-utils.js';
|
|
22
|
+
import { isRedirectEndpoint } from './resources.js';
|
|
16
23
|
|
|
17
24
|
/**
|
|
18
25
|
* Generate PHPUnit test files and fixture JSON files.
|
|
@@ -20,16 +27,6 @@ import { resolveWrapperParams } from '../shared/wrapper-utils.js';
|
|
|
20
27
|
export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
|
|
21
28
|
const files: GeneratedFile[] = [];
|
|
22
29
|
|
|
23
|
-
// Generate fixture JSON files
|
|
24
|
-
const fixtures = generateFixtures(spec);
|
|
25
|
-
for (const fixture of fixtures) {
|
|
26
|
-
files.push({
|
|
27
|
-
path: fixture.path,
|
|
28
|
-
content: fixture.content,
|
|
29
|
-
headerPlacement: 'skip',
|
|
30
|
-
});
|
|
31
|
-
}
|
|
32
|
-
|
|
33
30
|
// TestHelper is now hand-maintained in the target SDK (@oagen-ignore-file).
|
|
34
31
|
|
|
35
32
|
// Collect all operations per mount target using resolved per-operation mounts.
|
|
@@ -72,6 +69,16 @@ export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
|
|
|
72
69
|
overwriteExisting: true,
|
|
73
70
|
});
|
|
74
71
|
|
|
72
|
+
// Generate fixture JSON files
|
|
73
|
+
const fixtures = generateFixtures(spec);
|
|
74
|
+
for (const fixture of fixtures) {
|
|
75
|
+
files.push({
|
|
76
|
+
path: fixture.path,
|
|
77
|
+
content: fixture.content,
|
|
78
|
+
headerPlacement: 'skip',
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
75
82
|
return files;
|
|
76
83
|
}
|
|
77
84
|
|
|
@@ -146,6 +153,17 @@ function generateMountGroupTest(
|
|
|
146
153
|
lines.push(` $this->assertStringEndsWith('${expectedPath}', $request->getUri()->getPath());`);
|
|
147
154
|
// Query string serialization assertions
|
|
148
155
|
emitQueryAssertions(lines, op, ctx, hidden);
|
|
156
|
+
} else if (isRedirectEndpoint(op, resolvedOp)) {
|
|
157
|
+
// Redirect endpoint: URL is built locally, no HTTP request made.
|
|
158
|
+
// Pass all params (including optional) to verify they appear in the URL.
|
|
159
|
+
lines.push(' $client = $this->createMockClient([]);');
|
|
160
|
+
lines.push(
|
|
161
|
+
` $result = $client->${accessor}()->${method}(${buildTestArgs(op, ctx, { includeOptional: true, hidden })});`,
|
|
162
|
+
);
|
|
163
|
+
lines.push(' $this->assertIsString($result);');
|
|
164
|
+
lines.push(` $this->assertStringContainsString('${expectedPath}', $result);`);
|
|
165
|
+
// Query param assertions for the generated URL
|
|
166
|
+
emitRedirectQueryAssertions(lines, op, ctx, hidden, resolvedOp);
|
|
149
167
|
} else if (plan.responseModelName) {
|
|
150
168
|
const modelName = className(plan.responseModelName);
|
|
151
169
|
const fixtureName = `${snakeName(plan.responseModelName)}`;
|
|
@@ -363,11 +381,8 @@ function generateTestValue(ref: { kind: string; type?: string; name?: string },
|
|
|
363
381
|
const e = ctx.spec.enums.find((en) => en.name === ref.name);
|
|
364
382
|
if (e && e.values.length > 0) {
|
|
365
383
|
const enumClass = enumClassName(ref.name);
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
.filter(Boolean)
|
|
369
|
-
.map((p) => p.charAt(0).toUpperCase() + p.slice(1).toLowerCase())
|
|
370
|
-
.join('');
|
|
384
|
+
// Must match the case-name logic in enums.ts
|
|
385
|
+
const caseName = toPascalCase(String(e.values[0].name).toLowerCase());
|
|
371
386
|
return `\\WorkOS\\Resource\\${enumClass}::${caseName}`;
|
|
372
387
|
}
|
|
373
388
|
}
|
|
@@ -375,6 +390,13 @@ function generateTestValue(ref: { kind: string; type?: string; name?: string },
|
|
|
375
390
|
}
|
|
376
391
|
case 'array':
|
|
377
392
|
return '[]';
|
|
393
|
+
case 'map':
|
|
394
|
+
return '[]';
|
|
395
|
+
case 'nullable':
|
|
396
|
+
return generateTestValue(
|
|
397
|
+
(ref as unknown as { inner: { kind: string; type?: string; name?: string } }).inner,
|
|
398
|
+
ctx,
|
|
399
|
+
);
|
|
378
400
|
case 'model': {
|
|
379
401
|
if (ref.name) {
|
|
380
402
|
const modelClass = className(ref.name);
|
|
@@ -499,6 +521,60 @@ function emitQueryAssertions(lines: string[], op: Operation, ctx: EmitterContext
|
|
|
499
521
|
}
|
|
500
522
|
}
|
|
501
523
|
|
|
524
|
+
/**
|
|
525
|
+
* Emit query param assertions for redirect endpoint URLs.
|
|
526
|
+
* Parses the query string from the built URL and asserts visible params,
|
|
527
|
+
* hidden defaults (e.g., response_type), and inferred client fields (e.g., client_id).
|
|
528
|
+
*/
|
|
529
|
+
function emitRedirectQueryAssertions(
|
|
530
|
+
lines: string[],
|
|
531
|
+
op: Operation,
|
|
532
|
+
ctx: EmitterContext,
|
|
533
|
+
hidden: Set<string>,
|
|
534
|
+
resolvedOp?: ResolvedOperation,
|
|
535
|
+
): void {
|
|
536
|
+
const hasVisibleQueryParams = op.queryParams.some((q) => !hidden.has(q.name));
|
|
537
|
+
const defaults = getOpDefaults(resolvedOp);
|
|
538
|
+
const inferred = getOpInferFromClient(resolvedOp);
|
|
539
|
+
if (!hasVisibleQueryParams && Object.keys(defaults).length === 0 && inferred.length === 0) return;
|
|
540
|
+
|
|
541
|
+
lines.push(" parse_str(parse_url($result, PHP_URL_QUERY) ?? '', $query);");
|
|
542
|
+
|
|
543
|
+
// Assert visible query params (same logic as emitQueryAssertions but reading from $query parsed from URL)
|
|
544
|
+
for (const q of op.queryParams) {
|
|
545
|
+
if (hidden.has(q.name)) continue;
|
|
546
|
+
const innerType =
|
|
547
|
+
q.type.kind === 'nullable' ? (q.type as { inner: { kind: string; type?: string; name?: string } }).inner : q.type;
|
|
548
|
+
if (innerType.kind === 'enum' && innerType.name) {
|
|
549
|
+
const e = ctx.spec.enums.find((en) => en.name === innerType.name);
|
|
550
|
+
if (e && e.values.length > 0) {
|
|
551
|
+
lines.push(` $this->assertSame('${e.values[0].value}', $query['${q.name}']);`);
|
|
552
|
+
}
|
|
553
|
+
} else if (innerType.kind === 'primitive') {
|
|
554
|
+
switch (innerType.type) {
|
|
555
|
+
case 'string':
|
|
556
|
+
lines.push(` $this->assertSame('test_value', $query['${q.name}']);`);
|
|
557
|
+
break;
|
|
558
|
+
case 'integer':
|
|
559
|
+
case 'number':
|
|
560
|
+
case 'boolean':
|
|
561
|
+
lines.push(` $this->assertArrayHasKey('${q.name}', $query);`);
|
|
562
|
+
break;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// Assert hidden defaults (e.g., response_type => 'code')
|
|
568
|
+
for (const [key, value] of Object.entries(defaults)) {
|
|
569
|
+
lines.push(` $this->assertSame('${value}', $query['${key}']);`);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// Assert inferred client fields are present (e.g., client_id)
|
|
573
|
+
for (const key of inferred) {
|
|
574
|
+
lines.push(` $this->assertArrayHasKey('${key}', $query);`);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
502
578
|
/**
|
|
503
579
|
* Emit body field assertions for POST/PUT/PATCH operations.
|
|
504
580
|
* Only asserts primitive required fields (strings, numbers, booleans).
|
package/src/php/wrappers.ts
CHANGED
|
@@ -38,6 +38,7 @@ function emitWrapperMethod(
|
|
|
38
38
|
const op2 = resolvedOp.operation;
|
|
39
39
|
const returnDocType = op2.response.kind === 'model' ? `\\${ns}\\Resource\\${className(op2.response.name)}` : 'mixed';
|
|
40
40
|
docParts.push(`@return ${returnDocType}`);
|
|
41
|
+
docParts.push(`@throws \\${ns}\\Exception\\WorkOSException`);
|
|
41
42
|
lines.push(...phpDocComment(docParts.join('\n'), 4));
|
|
42
43
|
|
|
43
44
|
lines.push(` public function ${method}(`);
|
package/src/python/client.ts
CHANGED
|
@@ -7,17 +7,11 @@ import { NON_SPEC_SERVICES } from '../shared/non-spec-services.js';
|
|
|
7
7
|
|
|
8
8
|
/** Python-specific wiring for each non-spec service. */
|
|
9
9
|
interface PythonNonSpecWiring {
|
|
10
|
-
/** Python import line (e.g. "from .vault import Vault, AsyncVault") */
|
|
11
10
|
importLine: string;
|
|
12
|
-
/** Property name on the client class */
|
|
13
11
|
prop: string;
|
|
14
|
-
/** Sync class name */
|
|
15
12
|
syncClass: string;
|
|
16
|
-
/** Async class name, or null if no async variant */
|
|
17
13
|
asyncClass: string | null;
|
|
18
|
-
/** Constructor expression — 'self' if the class takes the client, '' if stateless */
|
|
19
14
|
ctorArg: 'self' | '';
|
|
20
|
-
/** One-line docstring for the client property */
|
|
21
15
|
docstring?: string;
|
|
22
16
|
}
|
|
23
17
|
|
|
@@ -159,13 +153,25 @@ function generateWorkOSClient(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
|
|
|
159
153
|
const resolvedName = resolveResourceClassName(service, ctx);
|
|
160
154
|
const clsName = className(resolvedName);
|
|
161
155
|
const dirName = serviceDirMap.get(service.name) ?? resolveServiceDir(resolvedName);
|
|
162
|
-
|
|
156
|
+
const importLine = `from .${dirToModule(dirName)}._resource import ${clsName}, Async${clsName}`;
|
|
157
|
+
if (importLine.length > 88) {
|
|
158
|
+
lines.push(`from .${dirToModule(dirName)}._resource import (`);
|
|
159
|
+
lines.push(` ${clsName},`);
|
|
160
|
+
lines.push(` Async${clsName},`);
|
|
161
|
+
lines.push(')');
|
|
162
|
+
} else {
|
|
163
|
+
lines.push(importLine);
|
|
164
|
+
}
|
|
163
165
|
}
|
|
164
|
-
// Non-spec service imports
|
|
166
|
+
// Non-spec service imports — wrapped in ignore markers so the merger
|
|
167
|
+
// matches them positionally and doesn't displace them.
|
|
168
|
+
lines.push('');
|
|
169
|
+
lines.push('# @oagen-ignore-start — non-spec service imports (hand-maintained)');
|
|
165
170
|
for (const s of NON_SPEC_SERVICES) {
|
|
166
171
|
const w = PYTHON_NON_SPEC_WIRING[s.id];
|
|
167
172
|
if (w) lines.push(w.importLine);
|
|
168
173
|
}
|
|
174
|
+
lines.push('# @oagen-ignore-end');
|
|
169
175
|
lines.push('');
|
|
170
176
|
lines.push('');
|
|
171
177
|
|
|
@@ -188,7 +194,7 @@ function generateWorkOSClient(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
|
|
|
188
194
|
generatedProps.add(prop);
|
|
189
195
|
}
|
|
190
196
|
emitCompatClientPropertyAliases(lines, generatedProps, false);
|
|
191
|
-
|
|
197
|
+
emitNonSpecClientAccessors(lines, false);
|
|
192
198
|
|
|
193
199
|
lines.push('');
|
|
194
200
|
lines.push('');
|
|
@@ -211,18 +217,38 @@ function generateWorkOSClient(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
|
|
|
211
217
|
asyncGeneratedProps.add(prop);
|
|
212
218
|
}
|
|
213
219
|
emitCompatClientPropertyAliases(lines, asyncGeneratedProps, true);
|
|
214
|
-
|
|
220
|
+
emitNonSpecClientAccessors(lines, true);
|
|
215
221
|
|
|
216
222
|
return [
|
|
217
223
|
{
|
|
218
224
|
path: `src/${ctx.namespace}/_client.py`,
|
|
219
225
|
content: lines.join('\n'),
|
|
220
|
-
integrateTarget: true,
|
|
221
226
|
overwriteExisting: true,
|
|
222
227
|
},
|
|
223
228
|
];
|
|
224
229
|
}
|
|
225
230
|
|
|
231
|
+
function emitNonSpecClientAccessors(lines: string[], isAsync: boolean): void {
|
|
232
|
+
lines.push('');
|
|
233
|
+
lines.push(' # @oagen-ignore-start — non-spec service accessors (hand-maintained)');
|
|
234
|
+
for (const s of NON_SPEC_SERVICES) {
|
|
235
|
+
const w = PYTHON_NON_SPEC_WIRING[s.id];
|
|
236
|
+
if (!w) continue;
|
|
237
|
+
const typeName = isAsync ? (w.asyncClass ?? w.syncClass) : w.syncClass;
|
|
238
|
+
const arg = w.ctorArg === 'self' ? 'self' : '';
|
|
239
|
+
|
|
240
|
+
lines.push('');
|
|
241
|
+
lines.push(' @functools.cached_property');
|
|
242
|
+
lines.push(` def ${w.prop}(self) -> ${typeName}:`);
|
|
243
|
+
if (w.docstring) {
|
|
244
|
+
lines.push(` """${w.docstring}"""`);
|
|
245
|
+
}
|
|
246
|
+
lines.push(` return ${typeName}(${arg})`);
|
|
247
|
+
}
|
|
248
|
+
lines.push('');
|
|
249
|
+
lines.push(' # @oagen-ignore-end');
|
|
250
|
+
}
|
|
251
|
+
|
|
226
252
|
function generateServiceInits(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
|
|
227
253
|
const files: GeneratedFile[] = [];
|
|
228
254
|
const topLevel = deduplicateByMount(spec.services, ctx);
|
|
@@ -274,24 +300,6 @@ function emitCompatClientPropertyAliases(lines: string[], generatedProps: Set<st
|
|
|
274
300
|
}
|
|
275
301
|
}
|
|
276
302
|
|
|
277
|
-
function emitCompatClientAccessors(lines: string[], isAsync: boolean): void {
|
|
278
|
-
for (const s of NON_SPEC_SERVICES) {
|
|
279
|
-
const w = PYTHON_NON_SPEC_WIRING[s.id];
|
|
280
|
-
if (!w) continue;
|
|
281
|
-
// Skip async-only services when emitting the sync client, and vice versa
|
|
282
|
-
const typeName = isAsync ? (w.asyncClass ?? w.syncClass) : w.syncClass;
|
|
283
|
-
const arg = w.ctorArg === 'self' ? 'self' : '';
|
|
284
|
-
|
|
285
|
-
lines.push('');
|
|
286
|
-
lines.push(' @functools.cached_property');
|
|
287
|
-
lines.push(` def ${w.prop}(self) -> ${typeName}:`);
|
|
288
|
-
if (w.docstring) {
|
|
289
|
-
lines.push(` """${w.docstring}"""`);
|
|
290
|
-
}
|
|
291
|
-
lines.push(` return ${typeName}(${arg})`);
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
|
|
295
303
|
/**
|
|
296
304
|
* Generate types/<service>/__init__.py re-export barrels so that
|
|
297
305
|
* `from workos.types.<service> import Model` continues to work.
|
package/src/python/enums.ts
CHANGED
|
@@ -42,7 +42,7 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
|
|
|
42
42
|
const canonicalCls = className(canonicalName);
|
|
43
43
|
const aliasCls = className(enumDef.name);
|
|
44
44
|
const lines: string[] = [];
|
|
45
|
-
lines.push('from
|
|
45
|
+
lines.push('from typing import TypeAlias');
|
|
46
46
|
// Use explicit __all__ to prevent ruff F401 from stripping the re-export
|
|
47
47
|
// Always use direct file import to avoid barrel dependency on the canonical
|
|
48
48
|
if (canonicalDir === dirName) {
|
|
@@ -71,7 +71,7 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
|
|
|
71
71
|
files.push({
|
|
72
72
|
path: `src/${ctx.namespace}/${dirName}/models/${fileName(aliasName)}.py`,
|
|
73
73
|
content: [
|
|
74
|
-
'from
|
|
74
|
+
'from typing import TypeAlias',
|
|
75
75
|
importLine,
|
|
76
76
|
'',
|
|
77
77
|
`${aliasName}: TypeAlias = ${canonicalCls}`,
|
|
@@ -96,7 +96,7 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
|
|
|
96
96
|
|
|
97
97
|
if (enumDef.values.length === 0) {
|
|
98
98
|
lines.push('from typing import Union');
|
|
99
|
-
lines.push('from
|
|
99
|
+
lines.push('from typing import TypeAlias');
|
|
100
100
|
lines.push('');
|
|
101
101
|
lines.push(`${cls}: TypeAlias = str`);
|
|
102
102
|
} else {
|
|
@@ -118,7 +118,7 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
|
|
|
118
118
|
if (allStrings) {
|
|
119
119
|
lines.push('from enum import Enum');
|
|
120
120
|
lines.push('from typing import Optional');
|
|
121
|
-
lines.push('from
|
|
121
|
+
lines.push('from typing import Literal, TypeAlias');
|
|
122
122
|
lines.push('');
|
|
123
123
|
lines.push('');
|
|
124
124
|
lines.push(`class ${cls}(str, Enum):`);
|
|
@@ -126,7 +126,7 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
|
|
|
126
126
|
lines.push('');
|
|
127
127
|
} else if (allIntegers) {
|
|
128
128
|
lines.push('from enum import IntEnum');
|
|
129
|
-
lines.push('from
|
|
129
|
+
lines.push('from typing import Literal, TypeAlias');
|
|
130
130
|
lines.push('');
|
|
131
131
|
lines.push('');
|
|
132
132
|
lines.push(`class ${cls}(IntEnum):`);
|
|
@@ -135,7 +135,7 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
|
|
|
135
135
|
} else {
|
|
136
136
|
// Mixed types — fall back to Union[Literal[...], str]
|
|
137
137
|
lines.push('from typing import Union');
|
|
138
|
-
lines.push('from
|
|
138
|
+
lines.push('from typing import Literal, TypeAlias');
|
|
139
139
|
lines.push('');
|
|
140
140
|
const literals = uniqueValues.map((v) => (typeof v.value === 'string' ? `"${v.value}"` : String(v.value)));
|
|
141
141
|
lines.push(`${cls}: TypeAlias = Union[Literal[${literals.join(', ')}], str]`);
|
|
@@ -196,7 +196,7 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
|
|
|
196
196
|
files.push({
|
|
197
197
|
path: `src/${ctx.namespace}/${dirName}/models/${fileName(aliasName)}.py`,
|
|
198
198
|
content: [
|
|
199
|
-
'from
|
|
199
|
+
'from typing import TypeAlias',
|
|
200
200
|
`from .${fileName(enumDef.name)} import ${cls}`,
|
|
201
201
|
'',
|
|
202
202
|
`${aliasName}: TypeAlias = ${cls}`,
|
package/src/python/models.ts
CHANGED
|
@@ -62,7 +62,7 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
62
62
|
const canonicalDir = resolveDir(canonicalService);
|
|
63
63
|
const canonicalClassName = className(canonicalName);
|
|
64
64
|
const lines: string[] = [];
|
|
65
|
-
lines.push('from
|
|
65
|
+
lines.push('from typing import TypeAlias');
|
|
66
66
|
// Always use direct file import to avoid barrel dependency on the canonical
|
|
67
67
|
if (canonicalDir === dirName) {
|
|
68
68
|
lines.push(`from .${fileName(canonicalName)} import ${canonicalClassName}`);
|
package/src/python/naming.ts
CHANGED
|
@@ -1,24 +1,7 @@
|
|
|
1
1
|
import type { Operation, Service, EmitterContext } from '@workos/oagen';
|
|
2
2
|
import { toPascalCase, toSnakeCase } from '@workos/oagen';
|
|
3
3
|
import { buildResolvedLookup, lookupMethodName, getMountTarget } from '../shared/resolved-ops.js';
|
|
4
|
-
import { stripUrnPrefix } from '../shared/naming-utils.js';
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* Map of lowercase acronym forms to their correct casing.
|
|
8
|
-
* Applied as a post-processing step after toPascalCase.
|
|
9
|
-
*/
|
|
10
|
-
const ACRONYM_FIXES: [RegExp, string][] = [
|
|
11
|
-
[/Workos/g, 'WorkOS'],
|
|
12
|
-
[/Sso/g, 'SSO'],
|
|
13
|
-
[/Mfa/g, 'MFA'],
|
|
14
|
-
[/Jwt/g, 'JWT'],
|
|
15
|
-
[/Cors/g, 'CORS'],
|
|
16
|
-
[/Saml/g, 'SAML'],
|
|
17
|
-
[/Scim/g, 'SCIM'],
|
|
18
|
-
[/Rbac/g, 'RBAC'],
|
|
19
|
-
[/Oauth/g, 'OAuth'],
|
|
20
|
-
[/Oidc/g, 'OIDC'],
|
|
21
|
-
];
|
|
4
|
+
import { stripUrnPrefix, applyAcronymFixes } from '../shared/naming-utils.js';
|
|
22
5
|
|
|
23
6
|
/**
|
|
24
7
|
* Python class names that collide with builtins or typing imports.
|
|
@@ -41,10 +24,7 @@ const PYTHON_RESERVED_CLASS_NAMES = new Set([
|
|
|
41
24
|
|
|
42
25
|
/** PascalCase class name with acronym preservation. */
|
|
43
26
|
export function className(name: string): string {
|
|
44
|
-
let result = toPascalCase(stripUrnPrefix(name));
|
|
45
|
-
for (const [pattern, replacement] of ACRONYM_FIXES) {
|
|
46
|
-
result = result.replace(pattern, replacement);
|
|
47
|
-
}
|
|
27
|
+
let result = applyAcronymFixes(toPascalCase(stripUrnPrefix(name)));
|
|
48
28
|
if (PYTHON_RESERVED_CLASS_NAMES.has(result)) {
|
|
49
29
|
result += 'Model';
|
|
50
30
|
}
|