@workos/oagen-emitters 0.3.0 → 0.5.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 (128) hide show
  1. package/.github/workflows/ci.yml +1 -1
  2. package/.github/workflows/lint.yml +1 -1
  3. package/.github/workflows/release-please.yml +2 -2
  4. package/.github/workflows/release.yml +1 -1
  5. package/.husky/pre-push +11 -0
  6. package/.node-version +1 -1
  7. package/.release-please-manifest.json +1 -1
  8. package/CHANGELOG.md +15 -0
  9. package/README.md +35 -224
  10. package/dist/index.d.mts +12 -1
  11. package/dist/index.d.mts.map +1 -1
  12. package/dist/index.mjs +2 -12737
  13. package/dist/plugin-BSop9f9z.mjs +21471 -0
  14. package/dist/plugin-BSop9f9z.mjs.map +1 -0
  15. package/dist/plugin.d.mts +7 -0
  16. package/dist/plugin.d.mts.map +1 -0
  17. package/dist/plugin.mjs +2 -0
  18. package/docs/sdk-architecture/dotnet.md +336 -0
  19. package/oagen.config.ts +5 -343
  20. package/package.json +10 -34
  21. package/smoke/sdk-dotnet.ts +45 -12
  22. package/src/dotnet/client.ts +89 -0
  23. package/src/dotnet/enums.ts +323 -0
  24. package/src/dotnet/fixtures.ts +236 -0
  25. package/src/dotnet/index.ts +248 -0
  26. package/src/dotnet/manifest.ts +36 -0
  27. package/src/dotnet/models.ts +320 -0
  28. package/src/dotnet/naming.ts +368 -0
  29. package/src/dotnet/resources.ts +943 -0
  30. package/src/dotnet/tests.ts +713 -0
  31. package/src/dotnet/type-map.ts +228 -0
  32. package/src/dotnet/wrappers.ts +197 -0
  33. package/src/go/client.ts +35 -3
  34. package/src/go/enums.ts +4 -0
  35. package/src/go/index.ts +15 -7
  36. package/src/go/models.ts +6 -1
  37. package/src/go/naming.ts +5 -17
  38. package/src/go/resources.ts +534 -73
  39. package/src/go/tests.ts +39 -3
  40. package/src/go/type-map.ts +8 -3
  41. package/src/go/wrappers.ts +79 -21
  42. package/src/index.ts +15 -0
  43. package/src/kotlin/client.ts +58 -0
  44. package/src/kotlin/enums.ts +189 -0
  45. package/src/kotlin/index.ts +92 -0
  46. package/src/kotlin/manifest.ts +55 -0
  47. package/src/kotlin/models.ts +486 -0
  48. package/src/kotlin/naming.ts +229 -0
  49. package/src/kotlin/overrides.ts +25 -0
  50. package/src/kotlin/resources.ts +998 -0
  51. package/src/kotlin/tests.ts +1133 -0
  52. package/src/kotlin/type-map.ts +123 -0
  53. package/src/kotlin/wrappers.ts +168 -0
  54. package/src/node/client.ts +84 -7
  55. package/src/node/field-plan.ts +12 -14
  56. package/src/node/fixtures.ts +39 -3
  57. package/src/node/index.ts +1 -0
  58. package/src/node/models.ts +281 -37
  59. package/src/node/resources.ts +319 -95
  60. package/src/node/tests.ts +108 -29
  61. package/src/node/type-map.ts +1 -31
  62. package/src/node/utils.ts +96 -6
  63. package/src/node/wrappers.ts +31 -1
  64. package/src/php/client.ts +11 -3
  65. package/src/php/models.ts +0 -33
  66. package/src/php/naming.ts +2 -21
  67. package/src/php/resources.ts +275 -19
  68. package/src/php/tests.ts +118 -18
  69. package/src/php/type-map.ts +16 -2
  70. package/src/php/wrappers.ts +7 -2
  71. package/src/plugin.ts +50 -0
  72. package/src/python/client.ts +50 -32
  73. package/src/python/enums.ts +35 -10
  74. package/src/python/index.ts +35 -27
  75. package/src/python/models.ts +139 -2
  76. package/src/python/naming.ts +2 -22
  77. package/src/python/resources.ts +234 -17
  78. package/src/python/tests.ts +260 -16
  79. package/src/python/type-map.ts +16 -2
  80. package/src/ruby/client.ts +238 -0
  81. package/src/ruby/enums.ts +149 -0
  82. package/src/ruby/index.ts +93 -0
  83. package/src/ruby/manifest.ts +35 -0
  84. package/src/ruby/models.ts +360 -0
  85. package/src/ruby/naming.ts +187 -0
  86. package/src/ruby/rbi.ts +313 -0
  87. package/src/ruby/resources.ts +799 -0
  88. package/src/ruby/tests.ts +459 -0
  89. package/src/ruby/type-map.ts +97 -0
  90. package/src/ruby/wrappers.ts +161 -0
  91. package/src/shared/model-utils.ts +357 -16
  92. package/src/shared/naming-utils.ts +83 -0
  93. package/src/shared/non-spec-services.ts +13 -0
  94. package/src/shared/resolved-ops.ts +75 -1
  95. package/src/shared/wrapper-utils.ts +12 -1
  96. package/test/dotnet/client.test.ts +121 -0
  97. package/test/dotnet/enums.test.ts +193 -0
  98. package/test/dotnet/errors.test.ts +9 -0
  99. package/test/dotnet/manifest.test.ts +82 -0
  100. package/test/dotnet/models.test.ts +258 -0
  101. package/test/dotnet/resources.test.ts +387 -0
  102. package/test/dotnet/tests.test.ts +202 -0
  103. package/test/entrypoint.test.ts +89 -0
  104. package/test/go/client.test.ts +6 -6
  105. package/test/go/resources.test.ts +156 -7
  106. package/test/kotlin/models.test.ts +135 -0
  107. package/test/kotlin/resources.test.ts +210 -0
  108. package/test/kotlin/tests.test.ts +176 -0
  109. package/test/node/client.test.ts +74 -0
  110. package/test/node/models.test.ts +134 -1
  111. package/test/node/resources.test.ts +343 -34
  112. package/test/node/utils.test.ts +140 -0
  113. package/test/php/client.test.ts +2 -1
  114. package/test/php/models.test.ts +5 -4
  115. package/test/php/resources.test.ts +103 -0
  116. package/test/php/tests.test.ts +67 -0
  117. package/test/plugin.test.ts +50 -0
  118. package/test/python/client.test.ts +56 -0
  119. package/test/python/models.test.ts +99 -0
  120. package/test/python/resources.test.ts +294 -0
  121. package/test/python/tests.test.ts +91 -0
  122. package/test/ruby/client.test.ts +81 -0
  123. package/test/ruby/resources.test.ts +386 -0
  124. package/test/shared/resolved-ops.test.ts +122 -0
  125. package/tsdown.config.ts +1 -1
  126. package/dist/index.mjs.map +0 -1
  127. package/scripts/generate-php.js +0 -13
  128. package/scripts/git-push-with-published-oagen.sh +0 -21
@@ -99,16 +99,98 @@ describe('generateResources', () => {
99
99
  const files = generateResources(services, ctx);
100
100
  const content = files[0].content;
101
101
 
102
- // Should have AutoPaginatable type import and createPaginatedList import
103
- expect(content).toContain("import type { AutoPaginatable } from '../common/utils/pagination'");
104
- expect(content).toContain("import { createPaginatedList } from '../common/utils/fetch-and-deserialize'");
102
+ // Should have AutoPaginatable value import and fetchAndDeserialize import
103
+ expect(content).toContain("import { AutoPaginatable } from '../common/utils/pagination'");
104
+ expect(content).toContain("import { fetchAndDeserialize } from '../common/utils/fetch-and-deserialize'");
105
+ // Options interface lives in its own file under interfaces/ so the
106
+ // per-service barrel picks it up.
107
+ expect(content).toContain(
108
+ "import type { ListOrganizationsOptions } from './interfaces/list-organizations-options.interface';",
109
+ );
105
110
 
106
- // Should generate options interface
107
- expect(content).toContain('export interface ListOrganizationsOptions extends PaginationOptions {');
108
- expect(content).toContain('domains?: string[];');
111
+ // The options interface file is emitted separately.
112
+ const optionsFile = files.find(
113
+ (f) => f.path === 'src/organizations/interfaces/list-organizations-options.interface.ts',
114
+ );
115
+ expect(optionsFile).toBeDefined();
116
+ expect(optionsFile!.content).toContain('export interface ListOrganizationsOptions extends PaginationOptions {');
117
+ expect(optionsFile!.content).toContain('domains?: string[];');
109
118
 
110
119
  // Should return AutoPaginatable
111
120
  expect(content).toContain('Promise<AutoPaginatable<Organization, ListOrganizationsOptions>>');
121
+
122
+ // `domains` has the same camelCase and snake_case spelling, so no wire
123
+ // serializer should be emitted; options are passed directly.
124
+ expect(content).not.toContain('serializeListOrganizationsOptions');
125
+ expect(content).toContain('new AutoPaginatable(');
126
+ expect(content).toContain('fetchAndDeserialize<OrganizationResponse, Organization>');
127
+ expect(content).toContain('options,');
128
+ });
129
+
130
+ it('emits wire-options serializer for paginated list with camelCase filter fields', () => {
131
+ // Regression test for PR #1535 reviewer comment r3075477146: list
132
+ // methods whose extended filter fields have divergent camelCase/snake_case
133
+ // spellings (e.g. `organizationId` ↔ `organization_id`) must translate
134
+ // keys before hitting the wire, otherwise the API silently ignores them.
135
+ const services: Service[] = [
136
+ {
137
+ name: 'Applications',
138
+ operations: [
139
+ {
140
+ name: 'listApplications',
141
+ httpMethod: 'get',
142
+ path: '/connect/applications',
143
+ pathParams: [],
144
+ queryParams: [
145
+ {
146
+ name: 'organization_id',
147
+ type: { kind: 'primitive', type: 'string' },
148
+ required: false,
149
+ description: 'Filter by organization ID.',
150
+ },
151
+ ],
152
+ headerParams: [],
153
+ response: { kind: 'model', name: 'ConnectApplication' },
154
+ errors: [],
155
+ pagination: {
156
+ strategy: 'cursor',
157
+ param: 'after',
158
+ dataPath: 'data',
159
+ itemType: { kind: 'model', name: 'ConnectApplication' },
160
+ },
161
+ injectIdempotencyKey: false,
162
+ },
163
+ ],
164
+ },
165
+ ];
166
+
167
+ const files = generateResources(services, ctx);
168
+ const content = files[0].content;
169
+
170
+ // Options interface uses camelCase (user-facing) and lives in its own file.
171
+ const optionsFile = files.find(
172
+ (f) => f.path === 'src/applications/interfaces/list-applications-options.interface.ts',
173
+ );
174
+ expect(optionsFile).toBeDefined();
175
+ expect(optionsFile!.content).toContain('export interface ListApplicationsOptions extends PaginationOptions {');
176
+ expect(optionsFile!.content).toContain('organizationId?: string;');
177
+
178
+ // Resource class imports the options type from the interface file.
179
+ expect(content).toContain(
180
+ "import type { ListApplicationsOptions } from './interfaces/list-applications-options.interface';",
181
+ );
182
+
183
+ // Wire-options serializer emits snake_case key for the extension field
184
+ // and leaves standard pagination fields unchanged.
185
+ expect(content).toContain(
186
+ 'const serializeListApplicationsOptions = (options: ListApplicationsOptions): PaginationOptions => {',
187
+ );
188
+ expect(content).toContain('wire.organization_id = options.organizationId');
189
+ expect(content).not.toContain('wire.organizationId');
190
+
191
+ // fetchAndDeserialize is invoked with the serialized options.
192
+ expect(content).toContain('serializeListApplicationsOptions(options)');
193
+ expect(content).toContain('new AutoPaginatable(');
112
194
  });
113
195
 
114
196
  it('uses item type not list wrapper type for paginated methods', () => {
@@ -150,7 +232,7 @@ describe('generateResources', () => {
150
232
  const content = files[0].content;
151
233
 
152
234
  // Should use item type (Connection) not list wrapper (ConnectionList)
153
- expect(content).toContain('createPaginatedList<ConnectionResponse, Connection,');
235
+ expect(content).toContain('fetchAndDeserialize<ConnectionResponse, Connection>');
154
236
  expect(content).toContain('deserializeConnection');
155
237
  expect(content).toContain('Promise<AutoPaginatable<Connection,');
156
238
 
@@ -191,6 +273,47 @@ describe('generateResources', () => {
191
273
  expect(content).toContain('await this.workos.delete(');
192
274
  });
193
275
 
276
+ it('generates unpaginated GET returning an array of models', () => {
277
+ // Regression test for PR #1535 reviewer comment (r3074705330): endpoints
278
+ // whose OpenAPI response is `type: array` must return `Model[]` and map
279
+ // the deserializer over each element, not treat the array as a single
280
+ // object (which silently produces garbage at runtime).
281
+ const services: Service[] = [
282
+ {
283
+ name: 'Secrets',
284
+ operations: [
285
+ {
286
+ name: 'listSecrets',
287
+ httpMethod: 'get',
288
+ path: '/applications/{id}/secrets',
289
+ pathParams: [
290
+ {
291
+ name: 'id',
292
+ type: { kind: 'primitive', type: 'string' },
293
+ required: true,
294
+ },
295
+ ],
296
+ queryParams: [],
297
+ headerParams: [],
298
+ response: { kind: 'array', items: { kind: 'model', name: 'Secret' } },
299
+ errors: [],
300
+ injectIdempotencyKey: false,
301
+ },
302
+ ],
303
+ },
304
+ ];
305
+
306
+ const files = generateResources(services, ctx);
307
+ const content = files[0].content;
308
+
309
+ expect(content).toContain('async listSecrets(id: string): Promise<Secret[]>');
310
+ expect(content).toContain('this.workos.get<SecretResponse[]>');
311
+ expect(content).toContain('return data.map(deserializeSecret);');
312
+ // Should NOT produce the single-object form — that was the bug.
313
+ expect(content).not.toMatch(/Promise<Secret>\s*\{/);
314
+ expect(content).not.toContain('return deserializeSecret(data);');
315
+ });
316
+
194
317
  it('generates POST method with body and idempotency', () => {
195
318
  const services: Service[] = [
196
319
  {
@@ -891,14 +1014,20 @@ describe('generateResources', () => {
891
1014
  // Should use the union type for the payload parameter
892
1015
  expect(content).toContain('payload: AuthByPassword | AuthByCode | AuthByMagicAuth');
893
1016
 
894
- // Should dispatch to the correct serializer based on the discriminator
895
- expect(content).toContain('switch ((payload as any).grantType)');
896
- expect(content).toContain("case 'password': return serializeAuthByPassword(payload as any)");
897
- expect(content).toContain("case 'authorization_code': return serializeAuthByCode(payload as any)");
1017
+ // Should dispatch to the correct serializer based on the discriminator,
1018
+ // using the typed discriminator so TS narrows payload per case.
1019
+ expect(content).toContain('switch (payload.grantType)');
1020
+ expect(content).toContain("case 'password': return serializeAuthByPassword(payload)");
1021
+ expect(content).toContain("case 'authorization_code': return serializeAuthByCode(payload)");
898
1022
  expect(content).toContain(
899
- "case 'urn:workos:oauth:grant-type:magic-auth:code': return serializeAuthByMagicAuth(payload as any)",
1023
+ "case 'urn:workos:oauth:grant-type:magic-auth:code': return serializeAuthByMagicAuth(payload)",
900
1024
  );
901
1025
 
1026
+ // Should not use `as any` casts — TS discriminated-union narrowing makes
1027
+ // them unnecessary and they suppress real type mismatches.
1028
+ expect(content).not.toContain('switch ((payload as any)');
1029
+ expect(content).not.toMatch(/return serialize\w+\(payload as any\)/);
1030
+
902
1031
  // Should import serializers for all union variants
903
1032
  expect(content).toContain('serializeAuthByPassword');
904
1033
  expect(content).toContain('serializeAuthByCode');
@@ -906,6 +1035,13 @@ describe('generateResources', () => {
906
1035
 
907
1036
  // Should NOT pass payload directly without serialization
908
1037
  expect(content).not.toMatch(/,\n\s+payload,\n/);
1038
+
1039
+ // Default branch must throw — silently forwarding unserialized camelCase
1040
+ // to the API produces malformed requests when the discriminator is unknown.
1041
+ expect(content).toContain('default:');
1042
+ expect(content).toContain('const _unknown: never = payload');
1043
+ expect(content).toContain('throw new Error');
1044
+ expect(content).not.toMatch(/default:\s*return payload/);
909
1045
  });
910
1046
 
911
1047
  it('generates discriminated union serializer dispatch for void method', () => {
@@ -945,13 +1081,13 @@ describe('generateResources', () => {
945
1081
  const files = generateResources(services, ctx);
946
1082
  const content = files[0].content;
947
1083
 
948
- // Should dispatch to the correct serializer
949
- expect(content).toContain('switch ((payload as any).grantType)');
950
- expect(content).toContain("case 'authorization_code': return serializeTokenByCode(payload as any)");
951
- expect(content).toContain("case 'refresh_token': return serializeTokenByRefresh(payload as any)");
1084
+ // Should dispatch to the correct serializer using the typed discriminator.
1085
+ expect(content).toContain('switch (payload.grantType)');
1086
+ expect(content).toContain("case 'authorization_code': return serializeTokenByCode(payload)");
1087
+ expect(content).toContain("case 'refresh_token': return serializeTokenByRefresh(payload)");
952
1088
  });
953
1089
 
954
- it('uses createPaginatedList helper in paginated methods', () => {
1090
+ it('uses AutoPaginatable pattern in paginated methods', () => {
955
1091
  const services: Service[] = [
956
1092
  {
957
1093
  name: 'Connections',
@@ -980,9 +1116,9 @@ describe('generateResources', () => {
980
1116
  const files = generateResources(services, ctx);
981
1117
  const content = files[0].content;
982
1118
 
983
- // Should use createPaginatedList helper for concise paginated methods
984
- expect(content).toContain('createPaginatedList<ConnectionResponse, Connection,');
985
- expect(content).toContain('this.workos,');
1119
+ // Should use AutoPaginatable + fetchAndDeserialize pattern for paginated methods
1120
+ expect(content).toContain('new AutoPaginatable(');
1121
+ expect(content).toContain('fetchAndDeserialize<ConnectionResponse, Connection>');
986
1122
  expect(content).toContain('deserializeConnection');
987
1123
  });
988
1124
 
@@ -1048,10 +1184,13 @@ describe('generateResources', () => {
1048
1184
  const content = files[0].content;
1049
1185
 
1050
1186
  // Should use service-prefixed options name instead of generic "ListOptions"
1051
- expect(content).toContain('export interface PaymentsListOptions extends PaginationOptions {');
1187
+ const optionsFile = files.find((f) => f.path.endsWith('payments-list-options.interface.ts'));
1188
+ expect(optionsFile).toBeDefined();
1189
+ expect(optionsFile!.content).toContain('export interface PaymentsListOptions extends PaginationOptions {');
1052
1190
  expect(content).toContain('Promise<AutoPaginatable<Connection, PaymentsListOptions>>');
1053
1191
  // Should NOT use the generic "ListOptions"
1054
1192
  expect(content).not.toContain('export interface ListOptions ');
1193
+ expect(files.every((f) => !f.path.endsWith('/list-options.interface.ts'))).toBe(true);
1055
1194
  });
1056
1195
 
1057
1196
  it('does not prefix ListOptions when method is not "list"', () => {
@@ -1090,10 +1229,11 @@ describe('generateResources', () => {
1090
1229
  ];
1091
1230
 
1092
1231
  const files = generateResources(services, ctx);
1093
- const content = files[0].content;
1094
1232
 
1095
1233
  // Method is "listOrganizations", not "list", so options name should be normal
1096
- expect(content).toContain('export interface ListOrganizationsOptions extends PaginationOptions {');
1234
+ const optionsFile = files.find((f) => f.path.endsWith('list-organizations-options.interface.ts'));
1235
+ expect(optionsFile).toBeDefined();
1236
+ expect(optionsFile!.content).toContain('export interface ListOrganizationsOptions extends PaginationOptions {');
1097
1237
  });
1098
1238
 
1099
1239
  it('removes skipIfExists when fully-covered service has methods absent from baseline', () => {
@@ -1268,11 +1408,72 @@ describe('generateResources', () => {
1268
1408
  },
1269
1409
  };
1270
1410
 
1411
+ const files = generateResources(services, overlayCtx);
1412
+ // Fully covered services with no new methods are skipped entirely
1413
+ expect(files.length).toBe(0);
1414
+ });
1415
+
1416
+ it('removes skipIfExists for purely oagen-managed services (no baseline)', () => {
1417
+ const services: Service[] = [
1418
+ {
1419
+ name: 'Applications',
1420
+ operations: [
1421
+ {
1422
+ name: 'create',
1423
+ httpMethod: 'post',
1424
+ path: '/connect/applications',
1425
+ pathParams: [],
1426
+ queryParams: [],
1427
+ headerParams: [],
1428
+ response: { kind: 'model', name: 'ConnectApplication' },
1429
+ errors: [],
1430
+ injectIdempotencyKey: false,
1431
+ },
1432
+ ],
1433
+ },
1434
+ ];
1435
+
1436
+ const overlayCtx: EmitterContext = {
1437
+ namespace: 'workos',
1438
+ namespacePascal: 'WorkOS',
1439
+ spec: { ...emptySpec, services, models: [] },
1440
+ overlayLookup: {
1441
+ methodByOperation: new Map([
1442
+ [
1443
+ 'POST /connect/applications',
1444
+ {
1445
+ className: 'Applications',
1446
+ methodName: 'create',
1447
+ params: [],
1448
+ returnType: 'ConnectApplication',
1449
+ },
1450
+ ],
1451
+ ]),
1452
+ httpKeyByMethod: new Map(),
1453
+ interfaceByName: new Map(),
1454
+ typeAliasByName: new Map(),
1455
+ requiredExports: new Map(),
1456
+ modelNameByIR: new Map(),
1457
+ fileBySymbol: new Map(),
1458
+ },
1459
+ apiSurface: {
1460
+ language: 'node',
1461
+ extractedFrom: 'test',
1462
+ extractedAt: '2024-01-01',
1463
+ // No baseline class for Applications — purely oagen-managed.
1464
+ classes: {},
1465
+ interfaces: {},
1466
+ typeAliases: {},
1467
+ enums: {},
1468
+ exports: {},
1469
+ },
1470
+ };
1471
+
1271
1472
  const files = generateResources(services, overlayCtx);
1272
1473
  expect(files.length).toBe(1);
1273
1474
 
1274
- // skipIfExists should stay true because all methods exist in baseline
1275
- expect(files[0].skipIfExists).toBe(true);
1475
+ // skipIfExists must be removed so emitter improvements always overwrite.
1476
+ expect(files[0].skipIfExists).toBeUndefined();
1276
1477
  });
1277
1478
  });
1278
1479
 
@@ -1647,7 +1848,7 @@ describe('partial service coverage', () => {
1647
1848
  expect(content).toContain('async createEvent');
1648
1849
  });
1649
1850
 
1650
- it('generates resource class for fully covered services to provide JSDoc', () => {
1851
+ it('skips fully covered services with no new methods', () => {
1651
1852
  const services: Service[] = [
1652
1853
  {
1653
1854
  name: 'Permissions',
@@ -1719,14 +1920,9 @@ describe('partial service coverage', () => {
1719
1920
  };
1720
1921
 
1721
1922
  const files = generateResources(services, ctxCovered);
1722
- expect(files.length).toBe(1);
1723
- const content = files[0].content;
1724
- // All methods should have generated JSDoc — the merger matches by name
1725
- // and handles @deprecated preservation, so the emitter always provides
1726
- // docstrings for the merger to work with.
1727
- expect(content).toContain('List all permissions.');
1728
- // skipIfExists should remain true for covered services
1729
- expect(files[0].skipIfExists).toBe(true);
1923
+ // Fully covered services with no new methods are skipped entirely
1924
+ // (JSDoc-only updates are deferred until a structural change touches the file)
1925
+ expect(files.length).toBe(0);
1730
1926
  });
1731
1927
 
1732
1928
  it('uses resolved operation method names when provided', () => {
@@ -1846,4 +2042,117 @@ describe('partial service coverage', () => {
1846
2042
  const createMethods = methodNames.filter((n) => n.toLowerCase().startsWith('create'));
1847
2043
  expect(new Set(createMethods).size).toBe(createMethods.length); // all unique
1848
2044
  });
2045
+
2046
+ it('omits @param payload when overlay method has no payload param', () => {
2047
+ const services: Service[] = [
2048
+ {
2049
+ name: 'AuditLogs',
2050
+ operations: [
2051
+ {
2052
+ name: 'createEvent',
2053
+ httpMethod: 'post',
2054
+ path: '/audit_logs/events',
2055
+ pathParams: [],
2056
+ queryParams: [],
2057
+ headerParams: [],
2058
+ requestBody: { kind: 'model', name: 'CreateAuditLogEvent' },
2059
+ response: { kind: 'primitive', type: 'unknown' },
2060
+ errors: [],
2061
+ injectIdempotencyKey: false,
2062
+ },
2063
+ ],
2064
+ },
2065
+ ];
2066
+
2067
+ const overlayCtx: EmitterContext = {
2068
+ namespace: 'workos',
2069
+ namespacePascal: 'WorkOS',
2070
+ spec: { ...emptySpec, services, models: [] },
2071
+ overlayLookup: {
2072
+ methodByOperation: new Map([
2073
+ [
2074
+ 'POST /audit_logs/events',
2075
+ {
2076
+ className: 'AuditLogs',
2077
+ methodName: 'createEvent',
2078
+ params: [
2079
+ { name: 'organization', type: 'string', optional: false },
2080
+ { name: 'event', type: 'CreateAuditLogEventOptions', optional: false },
2081
+ { name: 'options', type: 'CreateAuditLogEventRequestOptions', optional: true },
2082
+ ],
2083
+ returnType: 'Promise<void>',
2084
+ },
2085
+ ],
2086
+ ]),
2087
+ httpKeyByMethod: new Map(),
2088
+ interfaceByName: new Map(),
2089
+ typeAliasByName: new Map(),
2090
+ requiredExports: new Map(),
2091
+ modelNameByIR: new Map(),
2092
+ fileBySymbol: new Map(),
2093
+ },
2094
+ };
2095
+
2096
+ const files = generateResources(services, overlayCtx);
2097
+ const content = files[0].content;
2098
+ // Overlay has (organization, event, options) — no payload param
2099
+ expect(content).not.toContain('@param payload');
2100
+ });
2101
+
2102
+ it('documents @param options when overlay folds path params into options', () => {
2103
+ const services: Service[] = [
2104
+ {
2105
+ name: 'FeatureFlags',
2106
+ operations: [
2107
+ {
2108
+ name: 'addFeatureFlagTarget',
2109
+ httpMethod: 'post',
2110
+ path: '/feature-flags/{slug}/targets/{target_id}',
2111
+ pathParams: [
2112
+ { name: 'slug', type: { kind: 'primitive', type: 'string' }, required: true },
2113
+ { name: 'target_id', type: { kind: 'primitive', type: 'string' }, required: true },
2114
+ ],
2115
+ queryParams: [],
2116
+ headerParams: [],
2117
+ response: { kind: 'primitive', type: 'unknown' },
2118
+ errors: [],
2119
+ injectIdempotencyKey: false,
2120
+ },
2121
+ ],
2122
+ },
2123
+ ];
2124
+
2125
+ const overlayCtx: EmitterContext = {
2126
+ namespace: 'workos',
2127
+ namespacePascal: 'WorkOS',
2128
+ spec: { ...emptySpec, services, models: [] },
2129
+ overlayLookup: {
2130
+ methodByOperation: new Map([
2131
+ [
2132
+ 'POST /feature-flags/{slug}/targets/{target_id}',
2133
+ {
2134
+ className: 'FeatureFlags',
2135
+ methodName: 'addFlagTarget',
2136
+ params: [{ name: 'options', type: 'AddFlagTargetOptions', optional: false }],
2137
+ returnType: 'Promise<void>',
2138
+ },
2139
+ ],
2140
+ ]),
2141
+ httpKeyByMethod: new Map(),
2142
+ interfaceByName: new Map(),
2143
+ typeAliasByName: new Map(),
2144
+ requiredExports: new Map(),
2145
+ modelNameByIR: new Map(),
2146
+ fileBySymbol: new Map(),
2147
+ },
2148
+ };
2149
+
2150
+ const files = generateResources(services, overlayCtx);
2151
+ const content = files[0].content;
2152
+ // Path params (slug, targetId) are folded into options — should not appear as top-level @param
2153
+ expect(content).not.toContain('@param slug');
2154
+ expect(content).not.toContain('@param targetId');
2155
+ // Should have @param options since it's in the overlay signature
2156
+ expect(content).toContain('@param options');
2157
+ });
1849
2158
  });
@@ -0,0 +1,140 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { modelHasNewFields } from '../../src/node/utils.js';
3
+ import type { EmitterContext, ApiSpec, Model } from '@workos/oagen';
4
+ import { defaultSdkBehavior } from '@workos/oagen';
5
+
6
+ const emptySpec: ApiSpec = {
7
+ name: 'Test',
8
+ version: '1.0.0',
9
+ baseUrl: '',
10
+ services: [],
11
+ models: [],
12
+ enums: [],
13
+ sdk: defaultSdkBehavior(),
14
+ };
15
+
16
+ const ctx: EmitterContext = {
17
+ namespace: 'workos',
18
+ namespacePascal: 'WorkOS',
19
+ spec: emptySpec,
20
+ };
21
+
22
+ describe('modelHasNewFields', () => {
23
+ const model: Model = {
24
+ name: 'Organization',
25
+ fields: [
26
+ { name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
27
+ { name: 'name', type: { kind: 'primitive', type: 'string' }, required: true },
28
+ ],
29
+ };
30
+
31
+ it('returns true when no apiSurface exists (Scenario B)', () => {
32
+ expect(modelHasNewFields(model, ctx)).toBe(true);
33
+ });
34
+
35
+ it('returns true when model has no baseline entry (new model)', () => {
36
+ const ctxWithSurface: EmitterContext = {
37
+ ...ctx,
38
+ apiSurface: {
39
+ language: 'node',
40
+ extractedFrom: 'test',
41
+ extractedAt: '2024-01-01',
42
+ classes: {},
43
+ interfaces: {},
44
+ typeAliases: {},
45
+ enums: {},
46
+ exports: {},
47
+ },
48
+ };
49
+ expect(modelHasNewFields(model, ctxWithSurface)).toBe(true);
50
+ });
51
+
52
+ it('returns false when all fields exist in baseline', () => {
53
+ const ctxWithBaseline: EmitterContext = {
54
+ ...ctx,
55
+ apiSurface: {
56
+ language: 'node',
57
+ extractedFrom: 'test',
58
+ extractedAt: '2024-01-01',
59
+ classes: {},
60
+ typeAliases: {},
61
+ enums: {},
62
+ exports: {},
63
+ interfaces: {
64
+ Organization: {
65
+ name: 'Organization',
66
+ fields: {
67
+ id: { name: 'id', type: 'string', optional: false },
68
+ name: { name: 'name', type: 'string', optional: false },
69
+ },
70
+ extends: [],
71
+ },
72
+ },
73
+ },
74
+ };
75
+ expect(modelHasNewFields(model, ctxWithBaseline)).toBe(false);
76
+ });
77
+
78
+ it('returns true when model has one new field not in baseline', () => {
79
+ const modelWithNewField: Model = {
80
+ name: 'Organization',
81
+ fields: [
82
+ { name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
83
+ { name: 'name', type: { kind: 'primitive', type: 'string' }, required: true },
84
+ { name: 'slug', type: { kind: 'primitive', type: 'string' }, required: false },
85
+ ],
86
+ };
87
+ const ctxWithBaseline: EmitterContext = {
88
+ ...ctx,
89
+ apiSurface: {
90
+ language: 'node',
91
+ extractedFrom: 'test',
92
+ extractedAt: '2024-01-01',
93
+ classes: {},
94
+ typeAliases: {},
95
+ enums: {},
96
+ exports: {},
97
+ interfaces: {
98
+ Organization: {
99
+ name: 'Organization',
100
+ fields: {
101
+ id: { name: 'id', type: 'string', optional: false },
102
+ name: { name: 'name', type: 'string', optional: false },
103
+ },
104
+ extends: [],
105
+ },
106
+ },
107
+ },
108
+ };
109
+ expect(modelHasNewFields(modelWithNewField, ctxWithBaseline)).toBe(true);
110
+ });
111
+
112
+ it('converts snake_case IR field names to camelCase for baseline comparison', () => {
113
+ const snakeModel: Model = {
114
+ name: 'Organization',
115
+ fields: [{ name: 'organization_id', type: { kind: 'primitive', type: 'string' }, required: true }],
116
+ };
117
+ const ctxWithBaseline: EmitterContext = {
118
+ ...ctx,
119
+ apiSurface: {
120
+ language: 'node',
121
+ extractedFrom: 'test',
122
+ extractedAt: '2024-01-01',
123
+ classes: {},
124
+ typeAliases: {},
125
+ enums: {},
126
+ exports: {},
127
+ interfaces: {
128
+ Organization: {
129
+ name: 'Organization',
130
+ fields: {
131
+ organizationId: { name: 'organizationId', type: 'string', optional: false },
132
+ },
133
+ extends: [],
134
+ },
135
+ },
136
+ },
137
+ };
138
+ expect(modelHasNewFields(snakeModel, ctxWithBaseline)).toBe(false);
139
+ });
140
+ });
@@ -74,8 +74,9 @@ describe('generateClient', () => {
74
74
  expect(result[0].content).toContain("string $baseUrl = 'https://api.example.com'");
75
75
  expect(result[0].content).toContain('int $timeout = 60');
76
76
  expect(result[0].content).toContain('int $maxRetries = 3');
77
+ expect(result[0].content).toContain('?string $userAgent = null');
77
78
  expect(result[0].content).toContain(
78
- 'new HttpClient($apiKey, $clientId, $baseUrl, $timeout, $maxRetries, $handler)',
79
+ 'new HttpClient($apiKey, $clientId, $baseUrl, $timeout, $maxRetries, $handler, $userAgent)',
79
80
  );
80
81
  expect(result[0].content).not.toContain('self::$apiKey = $apiKey;');
81
82
  expect(result[0].content).not.toContain('self::$clientId = $clientId;');
@@ -436,7 +436,7 @@ describe('generateModels', () => {
436
436
  expect(file!.content).not.toContain('instanceof \\BackedEnum');
437
437
  });
438
438
 
439
- it('deduplicates structurally identical models', () => {
439
+ it('emits all structurally identical models as full classes', () => {
440
440
  const models: Model[] = [
441
441
  {
442
442
  name: 'FlagCreatedContextActor',
@@ -464,11 +464,12 @@ describe('generateModels', () => {
464
464
  const specWithModels = { ...emptySpec, models };
465
465
  const result = generateModels(models, { ...ctx, spec: specWithModels });
466
466
 
467
- // Only the trait + one canonical model file should be emitted (not 3)
467
+ // PHP readonly classes cannot be aliased, so all models are emitted as full classes
468
468
  const modelFiles = result.filter((f) => !f.path.includes('Trait'));
469
- expect(modelFiles).toHaveLength(1);
470
- // Shortest class name wins as canonical
469
+ expect(modelFiles).toHaveLength(3);
471
470
  expect(modelFiles[0].path).toContain('FlagCreatedContextActor');
471
+ expect(modelFiles[1].path).toContain('FlagUpdatedContextActor');
472
+ expect(modelFiles[2].path).toContain('FlagDeletedContextActor');
472
473
  });
473
474
 
474
475
  it('does not produce double |null in @var for nullable optional arrays', () => {