@workos/oagen-emitters 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1,3 +1,5 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
1
3
  import { assignModelsToServices, assignModelsToServices as assignModelsToServices$1, collectFieldDependencies, mapTypeRef, planOperation, toCamelCase, toKebabCase, toPascalCase, toSnakeCase, walkTypeRef } from "@workos/oagen";
2
4
  //#region src/node/naming.ts
3
5
  /** kebab-case file name (without extension). */
@@ -45,9 +47,84 @@ function buildServiceNameMap(services, ctx) {
45
47
  for (const service of services) map.set(service.name, resolveServiceName(service, ctx));
46
48
  return map;
47
49
  }
50
+ /**
51
+ * Explicit method name overrides for operations where the spec's operationId
52
+ * does not match the desired SDK method name and the spec cannot be changed.
53
+ * Key: "HTTP_METHOD /path", Value: camelCase method name.
54
+ */
55
+ const METHOD_NAME_OVERRIDES = { "POST /portal/generate_link": "generatePortalLink" };
56
+ /**
57
+ * Explicit service directory overrides. Maps a resolved PascalCase service name
58
+ * to a target directory (kebab-case). Use this when the spec's tag grouping
59
+ * does not match the desired SDK directory layout and the spec cannot be changed.
60
+ */
61
+ const SERVICE_DIR_OVERRIDES = {
62
+ ApplicationClientSecrets: "workos-connect",
63
+ Applications: "workos-connect",
64
+ Connections: "sso",
65
+ Directories: "directory-sync",
66
+ DirectoryGroups: "directory-sync",
67
+ DirectoryUsers: "directory-sync",
68
+ FeatureFlagsTargets: "feature-flags",
69
+ MultiFactorAuth: "mfa",
70
+ MultiFactorAuthChallenges: "mfa",
71
+ OrganizationsApiKeys: "organizations",
72
+ WebhooksEndpoints: "webhooks",
73
+ UserManagementAuthentication: "user-management",
74
+ UserManagementCorsOrigins: "user-management",
75
+ UserManagementDataProviders: "user-management",
76
+ UserManagementInvitations: "user-management",
77
+ UserManagementJWTTemplate: "user-management",
78
+ UserManagementMagicAuth: "user-management",
79
+ UserManagementMultiFactorAuthentication: "user-management",
80
+ UserManagementOrganizationMembership: "user-management",
81
+ UserManagementRedirectUris: "user-management",
82
+ UserManagementSessionTokens: "user-management",
83
+ UserManagementUsers: "user-management",
84
+ UserManagementUsersAuthorizedApplications: "user-management",
85
+ WorkOSConnect: "workos-connect"
86
+ };
87
+ /**
88
+ * Maps a service (by PascalCase name) to the existing hand-written class that
89
+ * already covers its endpoints. When a service appears here:
90
+ * - `resolveClassName` returns the target class (so generated code merges in)
91
+ * - `isServiceCoveredByExisting` returns true
92
+ * - `hasMethodsAbsentFromBaseline` checks the target class for missing methods,
93
+ * so new endpoints are added to the existing class rather than silently dropped
94
+ */
95
+ const SERVICE_COVERED_BY = {
96
+ Connections: "SSO",
97
+ Directories: "DirectorySync",
98
+ DirectoryGroups: "DirectorySync",
99
+ DirectoryUsers: "DirectorySync",
100
+ FeatureFlagsTargets: "FeatureFlags",
101
+ MultiFactorAuth: "Mfa",
102
+ MultiFactorAuthChallenges: "Mfa",
103
+ OrganizationsApiKeys: "Organizations",
104
+ UserManagementAuthentication: "UserManagement",
105
+ UserManagementInvitations: "UserManagement",
106
+ UserManagementMagicAuth: "UserManagement",
107
+ UserManagementMultiFactorAuthentication: "UserManagement",
108
+ UserManagementOrganizationMembership: "UserManagement",
109
+ UserManagementUsers: "UserManagement"
110
+ };
111
+ /**
112
+ * Explicit class name overrides. Maps the default PascalCase service name
113
+ * to the desired SDK class name when toPascalCase produces the wrong casing.
114
+ */
115
+ const CLASS_NAME_OVERRIDES = { WorkosConnect: "WorkOSConnect" };
116
+ /**
117
+ * Resolve the output directory for a service, checking overrides first.
118
+ * Falls back to the standard kebab-case conversion.
119
+ */
120
+ function resolveServiceDir(resolvedServiceName) {
121
+ return SERVICE_DIR_OVERRIDES[resolvedServiceName] ?? serviceDirName(resolvedServiceName);
122
+ }
48
123
  /** Resolve the SDK method name for an operation, checking overlay first. */
49
124
  function resolveMethodName(op, _service, ctx) {
50
125
  const httpKey = `${op.httpMethod.toUpperCase()} ${op.path}`;
126
+ const override = METHOD_NAME_OVERRIDES[httpKey];
127
+ if (override) return override;
51
128
  const existing = ctx.overlayLookup?.methodByOperation?.get(httpKey);
52
129
  if (existing) {
53
130
  if (/\/\{[^}]+\}$/.test(op.path) && existing.methodName.endsWith("s") && !existing.methodName.endsWith("ss")) {
@@ -61,12 +138,15 @@ function resolveMethodName(op, _service, ctx) {
61
138
  }
62
139
  /** Resolve the SDK class name for a service, checking overlay for existing names. */
63
140
  function resolveClassName(service, ctx) {
141
+ const coveredBy = SERVICE_COVERED_BY[toPascalCase(service.name)];
142
+ if (coveredBy) return coveredBy;
64
143
  if (ctx.overlayLookup?.methodByOperation) for (const op of service.operations) {
65
144
  const httpKey = `${op.httpMethod.toUpperCase()} ${op.path}`;
66
145
  const existing = ctx.overlayLookup.methodByOperation.get(httpKey);
67
- if (existing) return existing.className;
146
+ if (existing) return CLASS_NAME_OVERRIDES[existing.className] ?? existing.className;
68
147
  }
69
- return toPascalCase(service.name);
148
+ const defaultName = toPascalCase(service.name);
149
+ return CLASS_NAME_OVERRIDES[defaultName] ?? defaultName;
70
150
  }
71
151
  /** Resolve the interface name for a model, checking overlay first. */
72
152
  function resolveInterfaceName(name, ctx) {
@@ -330,7 +410,7 @@ function buildKnownTypeNames(models, ctx) {
330
410
  function createServiceDirResolver(models, services, ctx) {
331
411
  const modelToService = assignModelsToServices(models, services);
332
412
  const serviceNameMap = buildServiceNameMap(services, ctx);
333
- const resolveDir = (irService) => irService ? serviceDirName(serviceNameMap.get(irService) ?? irService) : "common";
413
+ const resolveDir = (irService) => irService ? resolveServiceDir(serviceNameMap.get(irService) ?? irService) : "common";
334
414
  return {
335
415
  modelToService,
336
416
  serviceNameMap,
@@ -458,6 +538,7 @@ function buildDeduplicationMap(models, ctx) {
458
538
  * endpoints (e.g., `GET /connections`).
459
539
  */
460
540
  function isServiceCoveredByExisting(service, ctx) {
541
+ if (SERVICE_COVERED_BY[toPascalCase(service.name)]) return true;
461
542
  const overlay = ctx.overlayLookup?.methodByOperation;
462
543
  if (!overlay || overlay.size === 0) return false;
463
544
  if (service.operations.length === 0) return false;
@@ -472,6 +553,37 @@ function isServiceCoveredByExisting(service, ctx) {
472
553
  });
473
554
  }
474
555
  /**
556
+ * Check whether a fully-covered service has operations whose overlay-mapped
557
+ * methods are missing from the baseline class. Returns true when at least
558
+ * one operation maps to a method name that the baseline class does not have,
559
+ * meaning the merger needs to add new methods (skipIfExists must be removed).
560
+ */
561
+ function hasMethodsAbsentFromBaseline(service, ctx) {
562
+ const baselineClasses = ctx.apiSurface?.classes;
563
+ if (!baselineClasses) return false;
564
+ const targetClassName = SERVICE_COVERED_BY[toPascalCase(service.name)];
565
+ if (targetClassName) {
566
+ const cls = baselineClasses[targetClassName];
567
+ if (!cls) return true;
568
+ for (const op of service.operations) {
569
+ const method = resolveMethodName(op, service, ctx);
570
+ if (!cls.methods?.[method]) return true;
571
+ }
572
+ return false;
573
+ }
574
+ const overlay = ctx.overlayLookup?.methodByOperation;
575
+ if (!overlay) return false;
576
+ for (const op of service.operations) {
577
+ const httpKey = `${op.httpMethod.toUpperCase()} ${op.path}`;
578
+ const match = overlay.get(httpKey);
579
+ if (!match) continue;
580
+ const cls = baselineClasses[match.className];
581
+ if (!cls) continue;
582
+ if (!cls.methods?.[match.methodName]) return true;
583
+ }
584
+ return false;
585
+ }
586
+ /**
475
587
  * Return operations in a service that are NOT covered by existing hand-written
476
588
  * service classes. For fully uncovered services, returns all operations.
477
589
  * For partially covered services, returns only the uncovered operations.
@@ -495,7 +607,7 @@ function generateEnums(enums, ctx) {
495
607
  if (enums.length === 0) return [];
496
608
  const enumToService = assignEnumsToServices(enums, ctx.spec.services);
497
609
  const serviceNameMap = buildServiceNameMap(ctx.spec.services, ctx);
498
- const resolveDir = (irService) => irService ? serviceDirName(serviceNameMap.get(irService) ?? irService) : "common";
610
+ const resolveDir = (irService) => irService ? resolveServiceDir(serviceNameMap.get(irService) ?? irService) : "common";
499
611
  const files = [];
500
612
  for (const enumDef of enums) {
501
613
  const dirName = resolveDir(enumToService.get(enumDef.name));
@@ -513,7 +625,7 @@ function generateEnums(enums, ctx) {
513
625
  lines.push(` ${memberName} = ${valueStr},`);
514
626
  }
515
627
  for (const val of missingValues) {
516
- const memberName = val.replace(/[^a-zA-Z0-9]+/g, "");
628
+ const memberName = toPascalCase(val);
517
629
  lines.push(` ${memberName} = '${val}',`);
518
630
  }
519
631
  lines.push("}");
@@ -725,54 +837,60 @@ function generateModels(models, ctx) {
725
837
  const typeParams = renderTypeParams(model, genericDefaults);
726
838
  const seenDomainFields = /* @__PURE__ */ new Set();
727
839
  if (model.description) lines.push(...docComment(model.description));
728
- lines.push(`export interface ${domainName}${typeParams} {`);
729
- for (const field of model.fields) {
730
- const domainFieldName = fieldName(field.name);
731
- if (seenDomainFields.has(domainFieldName)) continue;
732
- seenDomainFields.add(domainFieldName);
733
- if (field.description || field.deprecated || field.readOnly || field.writeOnly || field.default !== void 0) {
734
- const parts = [];
735
- if (field.description) parts.push(field.description);
736
- if (field.readOnly) parts.push("@readonly");
737
- if (field.writeOnly) parts.push("@writeonly");
738
- if (field.default !== void 0) parts.push(`@default ${JSON.stringify(field.default)}`);
739
- if (field.deprecated) parts.push("@deprecated");
740
- lines.push(...docComment(parts.join("\n"), 2));
741
- }
742
- const baselineField = baselineDomain?.fields?.[domainFieldName];
743
- const domainWireField = wireFieldName(field.name);
744
- const responseBaselineField = baselineResponse?.fields?.[domainWireField];
745
- const domainResponseOptionalMismatch = baselineField && !baselineField.optional && responseBaselineField && responseBaselineField.optional;
746
- const readonlyPrefix = field.readOnly ? "readonly " : "";
747
- if (baselineField && !domainResponseOptionalMismatch && baselineTypeResolvable(baselineField.type, importableNames) && baselineFieldCompatible(baselineField, field)) {
748
- const opt = baselineField.optional ? "?" : "";
749
- lines.push(` ${readonlyPrefix}${domainFieldName}${opt}: ${baselineField.type};`);
750
- } else {
751
- const isNewFieldOnExistingModel = baselineDomain && !baselineField;
752
- const isNewFieldOnExistingResponse = !baselineDomain && baselineResponse && !responseBaselineField;
753
- const opt = !field.required || isNewFieldOnExistingModel || domainResponseOptionalMismatch || isNewFieldOnExistingResponse ? "?" : "";
754
- lines.push(` ${readonlyPrefix}${domainFieldName}${opt}: ${mapTypeRef$1(field.type, modelTypeRefOpts)};`);
840
+ if (model.fields.length === 0) lines.push(`export type ${domainName}${typeParams} = object;`);
841
+ else {
842
+ lines.push(`export interface ${domainName}${typeParams} {`);
843
+ for (const field of model.fields) {
844
+ const domainFieldName = fieldName(field.name);
845
+ if (seenDomainFields.has(domainFieldName)) continue;
846
+ seenDomainFields.add(domainFieldName);
847
+ if (field.description || field.deprecated || field.readOnly || field.writeOnly || field.default !== void 0) {
848
+ const parts = [];
849
+ if (field.description) parts.push(field.description);
850
+ if (field.readOnly) parts.push("@readonly");
851
+ if (field.writeOnly) parts.push("@writeonly");
852
+ if (field.default !== void 0) parts.push(`@default ${JSON.stringify(field.default)}`);
853
+ if (field.deprecated) parts.push("@deprecated");
854
+ lines.push(...docComment(parts.join("\n"), 2));
855
+ }
856
+ const baselineField = baselineDomain?.fields?.[domainFieldName];
857
+ const domainWireField = wireFieldName(field.name);
858
+ const responseBaselineField = baselineResponse?.fields?.[domainWireField];
859
+ const domainResponseOptionalMismatch = baselineField && !baselineField.optional && responseBaselineField && responseBaselineField.optional;
860
+ const readonlyPrefix = field.readOnly ? "readonly " : "";
861
+ if (baselineField && !domainResponseOptionalMismatch && baselineTypeResolvable(baselineField.type, importableNames) && baselineFieldCompatible(baselineField, field)) {
862
+ const opt = baselineField.optional ? "?" : "";
863
+ lines.push(` ${readonlyPrefix}${domainFieldName}${opt}: ${baselineField.type};`);
864
+ } else {
865
+ const isNewFieldOnExistingModel = baselineDomain && !baselineField;
866
+ const isNewFieldOnExistingResponse = !baselineDomain && baselineResponse && !responseBaselineField;
867
+ const opt = !field.required || isNewFieldOnExistingModel || domainResponseOptionalMismatch || isNewFieldOnExistingResponse ? "?" : "";
868
+ lines.push(` ${readonlyPrefix}${domainFieldName}${opt}: ${mapTypeRef$1(field.type, modelTypeRefOpts)};`);
869
+ }
755
870
  }
871
+ lines.push("}");
756
872
  }
757
- lines.push("}");
758
873
  lines.push("");
759
874
  const seenWireFields = /* @__PURE__ */ new Set();
760
- lines.push(`export interface ${responseName}${typeParams} {`);
761
- for (const field of model.fields) {
762
- const wireField = wireFieldName(field.name);
763
- if (seenWireFields.has(wireField)) continue;
764
- seenWireFields.add(wireField);
765
- const baselineField = baselineResponse?.fields?.[wireField];
766
- if (baselineField && baselineTypeResolvable(baselineField.type, importableNames) && baselineFieldCompatible(baselineField, field)) {
767
- const opt = baselineField.optional ? "?" : "";
768
- lines.push(` ${wireField}${opt}: ${baselineField.type};`);
769
- } else {
770
- const isNewFieldOnExistingModel = baselineResponse && !baselineField;
771
- const opt = !field.required || isNewFieldOnExistingModel ? "?" : "";
772
- lines.push(` ${wireField}${opt}: ${mapWireTypeRef(field.type, modelWireTypeRefOpts)};`);
875
+ if (model.fields.length === 0) lines.push(`export type ${responseName}${typeParams} = object;`);
876
+ else {
877
+ lines.push(`export interface ${responseName}${typeParams} {`);
878
+ for (const field of model.fields) {
879
+ const wireField = wireFieldName(field.name);
880
+ if (seenWireFields.has(wireField)) continue;
881
+ seenWireFields.add(wireField);
882
+ const baselineField = baselineResponse?.fields?.[wireField];
883
+ if (baselineField && baselineTypeResolvable(baselineField.type, importableNames) && baselineFieldCompatible(baselineField, field)) {
884
+ const opt = baselineField.optional ? "?" : "";
885
+ lines.push(` ${wireField}${opt}: ${baselineField.type};`);
886
+ } else {
887
+ const isNewFieldOnExistingModel = baselineResponse && !baselineField;
888
+ const opt = !field.required || isNewFieldOnExistingModel ? "?" : "";
889
+ lines.push(` ${wireField}${opt}: ${mapWireTypeRef(field.type, modelWireTypeRefOpts)};`);
890
+ }
773
891
  }
892
+ lines.push("}");
774
893
  }
775
- lines.push("}");
776
894
  files.push({
777
895
  path: `src/${dirName}/interfaces/${fileName(model.name)}.interface.ts`,
778
896
  content: pruneUnusedImports(lines).join("\n"),
@@ -1280,7 +1398,7 @@ function generateFixtures(spec, ctx) {
1280
1398
  if (spec.models.length === 0) return [];
1281
1399
  const { modelToService, resolveDir } = ctx ? createServiceDirResolver(spec.models, ctx.spec.services, ctx) : {
1282
1400
  modelToService: assignModelsToServices$1(spec.models, spec.services),
1283
- resolveDir: (irService) => irService ? serviceDirName(irService) : "common"
1401
+ resolveDir: (irService) => irService ? resolveServiceDir(irService) : "common"
1284
1402
  };
1285
1403
  const modelMap = new Map(spec.models.map((m) => [m.name, m]));
1286
1404
  const enumMap = new Map(spec.enums.map((e) => [e.name, e]));
@@ -1296,7 +1414,7 @@ function generateFixtures(spec, ctx) {
1296
1414
  });
1297
1415
  }
1298
1416
  for (const service of spec.services) {
1299
- const serviceDir = serviceDirName(ctx ? resolveResourceClassName(service, ctx) : service.name);
1417
+ const serviceDir = resolveServiceDir(ctx ? resolveResourceClassName(service, ctx) : service.name);
1300
1418
  for (const op of service.operations) if (op.pagination) {
1301
1419
  let itemModel = op.pagination.itemType.kind === "model" ? modelMap.get(op.pagination.itemType.name) : null;
1302
1420
  if (itemModel) {
@@ -1444,18 +1562,261 @@ function paginatedOptionsName(method, resolvedServiceName) {
1444
1562
  function httpMethodNeedsBody(method) {
1445
1563
  return method === "post" || method === "put" || method === "patch";
1446
1564
  }
1565
+ /** Map HTTP methods to expected CRUD verb prefixes for method name matching. */
1566
+ const HTTP_VERB_PREFIXES = {
1567
+ get: [
1568
+ "list",
1569
+ "get",
1570
+ "fetch",
1571
+ "retrieve",
1572
+ "find"
1573
+ ],
1574
+ post: [
1575
+ "create",
1576
+ "add",
1577
+ "insert",
1578
+ "send"
1579
+ ],
1580
+ put: [
1581
+ "set",
1582
+ "update",
1583
+ "replace",
1584
+ "put"
1585
+ ],
1586
+ patch: [
1587
+ "update",
1588
+ "patch",
1589
+ "modify"
1590
+ ],
1591
+ delete: [
1592
+ "delete",
1593
+ "remove",
1594
+ "revoke"
1595
+ ]
1596
+ };
1597
+ /** Split a camelCase/PascalCase name into lowercase word parts. */
1598
+ function splitCamelWords(name) {
1599
+ const parts = [];
1600
+ let start = 0;
1601
+ for (let i = 1; i < name.length; i++) if (name[i] >= "A" && name[i] <= "Z") {
1602
+ parts.push(name.slice(start, i).toLowerCase());
1603
+ start = i;
1604
+ }
1605
+ parts.push(name.slice(start).toLowerCase());
1606
+ return parts;
1607
+ }
1608
+ /** Naive singularize: strip trailing 's' unless it ends in 'ss'. */
1609
+ function singularize(word) {
1610
+ return word.endsWith("s") && !word.endsWith("ss") ? word.slice(0, -1) : word;
1611
+ }
1612
+ /**
1613
+ * Batch-reconcile generated method names against the api-surface class methods.
1614
+ *
1615
+ * When the overlay doesn't map an operation (missing from previous manifest or
1616
+ * fuzzy-matching failed), the emitter falls back to the spec-derived name which
1617
+ * often doesn't match the hand-written SDK. This function uses three passes
1618
+ * with progressive exclusion to find the best surface match:
1619
+ *
1620
+ * 1. **Overlay-resolved** — already correct, mark as taken.
1621
+ * 2. **Word-set match** — same content words regardless of order (handles
1622
+ * `listRolesOrganizations` ↔ `listOrganizationRoles`).
1623
+ * 3. **Path-context match** — all surface-method content words appear in the
1624
+ * operation's URL path segments (handles `findById` ↔ `getResource`).
1625
+ *
1626
+ * After each pass, matched surface methods are removed from the pool so that
1627
+ * ambiguous cases (e.g., `listEnvironmentRoles` vs `listOrganizationRoles`)
1628
+ * resolve by elimination.
1629
+ */
1630
+ function reconcileMethodNames(plans, service, ctx) {
1631
+ const className = resolveResourceClassName(service, ctx);
1632
+ const classMethods = ctx.apiSurface?.classes?.[className]?.methods;
1633
+ if (!classMethods) return;
1634
+ const available = new Set(Object.keys(classMethods));
1635
+ const resolved = /* @__PURE__ */ new Map();
1636
+ const thisServicePaths = new Set(service.operations.map((op) => op.path));
1637
+ if (ctx.overlayLookup?.methodByOperation) for (const [httpKey, info] of ctx.overlayLookup.methodByOperation) {
1638
+ if (info.className !== className) continue;
1639
+ const path = httpKey.split(" ")[1];
1640
+ if (thisServicePaths.has(path)) continue;
1641
+ available.delete(info.methodName);
1642
+ }
1643
+ const overlayResolved = /* @__PURE__ */ new Set();
1644
+ for (const plan of plans) {
1645
+ const httpKey = `${plan.op.httpMethod.toUpperCase()} ${plan.op.path}`;
1646
+ if (ctx.overlayLookup?.methodByOperation?.get(httpKey)) {
1647
+ overlayResolved.add(plan);
1648
+ if (available.has(plan.method)) {
1649
+ resolved.set(plan, plan.method);
1650
+ available.delete(plan.method);
1651
+ }
1652
+ }
1653
+ }
1654
+ const verbMatches = (methodName, httpMethod, specVerb) => {
1655
+ const prefixes = HTTP_VERB_PREFIXES[httpMethod] ?? [];
1656
+ const lower = methodName.toLowerCase();
1657
+ if (!prefixes.some((p) => lower.startsWith(p))) return false;
1658
+ if (specVerb) {
1659
+ const surfaceVerb = splitCamelWords(methodName)[0];
1660
+ if (specVerb === "list" && surfaceVerb !== "list") return false;
1661
+ if (specVerb !== "list" && surfaceVerb === "list") return false;
1662
+ }
1663
+ return true;
1664
+ };
1665
+ for (const plan of plans) {
1666
+ if (resolved.has(plan)) continue;
1667
+ const specVerb = splitCamelWords(plan.method)[0];
1668
+ const specWords = splitCamelWords(plan.method).slice(1).map(singularize);
1669
+ const specSet = new Set(specWords);
1670
+ if (specSet.size === 0) continue;
1671
+ let match = null;
1672
+ for (const name of available) {
1673
+ if (!verbMatches(name, plan.op.httpMethod, specVerb)) continue;
1674
+ const methodWords = splitCamelWords(name).slice(1).map(singularize);
1675
+ if (methodWords.length !== specWords.length) continue;
1676
+ const methodSet = new Set(methodWords);
1677
+ if (specSet.size === methodSet.size && [...specSet].every((w) => methodSet.has(w))) {
1678
+ if (match !== null) {
1679
+ match = null;
1680
+ break;
1681
+ }
1682
+ match = name;
1683
+ }
1684
+ }
1685
+ if (match) {
1686
+ resolved.set(plan, match);
1687
+ available.delete(match);
1688
+ }
1689
+ }
1690
+ for (const plan of plans) {
1691
+ if (resolved.has(plan)) continue;
1692
+ const specVerb = splitCamelWords(plan.method)[0];
1693
+ const pathSegments = plan.op.path.split("/").filter((s) => s && !s.startsWith("{"));
1694
+ const pathWords = new Set(pathSegments.flatMap((s) => s.split("_")).map(singularize));
1695
+ let bestMatch = null;
1696
+ let bestLen = 0;
1697
+ let ambiguous = false;
1698
+ for (const name of available) {
1699
+ if (!verbMatches(name, plan.op.httpMethod, specVerb)) continue;
1700
+ const methodWords = splitCamelWords(name).slice(1).map(singularize);
1701
+ if (methodWords.length === 0) continue;
1702
+ if (!methodWords.every((w) => pathWords.has(w))) continue;
1703
+ if (methodWords.length < 2 && pathSegments.length > 2) continue;
1704
+ if (methodWords.length > bestLen) {
1705
+ bestMatch = name;
1706
+ bestLen = methodWords.length;
1707
+ ambiguous = false;
1708
+ } else if (methodWords.length === bestLen) ambiguous = true;
1709
+ }
1710
+ if (bestMatch && !ambiguous) {
1711
+ resolved.set(plan, bestMatch);
1712
+ available.delete(bestMatch);
1713
+ }
1714
+ }
1715
+ for (const plan of plans) {
1716
+ if (resolved.has(plan)) continue;
1717
+ const specVerb = splitCamelWords(plan.method)[0];
1718
+ const specWords = splitCamelWords(plan.method).slice(1).map(singularize);
1719
+ const specSet = new Set(specWords);
1720
+ let match = null;
1721
+ for (const name of available) {
1722
+ if (!verbMatches(name, plan.op.httpMethod, specVerb)) continue;
1723
+ const methodWords = splitCamelWords(name).slice(1).map(singularize);
1724
+ if (methodWords.length !== specWords.length) continue;
1725
+ const methodSet = new Set(methodWords);
1726
+ if (specSet.size === methodSet.size && [...specSet].every((w) => methodSet.has(w))) {
1727
+ if (match !== null) {
1728
+ match = null;
1729
+ break;
1730
+ }
1731
+ match = name;
1732
+ }
1733
+ }
1734
+ if (match) {
1735
+ resolved.set(plan, match);
1736
+ available.delete(match);
1737
+ continue;
1738
+ }
1739
+ const pathSegments = plan.op.path.split("/").filter((s) => s && !s.startsWith("{"));
1740
+ const pathWords = new Set(pathSegments.flatMap((s) => s.split("_")).map(singularize));
1741
+ let bestMatch = null;
1742
+ let bestLen = 0;
1743
+ let ambiguous = false;
1744
+ for (const name of available) {
1745
+ if (!verbMatches(name, plan.op.httpMethod, specVerb)) continue;
1746
+ const methodWords = splitCamelWords(name).slice(1).map(singularize);
1747
+ if (methodWords.length === 0) continue;
1748
+ if (!methodWords.every((w) => pathWords.has(w))) continue;
1749
+ if (methodWords.length < 2 && pathSegments.length > 2) continue;
1750
+ if (methodWords.length > bestLen) {
1751
+ bestMatch = name;
1752
+ bestLen = methodWords.length;
1753
+ ambiguous = false;
1754
+ } else if (methodWords.length === bestLen) ambiguous = true;
1755
+ }
1756
+ if (bestMatch && !ambiguous) {
1757
+ resolved.set(plan, bestMatch);
1758
+ available.delete(bestMatch);
1759
+ }
1760
+ }
1761
+ for (const plan of plans) {
1762
+ if (overlayResolved.has(plan)) continue;
1763
+ const name = resolved.get(plan);
1764
+ if (name) plan.method = name;
1765
+ }
1766
+ }
1767
+ /**
1768
+ * Deduplicate method names within the plans array.
1769
+ *
1770
+ * When `disambiguateOperationNames()` in `@workos/oagen` fails (e.g., for
1771
+ * single-segment paths like `/organizations`), two operations can resolve to
1772
+ * the same method name. Disambiguate by appending a path-derived suffix.
1773
+ */
1774
+ function deduplicateMethodNames(plans, _ctx) {
1775
+ const nameCount = /* @__PURE__ */ new Map();
1776
+ for (const p of plans) nameCount.set(p.method, (nameCount.get(p.method) ?? 0) + 1);
1777
+ for (const [name, count] of nameCount) {
1778
+ if (count <= 1) continue;
1779
+ const dupes = plans.filter((p) => p.method === name);
1780
+ if (new Set(dupes.map((d) => d.op.path.replace(/\/\{[^}]+\}$/, ""))).size <= 1) continue;
1781
+ const nameWords = new Set(splitCamelWords(name).map(singularize));
1782
+ const scored = dupes.map((d) => {
1783
+ return {
1784
+ plan: d,
1785
+ score: d.op.path.split("/").filter((s) => s && !s.startsWith("{")).flatMap((s) => s.split("_")).map(singularize).filter((w) => nameWords.has(w)).length
1786
+ };
1787
+ });
1788
+ scored.sort((a, b) => b.score - a.score);
1789
+ for (let i = 1; i < scored.length; i++) {
1790
+ const dupe = scored[i].plan;
1791
+ const suffix = dupe.op.path.split("/").filter((s) => s && !s.startsWith("{"))[0] ?? "";
1792
+ if (suffix) dupe.method = toCamelCase(`${name}_${suffix}`);
1793
+ }
1794
+ const stillDuped = /* @__PURE__ */ new Map();
1795
+ for (const dupe of dupes) {
1796
+ const group = stillDuped.get(dupe.method) ?? [];
1797
+ group.push(dupe);
1798
+ stillDuped.set(dupe.method, group);
1799
+ }
1800
+ for (const [, group] of stillDuped) {
1801
+ if (group.length <= 1) continue;
1802
+ for (let i = 1; i < group.length; i++) group[i].method = `${group[i].method}${i + 1}`;
1803
+ }
1804
+ }
1805
+ }
1447
1806
  function generateResources(services, ctx) {
1448
1807
  if (services.length === 0) return [];
1449
1808
  const files = [];
1450
1809
  for (const service of services) {
1451
- if (isServiceCoveredByExisting(service, ctx)) continue;
1810
+ if (isServiceCoveredByExisting(service, ctx)) {
1811
+ const file = generateResourceClass(service, ctx);
1812
+ if (hasMethodsAbsentFromBaseline(service, ctx)) delete file.skipIfExists;
1813
+ files.push(file);
1814
+ continue;
1815
+ }
1452
1816
  const ops = uncoveredOperations(service, ctx);
1453
1817
  if (ops.length === 0) continue;
1454
1818
  if (ops.length < service.operations.length) {
1455
- const file = generateResourceClass({
1456
- ...service,
1457
- operations: ops
1458
- }, ctx);
1819
+ const file = generateResourceClass(service, ctx);
1459
1820
  delete file.skipIfExists;
1460
1821
  files.push(file);
1461
1822
  } else files.push(generateResourceClass(service, ctx));
@@ -1464,7 +1825,7 @@ function generateResources(services, ctx) {
1464
1825
  }
1465
1826
  function generateResourceClass(service, ctx) {
1466
1827
  const resolvedName = resolveResourceClassName(service, ctx);
1467
- const serviceDir = serviceDirName(resolvedName);
1828
+ const serviceDir = resolveServiceDir(resolvedName);
1468
1829
  const serviceClass = resolvedName;
1469
1830
  const resourcePath = `src/${serviceDir}/${fileName(resolvedName)}.ts`;
1470
1831
  const plans = service.operations.map((op) => ({
@@ -1472,6 +1833,8 @@ function generateResourceClass(service, ctx) {
1472
1833
  plan: planOperation(op),
1473
1834
  method: resolveMethodName(op, service, ctx)
1474
1835
  }));
1836
+ reconcileMethodNames(plans, service, ctx);
1837
+ deduplicateMethodNames(plans, ctx);
1475
1838
  if (ctx.overlayLookup?.methodByOperation) {
1476
1839
  const methodOrder = /* @__PURE__ */ new Map();
1477
1840
  let pos = 0;
@@ -1499,7 +1862,7 @@ function generateResourceClass(service, ctx) {
1499
1862
  const bodyInfo = extractRequestBodyType(op, ctx);
1500
1863
  if (bodyInfo?.kind === "model") requestModels.add(bodyInfo.name);
1501
1864
  else if (bodyInfo?.kind === "union") if (bodyInfo.discriminator) for (const name of bodyInfo.modelNames) requestModels.add(name);
1502
- else for (const name of bodyInfo.modelNames) paramModels.add(name);
1865
+ else for (const name of bodyInfo.modelNames) requestModels.add(name);
1503
1866
  const queryParams = plan.isPaginated ? op.queryParams.filter((p) => !PAGINATION_PARAM_NAMES.has(p.name)) : op.queryParams;
1504
1867
  for (const param of [...queryParams, ...op.pathParams]) collectParamTypeRefs(param.type, paramEnums, paramModels);
1505
1868
  }
@@ -1744,7 +2107,7 @@ function renderDeleteWithBodyMethod(lines, op, plan, method, pathStr, ctx, specE
1744
2107
  } else if (bodyInfo?.kind === "union") {
1745
2108
  requestType = bodyInfo.typeStr;
1746
2109
  if (bodyInfo.discriminator) bodyExpr = renderUnionBodySerializer(bodyInfo.discriminator, ctx);
1747
- else bodyExpr = "payload";
2110
+ else bodyExpr = renderNonDiscriminatedUnionBodySerializer(bodyInfo.modelNames, ctx);
1748
2111
  } else {
1749
2112
  requestType = "Record<string, unknown>";
1750
2113
  bodyExpr = "payload";
@@ -1766,7 +2129,7 @@ function renderBodyMethod(lines, op, plan, method, responseModel, pathStr, ctx,
1766
2129
  } else if (bodyInfo?.kind === "union") {
1767
2130
  requestType = bodyInfo.typeStr;
1768
2131
  if (bodyInfo.discriminator) bodyExpr = renderUnionBodySerializer(bodyInfo.discriminator, ctx);
1769
- else bodyExpr = "payload";
2132
+ else bodyExpr = renderNonDiscriminatedUnionBodySerializer(bodyInfo.modelNames, ctx);
1770
2133
  } else {
1771
2134
  requestType = "Record<string, unknown>";
1772
2135
  bodyExpr = "payload";
@@ -1839,7 +2202,7 @@ function renderVoidMethod(lines, op, plan, method, pathStr, ctx, specEnumNames)
1839
2202
  } else if (bodyInfo?.kind === "union") {
1840
2203
  bodyParam = `payload: ${bodyInfo.typeStr}`;
1841
2204
  if (bodyInfo.discriminator) bodyExpr = renderUnionBodySerializer(bodyInfo.discriminator, ctx);
1842
- else bodyExpr = "payload";
2205
+ else bodyExpr = renderNonDiscriminatedUnionBodySerializer(bodyInfo.modelNames, ctx);
1843
2206
  } else {
1844
2207
  bodyParam = "payload: Record<string, unknown>";
1845
2208
  bodyExpr = "payload";
@@ -1930,6 +2293,90 @@ function renderUnionBodySerializer(disc, ctx) {
1930
2293
  }
1931
2294
  return `(() => { switch ((payload as any).${prop}) { ${cases.join("; ")}; default: return payload } })()`;
1932
2295
  }
2296
+ /**
2297
+ * Generate an IIFE expression that dispatches to the correct serializer for a
2298
+ * non-discriminated union request body. Inspects model fields to find a
2299
+ * required field unique to each variant and uses `'field' in payload` guards.
2300
+ * Falls back to `payload` only when no variant can be distinguished.
2301
+ */
2302
+ function renderNonDiscriminatedUnionBodySerializer(modelNames, ctx) {
2303
+ const modelMap = new Map(ctx.spec.models.map((m) => [m.name, m]));
2304
+ const implicitDisc = detectImplicitDiscriminator(modelNames, modelMap);
2305
+ if (implicitDisc) return renderUnionBodySerializer(implicitDisc, ctx);
2306
+ const requiredFieldsByModel = /* @__PURE__ */ new Map();
2307
+ for (const name of modelNames) {
2308
+ const model = modelMap.get(name);
2309
+ if (!model) return "payload";
2310
+ requiredFieldsByModel.set(name, new Set(model.fields.filter((f) => f.required).map((f) => fieldName(f.name))));
2311
+ }
2312
+ const guards = [];
2313
+ let fallbackModel;
2314
+ for (const name of modelNames) {
2315
+ const myFields = requiredFieldsByModel.get(name);
2316
+ let uniqueField;
2317
+ for (const field of myFields) if (modelNames.every((other) => other === name || !requiredFieldsByModel.get(other)?.has(field))) {
2318
+ uniqueField = field;
2319
+ break;
2320
+ }
2321
+ if (uniqueField) guards.push({
2322
+ modelName: name,
2323
+ field: uniqueField
2324
+ });
2325
+ else if (!fallbackModel) fallbackModel = name;
2326
+ else return "payload";
2327
+ }
2328
+ if (guards.length === 0) return "payload";
2329
+ const parts = [];
2330
+ for (const { modelName, field } of guards) {
2331
+ const resolved = resolveInterfaceName(modelName, ctx);
2332
+ parts.push(`if ('${field}' in payload) return serialize${resolved}(payload as any)`);
2333
+ }
2334
+ if (fallbackModel) {
2335
+ const resolved = resolveInterfaceName(fallbackModel, ctx);
2336
+ parts.push(`return serialize${resolved}(payload as any)`);
2337
+ } else parts.push("return payload");
2338
+ return `(() => { ${parts.join("; ")} })()`;
2339
+ }
2340
+ /**
2341
+ * Detect an implicit discriminator from literal-typed fields.
2342
+ * Returns a discriminator descriptor if all variants share a required field
2343
+ * whose type is `kind: 'literal'` with a distinct value per variant.
2344
+ */
2345
+ function detectImplicitDiscriminator(modelNames, modelMap) {
2346
+ if (modelNames.length < 2) return null;
2347
+ const firstModel = modelMap.get(modelNames[0]);
2348
+ if (!firstModel) return null;
2349
+ const candidates = firstModel.fields.filter((f) => f.required && f.type.kind === "literal");
2350
+ for (const candidate of candidates) {
2351
+ const mapping = {};
2352
+ const values = /* @__PURE__ */ new Set();
2353
+ let valid = true;
2354
+ for (const name of modelNames) {
2355
+ const model = modelMap.get(name);
2356
+ if (!model) {
2357
+ valid = false;
2358
+ break;
2359
+ }
2360
+ const field = model.fields.find((f) => f.name === candidate.name);
2361
+ if (!field || !field.required || field.type.kind !== "literal") {
2362
+ valid = false;
2363
+ break;
2364
+ }
2365
+ const val = field.type.value;
2366
+ if (values.has(val)) {
2367
+ valid = false;
2368
+ break;
2369
+ }
2370
+ values.add(val);
2371
+ mapping[String(val)] = name;
2372
+ }
2373
+ if (valid && Object.keys(mapping).length === modelNames.length) return {
2374
+ property: candidate.name,
2375
+ mapping
2376
+ };
2377
+ }
2378
+ return null;
2379
+ }
1933
2380
  function extractRequestBodyType(op, ctx) {
1934
2381
  if (!op.requestBody) return null;
1935
2382
  if (op.requestBody.kind === "model") return {
@@ -1989,7 +2436,7 @@ function generateWorkOSClient(spec, ctx) {
1989
2436
  for (const service of spec.services) {
1990
2437
  if (coveredServices.has(service.name)) continue;
1991
2438
  const resolvedName = resolveResourceClassName(service, ctx);
1992
- const serviceDir = serviceDirName(resolvedName);
2439
+ const serviceDir = resolveServiceDir(resolvedName);
1993
2440
  lines.push(`import { ${resolvedName} } from './${serviceDir}/${fileName(resolvedName)}';`);
1994
2441
  }
1995
2442
  lines.push("");
@@ -2139,9 +2586,52 @@ function generateBarrel(spec, ctx) {
2139
2586
  const coveredServicesBarrel = /* @__PURE__ */ new Set();
2140
2587
  for (const service of spec.services) if (isServiceCoveredByExisting(service, ctx)) coveredServicesBarrel.add(service.name);
2141
2588
  const exportedDirs = /* @__PURE__ */ new Set();
2589
+ const dirAllNames = /* @__PURE__ */ new Map();
2590
+ for (const service of spec.services) {
2591
+ const iDir = resolveDir(service.name);
2592
+ if (!dirAllNames.has(iDir)) dirAllNames.set(iDir, /* @__PURE__ */ new Set());
2593
+ const names = dirAllNames.get(iDir);
2594
+ for (const model of spec.models) {
2595
+ if (modelToService.get(model.name) !== service.name) continue;
2596
+ if (isListMetadataModel(model) || isListWrapperModel(model)) continue;
2597
+ names.add(resolveInterfaceName(model.name, ctx));
2598
+ names.add(wireInterfaceName(resolveInterfaceName(model.name, ctx)));
2599
+ }
2600
+ }
2601
+ if (ctx.apiSurface?.interfaces) for (const [name, iface] of Object.entries(ctx.apiSurface.interfaces)) {
2602
+ const sourceFile = iface.sourceFile;
2603
+ if (!sourceFile) continue;
2604
+ const match = sourceFile.match(/^src\/([^/]+)\/interfaces\//);
2605
+ if (match) {
2606
+ const dirName = match[1];
2607
+ if (!dirAllNames.has(dirName)) dirAllNames.set(dirName, /* @__PURE__ */ new Set());
2608
+ dirAllNames.get(dirName).add(name);
2609
+ }
2610
+ }
2611
+ if (ctx.apiSurface?.typeAliases) for (const [name, alias] of Object.entries(ctx.apiSurface.typeAliases)) {
2612
+ const sourceFile = alias.sourceFile;
2613
+ if (!sourceFile) continue;
2614
+ const match = sourceFile.match(/^src\/([^/]+)\/interfaces\//);
2615
+ if (match) {
2616
+ const dirName = match[1];
2617
+ if (!dirAllNames.has(dirName)) dirAllNames.set(dirName, /* @__PURE__ */ new Set());
2618
+ dirAllNames.get(dirName).add(name);
2619
+ }
2620
+ }
2621
+ const unsafeStarDirs = /* @__PURE__ */ new Set();
2622
+ const allDirEntries = [...dirAllNames.entries()];
2623
+ for (let i = 0; i < allDirEntries.length; i++) for (let j = i + 1; j < allDirEntries.length; j++) {
2624
+ const [dirA, namesA] = allDirEntries[i];
2625
+ const [dirB, namesB] = allDirEntries[j];
2626
+ for (const name of namesA) if (namesB.has(name)) {
2627
+ unsafeStarDirs.add(dirA);
2628
+ unsafeStarDirs.add(dirB);
2629
+ break;
2630
+ }
2631
+ }
2142
2632
  for (const service of spec.services) {
2143
2633
  const resolvedName = resolveResourceClassName(service, ctx);
2144
- const serviceDir = serviceDirName(resolvedName);
2634
+ const serviceDir = resolveServiceDir(resolvedName);
2145
2635
  const interfacesDir = resolveDir(service.name);
2146
2636
  const serviceModels = spec.models.filter((m) => {
2147
2637
  if (modelToService.get(m.name) !== service.name) return false;
@@ -2151,8 +2641,12 @@ function generateBarrel(spec, ctx) {
2151
2641
  const serviceEnums = spec.enums.filter((e) => {
2152
2642
  return findEnumService(e.name, spec.services) === service.name;
2153
2643
  });
2154
- const hasConflict = serviceModels.some((m) => existingSdkExports.has(resolveInterfaceName(m.name, ctx))) || serviceEnums.some((e) => existingSdkExports.has(e.name));
2155
- if ((serviceModels.length > 0 || serviceEnums.length > 0) && !exportedDirs.has(interfacesDir) && !hasConflict) {
2644
+ const hasConflict = serviceModels.some((m) => {
2645
+ const name = resolveInterfaceName(m.name, ctx);
2646
+ return existingSdkExports.has(name) || exportedNames.has(name) || exportedNames.has(wireInterfaceName(name));
2647
+ }) || serviceEnums.some((e) => existingSdkExports.has(e.name) || exportedNames.has(e.name));
2648
+ const isCovered = coveredServicesBarrel.has(service.name);
2649
+ if ((serviceModels.length > 0 || serviceEnums.length > 0) && !exportedDirs.has(interfacesDir) && !hasConflict && !unsafeStarDirs.has(interfacesDir) && !isCovered) {
2156
2650
  exportedDirs.add(interfacesDir);
2157
2651
  lines.push(`export * from './${interfacesDir}/interfaces';`);
2158
2652
  for (const model of serviceModels) {
@@ -2887,7 +3381,7 @@ export function fetchBody({ raw = false } = {}): any {
2887
3381
  export function testUnauthorized(fn: () => Promise<any>) {
2888
3382
  it('throws on unauthorized', async () => {
2889
3383
  fetchOnce({ message: 'Unauthorized' }, { status: 401 });
2890
- await expect(fn()).rejects.toThrow('Unauthorized');
3384
+ await expect(fn()).rejects.toThrow('Could not authorize the request');
2891
3385
  });
2892
3386
  }
2893
3387
 
@@ -2941,7 +3435,8 @@ function generateTests(spec, ctx) {
2941
3435
  for (const f of fixtures) files.push({
2942
3436
  path: f.path,
2943
3437
  content: f.content,
2944
- headerPlacement: "skip"
3438
+ headerPlacement: "skip",
3439
+ integrateTarget: false
2945
3440
  });
2946
3441
  const modelMap = new Map(spec.models.map((m) => [m.name, m]));
2947
3442
  for (const service of spec.services) {
@@ -2960,7 +3455,7 @@ function generateTests(spec, ctx) {
2960
3455
  }
2961
3456
  function generateServiceTest(service, spec, ctx, modelMap) {
2962
3457
  const resolvedName = resolveResourceClassName(service, ctx);
2963
- const serviceDir = serviceDirName(resolvedName);
3458
+ const serviceDir = resolveServiceDir(resolvedName);
2964
3459
  const serviceClass = resolvedName;
2965
3460
  const serviceProp = servicePropertyName(resolvedName);
2966
3461
  const testPath = `src/${serviceDir}/${fileName(resolvedName)}.spec.ts`;
@@ -3219,8 +3714,9 @@ function buildCallArgs(op, plan, modelMap) {
3219
3714
  const hasBody = plan.hasBody;
3220
3715
  if (isPaginated) return pathArgs || "";
3221
3716
  if (hasBody) {
3222
- const fb = fallbackBodyArg(op, modelMap);
3223
- return pathArgs ? `${pathArgs}, ${fb}` : fb;
3717
+ const payload = buildTestPayload(op, modelMap);
3718
+ const bodyArg = payload ? payload.camelCaseObj : fallbackBodyArg(op, modelMap);
3719
+ return pathArgs ? `${pathArgs}, ${bodyArg}` : bodyArg;
3224
3720
  }
3225
3721
  return pathArgs || "";
3226
3722
  }
@@ -3307,10 +3803,12 @@ function buildFieldAssertions(model, accessor, modelMap) {
3307
3803
  return assertions;
3308
3804
  }
3309
3805
  /**
3310
- * Return a JS literal string for the expected fixture value of a primitive field.
3311
- * Returns null for non-primitive or complex types (arrays, models, etc.).
3806
+ * Return a JS literal string for the expected fixture value of a field.
3807
+ * Returns null for types that cannot be deterministically generated.
3808
+ * When a modelMap is provided, recursively builds object literals for nested model types.
3809
+ * When wire is true, uses snake_case keys for nested model objects (wire format).
3312
3810
  */
3313
- function fixtureValueForType(ref, name, modelName) {
3811
+ function fixtureValueForType(ref, name, modelName, modelMap, wire) {
3314
3812
  switch (ref.kind) {
3315
3813
  case "primitive": return fixtureValueForPrimitive(ref.type, ref.format, name, modelName);
3316
3814
  case "literal": return typeof ref.value === "string" ? `'${ref.value}'` : String(ref.value);
@@ -3321,10 +3819,24 @@ function fixtureValueForType(ref, name, modelName) {
3321
3819
  }
3322
3820
  return null;
3323
3821
  case "array": {
3324
- const itemValue = fixtureValueForType(ref.items, name, modelName);
3822
+ const itemValue = fixtureValueForType(ref.items, name, modelName, modelMap, wire);
3325
3823
  if (itemValue !== null) return `[${itemValue}]`;
3326
3824
  return null;
3327
3825
  }
3826
+ case "model": {
3827
+ if (!modelMap) return null;
3828
+ const nested = modelMap.get(ref.name);
3829
+ if (!nested) return null;
3830
+ const requiredFields = nested.fields.filter((f) => f.required);
3831
+ const entries = [];
3832
+ for (const field of requiredFields) {
3833
+ const value = fixtureValueForType(field.type, field.name, nested.name, modelMap, wire);
3834
+ if (value === null) return null;
3835
+ const key = wire ? wireFieldName(field.name) : fieldName(field.name);
3836
+ entries.push(`${key}: ${value}`);
3837
+ }
3838
+ return `{ ${entries.join(", ")} }`;
3839
+ }
3328
3840
  default: return null;
3329
3841
  }
3330
3842
  }
@@ -3368,16 +3880,17 @@ function buildTestPayload(op, modelMap) {
3368
3880
  const model = modelMap.get(op.requestBody.name);
3369
3881
  if (!model) return null;
3370
3882
  const fields = model.fields.filter((f) => f.required);
3371
- const usableFields = fields.filter((f) => fixtureValueForType(f.type, f.name, model.name) !== null);
3883
+ const usableFields = fields.filter((f) => fixtureValueForType(f.type, f.name, model.name, modelMap) !== null);
3372
3884
  if (usableFields.length === 0 || usableFields.length < fields.length) return null;
3373
3885
  const camelEntries = [];
3374
3886
  const snakeEntries = [];
3375
3887
  for (const field of usableFields) {
3376
- const value = fixtureValueForType(field.type, field.name, model.name);
3888
+ const camelValue = fixtureValueForType(field.type, field.name, model.name, modelMap);
3889
+ const wireValue = fixtureValueForType(field.type, field.name, model.name, modelMap, true);
3377
3890
  const camelKey = fieldName(field.name);
3378
3891
  const snakeKey = wireFieldName(field.name);
3379
- camelEntries.push(`${camelKey}: ${value}`);
3380
- snakeEntries.push(`${snakeKey}: ${value}`);
3892
+ camelEntries.push(`${camelKey}: ${camelValue}`);
3893
+ snakeEntries.push(`${snakeKey}: ${wireValue}`);
3381
3894
  }
3382
3895
  return {
3383
3896
  camelCaseObj: `{ ${camelEntries.join(", ")} }`,
@@ -3413,7 +3926,7 @@ function generateSerializerTests(spec, ctx) {
3413
3926
  const modelToService = assignModelsToServices$1(spec.models, spec.services);
3414
3927
  const serviceNameMap = /* @__PURE__ */ new Map();
3415
3928
  for (const service of spec.services) serviceNameMap.set(service.name, resolveResourceClassName(service, ctx));
3416
- const resolveDir = (irService) => irService ? serviceDirName(serviceNameMap.get(irService) ?? irService) : "common";
3929
+ const resolveDir = (irService) => irService ? resolveServiceDir(serviceNameMap.get(irService) ?? irService) : "common";
3417
3930
  const eligibleModels = spec.models.filter((m) => modelNeedsRoundTripTest(m) && !isListMetadataModel(m) && !isListWrapperModel(m));
3418
3931
  if (eligibleModels.length === 0) return files;
3419
3932
  const modelsByDir = /* @__PURE__ */ new Map();
@@ -3483,37 +3996,72 @@ function generateManifest(spec, ctx) {
3483
3996
  }
3484
3997
  //#endregion
3485
3998
  //#region src/node/index.ts
3999
+ /** Ensure every generated file's content ends with a trailing newline. */
4000
+ function ensureTrailingNewlines(files) {
4001
+ for (const f of files) if (f.content && !f.content.endsWith("\n")) f.content += "\n";
4002
+ return files;
4003
+ }
3486
4004
  const nodeEmitter = {
3487
4005
  language: "node",
3488
4006
  generateModels(models, ctx) {
3489
- return [...generateModels(models, ctx), ...generateSerializers(models, ctx)];
4007
+ return ensureTrailingNewlines([...generateModels(models, ctx), ...generateSerializers(models, ctx)]);
3490
4008
  },
3491
4009
  generateEnums(enums, ctx) {
3492
- return generateEnums(enums, ctx);
4010
+ return ensureTrailingNewlines(generateEnums(enums, ctx));
3493
4011
  },
3494
4012
  generateResources(services, ctx) {
3495
- return generateResources(services, ctx);
4013
+ return ensureTrailingNewlines(generateResources(services, ctx));
3496
4014
  },
3497
4015
  generateClient(spec, ctx) {
3498
- return generateClient(spec, ctx);
4016
+ return ensureTrailingNewlines(generateClient(spec, ctx));
3499
4017
  },
3500
4018
  generateErrors(ctx) {
3501
- return generateErrors(ctx);
4019
+ return ensureTrailingNewlines(generateErrors(ctx));
3502
4020
  },
3503
4021
  generateConfig(_ctx) {
3504
- return [...generateConfig(), ...generateCommon()];
4022
+ return ensureTrailingNewlines([...generateConfig(), ...generateCommon()]);
3505
4023
  },
3506
4024
  generateTypeSignatures(_spec, _ctx) {
3507
4025
  return [];
3508
4026
  },
3509
4027
  generateTests(spec, ctx) {
3510
- return generateTests(spec, ctx);
4028
+ return ensureTrailingNewlines(generateTests(spec, ctx));
3511
4029
  },
3512
4030
  generateManifest(spec, ctx) {
3513
- return generateManifest(spec, ctx);
4031
+ return ensureTrailingNewlines(generateManifest(spec, ctx));
3514
4032
  },
3515
4033
  fileHeader() {
3516
4034
  return "// This file is auto-generated by oagen. Do not edit.";
4035
+ },
4036
+ formatCommand(targetDir) {
4037
+ const hasPrettier = fs.existsSync(path.join(targetDir, ".prettierrc"));
4038
+ const hasEslint = fs.existsSync(path.join(targetDir, "eslint.config.mjs")) || fs.existsSync(path.join(targetDir, "eslint.config.js")) || fs.existsSync(path.join(targetDir, ".eslintrc.json")) || fs.existsSync(path.join(targetDir, ".eslintrc.js"));
4039
+ if (hasPrettier && hasEslint) return {
4040
+ cmd: "bash",
4041
+ args: [
4042
+ "-c",
4043
+ "npx eslint --fix --no-error-on-unmatched-pattern \"$@\" 2>/dev/null; npx prettier --write --log-level silent \"$@\"",
4044
+ "--"
4045
+ ]
4046
+ };
4047
+ if (hasPrettier) return {
4048
+ cmd: "npx",
4049
+ args: [
4050
+ "prettier",
4051
+ "--write",
4052
+ "--log-level",
4053
+ "silent"
4054
+ ]
4055
+ };
4056
+ if (hasEslint) return {
4057
+ cmd: "npx",
4058
+ args: [
4059
+ "eslint",
4060
+ "--fix",
4061
+ "--no-error-on-unmatched-pattern"
4062
+ ]
4063
+ };
4064
+ return null;
3517
4065
  }
3518
4066
  };
3519
4067
  //#endregion