@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.
Files changed (67) hide show
  1. package/.release-please-manifest.json +1 -1
  2. package/CHANGELOG.md +16 -0
  3. package/dist/index.d.mts.map +1 -1
  4. package/dist/index.mjs +1 -1
  5. package/dist/{plugin-1ckLMpgo.mjs → plugin-BXDPA9pJ.mjs} +581 -172
  6. package/dist/plugin-BXDPA9pJ.mjs.map +1 -0
  7. package/dist/plugin.mjs +1 -1
  8. package/docs/sdk-architecture/rust.md +2 -2
  9. package/package.json +5 -5
  10. package/src/dotnet/enums.ts +11 -5
  11. package/src/dotnet/fixtures.ts +5 -2
  12. package/src/dotnet/index.ts +2 -1
  13. package/src/dotnet/models.ts +41 -10
  14. package/src/dotnet/naming.ts +10 -0
  15. package/src/dotnet/resources.ts +3 -3
  16. package/src/dotnet/tests.ts +8 -4
  17. package/src/go/fixtures.ts +4 -2
  18. package/src/go/index.ts +4 -0
  19. package/src/go/models.ts +4 -2
  20. package/src/go/naming.ts +10 -0
  21. package/src/go/resources.ts +22 -9
  22. package/src/go/tests.ts +3 -3
  23. package/src/kotlin/enums.ts +21 -11
  24. package/src/kotlin/index.ts +2 -1
  25. package/src/kotlin/models.ts +24 -9
  26. package/src/kotlin/naming.ts +11 -0
  27. package/src/kotlin/resources.ts +2 -2
  28. package/src/kotlin/tests.ts +7 -3
  29. package/src/node/enums.ts +8 -5
  30. package/src/node/field-plan.ts +3 -3
  31. package/src/node/index.ts +2 -1
  32. package/src/node/models.ts +69 -22
  33. package/src/node/naming.ts +10 -0
  34. package/src/node/options.ts +45 -1
  35. package/src/node/resources.ts +67 -18
  36. package/src/node/tests.ts +302 -31
  37. package/src/php/enums.ts +18 -5
  38. package/src/php/index.ts +13 -4
  39. package/src/php/models.ts +22 -10
  40. package/src/php/naming.ts +10 -0
  41. package/src/php/resources.ts +6 -4
  42. package/src/php/tests.ts +17 -5
  43. package/src/python/enums.ts +39 -28
  44. package/src/python/fixtures.ts +4 -3
  45. package/src/python/index.ts +2 -1
  46. package/src/python/models.ts +39 -24
  47. package/src/python/naming.ts +10 -0
  48. package/src/python/resources.ts +3 -3
  49. package/src/python/tests.ts +14 -9
  50. package/src/ruby/enums.ts +28 -19
  51. package/src/ruby/index.ts +2 -1
  52. package/src/ruby/models.ts +33 -19
  53. package/src/ruby/naming.ts +10 -0
  54. package/src/ruby/rbi.ts +20 -7
  55. package/src/ruby/resources.ts +2 -2
  56. package/src/ruby/tests.ts +6 -3
  57. package/src/rust/enums.ts +9 -1
  58. package/src/rust/index.ts +2 -1
  59. package/src/rust/models.ts +100 -15
  60. package/src/rust/naming.ts +10 -0
  61. package/src/rust/resources.ts +14 -3
  62. package/src/rust/tests.ts +2 -2
  63. package/src/shared/file-header.ts +13 -0
  64. package/src/shared/resolved-ops.ts +47 -0
  65. package/test/rust/models.test.ts +49 -0
  66. package/test/shared/synthetic-enum-seed.test.ts +79 -0
  67. package/dist/plugin-1ckLMpgo.mjs.map +0 -1
@@ -1,11 +1,12 @@
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,
7
7
  collectNonPaginatedResponseModelNames,
8
8
  } from '../shared/model-utils.js';
9
+ import { isModelInScope } from '../shared/resolved-ops.js';
9
10
 
10
11
  /** Folder under lib/workos/ for models not owned by any service. */
11
12
  export const SHARED_MODEL_DIR = 'shared';
@@ -119,19 +120,25 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
119
120
  lines.push('module WorkOS');
120
121
  lines.push(` ${cls} = ${canonCls}`);
121
122
  lines.push('end');
122
- files.push({
123
- path: `lib/workos/${dirFor(model.name)}/${file}.rb`,
124
- content: lines.join('\n'),
125
- integrateTarget: true,
126
- overwriteExisting: true,
127
- });
123
+ // FR-1.4: write the per-model file only when in scope. Zeitwerk autoloads
124
+ // by path, so there is no barrel to keep in sync; out-of-scope alias files
125
+ // are left untouched on disk.
126
+ if (isModelInScope(model.name, ctx)) {
127
+ files.push({
128
+ path: `lib/workos/${dirFor(model.name)}/${file}.rb`,
129
+ content: lines.join('\n'),
130
+ integrateTarget: true,
131
+ overwriteExisting: true,
132
+ });
133
+ }
128
134
  continue;
129
135
  }
130
136
 
131
137
  // Deduplicate field names that collide after snake_case.
132
138
  const seenFieldNames = new Set<string>();
133
139
  const fields = model.fields.filter((f) => {
134
- const n = fieldName(f.name);
140
+ // Dedup on the DOMAIN accessor name (honors fieldHints override).
141
+ const n = domainFieldName(f);
135
142
  if (seenFieldNames.has(n)) return false;
136
143
  seenFieldNames.add(n);
137
144
  return true;
@@ -151,7 +158,8 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
151
158
  lines.push(' HASH_ATTRS = {');
152
159
  for (let i = 0; i < fields.length; i++) {
153
160
  const field = fields[i];
154
- const fname = fieldName(field.name);
161
+ // DOMAIN attr symbol (honors fieldHints); the key below is the WIRE name.
162
+ const fname = domainFieldName(field);
155
163
  const sep = i === fields.length - 1 ? '' : ',';
156
164
  lines.push(` ${rubyHashLiteralKey(field.name)} :${fname}${sep}`);
157
165
  }
@@ -162,14 +170,14 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
162
170
  if (deprecatedFields.length > 0) {
163
171
  for (const f of deprecatedFields) {
164
172
  const desc = f.description ? ` ${f.description.split('\n')[0].trim()}` : '';
165
- lines.push(` # @!attribute ${fieldName(f.name)}`);
173
+ lines.push(` # @!attribute ${domainFieldName(f)}`);
166
174
  lines.push(` # @deprecated${desc}`);
167
175
  }
168
176
  lines.push('');
169
177
  }
170
178
 
171
179
  if (accessorFields.length > 0) {
172
- const attrs = accessorFields.map((f) => `:${fieldName(f.name)}`);
180
+ const attrs = accessorFields.map((f) => `:${domainFieldName(f)}`);
173
181
  if (attrs.length === 1) {
174
182
  lines.push(` attr_accessor ${attrs[0]}`);
175
183
  } else {
@@ -184,7 +192,7 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
184
192
 
185
193
  // Emit deprecated field accessors with runtime warnings.
186
194
  for (const f of deprecatedFields) {
187
- const fname = fieldName(f.name);
195
+ const fname = domainFieldName(f);
188
196
  lines.push(` def ${fname}`);
189
197
  lines.push(
190
198
  ` warn "[DEPRECATION] \\\`${fname}\\\` is deprecated and will be removed in a future version.", uplevel: 1`,
@@ -202,7 +210,8 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
202
210
  lines.push(' def initialize(json)');
203
211
  lines.push(' hash = self.class.normalize(json)');
204
212
  for (const field of fields) {
205
- const fname = fieldName(field.name);
213
+ // DOMAIN ivar name (honors fieldHints); rawKey is the WIRE key read from the hash.
214
+ const fname = domainFieldName(field);
206
215
  const rawKey = field.name;
207
216
  lines.push(` ${deserializeAssignment(fname, rawKey, field.type, field.required, enumNames, modelNames)}`);
208
217
  }
@@ -211,12 +220,17 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
211
220
  lines.push(' end');
212
221
  lines.push('end');
213
222
 
214
- files.push({
215
- path: `lib/workos/${dirFor(model.name)}/${file}.rb`,
216
- content: lines.join('\n'),
217
- integrateTarget: true,
218
- overwriteExisting: true,
219
- });
223
+ // FR-1.4: write the per-model file only when in scope. Zeitwerk autoloads by
224
+ // path, so there is no barrel to keep in sync; out-of-scope model files are
225
+ // left untouched on disk.
226
+ if (isModelInScope(model.name, ctx)) {
227
+ files.push({
228
+ path: `lib/workos/${dirFor(model.name)}/${file}.rb`,
229
+ content: lines.join('\n'),
230
+ integrateTarget: true,
231
+ overwriteExisting: true,
232
+ });
233
+ }
220
234
  }
221
235
 
222
236
  return files;
@@ -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,
@@ -14,6 +15,8 @@ import {
14
15
  import {
15
16
  buildResolvedLookup,
16
17
  groupByMount,
18
+ isMountInScope,
19
+ isModelInScope,
17
20
  lookupResolved,
18
21
  buildHiddenParams,
19
22
  collectGroupedParamNames,
@@ -92,7 +95,8 @@ export function generateRbiFiles(spec: ApiSpec, ctx: EmitterContext): GeneratedF
92
95
  // Field accessors
93
96
  const seenFieldNames = new Set<string>();
94
97
  for (const f of model.fields) {
95
- const fname = fieldName(f.name);
98
+ // DOMAIN accessor name in the .rbi (honors fieldHints override).
99
+ const fname = domainFieldName(f);
96
100
  if (seenFieldNames.has(fname)) continue;
97
101
  seenFieldNames.add(fname);
98
102
  const sorbetType = f.required ? mapSorbetType(f.type) : wrapNilable(mapSorbetType(f.type));
@@ -114,12 +118,17 @@ export function generateRbiFiles(spec: ApiSpec, ctx: EmitterContext): GeneratedF
114
118
  lines.push(' end');
115
119
  lines.push('end');
116
120
 
117
- files.push({
118
- path: `rbi/workos/${fileName(model.name)}.rbi`,
119
- content: lines.join('\n'),
120
- integrateTarget: true,
121
- overwriteExisting: true,
122
- });
121
+ // FR-1.4: write the per-model .rbi only when in scope. The client.rbi
122
+ // aggregate (section 3) stays on the full set so sigs for out-of-scope
123
+ // services whose .rb/.rbi still exist keep resolving.
124
+ if (isModelInScope(model.name, ctx)) {
125
+ files.push({
126
+ path: `rbi/workos/${fileName(model.name)}.rbi`,
127
+ content: lines.join('\n'),
128
+ integrateTarget: true,
129
+ overwriteExisting: true,
130
+ });
131
+ }
123
132
  }
124
133
 
125
134
  // 2. Generate service RBI files
@@ -135,6 +144,10 @@ export function generateRbiFiles(spec: ApiSpec, ctx: EmitterContext): GeneratedF
135
144
  const exportedClasses = buildExportedClassNameSet(ctx);
136
145
 
137
146
  for (const [mountTarget, group] of groups) {
147
+ // Scoped run: emit per-service .rbi only for selected mount targets. The
148
+ // client.rbi aggregate loop below intentionally stays on the FULL `groups`
149
+ // set so it keeps emitting sigs for every service whose .rb still exists.
150
+ if (!isMountInScope(mountTarget, ctx)) continue;
138
151
  const resolvedTarget = resolveServiceTarget(mountTarget, exportedClasses);
139
152
  const cls = className(resolvedTarget);
140
153
  const lines: string[] = [];
@@ -15,7 +15,7 @@ import { mapTypeRefForYard } from './type-map.js';
15
15
  import {
16
16
  buildResolvedLookup,
17
17
  lookupResolved,
18
- groupByMount,
18
+ scopedMountGroups,
19
19
  getOpDefaults,
20
20
  getOpInferFromClient,
21
21
  buildHiddenParams,
@@ -33,7 +33,7 @@ import { buildGroupOwnerMap, collectVariantsForMountTarget, emitInlineVariantCla
33
33
  export function generateResources(services: Service[], ctx: EmitterContext): GeneratedFile[] {
34
34
  const files: GeneratedFile[] = [];
35
35
 
36
- const groups = groupByMount(ctx);
36
+ const groups = scopedMountGroups(ctx);
37
37
  const lookup = buildResolvedLookup(ctx);
38
38
  const modelNames = new Set(ctx.spec.models.map((m) => m.name));
39
39
  const enumNames = new Set(ctx.spec.enums.map((e) => e.name));
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,
@@ -12,7 +13,7 @@ import {
12
13
  } from './naming.js';
13
14
  import {
14
15
  buildResolvedLookup,
15
- groupByMount,
16
+ scopedMountGroups,
16
17
  lookupResolved,
17
18
  buildHiddenParams,
18
19
  collectBodyFieldTypes,
@@ -33,7 +34,7 @@ import { buildGroupOwnerMap, pickVariantParamType } from './parameter-groups.js'
33
34
  export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
34
35
  const files: GeneratedFile[] = [];
35
36
 
36
- const groups = groupByMount(ctx);
37
+ const groups = scopedMountGroups(ctx);
37
38
  const models = spec.models as Model[];
38
39
  const modelByName = new Map<string, Model>();
39
40
  for (const m of models) modelByName.set(m.name, m);
@@ -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/enums.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { Enum, EmitterContext, GeneratedFile } from '@workos/oagen';
2
2
  import { typeName, moduleName, variantName } from './naming.js';
3
+ import { isEnumInScope } from '../shared/resolved-ops.js';
3
4
 
4
5
  /**
5
6
  * Generate one Rust source file per enum under `src/enums/`, plus a
@@ -17,7 +18,7 @@ import { typeName, moduleName, variantName } from './naming.js';
17
18
  * variant and re-serialize as the canonical wire string.
18
19
  * - `Display`, `FromStr`, and `AsRef<str>` are implemented for ergonomics.
19
20
  */
20
- export function generateEnums(enums: Enum[], _ctx: EmitterContext): GeneratedFile[] {
21
+ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile[] {
21
22
  const files: GeneratedFile[] = [];
22
23
  const seen = new Set<string>();
23
24
  const moduleNames: string[] = [];
@@ -27,8 +28,15 @@ export function generateEnums(enums: Enum[], _ctx: EmitterContext): GeneratedFil
27
28
  const mod = moduleName(e.name);
28
29
  if (seen.has(mod)) continue;
29
30
  seen.add(mod);
31
+ // The barrel (`src/enums/mod.rs`) must declare every enum's module so Rust
32
+ // compiles even in a scoped run — `moduleNames` is collected from the FULL
33
+ // enum set regardless of scope.
30
34
  moduleNames.push(mod);
31
35
 
36
+ // Only the per-enum `.rs` FILE write is scoped (FR-1.4). In a scoped run we
37
+ // skip emitting files for out-of-scope enums, but the barrel above still
38
+ // declares their modules (their existing `.rs` files stay untouched on disk).
39
+ if (!isEnumInScope(e.name, ctx)) continue;
32
40
  files.push({
33
41
  path: `src/enums/${mod}.rs`,
34
42
  content: renderEnum(e),
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,7 +1,8 @@
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
+ import { isModelInScope } from '../shared/resolved-ops.js';
5
6
 
6
7
  const HEADER_PLACEHOLDER = ''; // engine prepends fileHeader()
7
8
 
@@ -18,6 +19,13 @@ export function generateModels(models: Model[], ctx: EmitterContext, registry: U
18
19
  const moduleNames: string[] = [];
19
20
  const seen = new Set<string>();
20
21
 
22
+ // Map of variant-model name -> discriminator wire-property for every model
23
+ // that appears as an arm of an internally-tagged (`#[serde(tag = ...)]`)
24
+ // union. serde consumes that property as the enum tag and strips it from the
25
+ // variant body during deserialization, so a *required* field of the same
26
+ // name can never be satisfied ("missing field `type`"). See renderField.
27
+ const taggedVariantFields = collectTaggedVariantFields(models);
28
+
21
29
  for (const model of models) {
22
30
  // Empty-field, non-discriminator models still need to be emitted as an
23
31
  // empty struct so request bodies that reference them (e.g. an empty
@@ -25,11 +33,27 @@ export function generateModels(models: Model[], ctx: EmitterContext, registry: U
25
33
  const mod = moduleName(model.name);
26
34
  if (seen.has(mod)) continue;
27
35
  seen.add(mod);
36
+ // The barrel (`src/models/mod.rs`) must declare every model's module so
37
+ // Rust compiles even in a scoped run — `moduleNames` is collected from the
38
+ // FULL model set regardless of scope.
28
39
  moduleNames.push(mod);
29
40
 
41
+ // renderModel registers inline unions into `registry` as a side effect, and
42
+ // `_unions.rs` is rendered later (in generateClient) from that registry — so
43
+ // it MUST run for every model, even out-of-scope ones, or scoped runs drop
44
+ // unions. Compute content unconditionally; only the per-model `.rs` FILE write
45
+ // is scoped (FR-1.4). The barrel above still declares every module, and an
46
+ // out-of-scope model's existing `.rs` file stays untouched on disk.
30
47
  const hintPath = ctx.overlayLookup?.fileBySymbol?.get(model.name);
31
48
  const path = hintPath ?? `src/models/${mod}.rs`;
32
- files.push({ path, content: renderModel(model, registry), overwriteExisting: true });
49
+ const content = renderModel(model, registry, taggedVariantFields.get(model.name));
50
+ if (isModelInScope(model.name, ctx)) {
51
+ files.push({
52
+ path,
53
+ content,
54
+ overwriteExisting: true,
55
+ });
56
+ }
33
57
  }
34
58
 
35
59
  // Always include the unions module in the barrel so downstream stages
@@ -47,7 +71,48 @@ export function generateModels(models: Model[], ctx: EmitterContext, registry: U
47
71
  return files;
48
72
  }
49
73
 
50
- function renderModel(model: Model, registry: UnionRegistry): string {
74
+ /**
75
+ * Walk every model field and record which models are arms of an
76
+ * internally-tagged union, mapped to that union's discriminator property.
77
+ */
78
+ function collectTaggedVariantFields(models: Model[]): Map<string, string> {
79
+ const out = new Map<string, string>();
80
+ const visit = (ref: TypeRef): void => {
81
+ switch (ref.kind) {
82
+ case 'union':
83
+ if (ref.discriminator?.property) {
84
+ for (const variant of ref.variants) {
85
+ const name = variantModelName(variant);
86
+ if (name) out.set(name, ref.discriminator.property);
87
+ }
88
+ }
89
+ ref.variants.forEach(visit);
90
+ break;
91
+ case 'array':
92
+ visit(ref.items);
93
+ break;
94
+ case 'nullable':
95
+ visit(ref.inner);
96
+ break;
97
+ case 'map':
98
+ visit(ref.valueType);
99
+ break;
100
+ default:
101
+ break;
102
+ }
103
+ };
104
+ for (const model of models) for (const field of model.fields) visit(field.type);
105
+ return out;
106
+ }
107
+
108
+ /** Resolve the underlying model name of a union arm, unwrapping a nullable. */
109
+ function variantModelName(ref: TypeRef): string | null {
110
+ if (ref.kind === 'model') return ref.name;
111
+ if (ref.kind === 'nullable') return variantModelName(ref.inner);
112
+ return null;
113
+ }
114
+
115
+ function renderModel(model: Model, registry: UnionRegistry, tagField?: string): string {
51
116
  const lines: string[] = [];
52
117
  lines.push(HEADER_PLACEHOLDER);
53
118
  // Match rustfmt's canonical grouping: keyword-rooted paths (`super`,
@@ -65,7 +130,7 @@ function renderModel(model: Model, registry: UnionRegistry): string {
65
130
  lines.push('#[derive(Debug, Clone, Serialize, Deserialize)]');
66
131
 
67
132
  const resolvedNames = resolveFieldNames(model.fields);
68
- const fieldLines = model.fields.map((f, i) => renderField(f, resolvedNames[i]!, model.name, registry));
133
+ const fieldLines = model.fields.map((f, i) => renderField(f, resolvedNames[i]!, model.name, registry, tagField));
69
134
 
70
135
  // rustfmt collapses zero-field structs to `pub struct Foo {}` on a single
71
136
  // line. Match that shape so `cargo fmt --check` passes.
@@ -80,17 +145,20 @@ function renderModel(model: Model, registry: UnionRegistry): string {
80
145
  }
81
146
 
82
147
  /**
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.
148
+ * Resolve unique Rust identifiers for struct fields. The domain identifier
149
+ * honors a `fieldHints` override (`domainName`, e.g. wire `connection_type`
150
+ * domain `type`); the wire name (and the `#[serde(rename = ...)]` key emitted
151
+ * in `renderField`) still derives from `f.name`. Multiple names can collide
152
+ * after snake-casing (e.g. `integration_type` and `integrationType` both
153
+ * become `integration_type`). Subsequent collisions get a numeric suffix so
154
+ * the struct compiles; serde `rename` preserves the original wire name in every
155
+ * case.
88
156
  */
89
157
  function resolveFieldNames(fields: Field[]): string[] {
90
158
  const used = new Set<string>();
91
159
  const out: string[] = [];
92
160
  for (const f of fields) {
93
- const base = fieldName(f.name);
161
+ const base = domainFieldName(f);
94
162
  let candidate = base;
95
163
  let suffix = 2;
96
164
  while (used.has(candidate)) {
@@ -103,7 +171,13 @@ function resolveFieldNames(fields: Field[]): string[] {
103
171
  return out;
104
172
  }
105
173
 
106
- function renderField(field: Field, rustField: string, modelName: string, registry: UnionRegistry): string {
174
+ function renderField(
175
+ field: Field,
176
+ rustField: string,
177
+ modelName: string,
178
+ registry: UnionRegistry,
179
+ tagField?: string,
180
+ ): string {
107
181
  const lines: string[] = [];
108
182
  const hasDescription = !!field.description;
109
183
  if (hasDescription) {
@@ -128,9 +202,20 @@ function renderField(field: Field, rustField: string, modelName: string, registr
128
202
  // the value is a credential or token. Wire format is unchanged.
129
203
  baseType = applySecretRedaction(baseType, field.name);
130
204
 
131
- if (rename) lines.push(` #[serde(rename = "${rename}")]`);
132
- if (baseType.startsWith('Option<')) {
133
- lines.push(' #[serde(skip_serializing_if = "Option::is_none", default)]');
205
+ if (tagField === field.name) {
206
+ // This field is the discriminator of an internally-tagged union it belongs
207
+ // to. serde reads it as the enum tag and strips it from the variant body,
208
+ // so `default` lets the struct deserialize without it; `skip_serializing`
209
+ // stops the struct from re-emitting it (serde injects the tag itself,
210
+ // which would otherwise produce a duplicate key). Standalone uses of the
211
+ // struct still deserialize the value normally because the key is present.
212
+ const args = rename ? `rename = "${rename}", default, skip_serializing` : 'default, skip_serializing';
213
+ lines.push(` #[serde(${args})]`);
214
+ } else {
215
+ if (rename) lines.push(` #[serde(rename = "${rename}")]`);
216
+ if (baseType.startsWith('Option<')) {
217
+ lines.push(' #[serde(skip_serializing_if = "Option::is_none", default)]');
218
+ }
134
219
  }
135
220
  if (field.deprecated) lines.push(' #[deprecated]');
136
221
  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,11 +10,11 @@ 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';
17
- import { groupByMount, buildResolvedLookup } from '../shared/resolved-ops.js';
17
+ import { groupByMount, buildResolvedLookup, isMountInScope } from '../shared/resolved-ops.js';
18
18
  import { resolveWrapperParams, type ResolvedWrapperParam } from '../shared/wrapper-utils.js';
19
19
 
20
20
  /**
@@ -32,7 +32,14 @@ export function generateResources(_services: Service[], ctx: EmitterContext, reg
32
32
  if (group.operations.length === 0) continue;
33
33
  const basename = moduleName(mountName);
34
34
  const struct = mountStructName(mountName);
35
+ // The barrel (`src/resources/mod.rs`) must list every mount's module so
36
+ // Rust compiles even in a scoped run — `exports` is collected from the
37
+ // FULL groupByMount set regardless of scope.
35
38
  exports.push({ module: basename, struct });
39
+ // Only the per-service resource `.rs` FILE write is scoped. In a scoped
40
+ // run we skip emitting files for out-of-scope mounts, but the barrel above
41
+ // still references their modules (their existing `.rs` files stay on disk).
42
+ if (!isMountInScope(mountName, ctx)) continue;
36
43
  files.push({
37
44
  path: `src/resources/${basename}.rs`,
38
45
  content: renderMountGroup(mountName, group.resolvedOps, ctx, registry, lookup),
@@ -617,7 +624,11 @@ function registerSyntheticBody(
617
624
  if (!f.required && !rust.startsWith('Option<')) rust = makeOptional(rust);
618
625
  rust = applySecretRedaction(rust, f.name);
619
626
  return {
620
- rustName: fieldName(f.name),
627
+ // Domain identifier honors a `fieldHints` override (e.g. wire
628
+ // `connection_type` → domain `type`); `wireName` keeps `f.name`, and
629
+ // the `#[serde(rename = wireName)]` emitted in GroupEmitter.render
630
+ // fires whenever the two differ.
631
+ rustName: domainFieldName(f),
621
632
  wireName: f.name,
622
633
  rustType: rust,
623
634
  required: !!f.required && !rust.startsWith('Option<'),
package/src/rust/tests.ts CHANGED
@@ -11,7 +11,7 @@ import type {
11
11
  TypeRef,
12
12
  } from '@workos/oagen';
13
13
  import { methodName, moduleName, typeName } from './naming.js';
14
- import { groupByMount } from '../shared/resolved-ops.js';
14
+ import { scopedMountGroups } from '../shared/resolved-ops.js';
15
15
  import { exampleFor, generateFixtures } from './fixtures.js';
16
16
  import { resolveWrapperParams } from '../shared/wrapper-utils.js';
17
17
  import { isInlineEnvelopeList } from './resources.js';
@@ -43,7 +43,7 @@ export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
43
43
  overwriteExisting: true,
44
44
  });
45
45
 
46
- const groups = groupByMount(ctx);
46
+ const groups = scopedMountGroups(ctx);
47
47
  const modelMap = new Map(spec.models.map((m) => [m.name, m]));
48
48
  const enumMap = new Map(spec.enums.map((e) => [e.name, e]));
49
49
 
@@ -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.';
@@ -94,6 +94,53 @@ export function groupByMount(ctx: EmitterContext): Map<string, MountGroup> {
94
94
  return groups;
95
95
  }
96
96
 
97
+ /**
98
+ * Like {@link groupByMount}, but for a scoped (`--services`) run returns ONLY the
99
+ * mount groups the run selected (`ctx.scopedServices`, POST-MOUNT names). When
100
+ * scoping is inactive the full set is returned unchanged.
101
+ *
102
+ * Use this for PER-SERVICE resource/test emission. Do NOT use it for
103
+ * aggregate/barrel files (Rust `mod.rs`, Ruby `client.rbi`, the root client) —
104
+ * those must continue to list every service, so they keep calling
105
+ * {@link groupByMount} over the full set; otherwise a scoped run would drop
106
+ * sibling modules and break the build/type-check.
107
+ */
108
+ export function scopedMountGroups(ctx: EmitterContext): Map<string, MountGroup> {
109
+ const groups = groupByMount(ctx);
110
+ const scope = ctx.scopedServices;
111
+ if (!scope || scope.size === 0) return groups;
112
+ return new Map([...groups].filter(([mountName]) => scope.has(mountName)));
113
+ }
114
+
115
+ /**
116
+ * True when a POST-MOUNT service name should be emitted in the current run.
117
+ * Inactive scoping (no `ctx.scopedServices`) ⇒ everything is in scope. Use this
118
+ * for inline per-service gates (e.g. manifest loops keyed by `getMountTarget`).
119
+ */
120
+ export function isMountInScope(mountName: string, ctx: EmitterContext): boolean {
121
+ const scope = ctx.scopedServices;
122
+ return !scope || scope.size === 0 || scope.has(mountName);
123
+ }
124
+
125
+ /**
126
+ * True when a MODEL's per-model FILE should be written in the current run (FR-1.4).
127
+ * A scoped run sets `ctx.scopedModelNames` to the models reachable from the
128
+ * selected services; out-of-scope models are left untouched on disk. Inactive
129
+ * scoping ⇒ everything is in scope. NOTE: gate only the per-model FILE write —
130
+ * the model must still appear in barrels/indexes (built from the full set) so the
131
+ * untouched on-disk file stays importable.
132
+ */
133
+ export function isModelInScope(modelName: string, ctx: EmitterContext): boolean {
134
+ const scope = ctx.scopedModelNames;
135
+ return !scope || scope.has(modelName);
136
+ }
137
+
138
+ /** Like {@link isModelInScope} but for an ENUM's per-enum file (`ctx.scopedEnumNames`). */
139
+ export function isEnumInScope(enumName: string, ctx: EmitterContext): boolean {
140
+ const scope = ctx.scopedEnumNames;
141
+ return !scope || scope.has(enumName);
142
+ }
143
+
97
144
  /**
98
145
  * Get the mount target for an IR service.
99
146
  * Checks the first resolved operation that belongs to this service.