@workos/oagen-emitters 0.18.2 → 0.18.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/.release-please-manifest.json +1 -1
  2. package/CHANGELOG.md +17 -0
  3. package/dist/index.d.mts.map +1 -1
  4. package/dist/index.mjs +1 -1
  5. package/dist/{plugin-bqfwowQ3.mjs → plugin-Cciic50q.mjs} +457 -101
  6. package/dist/plugin-Cciic50q.mjs.map +1 -0
  7. package/dist/plugin.mjs +1 -1
  8. package/docs/sdk-architecture/rust.md +2 -2
  9. package/package.json +3 -3
  10. package/src/dotnet/fixtures.ts +17 -3
  11. package/src/dotnet/index.ts +2 -1
  12. package/src/dotnet/models.ts +30 -5
  13. package/src/dotnet/naming.ts +10 -0
  14. package/src/dotnet/tests.ts +5 -1
  15. package/src/go/fixtures.ts +4 -2
  16. package/src/go/index.ts +4 -0
  17. package/src/go/models.ts +4 -2
  18. package/src/go/naming.ts +10 -0
  19. package/src/go/resources.ts +19 -6
  20. package/src/kotlin/index.ts +2 -1
  21. package/src/kotlin/models.ts +5 -2
  22. package/src/kotlin/naming.ts +11 -0
  23. package/src/kotlin/tests.ts +5 -1
  24. package/src/node/field-plan.ts +3 -3
  25. package/src/node/index.ts +2 -1
  26. package/src/node/models.ts +40 -1
  27. package/src/node/naming.ts +10 -0
  28. package/src/node/options.ts +45 -1
  29. package/src/node/resources.ts +76 -19
  30. package/src/node/tests.ts +296 -30
  31. package/src/php/index.ts +2 -1
  32. package/src/php/models.ts +11 -5
  33. package/src/php/naming.ts +10 -0
  34. package/src/php/tests.ts +11 -2
  35. package/src/python/fixtures.ts +4 -3
  36. package/src/python/index.ts +2 -1
  37. package/src/python/models.ts +12 -6
  38. package/src/python/naming.ts +10 -0
  39. package/src/python/tests.ts +11 -6
  40. package/src/ruby/index.ts +2 -1
  41. package/src/ruby/models.ts +10 -7
  42. package/src/ruby/naming.ts +10 -0
  43. package/src/ruby/rbi.ts +3 -1
  44. package/src/ruby/tests.ts +4 -1
  45. package/src/rust/index.ts +2 -1
  46. package/src/rust/models.ts +87 -15
  47. package/src/rust/naming.ts +10 -0
  48. package/src/rust/resources.ts +6 -2
  49. package/src/shared/file-header.ts +13 -0
  50. package/test/node/resources.test.ts +31 -2
  51. package/test/rust/models.test.ts +49 -0
  52. package/dist/plugin-bqfwowQ3.mjs.map +0 -1
package/src/php/index.ts CHANGED
@@ -19,6 +19,7 @@ import { generateTests } from './tests.js';
19
19
  import { buildOperationsMap } from './manifest.js';
20
20
  import { initializeEnumDedup } from './naming.js';
21
21
  import { enrichModelsFromSpec, getSyntheticEnums } from '../shared/model-utils.js';
22
+ import { AUTOGEN_NOTICE } from '../shared/file-header.js';
22
23
 
23
24
  /** Initialize enum deduplication from spec data. */
24
25
  function ensureNamingInitialized(ctx: EmitterContext): void {
@@ -100,7 +101,7 @@ export const phpEmitter: Emitter = {
100
101
  },
101
102
 
102
103
  fileHeader(): string {
103
- return '<?php\n\ndeclare(strict_types=1);\n\n// This file is auto-generated by oagen. Do not edit.';
104
+ return `<?php\n\ndeclare(strict_types=1);\n\n// ${AUTOGEN_NOTICE}`;
104
105
  },
105
106
 
106
107
  formatCommand(targetDir: string): FormatCommand | null {
package/src/php/models.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { Model, TypeRef, EmitterContext, GeneratedFile } from '@workos/oagen';
2
2
  import { mapTypeRef, mapTypeRefForPHPDoc } from './type-map.js';
3
- import { className, enumClassName, fieldName } from './naming.js';
3
+ import { className, enumClassName, domainFieldName } from './naming.js';
4
4
  import { phpDocComment } from './utils.js';
5
5
 
6
6
  // Import and re-export shared model detection utilities
@@ -67,7 +67,8 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
67
67
  // Deduplicate fields that map to the same PHP name
68
68
  const seenNames = new Set<string>();
69
69
  const allFields = [...requiredFields, ...optionalFields].filter((f) => {
70
- const phpName = fieldName(f.name);
70
+ // DOMAIN identifier: the PHP property name (honors a `domainName` override).
71
+ const phpName = domainFieldName(f);
71
72
  if (seenNames.has(phpName)) return false;
72
73
  seenNames.add(phpName);
73
74
  return true;
@@ -75,7 +76,8 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
75
76
 
76
77
  for (let i = 0; i < allFields.length; i++) {
77
78
  const field = allFields[i];
78
- const phpName = fieldName(field.name);
79
+ // DOMAIN identifier: the promoted constructor property name.
80
+ const phpName = domainFieldName(field);
79
81
  const phpType = mapTypeRef(field.type);
80
82
  const isOptional = !field.required;
81
83
  const comma = i < allFields.length - 1 ? ',' : ',';
@@ -108,7 +110,9 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
108
110
  lines.push(` return new self(`);
109
111
  for (let i = 0; i < allFields.length; i++) {
110
112
  const field = allFields[i];
111
- const phpName = fieldName(field.name);
113
+ // DOMAIN identifier: the named constructor argument (PHP property).
114
+ const phpName = domainFieldName(field);
115
+ // WIRE key: the JSON key read from `$data[...]` (stays `field.name`).
112
116
  const wireName = field.name;
113
117
  const comma = i < allFields.length - 1 ? ',' : ',';
114
118
  const accessor = generateFromArrayAccessor(field.type, wireName, field.required);
@@ -124,7 +128,9 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
124
128
  lines.push(' {');
125
129
  lines.push(' return [');
126
130
  for (const field of allFields) {
127
- const phpName = fieldName(field.name);
131
+ // DOMAIN identifier: the `$this->...` property being serialized.
132
+ const phpName = domainFieldName(field);
133
+ // WIRE key: the JSON key emitted into the array (stays `field.name`).
128
134
  const wireName = field.name;
129
135
  const serialized = generateToArrayValue(field.type, `$this->${phpName}`, !field.required);
130
136
  lines.push(` '${wireName}' => ${serialized},`);
package/src/php/naming.ts CHANGED
@@ -138,6 +138,16 @@ export function fieldName(name: string): string {
138
138
  return toCamelCase(name);
139
139
  }
140
140
 
141
+ /**
142
+ * camelCase DOMAIN property name for a model field, honoring a `domainName`
143
+ * override (set via the `fieldHints` config) so a wire field can surface under
144
+ * a friendlier PHP property name. The wire key (see {@link wireName}) still
145
+ * derives from `field.name`. No-op when `domainName` is unset.
146
+ */
147
+ export function domainFieldName(field: { name: string; domainName?: string }): string {
148
+ return fieldName(field.domainName ?? field.name);
149
+ }
150
+
141
151
  /** snake_case name for fixtures and other snake_case contexts. */
142
152
  export function snakeName(name: string): string {
143
153
  return toSnakeCase(stripUrnPrefix(name));
package/src/php/tests.ts CHANGED
@@ -8,7 +8,14 @@ import type {
8
8
  ResolvedOperation,
9
9
  } from '@workos/oagen';
10
10
  import { planOperation, toCamelCase, toPascalCase } from '@workos/oagen';
11
- import { className, enumClassName, resolveMethodName, snakeName, servicePropertyName } from './naming.js';
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 {
@@ -503,7 +510,9 @@ function emitFieldHydrationAssertions(
503
510
 
504
511
  for (const f of assertFields) {
505
512
  if (!f) continue;
506
- const phpProp = toCamelCase(f.name);
513
+ // DOMAIN identifier: the deserialized model's PHP property (honors `domainName`).
514
+ // The fixture key `f.name` is the WIRE key and stays unchanged.
515
+ const phpProp = domainFieldName(f);
507
516
  lines.push(` $this->assertSame(${fixtureVar}['${f.name}'], ${resultVar}->${phpProp});`);
508
517
  }
509
518
  }
@@ -1,6 +1,6 @@
1
1
  import type { Model, TypeRef, Enum } from '@workos/oagen';
2
2
 
3
- import { fileName, fieldName } from './naming.js';
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 snake_case name (matching model generation in models.ts)
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 = fieldName(f.name);
111
+ const pyName = domainFieldName(f);
111
112
  if (seenFieldNames.has(pyName)) return false;
112
113
  seenFieldNames.add(pyName);
113
114
  return true;
@@ -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 '# This file is auto-generated by oagen. Do not edit.';
90
+ return `# ${AUTOGEN_NOTICE}`;
90
91
  },
91
92
 
92
93
  formatCommand(_targetDir: string): FormatCommand | null {
@@ -1,7 +1,7 @@
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, fieldName, fileName, buildMountDirMap, dirToModule } from './naming.js';
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
7
 
@@ -232,7 +232,9 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
232
232
  // Deduplicate fields that map to the same snake_case name
233
233
  const seenFieldNames = new Set<string>();
234
234
  const deduplicatedFields = model.fields.filter((f) => {
235
- const pyName = fieldName(f.name);
235
+ // Dedup on the DOMAIN identifier (the dataclass attribute name), which
236
+ // honors a `domainName` override; the wire key stays `field.name`.
237
+ const pyName = domainFieldName(f);
236
238
  if (seenFieldNames.has(pyName)) return false;
237
239
  seenFieldNames.add(pyName);
238
240
  return true;
@@ -343,7 +345,8 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
343
345
  };
344
346
 
345
347
  for (const field of requiredFields) {
346
- const pyFieldName = fieldName(field.name);
348
+ // DOMAIN identifier: the dataclass attribute name (honors `domainName`).
349
+ const pyFieldName = domainFieldName(field);
347
350
  const pyType = rewriteDiscriminatorType(resolveModelFieldType(field.type));
348
351
  if (field.description || field.deprecated) {
349
352
  const parts: string[] = [];
@@ -357,7 +360,8 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
357
360
  }
358
361
 
359
362
  for (const field of optionalFields) {
360
- const pyFieldName = fieldName(field.name);
363
+ // DOMAIN identifier: the dataclass attribute name (honors `domainName`).
364
+ const pyFieldName = domainFieldName(field);
361
365
  const innerType =
362
366
  field.type.kind === 'nullable' ? resolveModelFieldType(field.type.inner) : resolveModelFieldType(field.type);
363
367
  const pyType = `Optional[${rewriteDiscriminatorType(innerType)}]`;
@@ -383,7 +387,8 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
383
387
  const fieldAssignmentLines: string[] = [];
384
388
 
385
389
  for (const field of [...requiredFields, ...optionalFields]) {
386
- const pyFieldName = fieldName(field.name);
390
+ // DOMAIN identifier (LHS of `cls(...)`); the wire key below stays `field.name`.
391
+ const pyFieldName = domainFieldName(field);
387
392
  const wireKey = field.name; // Wire keys are snake_case from the spec
388
393
  const isRequired = !isOptionalField(model.name, field, ctx);
389
394
 
@@ -424,7 +429,8 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
424
429
  lines.push(' result: Dict[str, Any] = {}');
425
430
 
426
431
  for (const field of [...requiredFields, ...optionalFields]) {
427
- const pyFieldName = fieldName(field.name);
432
+ // DOMAIN identifier (`self.<attr>`); the wire key below stays `field.name`.
433
+ const pyFieldName = domainFieldName(field);
428
434
  const wireKey = field.name;
429
435
  const isRequired = !isOptionalField(model.name, field, ctx);
430
436
 
@@ -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.
@@ -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,
@@ -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
- results.push({ field: fieldName(f.name), value: `"${val}"` });
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: fieldName(f.name), value: val ? 'True' : 'False', isBool: true });
1034
+ results.push({ field: domainFieldName(f), value: val ? 'True' : 'False', isBool: true });
1033
1035
  } else if (typeof val === 'number') {
1034
- results.push({ field: fieldName(f.name), value: String(val) });
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
- if (bodyModel?.fields.some((f) => fieldName(f.name) === fieldName(param.name))) continue;
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 that map to the same snake_case name (mirrors models.ts)
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 = fieldName(f.name);
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/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# This file is auto-generated by oagen. Do not edit.`;
97
+ return `# frozen_string_literal: true\n\n# ${AUTOGEN_NOTICE}`;
97
98
  },
98
99
 
99
100
  formatCommand(targetDir: string): FormatCommand | null {
@@ -1,6 +1,6 @@
1
1
  import type { Model, EmitterContext, GeneratedFile, TypeRef, Field } from '@workos/oagen';
2
2
  import { walkTypeRef, assignModelsToServices } from '@workos/oagen';
3
- import { className, fieldName, fileName, buildMountDirMap } from './naming.js';
3
+ import { className, domainFieldName, fileName, buildMountDirMap } from './naming.js';
4
4
  import {
5
5
  isListWrapperModel,
6
6
  isListMetadataModel,
@@ -131,7 +131,8 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
131
131
  // Deduplicate field names that collide after snake_case.
132
132
  const seenFieldNames = new Set<string>();
133
133
  const fields = model.fields.filter((f) => {
134
- const n = fieldName(f.name);
134
+ // Dedup on the DOMAIN accessor name (honors fieldHints override).
135
+ const n = domainFieldName(f);
135
136
  if (seenFieldNames.has(n)) return false;
136
137
  seenFieldNames.add(n);
137
138
  return true;
@@ -151,7 +152,8 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
151
152
  lines.push(' HASH_ATTRS = {');
152
153
  for (let i = 0; i < fields.length; i++) {
153
154
  const field = fields[i];
154
- const fname = fieldName(field.name);
155
+ // DOMAIN attr symbol (honors fieldHints); the key below is the WIRE name.
156
+ const fname = domainFieldName(field);
155
157
  const sep = i === fields.length - 1 ? '' : ',';
156
158
  lines.push(` ${rubyHashLiteralKey(field.name)} :${fname}${sep}`);
157
159
  }
@@ -162,14 +164,14 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
162
164
  if (deprecatedFields.length > 0) {
163
165
  for (const f of deprecatedFields) {
164
166
  const desc = f.description ? ` ${f.description.split('\n')[0].trim()}` : '';
165
- lines.push(` # @!attribute ${fieldName(f.name)}`);
167
+ lines.push(` # @!attribute ${domainFieldName(f)}`);
166
168
  lines.push(` # @deprecated${desc}`);
167
169
  }
168
170
  lines.push('');
169
171
  }
170
172
 
171
173
  if (accessorFields.length > 0) {
172
- const attrs = accessorFields.map((f) => `:${fieldName(f.name)}`);
174
+ const attrs = accessorFields.map((f) => `:${domainFieldName(f)}`);
173
175
  if (attrs.length === 1) {
174
176
  lines.push(` attr_accessor ${attrs[0]}`);
175
177
  } else {
@@ -184,7 +186,7 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
184
186
 
185
187
  // Emit deprecated field accessors with runtime warnings.
186
188
  for (const f of deprecatedFields) {
187
- const fname = fieldName(f.name);
189
+ const fname = domainFieldName(f);
188
190
  lines.push(` def ${fname}`);
189
191
  lines.push(
190
192
  ` warn "[DEPRECATION] \\\`${fname}\\\` is deprecated and will be removed in a future version.", uplevel: 1`,
@@ -202,7 +204,8 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
202
204
  lines.push(' def initialize(json)');
203
205
  lines.push(' hash = self.class.normalize(json)');
204
206
  for (const field of fields) {
205
- const fname = fieldName(field.name);
207
+ // DOMAIN ivar name (honors fieldHints); rawKey is the WIRE key read from the hash.
208
+ const fname = domainFieldName(field);
206
209
  const rawKey = field.name;
207
210
  lines.push(` ${deserializeAssignment(fname, rawKey, field.type, field.required, enumNames, modelNames)}`);
208
211
  }
@@ -57,6 +57,16 @@ export function fieldName(name: string): string {
57
57
  return toSnakeCase(name);
58
58
  }
59
59
 
60
+ /**
61
+ * snake_case domain field name for a model field, honoring a `domainName`
62
+ * override (set via the `fieldHints` config) so a wire field can surface under
63
+ * a friendlier name. The wire key (still derived from `field.name`) is what
64
+ * gets sent/received over the wire — only the domain attr/accessor name changes.
65
+ */
66
+ export function domainFieldName(field: { name: string; domainName?: string }): string {
67
+ return toSnakeCase(field.domainName ?? field.name);
68
+ }
69
+
60
70
  /**
61
71
  * Ruby reserved words that cannot be used as parameter names.
62
72
  * When a path/query param name collides, suffix with underscore.
package/src/ruby/rbi.ts CHANGED
@@ -3,6 +3,7 @@ import { mapTypeRef as irMapTypeRef } from '@workos/oagen';
3
3
  import {
4
4
  className,
5
5
  fieldName,
6
+ domainFieldName,
6
7
  fileName,
7
8
  safeParamName,
8
9
  scopedGroupVariantClassName,
@@ -92,7 +93,8 @@ export function generateRbiFiles(spec: ApiSpec, ctx: EmitterContext): GeneratedF
92
93
  // Field accessors
93
94
  const seenFieldNames = new Set<string>();
94
95
  for (const f of model.fields) {
95
- const fname = fieldName(f.name);
96
+ // DOMAIN accessor name in the .rbi (honors fieldHints override).
97
+ const fname = domainFieldName(f);
96
98
  if (seenFieldNames.has(fname)) continue;
97
99
  seenFieldNames.add(fname);
98
100
  const sorbetType = f.required ? mapSorbetType(f.type) : wrapNilable(mapSorbetType(f.type));
package/src/ruby/tests.ts CHANGED
@@ -3,6 +3,7 @@ import {
3
3
  className,
4
4
  fileName,
5
5
  fieldName,
6
+ domainFieldName,
6
7
  safeParamName,
7
8
  scopedGroupVariantClassName,
8
9
  servicePropertyName,
@@ -235,7 +236,9 @@ function generateModelRoundTripTest(spec: ApiSpec): GeneratedFile {
235
236
  const dedupFields = new Set<string>();
236
237
  for (const f of model.fields) {
237
238
  const wireName = f.name;
238
- const rubyFieldName = fieldName(f.name);
239
+ // Dedup on the DOMAIN accessor name to mirror the model's field dedup
240
+ // (models.ts). The fixture/assertion keys below still use the WIRE name.
241
+ const rubyFieldName = domainFieldName(f);
239
242
  if (dedupFields.has(rubyFieldName)) continue;
240
243
  dedupFields.add(rubyFieldName);
241
244
  const stub = roundTripStub(f.type, enumNames);
package/src/rust/index.ts CHANGED
@@ -17,6 +17,7 @@ import { generateTests } from './tests.js';
17
17
  import { buildOperationsMap } from './manifest.js';
18
18
  import { UnionRegistry } from './type-map.js';
19
19
  import { enrichModelsFromSpec, getSyntheticEnums } from '../shared/model-utils.js';
20
+ import { AUTOGEN_NOTICE } from '../shared/file-header.js';
20
21
 
21
22
  /**
22
23
  * Shared per-emit registry that collects synthesised oneOf-style unions
@@ -98,7 +99,7 @@ export const rustEmitter: Emitter = {
98
99
  },
99
100
 
100
101
  fileHeader(): string {
101
- return '// Code generated by oagen. DO NOT EDIT.';
102
+ return `// ${AUTOGEN_NOTICE}`;
102
103
  },
103
104
 
104
105
  formatCommand(_targetDir: string): FormatCommand | null {
@@ -1,5 +1,5 @@
1
- import type { Model, EmitterContext, GeneratedFile, Field } from '@workos/oagen';
2
- import { typeName, fieldName, moduleName } from './naming.js';
1
+ import type { Model, EmitterContext, GeneratedFile, Field, TypeRef } from '@workos/oagen';
2
+ import { typeName, domainFieldName, moduleName } from './naming.js';
3
3
  import { mapTypeRef, makeOptional, UnionRegistry } from './type-map.js';
4
4
  import { applySecretRedaction } from './secret.js';
5
5
 
@@ -18,6 +18,13 @@ export function generateModels(models: Model[], ctx: EmitterContext, registry: U
18
18
  const moduleNames: string[] = [];
19
19
  const seen = new Set<string>();
20
20
 
21
+ // Map of variant-model name -> discriminator wire-property for every model
22
+ // that appears as an arm of an internally-tagged (`#[serde(tag = ...)]`)
23
+ // union. serde consumes that property as the enum tag and strips it from the
24
+ // variant body during deserialization, so a *required* field of the same
25
+ // name can never be satisfied ("missing field `type`"). See renderField.
26
+ const taggedVariantFields = collectTaggedVariantFields(models);
27
+
21
28
  for (const model of models) {
22
29
  // Empty-field, non-discriminator models still need to be emitted as an
23
30
  // empty struct so request bodies that reference them (e.g. an empty
@@ -29,7 +36,11 @@ export function generateModels(models: Model[], ctx: EmitterContext, registry: U
29
36
 
30
37
  const hintPath = ctx.overlayLookup?.fileBySymbol?.get(model.name);
31
38
  const path = hintPath ?? `src/models/${mod}.rs`;
32
- files.push({ path, content: renderModel(model, registry), overwriteExisting: true });
39
+ files.push({
40
+ path,
41
+ content: renderModel(model, registry, taggedVariantFields.get(model.name)),
42
+ overwriteExisting: true,
43
+ });
33
44
  }
34
45
 
35
46
  // Always include the unions module in the barrel so downstream stages
@@ -47,7 +58,48 @@ export function generateModels(models: Model[], ctx: EmitterContext, registry: U
47
58
  return files;
48
59
  }
49
60
 
50
- function renderModel(model: Model, registry: UnionRegistry): string {
61
+ /**
62
+ * Walk every model field and record which models are arms of an
63
+ * internally-tagged union, mapped to that union's discriminator property.
64
+ */
65
+ function collectTaggedVariantFields(models: Model[]): Map<string, string> {
66
+ const out = new Map<string, string>();
67
+ const visit = (ref: TypeRef): void => {
68
+ switch (ref.kind) {
69
+ case 'union':
70
+ if (ref.discriminator?.property) {
71
+ for (const variant of ref.variants) {
72
+ const name = variantModelName(variant);
73
+ if (name) out.set(name, ref.discriminator.property);
74
+ }
75
+ }
76
+ ref.variants.forEach(visit);
77
+ break;
78
+ case 'array':
79
+ visit(ref.items);
80
+ break;
81
+ case 'nullable':
82
+ visit(ref.inner);
83
+ break;
84
+ case 'map':
85
+ visit(ref.valueType);
86
+ break;
87
+ default:
88
+ break;
89
+ }
90
+ };
91
+ for (const model of models) for (const field of model.fields) visit(field.type);
92
+ return out;
93
+ }
94
+
95
+ /** Resolve the underlying model name of a union arm, unwrapping a nullable. */
96
+ function variantModelName(ref: TypeRef): string | null {
97
+ if (ref.kind === 'model') return ref.name;
98
+ if (ref.kind === 'nullable') return variantModelName(ref.inner);
99
+ return null;
100
+ }
101
+
102
+ function renderModel(model: Model, registry: UnionRegistry, tagField?: string): string {
51
103
  const lines: string[] = [];
52
104
  lines.push(HEADER_PLACEHOLDER);
53
105
  // Match rustfmt's canonical grouping: keyword-rooted paths (`super`,
@@ -65,7 +117,7 @@ function renderModel(model: Model, registry: UnionRegistry): string {
65
117
  lines.push('#[derive(Debug, Clone, Serialize, Deserialize)]');
66
118
 
67
119
  const resolvedNames = resolveFieldNames(model.fields);
68
- const fieldLines = model.fields.map((f, i) => renderField(f, resolvedNames[i]!, model.name, registry));
120
+ const fieldLines = model.fields.map((f, i) => renderField(f, resolvedNames[i]!, model.name, registry, tagField));
69
121
 
70
122
  // rustfmt collapses zero-field structs to `pub struct Foo {}` on a single
71
123
  // line. Match that shape so `cargo fmt --check` passes.
@@ -80,17 +132,20 @@ function renderModel(model: Model, registry: UnionRegistry): string {
80
132
  }
81
133
 
82
134
  /**
83
- * Resolve unique Rust identifiers for struct fields. Multiple wire names can
84
- * collide after `fieldName()` snake-cases them (e.g. `integration_type` and
85
- * `integrationType` both become `integration_type`). Subsequent collisions get
86
- * a numeric suffix so the struct compiles; serde `rename` preserves the
87
- * original wire name in every case.
135
+ * Resolve unique Rust identifiers for struct fields. The domain identifier
136
+ * honors a `fieldHints` override (`domainName`, e.g. wire `connection_type`
137
+ * domain `type`); the wire name (and the `#[serde(rename = ...)]` key emitted
138
+ * in `renderField`) still derives from `f.name`. Multiple names can collide
139
+ * after snake-casing (e.g. `integration_type` and `integrationType` both
140
+ * become `integration_type`). Subsequent collisions get a numeric suffix so
141
+ * the struct compiles; serde `rename` preserves the original wire name in every
142
+ * case.
88
143
  */
89
144
  function resolveFieldNames(fields: Field[]): string[] {
90
145
  const used = new Set<string>();
91
146
  const out: string[] = [];
92
147
  for (const f of fields) {
93
- const base = fieldName(f.name);
148
+ const base = domainFieldName(f);
94
149
  let candidate = base;
95
150
  let suffix = 2;
96
151
  while (used.has(candidate)) {
@@ -103,7 +158,13 @@ function resolveFieldNames(fields: Field[]): string[] {
103
158
  return out;
104
159
  }
105
160
 
106
- function renderField(field: Field, rustField: string, modelName: string, registry: UnionRegistry): string {
161
+ function renderField(
162
+ field: Field,
163
+ rustField: string,
164
+ modelName: string,
165
+ registry: UnionRegistry,
166
+ tagField?: string,
167
+ ): string {
107
168
  const lines: string[] = [];
108
169
  const hasDescription = !!field.description;
109
170
  if (hasDescription) {
@@ -128,9 +189,20 @@ function renderField(field: Field, rustField: string, modelName: string, registr
128
189
  // the value is a credential or token. Wire format is unchanged.
129
190
  baseType = applySecretRedaction(baseType, field.name);
130
191
 
131
- if (rename) lines.push(` #[serde(rename = "${rename}")]`);
132
- if (baseType.startsWith('Option<')) {
133
- lines.push(' #[serde(skip_serializing_if = "Option::is_none", default)]');
192
+ if (tagField === field.name) {
193
+ // This field is the discriminator of an internally-tagged union it belongs
194
+ // to. serde reads it as the enum tag and strips it from the variant body,
195
+ // so `default` lets the struct deserialize without it; `skip_serializing`
196
+ // stops the struct from re-emitting it (serde injects the tag itself,
197
+ // which would otherwise produce a duplicate key). Standalone uses of the
198
+ // struct still deserialize the value normally because the key is present.
199
+ const args = rename ? `rename = "${rename}", default, skip_serializing` : 'default, skip_serializing';
200
+ lines.push(` #[serde(${args})]`);
201
+ } else {
202
+ if (rename) lines.push(` #[serde(rename = "${rename}")]`);
203
+ if (baseType.startsWith('Option<')) {
204
+ lines.push(' #[serde(skip_serializing_if = "Option::is_none", default)]');
205
+ }
134
206
  }
135
207
  if (field.deprecated) lines.push(' #[deprecated]');
136
208
  lines.push(` pub ${rustField}: ${baseType},`);
@@ -77,6 +77,16 @@ export function fieldName(name: string): string {
77
77
  return escapeKeyword(toSnakeCase(name));
78
78
  }
79
79
 
80
+ /**
81
+ * snake_case domain field name for a model field, honoring a `domainName`
82
+ * override (set via the `fieldHints` config) so a wire field can surface under
83
+ * a friendlier identifier. The wire name (and thus the `#[serde(rename = ...)]`
84
+ * key) still derives from `field.name`.
85
+ */
86
+ export function domainFieldName(field: { name: string; domainName?: string }): string {
87
+ return escapeKeyword(toSnakeCase(field.domainName ?? field.name));
88
+ }
89
+
80
90
  /** PascalCase enum variant. */
81
91
  export function variantName(value: string | number): string {
82
92
  const s = String(value);
@@ -10,7 +10,7 @@ import type {
10
10
  TypeRef,
11
11
  } from '@workos/oagen';
12
12
  import { planOperation } from '@workos/oagen';
13
- import { fieldName, methodName, typeName, moduleName, variantName } from './naming.js';
13
+ import { fieldName, domainFieldName, methodName, typeName, moduleName, variantName } from './naming.js';
14
14
  import { mapTypeRef, makeOptional, UnionRegistry } from './type-map.js';
15
15
  import { applySecretRedaction } from './secret.js';
16
16
  import { parsePathTemplate } from '../shared/path-template.js';
@@ -617,7 +617,11 @@ function registerSyntheticBody(
617
617
  if (!f.required && !rust.startsWith('Option<')) rust = makeOptional(rust);
618
618
  rust = applySecretRedaction(rust, f.name);
619
619
  return {
620
- rustName: fieldName(f.name),
620
+ // Domain identifier honors a `fieldHints` override (e.g. wire
621
+ // `connection_type` → domain `type`); `wireName` keeps `f.name`, and
622
+ // the `#[serde(rename = wireName)]` emitted in GroupEmitter.render
623
+ // fires whenever the two differ.
624
+ rustName: domainFieldName(f),
621
625
  wireName: f.name,
622
626
  rustType: rust,
623
627
  required: !!f.required && !rust.startsWith('Option<'),
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Canonical "do not edit" banner text shared by every emitter's
3
+ * `fileHeader()`. The text is comment-syntax agnostic — each emitter prefixes
4
+ * it with the appropriate comment marker for its language (`//`, `#`, etc.).
5
+ *
6
+ * Keeping this in one place prevents the wording from drifting between
7
+ * languages (Rust previously emitted a different banner).
8
+ *
9
+ * Go intentionally does NOT use this constant: its header must match the
10
+ * standard `^// Code generated .* DO NOT EDIT\.$` marker that Go tooling
11
+ * relies on to recognize generated files. See `src/go/index.ts`.
12
+ */
13
+ export const AUTOGEN_NOTICE = 'This file is auto-generated by oagen. Do not edit.';