@workos/oagen-emitters 0.18.3 → 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 (51) hide show
  1. package/.release-please-manifest.json +1 -1
  2. package/CHANGELOG.md +9 -0
  3. package/dist/index.d.mts.map +1 -1
  4. package/dist/index.mjs +1 -1
  5. package/dist/{plugin-1ckLMpgo.mjs → plugin-Cciic50q.mjs} +443 -99
  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 +5 -2
  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 +55 -17
  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/rust/models.test.ts +49 -0
  51. package/dist/plugin-1ckLMpgo.mjs.map +0 -1
package/dist/plugin.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { t as workosEmittersPlugin } from "./plugin-1ckLMpgo.mjs";
1
+ import { t as workosEmittersPlugin } from "./plugin-Cciic50q.mjs";
2
2
  export { workosEmittersPlugin };
@@ -316,8 +316,8 @@ surface that the `rust` extractor reads.
316
316
  Every generated file begins with:
317
317
 
318
318
  ```rust
319
- // Code generated by oagen. DO NOT EDIT.
319
+ // This file is auto-generated by oagen. Do not edit.
320
320
  ```
321
321
 
322
- `Cargo.toml` uses the TOML-style equivalent (`# Code generated by oagen. DO NOT EDIT.`)
322
+ `Cargo.toml` uses the TOML-style equivalent (`# This file is auto-generated by oagen. Do not edit.`)
323
323
  and JSON fixtures skip the header (`headerPlacement: 'skip'`).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@workos/oagen-emitters",
3
- "version": "0.18.3",
3
+ "version": "0.18.4",
4
4
  "description": "WorkOS' oagen emitters",
5
5
  "license": "MIT",
6
6
  "author": "WorkOS",
@@ -40,7 +40,7 @@
40
40
  "devDependencies": {
41
41
  "@commitlint/cli": "^21.0.2",
42
42
  "@commitlint/config-conventional": "^21.0.2",
43
- "@types/node": "^25.9.3",
43
+ "@types/node": "^26.0.0",
44
44
  "husky": "^9.1.7",
45
45
  "oxfmt": "^0.55.0",
46
46
  "oxlint": "^1.70.0",
@@ -54,6 +54,6 @@
54
54
  "node": ">=24.10.0"
55
55
  },
56
56
  "dependencies": {
57
- "@workos/oagen": "^0.22.6"
57
+ "@workos/oagen": "^0.22.7"
58
58
  }
59
59
  }
@@ -1,5 +1,5 @@
1
1
  import type { Model, TypeRef, Enum } from '@workos/oagen';
2
- import { fixtureFileName, fieldName } from './naming.js';
2
+ import { fixtureFileName, domainFieldName } from './naming.js';
3
3
  import { isListMetadataModel, isListWrapperModel } from './models.js';
4
4
  import { collectNonPaginatedResponseModelNames } from '../shared/model-utils.js';
5
5
 
@@ -155,7 +155,10 @@ export function generateModelFixture(
155
155
 
156
156
  const seenFieldNames = new Set<string>();
157
157
  const deduplicatedFields = model.fields.filter((f) => {
158
- const csName = fieldName(f.name);
158
+ // Dedup on the DOMAIN identifier (the C# property name, honoring a
159
+ // `domainName` override) to mirror the dedup in models.ts. The fixture
160
+ // payload below still keys on the wire name (`field.name`).
161
+ const csName = domainFieldName(f);
159
162
  if (seenFieldNames.has(csName)) return false;
160
163
  seenFieldNames.add(csName);
161
164
  return true;
@@ -20,6 +20,7 @@ import { generateTests } from './tests.js';
20
20
  import { buildOperationsMap } from './manifest.js';
21
21
  import { generateWrapperOptionsClasses } from './wrappers.js';
22
22
  import { groupByMount } from '../shared/resolved-ops.js';
23
+ import { AUTOGEN_NOTICE } from '../shared/file-header.js';
23
24
  import { discriminatedUnions, resolveModelName } from './type-map.js';
24
25
  import { modelClassName } from './naming.js';
25
26
 
@@ -315,7 +316,7 @@ export const dotnetEmitter: Emitter = {
315
316
  },
316
317
 
317
318
  fileHeader(): string {
318
- return '// This file is auto-generated by oagen. Do not edit.';
319
+ return `// ${AUTOGEN_NOTICE}`;
319
320
  },
320
321
 
321
322
  formatCommand(targetDir: string): FormatCommand | null {
@@ -12,6 +12,7 @@ import {
12
12
  import {
13
13
  articleFor,
14
14
  fieldName,
15
+ domainFieldName,
15
16
  humanize,
16
17
  emitXmlDoc,
17
18
  deprecationMessage,
@@ -93,7 +94,10 @@ export function generateModels(models: Model[], ctx: EmitterContext, discCtx?: D
93
94
  const baseClassName = modelClassName(model.name);
94
95
  const fieldMap = new Map<string, string>();
95
96
  for (const field of model.fields) {
96
- let csName = fieldName(field.name);
97
+ // DOMAIN identifier: the C# property name used for inheritance
98
+ // comparison (honors a `domainName` override). Must match the
99
+ // property name emitted below so variant fields dedup correctly.
100
+ let csName = domainFieldName(field);
97
101
  if (csName === baseClassName) csName = `${csName}Value`;
98
102
  fieldMap.set(csName, mapTypeRef(field.type));
99
103
  }
@@ -119,8 +123,16 @@ export function generateModels(models: Model[], ctx: EmitterContext, discCtx?: D
119
123
  // Required enums need JsonProperty / STJS; a field whose PascalCase name
120
124
  // collides with the enclosing class needs the same imports for the wire-
121
125
  // name override emitted below.
122
- const hasClassNameCollision = model.fields.some((f) => fieldName(f.name) === csClassName);
123
- const needsJsonAttrs = hasClassNameCollision || model.fields.some((f) => f.required && isEnumRef(f.type));
126
+ // DOMAIN identifier: the emitted C# property name (honors `domainName`)
127
+ // is what can collide with the enclosing class name.
128
+ const hasClassNameCollision = model.fields.some((f) => domainFieldName(f) === csClassName);
129
+ // A `domainName` override renames the C# property away from the wire key
130
+ // (e.g. wire `connection_type` surfaced as domain `Type`). The
131
+ // SnakeCaseLower naming policy would otherwise serialize the domain name,
132
+ // so these fields need an explicit pinned wire name (and thus the imports).
133
+ const hasDomainRename = model.fields.some((f) => domainFieldName(f) !== fieldName(f.name));
134
+ const needsJsonAttrs =
135
+ hasClassNameCollision || hasDomainRename || model.fields.some((f) => f.required && isEnumRef(f.type));
124
136
 
125
137
  lines.push(`namespace ${ctx.namespacePascal}`);
126
138
  lines.push('{');
@@ -175,9 +187,16 @@ export function generateModels(models: Model[], ctx: EmitterContext, discCtx?: D
175
187
  // when that happens. Track the rename so we emit an explicit
176
188
  // `[JsonProperty]` attribute below — the SnakeCaseLower naming policy
177
189
  // would otherwise serialize `ErrorValue` as `error_value`, not `error`.
178
- let csFieldName = fieldName(field.name);
190
+ // DOMAIN identifier: the C# property name, honoring a `domainName`
191
+ // override (e.g. wire `connection_type` → domain `Type`). The wire key
192
+ // passed to `emitJsonPropertyAttributes` below still derives from
193
+ // `field.name`.
194
+ let csFieldName = domainFieldName(field);
179
195
  const collidesWithClassName = csFieldName === csClassName;
180
196
  if (collidesWithClassName) csFieldName = `${csFieldName}Value`;
197
+ // When the domain rename diverges from the wire key, the SnakeCaseLower
198
+ // naming policy can't recover the wire name from the property — pin it.
199
+ const hasDomainOverride = domainFieldName(field) !== fieldName(field.name);
181
200
  if (seenFieldNames.has(csFieldName)) continue;
182
201
  seenFieldNames.add(csFieldName);
183
202
 
@@ -257,8 +276,14 @@ export function generateModels(models: Model[], ctx: EmitterContext, discCtx?: D
257
276
  }
258
277
 
259
278
  const isRequiredEnum = field.required && isEnumRef(field.type) && constInit === null;
279
+ // WIRE key: always derives from `field.name`. Pin it explicitly when the
280
+ // C# property name (collision suffix or `domainName` override) no longer
281
+ // round-trips to the wire name via the SnakeCaseLower naming policy.
260
282
  lines.push(
261
- ...emitJsonPropertyAttributes(field.name, { isRequiredEnum, explicitWireName: collidesWithClassName }),
283
+ ...emitJsonPropertyAttributes(field.name, {
284
+ isRequiredEnum,
285
+ explicitWireName: collidesWithClassName || hasDomainOverride,
286
+ }),
262
287
  );
263
288
  // Discriminated-union-typed field: attach the variant-dispatching converter
264
289
  // so Newtonsoft picks the right subtype on deserialization. The converter
@@ -39,6 +39,16 @@ export function fieldName(name: string): string {
39
39
  return toPascalCase(name);
40
40
  }
41
41
 
42
+ /**
43
+ * PascalCase domain property name for a model field, honoring a `domainName`
44
+ * override (set via the `fieldHints` config) so a wire field can surface under
45
+ * a friendlier C# property name. The wire/serialization key (the
46
+ * `[JsonPropertyName("...")]` value) still derives from `field.name`.
47
+ */
48
+ export function domainFieldName(field: { name: string; domainName?: string }): string {
49
+ return toPascalCase(field.domainName ?? field.name);
50
+ }
51
+
42
52
  /** PascalCase directory name for service modules. */
43
53
  export function moduleName(name: string): string {
44
54
  return toPascalCase(name);
@@ -3,6 +3,7 @@ import { planOperation } from '@workos/oagen';
3
3
  import {
4
4
  fixtureFileName,
5
5
  fieldName as csFieldName,
6
+ domainFieldName as csDomainFieldName,
6
7
  methodName as csMethodName,
7
8
  appendAsyncSuffix,
8
9
  modelClassName,
@@ -694,7 +695,10 @@ function buildFixtureAssertions(model: import('@workos/oagen').Model, spec: ApiS
694
695
  if (field.type.kind !== 'primitive' || field.type.type !== 'string') continue;
695
696
  if (field.type.format === 'date-time' || field.type.format === 'date') continue;
696
697
  if (field.type.format === 'binary') continue;
697
- const csField = csFieldName(field.name);
698
+ // DOMAIN identifier: the C# property accessed on the deserialized model
699
+ // (honors a `domainName` override). The fixture lookup below uses the wire
700
+ // key (`field.name`).
701
+ const csField = csDomainFieldName(field);
698
702
  const val = fixture[field.name];
699
703
  if (typeof val === 'string' && val.length > 0) {
700
704
  assertions.push(`Assert.Equal(${csStringLiteral(val)}, result.${csField});`);
@@ -1,5 +1,5 @@
1
1
  import type { Model, TypeRef, Enum } from '@workos/oagen';
2
- import { fileName, fieldName } from './naming.js';
2
+ import { fileName, domainFieldName } from './naming.js';
3
3
  import { isListMetadataModel, isListWrapperModel } from './models.js';
4
4
  import { collectNonPaginatedResponseModelNames, collectReferencedListMetadataModels } from '../shared/model-utils.js';
5
5
 
@@ -131,7 +131,9 @@ export function generateModelFixture(
131
131
 
132
132
  const seenFieldNames = new Set<string>();
133
133
  const deduplicatedFields = model.fields.filter((f) => {
134
- const goName = fieldName(f.name);
134
+ // Dedup by the domain Go field name to mirror the struct in models.ts; the
135
+ // fixture key itself (wireName below) still derives from field.name.
136
+ const goName = domainFieldName(f);
135
137
  if (seenFieldNames.has(goName)) return false;
136
138
  seenFieldNames.add(goName);
137
139
  return true;
package/src/go/index.ts CHANGED
@@ -88,6 +88,10 @@ export const goEmitter: Emitter = {
88
88
  },
89
89
 
90
90
  fileHeader(): string {
91
+ // Go-specific: this exact form matches the standard generated-file regex
92
+ // (`^// Code generated .* DO NOT EDIT\.$`) that gofmt, gopls, golangci-lint,
93
+ // and other Go tooling use to classify a file as generated. It intentionally
94
+ // does NOT use the shared AUTOGEN_NOTICE, which would break that detection.
91
95
  return '// Code generated by oagen. DO NOT EDIT.';
92
96
  },
93
97
 
package/src/go/models.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { Model, EmitterContext, GeneratedFile, TypeRef, Service } from '@workos/oagen';
2
2
  import { walkTypeRef } from '@workos/oagen';
3
3
  import { mapTypeRef } from './type-map.js';
4
- import { className, fieldName } from './naming.js';
4
+ import { className, domainFieldName } from './naming.js';
5
5
  import { lowerFirstForDoc, fieldDocComment, articleFor } from '../shared/naming-utils.js';
6
6
 
7
7
  // Import and re-export shared model detection utilities
@@ -185,7 +185,9 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
185
185
  // Deduplicate fields by Go field name
186
186
  const seenFieldNames = new Set<string>();
187
187
  for (const field of model.fields) {
188
- const goFieldName = fieldName(field.name);
188
+ // Domain identifier honors a `fieldHints` override (e.g. connection_type
189
+ // → type); the json struct tag below still derives from `field.name`.
190
+ const goFieldName = domainFieldName(field);
189
191
  if (seenFieldNames.has(goFieldName)) continue;
190
192
  seenFieldNames.add(goFieldName);
191
193
 
package/src/go/naming.ts CHANGED
@@ -61,6 +61,16 @@ export function fieldName(name: string): string {
61
61
  return applyAcronyms(toPascalCase(name));
62
62
  }
63
63
 
64
+ /**
65
+ * PascalCase domain field name for a model field, honoring a `domainName`
66
+ * override (set via the `fieldHints` config) so a wire field can surface under
67
+ * a friendlier name. The wire name (the `json:"..."` struct tag) still derives
68
+ * from `field.name`.
69
+ */
70
+ export function domainFieldName(field: { name: string; domainName?: string }): string {
71
+ return applyAcronyms(toPascalCase(field.domainName ?? field.name));
72
+ }
73
+
64
74
  /** snake_case module/directory name. */
65
75
  export function moduleName(name: string): string {
66
76
  return toSnakeCase(name);
@@ -9,7 +9,15 @@ import type {
9
9
  import { planOperation, toSnakeCase } from '@workos/oagen';
10
10
  import { isListWrapperModel } from './models.js';
11
11
  import { mapTypeRef, mapTypeRefValue } from './type-map.js';
12
- import { className, fieldName, methodName, resolveClassName, resolveMethodName, unexportedName } from './naming.js';
12
+ import {
13
+ className,
14
+ domainFieldName,
15
+ fieldName,
16
+ methodName,
17
+ resolveClassName,
18
+ resolveMethodName,
19
+ unexportedName,
20
+ } from './naming.js';
13
21
  import {
14
22
  buildResolvedLookup,
15
23
  lookupResolved,
@@ -404,7 +412,8 @@ function generateParamsStruct(
404
412
  for (const field of bodyModel.fields) {
405
413
  if (hidden.has(field.name)) continue;
406
414
  if (groupedParams.has(field.name)) continue;
407
- const goField = fieldName(field.name);
415
+ // Domain struct field; the json tag below keeps deriving from field.name.
416
+ const goField = domainFieldName(field);
408
417
  if (emittedFields.has(goField)) continue;
409
418
  emittedFields.add(goField);
410
419
  const isOptional = !field.required;
@@ -942,7 +951,8 @@ function emitHiddenParamsBodyStruct(
942
951
  if (hidden.has(field.name)) continue;
943
952
  if (groupedParamNames.has(field.name)) continue;
944
953
  if (!field.required) continue;
945
- const goField = fieldName(field.name);
954
+ // Domain struct field; the json tag below keeps deriving from field.name.
955
+ const goField = domainFieldName(field);
946
956
  const goType = mapTypeRef(field.type);
947
957
  lines.push(`\t${goField} ${goType} \`json:"${field.name}"\``);
948
958
  }
@@ -960,7 +970,8 @@ function emitHiddenParamsBodyStruct(
960
970
  if (hidden.has(field.name)) continue;
961
971
  if (groupedParamNames.has(field.name)) continue;
962
972
  if (field.required) continue;
963
- const goField = fieldName(field.name);
973
+ // Domain struct field; the json tag below keeps deriving from field.name.
974
+ const goField = domainFieldName(field);
964
975
  const goType = makeOptional(mapTypeRef(field.type));
965
976
  lines.push(`\t${goField} ${goType} \`json:"${field.name},omitempty"\``);
966
977
  }
@@ -1007,7 +1018,8 @@ function emitBodyWithHiddenParams(
1007
1018
  for (const field of bodyModel.fields) {
1008
1019
  if (hidden.has(field.name)) continue;
1009
1020
  if (!field.required) continue;
1010
- const goField = fieldName(field.name);
1021
+ // Domain struct field on both the body literal and the params struct.
1022
+ const goField = domainFieldName(field);
1011
1023
  lines.push(`\t\t${goField}: params.${goField},`);
1012
1024
  }
1013
1025
  }
@@ -1025,7 +1037,8 @@ function emitBodyWithHiddenParams(
1025
1037
  for (const field of bodyModel.fields) {
1026
1038
  if (hidden.has(field.name)) continue;
1027
1039
  if (field.required) continue;
1028
- const goField = fieldName(field.name);
1040
+ // Domain struct field on both the body struct and the params struct.
1041
+ const goField = domainFieldName(field);
1029
1042
  lines.push(`\tbody.${goField} = params.${goField}`);
1030
1043
  }
1031
1044
  }
@@ -19,6 +19,7 @@ import { generateTests } from './tests.js';
19
19
  import { buildOperationsMap } from './manifest.js';
20
20
  import { enrichModelsFromSpec, getSyntheticEnums } from '../shared/model-utils.js';
21
21
  import { flattenDiscriminatedUnionFields } from '../shared/union-flatten.js';
22
+ import { AUTOGEN_NOTICE } from '../shared/file-header.js';
22
23
 
23
24
  /** Ensure every generated file ends with a trailing newline. */
24
25
  function ensureTrailingNewlines(files: GeneratedFile[]): GeneratedFile[] {
@@ -95,7 +96,7 @@ export const kotlinEmitter: Emitter = {
95
96
  },
96
97
 
97
98
  fileHeader(): string {
98
- return '// This file is auto-generated by oagen. Do not edit.';
99
+ return `// ${AUTOGEN_NOTICE}`;
99
100
  },
100
101
 
101
102
  formatCommand(targetDir: string): FormatCommand | null {
@@ -1,6 +1,6 @@
1
1
  import type { Model, EmitterContext, GeneratedFile, TypeRef, Field } from '@workos/oagen';
2
2
  import { mapTypeRef, discriminatedUnions } from './type-map.js';
3
- import { className, propertyName, ktStringLiteral, humanize } from './naming.js';
3
+ import { className, domainPropertyName, ktStringLiteral, humanize } from './naming.js';
4
4
  import { enumCanonicalMap } from './enums.js';
5
5
  import {
6
6
  isListWrapperModel,
@@ -374,7 +374,10 @@ function renderFields(fields: Field[], overrideFields: Set<string> = new Set()):
374
374
 
375
375
  for (const rawField of fields) {
376
376
  const field = promoteFieldType(rawField);
377
- const kotlinName = propertyName(field.name);
377
+ // DOMAIN identifier: the data class property name. Honors a `domainName`
378
+ // override (e.g. connection_type -> type); the `@JsonProperty(...)` wire
379
+ // key below still derives from `field.name`.
380
+ const kotlinName = domainPropertyName(field);
378
381
  if (seen.has(kotlinName)) continue;
379
382
  seen.add(kotlinName);
380
383
 
@@ -57,6 +57,17 @@ export function propertyName(name: string): string {
57
57
  return escapeReserved(camel);
58
58
  }
59
59
 
60
+ /**
61
+ * camelCase domain property 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 (the `@JsonProperty("...")` argument) still
64
+ * derives from `field.name`. No-op when `domainName` is unset, so it is also
65
+ * safe on params. Only apply to model fields.
66
+ */
67
+ export function domainPropertyName(field: { name: string; domainName?: string }): string {
68
+ return propertyName(field.domainName ?? field.name);
69
+ }
70
+
60
71
  /** camelCase alias (kept for parity with other emitters). */
61
72
  export const fieldName = propertyName;
62
73
  export const localName = propertyName;
@@ -17,6 +17,7 @@ import {
17
17
  ktStringLiteral,
18
18
  className,
19
19
  propertyName,
20
+ domainPropertyName,
20
21
  buildExportedClassNameSet,
21
22
  } from './naming.js';
22
23
  import { mapTypeRef } from './type-map.js';
@@ -713,7 +714,10 @@ function buildResponseAssertions(
713
714
  for (const field of model.fields) {
714
715
  if (!field.required) continue;
715
716
  if (assertions.length >= MAX_RESPONSE_ASSERTIONS) break;
716
- const ktProp = propertyName(field.name);
717
+ // DOMAIN identifier: the property accessor on the deserialized model.
718
+ // Honors a `domainName` override; the synthesized JSON above keys off
719
+ // `field.name` (the wire key).
720
+ const ktProp = domainPropertyName(field);
717
721
  const type = field.type;
718
722
  if (type.kind === 'primitive') {
719
723
  if (type.format === 'date-time') continue;
@@ -388,7 +388,7 @@ export function serializerHasBaselineIncompatibility(
388
388
  const irDomainFields = new Set<string>();
389
389
  for (const field of model.fields) {
390
390
  irWireFields.add(wireFieldName(field.name));
391
- irDomainFields.add(fieldName(field.name));
391
+ irDomainFields.add(fieldName(field.domainName ?? field.name));
392
392
  }
393
393
 
394
394
  for (const [wireField2, fieldDef] of Object.entries(baselineResponse.fields)) {
@@ -463,7 +463,7 @@ export function planDeserializeField(
463
463
  skipFormatFields: Set<string>,
464
464
  ctx: EmitterContext,
465
465
  ): { line: string; skip: boolean } {
466
- const domain = fieldName(field.name);
466
+ const domain = fieldName(field.domainName ?? field.name);
467
467
  const wire = wireFieldName(field.name);
468
468
  const wireAccess = `response.${wire}`;
469
469
  const skip = skipFormatFields.has(field.name);
@@ -543,7 +543,7 @@ export function planSerializeField(
543
543
  ctx: EmitterContext,
544
544
  ): { line: string; skip: boolean } {
545
545
  const wire = wireFieldName(field.name);
546
- const domain = fieldName(field.name);
546
+ const domain = fieldName(field.domainName ?? field.name);
547
547
  const domainAccess = `model.${domain}`;
548
548
  const skip = skipFormatFields.has(field.name);
549
549
 
package/src/node/index.ts CHANGED
@@ -40,6 +40,7 @@ import { withNodeOperationOverrides } from './node-overrides.js';
40
40
  import { isNodeOwnedService, nodeOptions } from './options.js';
41
41
  import { setInlineEnumUnions, setDomainNameResolver } from './type-map.js';
42
42
  import { groupByMount } from '../shared/resolved-ops.js';
43
+ import { AUTOGEN_NOTICE } from '../shared/file-header.js';
43
44
  import { assignModelsToServices, createServiceDirResolver, relativeImport } from './utils.js';
44
45
  import { fileName } from './naming.js';
45
46
 
@@ -869,7 +870,7 @@ export const nodeEmitter: Emitter = {
869
870
  },
870
871
 
871
872
  fileHeader(): string {
872
- return '// This file is auto-generated by oagen. Do not edit.';
873
+ return `// ${AUTOGEN_NOTICE}`;
873
874
  },
874
875
 
875
876
  formatCommand(targetDir: string): FormatCommand | null {
@@ -527,7 +527,9 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
527
527
  } else {
528
528
  lines.push(`export interface ${domainName}${typeParams} {`);
529
529
  for (const field of model.fields) {
530
- const domainFieldName = fieldName(field.name);
530
+ // Domain identifier honors a `fieldHints` override (e.g. connection_type
531
+ // → type); the wire name below still derives from `field.name`.
532
+ const domainFieldName = fieldName(field.domainName ?? field.name);
531
533
  if (seenDomainFields.has(domainFieldName)) continue;
532
534
  seenDomainFields.add(domainFieldName);
533
535
  if (field.description || field.deprecated || field.readOnly || field.writeOnly || field.default !== undefined) {
@@ -546,6 +548,19 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
546
548
  baselineField && !baselineField.optional && responseBaselineField && responseBaselineField.optional;
547
549
  const readonlyPrefix = field.readOnly ? 'readonly ' : '';
548
550
  if (
551
+ genericDefaults.has(model.name) &&
552
+ baselineField &&
553
+ typeReferencesUnresolvable(baselineField.type, unresolvableNames)
554
+ ) {
555
+ // Baseline typed this field with the model's generic param (e.g.
556
+ // `customAttributes?: CustomAttributesType`). The emitted interface
557
+ // renames the param to `GenericType`; remap the field so the param is
558
+ // actually used — otherwise it trips TS6133 (declared, never read).
559
+ const opt = baselineField.optional ? '?' : '';
560
+ lines.push(
561
+ ` ${readonlyPrefix}${domainFieldName}${opt}: ${substituteGenericParam(baselineField.type, unresolvableNames)};`,
562
+ );
563
+ } else if (
549
564
  baselineField &&
550
565
  !domainResponseOptionalMismatch &&
551
566
  !hasDateTimeConversion(field.type) &&
@@ -597,6 +612,15 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
597
612
  seenWireFields.add(wireField);
598
613
  const baselineField = baselineResponse?.fields?.[wireField];
599
614
  if (
615
+ genericDefaults.has(model.name) &&
616
+ baselineField &&
617
+ typeReferencesUnresolvable(baselineField.type, unresolvableNames)
618
+ ) {
619
+ // Mirror the domain side: keep the generic param wired on the
620
+ // response interface so `GenericType` is used, not orphaned.
621
+ const opt = baselineField.optional ? '?' : '';
622
+ lines.push(` ${wireField}${opt}: ${substituteGenericParam(baselineField.type, unresolvableNames)};`);
623
+ } else if (
600
624
  baselineField &&
601
625
  baselineTypeResolvable(baselineField.type, importableNames) &&
602
626
  baselineFieldCompatible(baselineField, field)
@@ -1301,6 +1325,21 @@ function hasSpecificIRType(ref: TypeRef): boolean {
1301
1325
  }
1302
1326
  }
1303
1327
 
1328
+ /** True when a baseline field type references one of the model's generic
1329
+ * param identifiers (collected as `unresolvableNames` — bare type names that
1330
+ * resolve to nothing importable, which for a generic model are its params). */
1331
+ function typeReferencesUnresolvable(type: string, unresolvable: Set<string>): boolean {
1332
+ if (unresolvable.size === 0) return false;
1333
+ const names = type.match(/\b[A-Z][a-zA-Z0-9]*\b/g);
1334
+ return !!names && names.some((n) => unresolvable.has(n));
1335
+ }
1336
+
1337
+ /** Rewrite a baseline field type so references to the model's (renamed)
1338
+ * generic param resolve to the emitted `GenericType` param. */
1339
+ function substituteGenericParam(type: string, unresolvable: Set<string>): string {
1340
+ return type.replace(/\b[A-Z][a-zA-Z0-9]*\b/g, (name) => (unresolvable.has(name) ? 'GenericType' : name));
1341
+ }
1342
+
1304
1343
  function renderTypeParams(model: Model, genericDefaults?: Map<string, string>): string {
1305
1344
  if (!model.typeParams?.length) {
1306
1345
  if (genericDefaults?.has(model.name)) {
@@ -32,6 +32,16 @@ export function fieldName(name: string): string {
32
32
  return toCamelCase(name);
33
33
  }
34
34
 
35
+ /**
36
+ * camelCase domain field name for a model field, honoring a `domainName`
37
+ * override (set via the `fieldHints` config) so a wire field can surface under
38
+ * a friendlier name. The wire name (see {@link wireFieldName}) still derives
39
+ * from `field.name`.
40
+ */
41
+ export function domainFieldName(field: { name: string; domainName?: string }): string {
42
+ return toCamelCase(field.domainName ?? field.name);
43
+ }
44
+
35
45
  /** snake_case field name for wire/response interfaces. */
36
46
  export function wireFieldName(name: string): string {
37
47
  return toSnakeCase(name);
@@ -1,14 +1,35 @@
1
- import type { EmitterContext } from '@workos/oagen';
1
+ import type { EmitterContext, Operation, OperationPlan } from '@workos/oagen';
2
+ import { planOperation } from '@workos/oagen';
2
3
 
3
4
  export interface OperationOverride {
4
5
  methodName?: string;
5
6
  mountOn?: string;
6
7
  optionsType?: string;
7
8
  bodyFieldMap?: Record<string, string>;
9
+ /**
10
+ * Rename spec path parameters to the SDK options-object field they should be
11
+ * exposed as. Keys are the camelCase identifier the param resolves to via
12
+ * `fieldName(param.name)` — NOT the raw (possibly snake_case) spec key — since
13
+ * the lookup is keyed on that camelCase form (e.g. `{ resourceId: 'targetId' }`,
14
+ * even for a `resource_id` spec param). Applied to the destructure, the URL
15
+ * template binding, and generated tests so a published SDK field name can
16
+ * diverge from the spec path-param name without a global spec rewrite (which
17
+ * would ripple across every language).
18
+ */
19
+ pathFieldMap?: Record<string, string>;
8
20
  returnType?: string;
9
21
  returnDataProperty?: string;
10
22
  returnTypeImports?: string[];
11
23
  returnExpression?: string;
24
+ /**
25
+ * Override the response model the operation deserializes, by model name.
26
+ * Replaces the spec-derived response model so the resource (and its test)
27
+ * reference a different wire type / deserializer — e.g. mapping
28
+ * Authorization's role responses to the full `OrganizationRole` instead of
29
+ * the slim `Role`/`RoleResponse` shape shared with SSO and UserManagement.
30
+ * Node-only; never affects the global spec or other SDKs.
31
+ */
32
+ responseModel?: string;
12
33
  }
13
34
 
14
35
  export interface NodeEmitterOptions {
@@ -90,3 +111,26 @@ export function isHandOwnedType(ctx: EmitterContext, name: string | undefined):
90
111
  if (!configured || configured.length === 0) return false;
91
112
  return configured.includes(name);
92
113
  }
114
+
115
+ /**
116
+ * Resolve the Node operation override for an operation, keyed by "METHOD /path".
117
+ */
118
+ export function operationOverrideFor(ctx: EmitterContext, op: Operation): OperationOverride | undefined {
119
+ return nodeOptions(ctx).operationOverrides?.[`${op.httpMethod.toUpperCase()} ${op.path}`];
120
+ }
121
+
122
+ /**
123
+ * `planOperation` plus the Node `responseModel` override. When an operation
124
+ * override supplies `responseModel`, the resolved response model name is
125
+ * replaced so the resource and its generated test reference the desired wire
126
+ * type and deserializer. Use this everywhere the Node emitter would otherwise
127
+ * call `planOperation(op)` directly so resource and test stay in lockstep.
128
+ */
129
+ export function planOperationFor(op: Operation, ctx: EmitterContext): OperationPlan {
130
+ const plan = planOperation(op);
131
+ const responseModel = operationOverrideFor(ctx, op)?.responseModel;
132
+ if (responseModel && responseModel !== plan.responseModelName) {
133
+ return { ...plan, responseModelName: responseModel, isModelResponse: true };
134
+ }
135
+ return plan;
136
+ }