@workos/oagen-emitters 0.18.3 → 0.19.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 +16 -0
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/{plugin-1ckLMpgo.mjs → plugin-BXDPA9pJ.mjs} +581 -172
- package/dist/plugin-BXDPA9pJ.mjs.map +1 -0
- package/dist/plugin.mjs +1 -1
- package/docs/sdk-architecture/rust.md +2 -2
- package/package.json +5 -5
- package/src/dotnet/enums.ts +11 -5
- package/src/dotnet/fixtures.ts +5 -2
- package/src/dotnet/index.ts +2 -1
- package/src/dotnet/models.ts +41 -10
- package/src/dotnet/naming.ts +10 -0
- package/src/dotnet/resources.ts +3 -3
- package/src/dotnet/tests.ts +8 -4
- package/src/go/fixtures.ts +4 -2
- package/src/go/index.ts +4 -0
- package/src/go/models.ts +4 -2
- package/src/go/naming.ts +10 -0
- package/src/go/resources.ts +22 -9
- package/src/go/tests.ts +3 -3
- package/src/kotlin/enums.ts +21 -11
- package/src/kotlin/index.ts +2 -1
- package/src/kotlin/models.ts +24 -9
- package/src/kotlin/naming.ts +11 -0
- package/src/kotlin/resources.ts +2 -2
- package/src/kotlin/tests.ts +7 -3
- package/src/node/enums.ts +8 -5
- package/src/node/field-plan.ts +3 -3
- package/src/node/index.ts +2 -1
- package/src/node/models.ts +69 -22
- package/src/node/naming.ts +10 -0
- package/src/node/options.ts +45 -1
- package/src/node/resources.ts +67 -18
- package/src/node/tests.ts +302 -31
- package/src/php/enums.ts +18 -5
- package/src/php/index.ts +13 -4
- package/src/php/models.ts +22 -10
- package/src/php/naming.ts +10 -0
- package/src/php/resources.ts +6 -4
- package/src/php/tests.ts +17 -5
- package/src/python/enums.ts +39 -28
- package/src/python/fixtures.ts +4 -3
- package/src/python/index.ts +2 -1
- package/src/python/models.ts +39 -24
- package/src/python/naming.ts +10 -0
- package/src/python/resources.ts +3 -3
- package/src/python/tests.ts +14 -9
- package/src/ruby/enums.ts +28 -19
- package/src/ruby/index.ts +2 -1
- package/src/ruby/models.ts +33 -19
- package/src/ruby/naming.ts +10 -0
- package/src/ruby/rbi.ts +20 -7
- package/src/ruby/resources.ts +2 -2
- package/src/ruby/tests.ts +6 -3
- package/src/rust/enums.ts +9 -1
- package/src/rust/index.ts +2 -1
- package/src/rust/models.ts +100 -15
- package/src/rust/naming.ts +10 -0
- package/src/rust/resources.ts +14 -3
- package/src/rust/tests.ts +2 -2
- package/src/shared/file-header.ts +13 -0
- package/src/shared/resolved-ops.ts +47 -0
- package/test/rust/models.test.ts +49 -0
- package/test/shared/synthetic-enum-seed.test.ts +79 -0
- package/dist/plugin-1ckLMpgo.mjs.map +0 -1
package/src/php/resources.ts
CHANGED
|
@@ -12,7 +12,7 @@ import { mapTypeRef, mapTypeRefForPHPDoc } from './type-map.js';
|
|
|
12
12
|
import { className, fieldName, resolveMethodName, buildExportedClassNameSet, resolveServiceTarget } from './naming.js';
|
|
13
13
|
import { isListWrapperModel } from './models.js';
|
|
14
14
|
import {
|
|
15
|
-
|
|
15
|
+
scopedMountGroups,
|
|
16
16
|
buildResolvedLookup,
|
|
17
17
|
lookupResolved,
|
|
18
18
|
getOpDefaults,
|
|
@@ -44,10 +44,12 @@ export function generateResources(services: Service[], ctx: EmitterContext): Gen
|
|
|
44
44
|
const files: GeneratedFile[] = [];
|
|
45
45
|
const modelMap = new Map(ctx.spec.models.map((m) => [m.name, m]));
|
|
46
46
|
|
|
47
|
-
// Group operations by mount target
|
|
48
|
-
|
|
47
|
+
// Group operations by mount target. In a scoped (`--services`) run this
|
|
48
|
+
// returns only the selected post-mount services so we emit per-service
|
|
49
|
+
// resource files for those alone.
|
|
50
|
+
const mountGroups = scopedMountGroups(ctx);
|
|
49
51
|
const entries: Array<{ name: string; operations: Operation[] }> =
|
|
50
|
-
mountGroups.size > 0
|
|
52
|
+
mountGroups.size > 0 || ctx.scopedServices?.size
|
|
51
53
|
? [...mountGroups].map(([name, group]) => ({ name, operations: group.operations }))
|
|
52
54
|
: services.map((s) => ({ name: className(s.name), operations: s.operations }));
|
|
53
55
|
|
package/src/php/tests.ts
CHANGED
|
@@ -8,12 +8,19 @@ import type {
|
|
|
8
8
|
ResolvedOperation,
|
|
9
9
|
} from '@workos/oagen';
|
|
10
10
|
import { planOperation, toCamelCase, toPascalCase } from '@workos/oagen';
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
className,
|
|
13
|
+
enumClassName,
|
|
14
|
+
resolveMethodName,
|
|
15
|
+
snakeName,
|
|
16
|
+
servicePropertyName,
|
|
17
|
+
domainFieldName,
|
|
18
|
+
} from './naming.js';
|
|
12
19
|
import { isListWrapperModel } from './models.js';
|
|
13
20
|
import { generateFixtures } from './fixtures.js';
|
|
14
21
|
import {
|
|
15
22
|
getMountTarget,
|
|
16
|
-
|
|
23
|
+
scopedMountGroups,
|
|
17
24
|
buildHiddenParams,
|
|
18
25
|
getOpDefaults,
|
|
19
26
|
getOpInferFromClient,
|
|
@@ -32,9 +39,12 @@ export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
|
|
|
32
39
|
|
|
33
40
|
// Collect all operations per mount target using resolved per-operation mounts.
|
|
34
41
|
// This correctly handles operationHint mountOn overrides (e.g., audit_logs_retention → AuditLogs).
|
|
35
|
-
|
|
42
|
+
// In a scoped (`--services`) run this returns only the selected post-mount
|
|
43
|
+
// services so we emit per-service test files for those alone. ClientTest.php
|
|
44
|
+
// and fixtures below are built from `spec` and stay full.
|
|
45
|
+
const mountGroupsFromResolved = scopedMountGroups(ctx);
|
|
36
46
|
const mountGroups = new Map<string, { op: Operation; service: Service; resolvedOp?: ResolvedOperation }[]>();
|
|
37
|
-
if (mountGroupsFromResolved.size > 0) {
|
|
47
|
+
if (mountGroupsFromResolved.size > 0 || ctx.scopedServices?.size) {
|
|
38
48
|
for (const [target, group] of mountGroupsFromResolved) {
|
|
39
49
|
mountGroups.set(
|
|
40
50
|
target,
|
|
@@ -503,7 +513,9 @@ function emitFieldHydrationAssertions(
|
|
|
503
513
|
|
|
504
514
|
for (const f of assertFields) {
|
|
505
515
|
if (!f) continue;
|
|
506
|
-
|
|
516
|
+
// DOMAIN identifier: the deserialized model's PHP property (honors `domainName`).
|
|
517
|
+
// The fixture key `f.name` is the WIRE key and stays unchanged.
|
|
518
|
+
const phpProp = domainFieldName(f);
|
|
507
519
|
lines.push(` $this->assertSame(${fixtureVar}['${f.name}'], ${resultVar}->${phpProp});`);
|
|
508
520
|
}
|
|
509
521
|
}
|
package/src/python/enums.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { Enum, EmitterContext, GeneratedFile } from '@workos/oagen';
|
|
|
2
2
|
import { toUpperSnakeCase } from '@workos/oagen';
|
|
3
3
|
import { className, fileName, buildMountDirMap, dirToModule } from './naming.js';
|
|
4
4
|
import { computeSchemaPlacement } from './shared-schemas.js';
|
|
5
|
+
import { isEnumInScope } from '../shared/resolved-ops.js';
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Convert a PascalCase class name to a human-readable lowercase string,
|
|
@@ -38,6 +39,10 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
|
|
|
38
39
|
for (const enumDef of enums) {
|
|
39
40
|
const service = enumToService.get(enumDef.name);
|
|
40
41
|
const dirName = resolveDir(service);
|
|
42
|
+
// FR-1.4: write per-enum FILES only when in scope; the enum barrel is built
|
|
43
|
+
// separately (collectGeneratedEnumSymbolsByDir over the full set), so an
|
|
44
|
+
// out-of-scope enum left on disk stays exported and importable.
|
|
45
|
+
const enumInScope = isEnumInScope(enumDef.name, ctx);
|
|
41
46
|
|
|
42
47
|
// If this enum is an alias for a canonical enum, generate a type alias file
|
|
43
48
|
const canonicalName = aliasOf.get(enumDef.name);
|
|
@@ -73,12 +78,14 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
|
|
|
73
78
|
lines.push(' raise AttributeError(f"module {__name__!r} has no attribute {name!r}")');
|
|
74
79
|
}
|
|
75
80
|
lines.push(`__all__ = ["${aliasCls}"]`);
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
81
|
+
if (enumInScope) {
|
|
82
|
+
files.push({
|
|
83
|
+
path: `src/${ctx.namespace}/${dirName}/models/${fileName(enumDef.name)}.py`,
|
|
84
|
+
content: lines.join('\n'),
|
|
85
|
+
integrateTarget: true,
|
|
86
|
+
overwriteExisting: true,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
82
89
|
|
|
83
90
|
// Also generate compat alias files for dedup aliases (they may have compat aliases too)
|
|
84
91
|
for (const aliasName of compatAliases.get(enumDef.name) ?? []) {
|
|
@@ -107,12 +114,14 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
|
|
|
107
114
|
`__all__ = ["${aliasName}"]`,
|
|
108
115
|
].join('\n');
|
|
109
116
|
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
117
|
+
if (enumInScope) {
|
|
118
|
+
files.push({
|
|
119
|
+
path: `src/${ctx.namespace}/${dirName}/models/${fileName(aliasName)}.py`,
|
|
120
|
+
content: compatContent,
|
|
121
|
+
integrateTarget: true,
|
|
122
|
+
overwriteExisting: true,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
116
125
|
}
|
|
117
126
|
|
|
118
127
|
continue;
|
|
@@ -241,26 +250,28 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
|
|
|
241
250
|
);
|
|
242
251
|
}
|
|
243
252
|
|
|
244
|
-
|
|
245
|
-
path: `src/${ctx.namespace}/${dirName}/models/${fileName(enumDef.name)}.py`,
|
|
246
|
-
content: lines.join('\n'),
|
|
247
|
-
integrateTarget: true,
|
|
248
|
-
overwriteExisting: true,
|
|
249
|
-
});
|
|
250
|
-
|
|
251
|
-
for (const aliasName of compatAliases.get(enumDef.name) ?? []) {
|
|
253
|
+
if (enumInScope) {
|
|
252
254
|
files.push({
|
|
253
|
-
path: `src/${ctx.namespace}/${dirName}/models/${fileName(
|
|
254
|
-
content:
|
|
255
|
-
'from typing import TypeAlias',
|
|
256
|
-
`from .${fileName(enumDef.name)} import ${cls}`,
|
|
257
|
-
'',
|
|
258
|
-
`${aliasName}: TypeAlias = ${cls}`,
|
|
259
|
-
`__all__ = ["${aliasName}"]`,
|
|
260
|
-
].join('\n'),
|
|
255
|
+
path: `src/${ctx.namespace}/${dirName}/models/${fileName(enumDef.name)}.py`,
|
|
256
|
+
content: lines.join('\n'),
|
|
261
257
|
integrateTarget: true,
|
|
262
258
|
overwriteExisting: true,
|
|
263
259
|
});
|
|
260
|
+
|
|
261
|
+
for (const aliasName of compatAliases.get(enumDef.name) ?? []) {
|
|
262
|
+
files.push({
|
|
263
|
+
path: `src/${ctx.namespace}/${dirName}/models/${fileName(aliasName)}.py`,
|
|
264
|
+
content: [
|
|
265
|
+
'from typing import TypeAlias',
|
|
266
|
+
`from .${fileName(enumDef.name)} import ${cls}`,
|
|
267
|
+
'',
|
|
268
|
+
`${aliasName}: TypeAlias = ${cls}`,
|
|
269
|
+
`__all__ = ["${aliasName}"]`,
|
|
270
|
+
].join('\n'),
|
|
271
|
+
integrateTarget: true,
|
|
272
|
+
overwriteExisting: true,
|
|
273
|
+
});
|
|
274
|
+
}
|
|
264
275
|
}
|
|
265
276
|
}
|
|
266
277
|
|
package/src/python/fixtures.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Model, TypeRef, Enum } from '@workos/oagen';
|
|
2
2
|
|
|
3
|
-
import { fileName,
|
|
3
|
+
import { fileName, domainFieldName } from './naming.js';
|
|
4
4
|
import { isListMetadataModel, isListWrapperModel } from './models.js';
|
|
5
5
|
import { collectNonPaginatedResponseModelNames, collectReferencedListMetadataModels } from '../shared/model-utils.js';
|
|
6
6
|
|
|
@@ -104,10 +104,11 @@ export function generateModelFixture(
|
|
|
104
104
|
): Record<string, any> {
|
|
105
105
|
const fixture: Record<string, any> = {};
|
|
106
106
|
|
|
107
|
-
// Deduplicate fields by
|
|
107
|
+
// Deduplicate fields by DOMAIN identifier (matching model generation in
|
|
108
|
+
// models.ts, which honors `domainName`); the wire key below stays `field.name`.
|
|
108
109
|
const seenFieldNames = new Set<string>();
|
|
109
110
|
const deduplicatedFields = model.fields.filter((f) => {
|
|
110
|
-
const pyName =
|
|
111
|
+
const pyName = domainFieldName(f);
|
|
111
112
|
if (seenFieldNames.has(pyName)) return false;
|
|
112
113
|
seenFieldNames.add(pyName);
|
|
113
114
|
return true;
|
package/src/python/index.ts
CHANGED
|
@@ -11,6 +11,7 @@ import type {
|
|
|
11
11
|
|
|
12
12
|
import { generateModels } from './models.js';
|
|
13
13
|
import { detectDiscriminators } from '../shared/model-utils.js';
|
|
14
|
+
import { AUTOGEN_NOTICE } from '../shared/file-header.js';
|
|
14
15
|
import { generateEnums } from './enums.js';
|
|
15
16
|
import { generateResources } from './resources.js';
|
|
16
17
|
import { generateClient } from './client.js';
|
|
@@ -86,7 +87,7 @@ export const pythonEmitter: Emitter = {
|
|
|
86
87
|
},
|
|
87
88
|
|
|
88
89
|
fileHeader(): string {
|
|
89
|
-
return
|
|
90
|
+
return `# ${AUTOGEN_NOTICE}`;
|
|
90
91
|
},
|
|
91
92
|
|
|
92
93
|
formatCommand(_targetDir: string): FormatCommand | null {
|
package/src/python/models.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import type { Model, EmitterContext, GeneratedFile } from '@workos/oagen';
|
|
2
2
|
import { collectFieldDependencies, walkTypeRef } from '@workos/oagen';
|
|
3
3
|
import { mapTypeRef } from './type-map.js';
|
|
4
|
-
import { className,
|
|
4
|
+
import { className, domainFieldName, fileName, buildMountDirMap, dirToModule } from './naming.js';
|
|
5
5
|
import { collectGeneratedEnumSymbolsByDir } from './enums.js';
|
|
6
6
|
import { computeSchemaPlacement } from './shared-schemas.js';
|
|
7
|
+
import { isModelInScope } from '../shared/resolved-ops.js';
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* Generate Python dataclass model files from IR Model definitions.
|
|
@@ -169,12 +170,16 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
169
170
|
dispLines.push(` return cast("${variantTypeName}", dispatch_cls.from_dict(data))`);
|
|
170
171
|
dispLines.push(` return ${unknownClassName}.from_dict(data)`);
|
|
171
172
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
173
|
+
// FR-1.4: write the file only when in scope; the barrel tracking below is
|
|
174
|
+
// unconditional so out-of-scope models (left on disk) stay exported.
|
|
175
|
+
if (isModelInScope(model.name, ctx)) {
|
|
176
|
+
files.push({
|
|
177
|
+
path: `src/${ctx.namespace}/${dirName}/models/${fileName(model.name)}.py`,
|
|
178
|
+
content: dispLines.join('\n'),
|
|
179
|
+
integrateTarget: true,
|
|
180
|
+
overwriteExisting: true,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
178
183
|
|
|
179
184
|
if (!emittedModelSymbolsByDir.has(dirName)) emittedModelSymbolsByDir.set(dirName, []);
|
|
180
185
|
emittedModelSymbolsByDir.get(dirName)!.push(model.name);
|
|
@@ -216,12 +221,14 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
216
221
|
}
|
|
217
222
|
lines.push('');
|
|
218
223
|
lines.push(`${modelClassName}: TypeAlias = ${canonicalClassName}`);
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
224
|
+
if (isModelInScope(model.name, ctx)) {
|
|
225
|
+
files.push({
|
|
226
|
+
path: `src/${ctx.namespace}/${dirName}/models/${fileName(model.name)}.py`,
|
|
227
|
+
content: lines.join('\n'),
|
|
228
|
+
integrateTarget: true,
|
|
229
|
+
overwriteExisting: true,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
225
232
|
if (!emittedModelSymbolsByDir.has(dirName)) emittedModelSymbolsByDir.set(dirName, []);
|
|
226
233
|
emittedModelSymbolsByDir.get(dirName)!.push(model.name);
|
|
227
234
|
const aliasNatural = originalModelToService.get(model.name);
|
|
@@ -232,7 +239,9 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
232
239
|
// Deduplicate fields that map to the same snake_case name
|
|
233
240
|
const seenFieldNames = new Set<string>();
|
|
234
241
|
const deduplicatedFields = model.fields.filter((f) => {
|
|
235
|
-
|
|
242
|
+
// Dedup on the DOMAIN identifier (the dataclass attribute name), which
|
|
243
|
+
// honors a `domainName` override; the wire key stays `field.name`.
|
|
244
|
+
const pyName = domainFieldName(f);
|
|
236
245
|
if (seenFieldNames.has(pyName)) return false;
|
|
237
246
|
seenFieldNames.add(pyName);
|
|
238
247
|
return true;
|
|
@@ -343,7 +352,8 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
343
352
|
};
|
|
344
353
|
|
|
345
354
|
for (const field of requiredFields) {
|
|
346
|
-
|
|
355
|
+
// DOMAIN identifier: the dataclass attribute name (honors `domainName`).
|
|
356
|
+
const pyFieldName = domainFieldName(field);
|
|
347
357
|
const pyType = rewriteDiscriminatorType(resolveModelFieldType(field.type));
|
|
348
358
|
if (field.description || field.deprecated) {
|
|
349
359
|
const parts: string[] = [];
|
|
@@ -357,7 +367,8 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
357
367
|
}
|
|
358
368
|
|
|
359
369
|
for (const field of optionalFields) {
|
|
360
|
-
|
|
370
|
+
// DOMAIN identifier: the dataclass attribute name (honors `domainName`).
|
|
371
|
+
const pyFieldName = domainFieldName(field);
|
|
361
372
|
const innerType =
|
|
362
373
|
field.type.kind === 'nullable' ? resolveModelFieldType(field.type.inner) : resolveModelFieldType(field.type);
|
|
363
374
|
const pyType = `Optional[${rewriteDiscriminatorType(innerType)}]`;
|
|
@@ -383,7 +394,8 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
383
394
|
const fieldAssignmentLines: string[] = [];
|
|
384
395
|
|
|
385
396
|
for (const field of [...requiredFields, ...optionalFields]) {
|
|
386
|
-
|
|
397
|
+
// DOMAIN identifier (LHS of `cls(...)`); the wire key below stays `field.name`.
|
|
398
|
+
const pyFieldName = domainFieldName(field);
|
|
387
399
|
const wireKey = field.name; // Wire keys are snake_case from the spec
|
|
388
400
|
const isRequired = !isOptionalField(model.name, field, ctx);
|
|
389
401
|
|
|
@@ -424,7 +436,8 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
424
436
|
lines.push(' result: Dict[str, Any] = {}');
|
|
425
437
|
|
|
426
438
|
for (const field of [...requiredFields, ...optionalFields]) {
|
|
427
|
-
|
|
439
|
+
// DOMAIN identifier (`self.<attr>`); the wire key below stays `field.name`.
|
|
440
|
+
const pyFieldName = domainFieldName(field);
|
|
428
441
|
const wireKey = field.name;
|
|
429
442
|
const isRequired = !isOptionalField(model.name, field, ctx);
|
|
430
443
|
|
|
@@ -451,12 +464,14 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
451
464
|
|
|
452
465
|
lines.push(' return result');
|
|
453
466
|
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
467
|
+
if (isModelInScope(model.name, ctx)) {
|
|
468
|
+
files.push({
|
|
469
|
+
path: `src/${ctx.namespace}/${dirName}/models/${fileName(model.name)}.py`,
|
|
470
|
+
content: lines.join('\n'),
|
|
471
|
+
integrateTarget: true,
|
|
472
|
+
overwriteExisting: true,
|
|
473
|
+
});
|
|
474
|
+
}
|
|
460
475
|
if (!emittedModelSymbolsByDir.has(dirName)) emittedModelSymbolsByDir.set(dirName, []);
|
|
461
476
|
emittedModelSymbolsByDir.get(dirName)!.push(model.name);
|
|
462
477
|
const regularNatural = originalModelToService.get(model.name);
|
package/src/python/naming.ts
CHANGED
|
@@ -50,6 +50,16 @@ export function fieldName(name: string): string {
|
|
|
50
50
|
return toSnakeCase(name);
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
/**
|
|
54
|
+
* snake_case domain field name for a model field, honoring a `domainName`
|
|
55
|
+
* override (set via the `fieldHints` config) so a wire field can surface under
|
|
56
|
+
* a friendlier name. The wire name (still derived from `field.name`) is
|
|
57
|
+
* unaffected, so the API contract is preserved.
|
|
58
|
+
*/
|
|
59
|
+
export function domainFieldName(field: { name: string; domainName?: string }): string {
|
|
60
|
+
return toSnakeCase(field.domainName ?? field.name);
|
|
61
|
+
}
|
|
62
|
+
|
|
53
63
|
/**
|
|
54
64
|
* Python builtins that should not be shadowed by parameter names.
|
|
55
65
|
* When a path/query param name collides, suffix with underscore.
|
package/src/python/resources.ts
CHANGED
|
@@ -30,7 +30,7 @@ import {
|
|
|
30
30
|
buildResolvedLookup,
|
|
31
31
|
lookupMethodName,
|
|
32
32
|
lookupResolved,
|
|
33
|
-
|
|
33
|
+
scopedMountGroups,
|
|
34
34
|
getOpDefaults,
|
|
35
35
|
getOpInferFromClient,
|
|
36
36
|
buildHiddenParams as buildHiddenParamsShared,
|
|
@@ -1056,12 +1056,12 @@ export function generateResources(services: Service[], ctx: EmitterContext): Gen
|
|
|
1056
1056
|
const resolvedLookup = buildResolvedLookup(ctx);
|
|
1057
1057
|
const files: GeneratedFile[] = [];
|
|
1058
1058
|
const mountDirMap = buildMountDirMap(ctx);
|
|
1059
|
-
const mountGroups =
|
|
1059
|
+
const mountGroups = scopedMountGroups(ctx);
|
|
1060
1060
|
|
|
1061
1061
|
// Build mount group entries. When resolved operations are available, group by
|
|
1062
1062
|
// mount target. Otherwise fall back to one group per service (for tests).
|
|
1063
1063
|
const entries: Array<{ name: string; operations: Operation[] }> =
|
|
1064
|
-
mountGroups.size > 0
|
|
1064
|
+
mountGroups.size > 0 || ctx.scopedServices?.size
|
|
1065
1065
|
? [...mountGroups].map(([name, group]) => ({ name, operations: group.operations }))
|
|
1066
1066
|
: services.map((s) => ({ name: resolveClassName(s, ctx), operations: s.operations }));
|
|
1067
1067
|
|
package/src/python/tests.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
className,
|
|
14
14
|
fileName,
|
|
15
15
|
fieldName,
|
|
16
|
+
domainFieldName,
|
|
16
17
|
moduleName,
|
|
17
18
|
resolveMethodName,
|
|
18
19
|
buildMountDirMap,
|
|
@@ -24,7 +25,7 @@ import { generateFixtures, generateModelFixture } from './fixtures.js';
|
|
|
24
25
|
import { isListWrapperModel, isListMetadataModel } from './models.js';
|
|
25
26
|
import { collectNonPaginatedResponseModelNames, collectReferencedListMetadataModels } from '../shared/model-utils.js';
|
|
26
27
|
import {
|
|
27
|
-
|
|
28
|
+
scopedMountGroups,
|
|
28
29
|
buildResolvedLookup,
|
|
29
30
|
lookupResolved,
|
|
30
31
|
buildHiddenParams,
|
|
@@ -117,9 +118,9 @@ export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
|
|
|
117
118
|
const accessPaths = buildServiceAccessPaths(spec.services, ctx);
|
|
118
119
|
|
|
119
120
|
// Generate per-mount-target test files (merges all sub-services into one file)
|
|
120
|
-
const mountGroups =
|
|
121
|
+
const mountGroups = scopedMountGroups(ctx);
|
|
121
122
|
const testEntries: Array<{ name: string; operations: Operation[]; resolvedOps?: ResolvedOperation[] }> =
|
|
122
|
-
mountGroups.size > 0
|
|
123
|
+
mountGroups.size > 0 || ctx.scopedServices?.size
|
|
123
124
|
? [...mountGroups].map(([name, group]) => ({
|
|
124
125
|
name,
|
|
125
126
|
operations: group.operations,
|
|
@@ -1026,12 +1027,13 @@ function pickAssertableFields(
|
|
|
1026
1027
|
// Skip strings containing characters that are hard to represent as Python literals
|
|
1027
1028
|
if (val.includes('"') || val.includes("'") || val.includes('{') || val.includes('\\') || val.includes('\n'))
|
|
1028
1029
|
continue;
|
|
1029
|
-
|
|
1030
|
+
// DOMAIN identifier: asserted as `result.<attr>` (honors `domainName`).
|
|
1031
|
+
results.push({ field: domainFieldName(f), value: `"${val}"` });
|
|
1030
1032
|
} else if (typeof val === 'boolean') {
|
|
1031
1033
|
// Use "is True/False" to satisfy ruff E712
|
|
1032
|
-
results.push({ field:
|
|
1034
|
+
results.push({ field: domainFieldName(f), value: val ? 'True' : 'False', isBool: true });
|
|
1033
1035
|
} else if (typeof val === 'number') {
|
|
1034
|
-
results.push({ field:
|
|
1036
|
+
results.push({ field: domainFieldName(f), value: String(val) });
|
|
1035
1037
|
}
|
|
1036
1038
|
}
|
|
1037
1039
|
return results;
|
|
@@ -1114,7 +1116,9 @@ function buildTestArgs(op: Operation, spec: ApiSpec, hiddenParams?: Set<string>)
|
|
|
1114
1116
|
if (plan.hasBody && op.requestBody?.kind === 'model') {
|
|
1115
1117
|
const rbName = op.requestBody.name;
|
|
1116
1118
|
const bodyModel = spec.models.find((m) => m.name === rbName);
|
|
1117
|
-
|
|
1119
|
+
// Compare the body field's DOMAIN identifier (honors `domainName`)
|
|
1120
|
+
// against the param kwarg name; the param has no domainName override.
|
|
1121
|
+
if (bodyModel?.fields.some((f) => domainFieldName(f) === fieldName(param.name))) continue;
|
|
1118
1122
|
}
|
|
1119
1123
|
if (param.required && !pathParamNames.has(fieldName(param.name))) {
|
|
1120
1124
|
args.push(`${fieldName(param.name)}=${generateTestValue(param.type, param.name)}`);
|
|
@@ -1511,10 +1515,11 @@ function generateModelRoundTripTests(spec: ApiSpec, ctx: EmitterContext): Genera
|
|
|
1511
1515
|
// don't have a to_dict() and their round-trip semantics differ.
|
|
1512
1516
|
if (model.fields.length === 0) continue;
|
|
1513
1517
|
if ((model as any).discriminator) continue;
|
|
1514
|
-
// Deduplicate fields
|
|
1518
|
+
// Deduplicate fields by DOMAIN identifier (mirrors models.ts, which honors
|
|
1519
|
+
// `domainName`); the wire key stays `field.name`.
|
|
1515
1520
|
const seenFieldNames = new Set<string>();
|
|
1516
1521
|
const dedupFields = model.fields.filter((f) => {
|
|
1517
|
-
const pyName =
|
|
1522
|
+
const pyName = domainFieldName(f);
|
|
1518
1523
|
if (seenFieldNames.has(pyName)) return false;
|
|
1519
1524
|
seenFieldNames.add(pyName);
|
|
1520
1525
|
return true;
|
package/src/ruby/enums.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Enum, EmitterContext, GeneratedFile } from '@workos/oagen';
|
|
2
2
|
import { toUpperSnakeCase } from '@workos/oagen';
|
|
3
3
|
import { className, fileName } from './naming.js';
|
|
4
|
+
import { isEnumInScope } from '../shared/resolved-ops.js';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Generate Ruby enum class files.
|
|
@@ -9,7 +10,6 @@ import { className, fileName } from './naming.js';
|
|
|
9
10
|
* and a frozen `ALL` array of all values.
|
|
10
11
|
*/
|
|
11
12
|
export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile[] {
|
|
12
|
-
void ctx;
|
|
13
13
|
if (enums.length === 0) return [];
|
|
14
14
|
|
|
15
15
|
const files: GeneratedFile[] = [];
|
|
@@ -17,6 +17,9 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
|
|
|
17
17
|
|
|
18
18
|
for (const enumDef of enums) {
|
|
19
19
|
const cls = className(enumDef.name);
|
|
20
|
+
// FR-1.4: write the per-enum file only when in scope. Out-of-scope enum
|
|
21
|
+
// files are left untouched on disk; Zeitwerk autoloads them by path.
|
|
22
|
+
const enumInScope = isEnumInScope(enumDef.name, ctx);
|
|
20
23
|
|
|
21
24
|
// If this enum duplicates another (by value set), emit a Ruby constant
|
|
22
25
|
// alias. Zeitwerk autoloads the canonical when the alias is first
|
|
@@ -30,12 +33,14 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
|
|
|
30
33
|
lines.push(` ${cls} = ${canonicalCls}`);
|
|
31
34
|
lines.push(' end');
|
|
32
35
|
lines.push('end');
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
36
|
+
if (enumInScope) {
|
|
37
|
+
files.push({
|
|
38
|
+
path: `lib/workos/types/${fileName(enumDef.name)}.rb`,
|
|
39
|
+
content: lines.join('\n'),
|
|
40
|
+
integrateTarget: true,
|
|
41
|
+
overwriteExisting: true,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
39
44
|
continue;
|
|
40
45
|
}
|
|
41
46
|
|
|
@@ -60,12 +65,14 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
|
|
|
60
65
|
lines.push(' end');
|
|
61
66
|
lines.push(' end');
|
|
62
67
|
lines.push('end');
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
68
|
+
if (enumInScope) {
|
|
69
|
+
files.push({
|
|
70
|
+
path: `lib/workos/types/${fileName(enumDef.name)}.rb`,
|
|
71
|
+
content: lines.join('\n'),
|
|
72
|
+
integrateTarget: true,
|
|
73
|
+
overwriteExisting: true,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
69
76
|
continue;
|
|
70
77
|
}
|
|
71
78
|
|
|
@@ -108,12 +115,14 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
|
|
|
108
115
|
lines.push(' end');
|
|
109
116
|
lines.push('end');
|
|
110
117
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
118
|
+
if (enumInScope) {
|
|
119
|
+
files.push({
|
|
120
|
+
path: `lib/workos/types/${fileName(enumDef.name)}.rb`,
|
|
121
|
+
content: lines.join('\n'),
|
|
122
|
+
integrateTarget: true,
|
|
123
|
+
overwriteExisting: true,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
117
126
|
}
|
|
118
127
|
|
|
119
128
|
return files;
|
package/src/ruby/index.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { generateTests } from './tests.js';
|
|
|
16
16
|
import { buildOperationsMap } from './manifest.js';
|
|
17
17
|
import { generateRbiFiles } from './rbi.js';
|
|
18
18
|
import { enrichModelsFromSpec, getSyntheticEnums } from '../shared/model-utils.js';
|
|
19
|
+
import { AUTOGEN_NOTICE } from '../shared/file-header.js';
|
|
19
20
|
|
|
20
21
|
/** Ensure every generated file's content ends with a trailing newline. */
|
|
21
22
|
function ensureTrailingNewlines(files: GeneratedFile[]): GeneratedFile[] {
|
|
@@ -93,7 +94,7 @@ export const rubyEmitter: Emitter = {
|
|
|
93
94
|
},
|
|
94
95
|
|
|
95
96
|
fileHeader(): string {
|
|
96
|
-
return `# frozen_string_literal: true\n\n#
|
|
97
|
+
return `# frozen_string_literal: true\n\n# ${AUTOGEN_NOTICE}`;
|
|
97
98
|
},
|
|
98
99
|
|
|
99
100
|
formatCommand(targetDir: string): FormatCommand | null {
|