@workos/oagen-emitters 0.2.1 → 0.4.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 (136) hide show
  1. package/.husky/pre-commit +1 -0
  2. package/.release-please-manifest.json +1 -1
  3. package/CHANGELOG.md +15 -0
  4. package/README.md +129 -0
  5. package/dist/index.d.mts +13 -1
  6. package/dist/index.d.mts.map +1 -1
  7. package/dist/index.mjs +14549 -3385
  8. package/dist/index.mjs.map +1 -1
  9. package/docs/sdk-architecture/dotnet.md +336 -0
  10. package/docs/sdk-architecture/go.md +338 -0
  11. package/docs/sdk-architecture/php.md +315 -0
  12. package/docs/sdk-architecture/python.md +511 -0
  13. package/oagen.config.ts +328 -2
  14. package/package.json +9 -5
  15. package/scripts/generate-php.js +13 -0
  16. package/scripts/git-push-with-published-oagen.sh +21 -0
  17. package/smoke/sdk-dotnet.ts +45 -12
  18. package/smoke/sdk-go.ts +116 -42
  19. package/smoke/sdk-php.ts +28 -26
  20. package/smoke/sdk-python.ts +5 -2
  21. package/src/dotnet/client.ts +89 -0
  22. package/src/dotnet/enums.ts +323 -0
  23. package/src/dotnet/fixtures.ts +236 -0
  24. package/src/dotnet/index.ts +246 -0
  25. package/src/dotnet/manifest.ts +36 -0
  26. package/src/dotnet/models.ts +344 -0
  27. package/src/dotnet/naming.ts +330 -0
  28. package/src/dotnet/resources.ts +622 -0
  29. package/src/dotnet/tests.ts +693 -0
  30. package/src/dotnet/type-map.ts +201 -0
  31. package/src/dotnet/wrappers.ts +186 -0
  32. package/src/go/client.ts +141 -0
  33. package/src/go/enums.ts +196 -0
  34. package/src/go/fixtures.ts +212 -0
  35. package/src/go/index.ts +84 -0
  36. package/src/go/manifest.ts +36 -0
  37. package/src/go/models.ts +254 -0
  38. package/src/go/naming.ts +179 -0
  39. package/src/go/resources.ts +827 -0
  40. package/src/go/tests.ts +751 -0
  41. package/src/go/type-map.ts +82 -0
  42. package/src/go/wrappers.ts +261 -0
  43. package/src/index.ts +4 -0
  44. package/src/kotlin/client.ts +53 -0
  45. package/src/kotlin/enums.ts +162 -0
  46. package/src/kotlin/index.ts +92 -0
  47. package/src/kotlin/manifest.ts +55 -0
  48. package/src/kotlin/models.ts +395 -0
  49. package/src/kotlin/naming.ts +223 -0
  50. package/src/kotlin/overrides.ts +25 -0
  51. package/src/kotlin/resources.ts +667 -0
  52. package/src/kotlin/tests.ts +1019 -0
  53. package/src/kotlin/type-map.ts +123 -0
  54. package/src/kotlin/wrappers.ts +168 -0
  55. package/src/node/client.ts +128 -115
  56. package/src/node/enums.ts +9 -0
  57. package/src/node/errors.ts +37 -232
  58. package/src/node/field-plan.ts +726 -0
  59. package/src/node/fixtures.ts +9 -1
  60. package/src/node/index.ts +3 -9
  61. package/src/node/models.ts +178 -21
  62. package/src/node/naming.ts +49 -111
  63. package/src/node/resources.ts +527 -397
  64. package/src/node/sdk-errors.ts +41 -0
  65. package/src/node/tests.ts +69 -19
  66. package/src/node/type-map.ts +4 -2
  67. package/src/node/utils.ts +13 -71
  68. package/src/node/wrappers.ts +151 -0
  69. package/src/php/client.ts +179 -0
  70. package/src/php/enums.ts +67 -0
  71. package/src/php/errors.ts +9 -0
  72. package/src/php/fixtures.ts +181 -0
  73. package/src/php/index.ts +96 -0
  74. package/src/php/manifest.ts +36 -0
  75. package/src/php/models.ts +310 -0
  76. package/src/php/naming.ts +279 -0
  77. package/src/php/resources.ts +636 -0
  78. package/src/php/tests.ts +609 -0
  79. package/src/php/type-map.ts +90 -0
  80. package/src/php/utils.ts +18 -0
  81. package/src/php/wrappers.ts +152 -0
  82. package/src/python/client.ts +345 -0
  83. package/src/python/enums.ts +313 -0
  84. package/src/python/fixtures.ts +196 -0
  85. package/src/python/index.ts +95 -0
  86. package/src/python/manifest.ts +38 -0
  87. package/src/python/models.ts +688 -0
  88. package/src/python/naming.ts +189 -0
  89. package/src/python/resources.ts +1322 -0
  90. package/src/python/tests.ts +1335 -0
  91. package/src/python/type-map.ts +93 -0
  92. package/src/python/wrappers.ts +191 -0
  93. package/src/shared/model-utils.ts +472 -0
  94. package/src/shared/naming-utils.ts +154 -0
  95. package/src/shared/non-spec-services.ts +54 -0
  96. package/src/shared/resolved-ops.ts +109 -0
  97. package/src/shared/wrapper-utils.ts +70 -0
  98. package/test/dotnet/client.test.ts +121 -0
  99. package/test/dotnet/enums.test.ts +193 -0
  100. package/test/dotnet/errors.test.ts +9 -0
  101. package/test/dotnet/manifest.test.ts +82 -0
  102. package/test/dotnet/models.test.ts +260 -0
  103. package/test/dotnet/resources.test.ts +255 -0
  104. package/test/dotnet/tests.test.ts +202 -0
  105. package/test/go/client.test.ts +92 -0
  106. package/test/go/enums.test.ts +132 -0
  107. package/test/go/errors.test.ts +9 -0
  108. package/test/go/models.test.ts +265 -0
  109. package/test/go/resources.test.ts +408 -0
  110. package/test/go/tests.test.ts +143 -0
  111. package/test/kotlin/models.test.ts +135 -0
  112. package/test/kotlin/tests.test.ts +176 -0
  113. package/test/node/client.test.ts +92 -12
  114. package/test/node/enums.test.ts +2 -0
  115. package/test/node/errors.test.ts +2 -41
  116. package/test/node/models.test.ts +2 -0
  117. package/test/node/naming.test.ts +23 -0
  118. package/test/node/resources.test.ts +315 -84
  119. package/test/node/serializers.test.ts +3 -1
  120. package/test/node/type-map.test.ts +11 -0
  121. package/test/php/client.test.ts +95 -0
  122. package/test/php/enums.test.ts +173 -0
  123. package/test/php/errors.test.ts +9 -0
  124. package/test/php/models.test.ts +497 -0
  125. package/test/php/resources.test.ts +682 -0
  126. package/test/php/tests.test.ts +185 -0
  127. package/test/python/client.test.ts +200 -0
  128. package/test/python/enums.test.ts +228 -0
  129. package/test/python/errors.test.ts +16 -0
  130. package/test/python/manifest.test.ts +74 -0
  131. package/test/python/models.test.ts +716 -0
  132. package/test/python/resources.test.ts +617 -0
  133. package/test/python/tests.test.ts +202 -0
  134. package/src/node/common.ts +0 -273
  135. package/src/node/config.ts +0 -71
  136. package/src/node/serializers.ts +0 -746
@@ -1,6 +1,7 @@
1
1
  import { describe, it, expect } from 'vitest';
2
2
  import { generateResources, resolveResourceClassName, hasCompatibleConstructor } from '../../src/node/resources.js';
3
3
  import type { EmitterContext, ApiSpec, Service } from '@workos/oagen';
4
+ import { defaultSdkBehavior } from '@workos/oagen';
4
5
 
5
6
  const emptySpec: ApiSpec = {
6
7
  name: 'Test',
@@ -9,6 +10,7 @@ const emptySpec: ApiSpec = {
9
10
  services: [],
10
11
  models: [],
11
12
  enums: [],
13
+ sdk: defaultSdkBehavior(),
12
14
  };
13
15
 
14
16
  const ctx: EmitterContext = {
@@ -100,13 +102,93 @@ describe('generateResources', () => {
100
102
  // Should have AutoPaginatable type import and createPaginatedList import
101
103
  expect(content).toContain("import type { AutoPaginatable } from '../common/utils/pagination'");
102
104
  expect(content).toContain("import { createPaginatedList } 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
+ );
103
110
 
104
- // Should generate options interface
105
- expect(content).toContain('export interface ListOrganizationsOptions extends PaginationOptions {');
106
- 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[];');
107
118
 
108
119
  // Should return AutoPaginatable
109
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 and createPaginatedList should be called
124
+ // with just options (no 5th arg).
125
+ expect(content).not.toContain('serializeListOrganizationsOptions');
126
+ expect(content).toMatch(/createPaginatedList<[^>]+>\([^)]+options\);/);
127
+ });
128
+
129
+ it('emits wire-options serializer for paginated list with camelCase filter fields', () => {
130
+ // Regression test for PR #1535 reviewer comment r3075477146: list
131
+ // methods whose extended filter fields have divergent camelCase/snake_case
132
+ // spellings (e.g. `organizationId` ↔ `organization_id`) must translate
133
+ // keys before hitting the wire, otherwise the API silently ignores them.
134
+ const services: Service[] = [
135
+ {
136
+ name: 'Applications',
137
+ operations: [
138
+ {
139
+ name: 'listApplications',
140
+ httpMethod: 'get',
141
+ path: '/connect/applications',
142
+ pathParams: [],
143
+ queryParams: [
144
+ {
145
+ name: 'organization_id',
146
+ type: { kind: 'primitive', type: 'string' },
147
+ required: false,
148
+ description: 'Filter by organization ID.',
149
+ },
150
+ ],
151
+ headerParams: [],
152
+ response: { kind: 'model', name: 'ConnectApplication' },
153
+ errors: [],
154
+ pagination: {
155
+ strategy: 'cursor',
156
+ param: 'after',
157
+ dataPath: 'data',
158
+ itemType: { kind: 'model', name: 'ConnectApplication' },
159
+ },
160
+ injectIdempotencyKey: false,
161
+ },
162
+ ],
163
+ },
164
+ ];
165
+
166
+ const files = generateResources(services, ctx);
167
+ const content = files[0].content;
168
+
169
+ // Options interface uses camelCase (user-facing) and lives in its own file.
170
+ const optionsFile = files.find(
171
+ (f) => f.path === 'src/applications/interfaces/list-applications-options.interface.ts',
172
+ );
173
+ expect(optionsFile).toBeDefined();
174
+ expect(optionsFile!.content).toContain('export interface ListApplicationsOptions extends PaginationOptions {');
175
+ expect(optionsFile!.content).toContain('organizationId?: string;');
176
+
177
+ // Resource class imports the options type from the interface file.
178
+ expect(content).toContain(
179
+ "import type { ListApplicationsOptions } from './interfaces/list-applications-options.interface';",
180
+ );
181
+
182
+ // Wire-options serializer emits snake_case key for the extension field
183
+ // and leaves standard pagination fields unchanged.
184
+ expect(content).toContain(
185
+ 'const serializeListApplicationsOptions = (options: ListApplicationsOptions): PaginationOptions => {',
186
+ );
187
+ expect(content).toContain('wire.organization_id = options.organizationId');
188
+ expect(content).not.toContain('wire.organizationId');
189
+
190
+ // createPaginatedList is invoked with the serializer as the 5th arg.
191
+ expect(content).toMatch(/createPaginatedList<[^>]+>\([^)]+options,\s*serializeListApplicationsOptions\);/);
110
192
  });
111
193
 
112
194
  it('uses item type not list wrapper type for paginated methods', () => {
@@ -189,6 +271,47 @@ describe('generateResources', () => {
189
271
  expect(content).toContain('await this.workos.delete(');
190
272
  });
191
273
 
274
+ it('generates unpaginated GET returning an array of models', () => {
275
+ // Regression test for PR #1535 reviewer comment (r3074705330): endpoints
276
+ // whose OpenAPI response is `type: array` must return `Model[]` and map
277
+ // the deserializer over each element, not treat the array as a single
278
+ // object (which silently produces garbage at runtime).
279
+ const services: Service[] = [
280
+ {
281
+ name: 'Secrets',
282
+ operations: [
283
+ {
284
+ name: 'listSecrets',
285
+ httpMethod: 'get',
286
+ path: '/applications/{id}/secrets',
287
+ pathParams: [
288
+ {
289
+ name: 'id',
290
+ type: { kind: 'primitive', type: 'string' },
291
+ required: true,
292
+ },
293
+ ],
294
+ queryParams: [],
295
+ headerParams: [],
296
+ response: { kind: 'array', items: { kind: 'model', name: 'Secret' } },
297
+ errors: [],
298
+ injectIdempotencyKey: false,
299
+ },
300
+ ],
301
+ },
302
+ ];
303
+
304
+ const files = generateResources(services, ctx);
305
+ const content = files[0].content;
306
+
307
+ expect(content).toContain('async listSecrets(id: string): Promise<Secret[]>');
308
+ expect(content).toContain('this.workos.get<SecretResponse[]>');
309
+ expect(content).toContain('return data.map(deserializeSecret);');
310
+ // Should NOT produce the single-object form — that was the bug.
311
+ expect(content).not.toMatch(/Promise<Secret>\s*\{/);
312
+ expect(content).not.toContain('return deserializeSecret(data);');
313
+ });
314
+
192
315
  it('generates POST method with body and idempotency', () => {
193
316
  const services: Service[] = [
194
317
  {
@@ -309,7 +432,7 @@ describe('generateResources', () => {
309
432
  expect(content).toContain(' *');
310
433
  expect(content).toContain(' * You may optionally inform Radar that an attempt was successful.');
311
434
  expect(content).toContain(' * @param id - The unique identifier of the attempt.');
312
- expect(content).toContain(' * @returns {RadarAttempt}');
435
+ expect(content).toContain(' * @returns {Promise<RadarAttempt>}');
313
436
  expect(content).toContain(' * @deprecated');
314
437
  expect(content).toContain(' */');
315
438
  });
@@ -342,7 +465,61 @@ describe('generateResources', () => {
342
465
 
343
466
  const files = generateResources(services, ctx);
344
467
  const content = files[0].content;
345
- expect(content).toContain('@returns {Organization}');
468
+ expect(content).toContain('@returns {Promise<Organization>}');
469
+ });
470
+
471
+ it('renders @returns from overlay return type when available', () => {
472
+ const services: Service[] = [
473
+ {
474
+ name: 'Authorization',
475
+ operations: [
476
+ {
477
+ name: 'createEnvironmentRole',
478
+ httpMethod: 'post',
479
+ path: '/authorization/roles',
480
+ pathParams: [],
481
+ queryParams: [],
482
+ headerParams: [],
483
+ requestBody: { kind: 'model', name: 'CreateRoleInput' },
484
+ response: { kind: 'model', name: 'Role' },
485
+ errors: [],
486
+ injectIdempotencyKey: false,
487
+ },
488
+ ],
489
+ },
490
+ ];
491
+
492
+ const overlayCtx: EmitterContext = {
493
+ namespace: 'workos',
494
+ namespacePascal: 'WorkOS',
495
+ spec: { ...emptySpec, services, models: [] },
496
+ overlayLookup: {
497
+ methodByOperation: new Map([
498
+ [
499
+ 'POST /authorization/roles',
500
+ {
501
+ className: 'Authorization',
502
+ methodName: 'createEnvironmentRole',
503
+ params: [{ name: 'payload', type: 'CreateRoleInput', optional: false }],
504
+ returnType: 'Promise<EnvironmentRole>',
505
+ },
506
+ ],
507
+ ]),
508
+ httpKeyByMethod: new Map(),
509
+ interfaceByName: new Map(),
510
+ typeAliasByName: new Map(),
511
+ requiredExports: new Map(),
512
+ modelNameByIR: new Map(),
513
+ fileBySymbol: new Map(),
514
+ },
515
+ };
516
+
517
+ const files = generateResources(services, overlayCtx);
518
+ const content = files[0].content;
519
+ // JSDoc should use the overlay return type, not the spec schema name
520
+ expect(content).toContain('@returns {Promise<EnvironmentRole>}');
521
+ expect(content).not.toContain('@returns {Role}');
522
+ expect(content).not.toContain('@returns {Promise<Role>}');
346
523
  });
347
524
 
348
525
  it('renders query param docs for non-paginated operations', () => {
@@ -466,9 +643,9 @@ describe('generateResources', () => {
466
643
  const files = generateResources(services, ctx);
467
644
  const content = files[0].content;
468
645
  // Only emit a single @returns for the primary response model (no status-code variants)
469
- expect(content).toContain('@returns {Organization}');
470
- expect(content).not.toContain('@returns {Organization} 200');
471
- expect(content).not.toContain('@returns {Organization} 201');
646
+ expect(content).toContain('@returns {Promise<Organization>}');
647
+ expect(content).not.toContain('@returns {Promise<Organization>} 200');
648
+ expect(content).not.toContain('@returns {Promise<Organization>} 201');
472
649
  });
473
650
 
474
651
  it('generates DELETE-with-body method using deleteWithBody', () => {
@@ -835,14 +1012,20 @@ describe('generateResources', () => {
835
1012
  // Should use the union type for the payload parameter
836
1013
  expect(content).toContain('payload: AuthByPassword | AuthByCode | AuthByMagicAuth');
837
1014
 
838
- // Should dispatch to the correct serializer based on the discriminator
839
- expect(content).toContain('switch ((payload as any).grantType)');
840
- expect(content).toContain("case 'password': return serializeAuthByPassword(payload as any)");
841
- expect(content).toContain("case 'authorization_code': return serializeAuthByCode(payload as any)");
1015
+ // Should dispatch to the correct serializer based on the discriminator,
1016
+ // using the typed discriminator so TS narrows payload per case.
1017
+ expect(content).toContain('switch (payload.grantType)');
1018
+ expect(content).toContain("case 'password': return serializeAuthByPassword(payload)");
1019
+ expect(content).toContain("case 'authorization_code': return serializeAuthByCode(payload)");
842
1020
  expect(content).toContain(
843
- "case 'urn:workos:oauth:grant-type:magic-auth:code': return serializeAuthByMagicAuth(payload as any)",
1021
+ "case 'urn:workos:oauth:grant-type:magic-auth:code': return serializeAuthByMagicAuth(payload)",
844
1022
  );
845
1023
 
1024
+ // Should not use `as any` casts — TS discriminated-union narrowing makes
1025
+ // them unnecessary and they suppress real type mismatches.
1026
+ expect(content).not.toContain('switch ((payload as any)');
1027
+ expect(content).not.toMatch(/return serialize\w+\(payload as any\)/);
1028
+
846
1029
  // Should import serializers for all union variants
847
1030
  expect(content).toContain('serializeAuthByPassword');
848
1031
  expect(content).toContain('serializeAuthByCode');
@@ -850,6 +1033,13 @@ describe('generateResources', () => {
850
1033
 
851
1034
  // Should NOT pass payload directly without serialization
852
1035
  expect(content).not.toMatch(/,\n\s+payload,\n/);
1036
+
1037
+ // Default branch must throw — silently forwarding unserialized camelCase
1038
+ // to the API produces malformed requests when the discriminator is unknown.
1039
+ expect(content).toContain('default:');
1040
+ expect(content).toContain('const _unknown: never = payload');
1041
+ expect(content).toContain('throw new Error');
1042
+ expect(content).not.toMatch(/default:\s*return payload/);
853
1043
  });
854
1044
 
855
1045
  it('generates discriminated union serializer dispatch for void method', () => {
@@ -889,10 +1079,10 @@ describe('generateResources', () => {
889
1079
  const files = generateResources(services, ctx);
890
1080
  const content = files[0].content;
891
1081
 
892
- // Should dispatch to the correct serializer
893
- expect(content).toContain('switch ((payload as any).grantType)');
894
- expect(content).toContain("case 'authorization_code': return serializeTokenByCode(payload as any)");
895
- expect(content).toContain("case 'refresh_token': return serializeTokenByRefresh(payload as any)");
1082
+ // Should dispatch to the correct serializer using the typed discriminator.
1083
+ expect(content).toContain('switch (payload.grantType)');
1084
+ expect(content).toContain("case 'authorization_code': return serializeTokenByCode(payload)");
1085
+ expect(content).toContain("case 'refresh_token': return serializeTokenByRefresh(payload)");
896
1086
  });
897
1087
 
898
1088
  it('uses createPaginatedList helper in paginated methods', () => {
@@ -992,10 +1182,13 @@ describe('generateResources', () => {
992
1182
  const content = files[0].content;
993
1183
 
994
1184
  // Should use service-prefixed options name instead of generic "ListOptions"
995
- expect(content).toContain('export interface PaymentsListOptions extends PaginationOptions {');
1185
+ const optionsFile = files.find((f) => f.path.endsWith('payments-list-options.interface.ts'));
1186
+ expect(optionsFile).toBeDefined();
1187
+ expect(optionsFile!.content).toContain('export interface PaymentsListOptions extends PaginationOptions {');
996
1188
  expect(content).toContain('Promise<AutoPaginatable<Connection, PaymentsListOptions>>');
997
1189
  // Should NOT use the generic "ListOptions"
998
1190
  expect(content).not.toContain('export interface ListOptions ');
1191
+ expect(files.every((f) => !f.path.endsWith('/list-options.interface.ts'))).toBe(true);
999
1192
  });
1000
1193
 
1001
1194
  it('does not prefix ListOptions when method is not "list"', () => {
@@ -1034,10 +1227,11 @@ describe('generateResources', () => {
1034
1227
  ];
1035
1228
 
1036
1229
  const files = generateResources(services, ctx);
1037
- const content = files[0].content;
1038
1230
 
1039
1231
  // Method is "listOrganizations", not "list", so options name should be normal
1040
- expect(content).toContain('export interface ListOrganizationsOptions extends PaginationOptions {');
1232
+ const optionsFile = files.find((f) => f.path.endsWith('list-organizations-options.interface.ts'));
1233
+ expect(optionsFile).toBeDefined();
1234
+ expect(optionsFile!.content).toContain('export interface ListOrganizationsOptions extends PaginationOptions {');
1041
1235
  });
1042
1236
 
1043
1237
  it('removes skipIfExists when fully-covered service has methods absent from baseline', () => {
@@ -1218,6 +1412,69 @@ describe('generateResources', () => {
1218
1412
  // skipIfExists should stay true because all methods exist in baseline
1219
1413
  expect(files[0].skipIfExists).toBe(true);
1220
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
+
1472
+ const files = generateResources(services, overlayCtx);
1473
+ expect(files.length).toBe(1);
1474
+
1475
+ // skipIfExists must be removed so emitter improvements always overwrite.
1476
+ expect(files[0].skipIfExists).toBeUndefined();
1477
+ });
1221
1478
  });
1222
1479
 
1223
1480
  describe('resolveResourceClassName', () => {
@@ -1665,91 +1922,65 @@ describe('partial service coverage', () => {
1665
1922
  const files = generateResources(services, ctxCovered);
1666
1923
  expect(files.length).toBe(1);
1667
1924
  const content = files[0].content;
1668
- // Should contain JSDoc with description from the spec
1925
+ // All methods should have generated JSDoc the merger matches by name
1926
+ // and handles @deprecated preservation, so the emitter always provides
1927
+ // docstrings for the merger to work with.
1669
1928
  expect(content).toContain('List all permissions.');
1670
1929
  // skipIfExists should remain true for covered services
1671
1930
  expect(files[0].skipIfExists).toBe(true);
1672
1931
  });
1673
1932
 
1674
- it('reconciles method names against api-surface using word-set matching', () => {
1933
+ it('uses resolved operation method names when provided', () => {
1934
+ const op = {
1935
+ name: 'listRolesOrganizations',
1936
+ httpMethod: 'get' as const,
1937
+ path: '/authorization/organizations/{organizationId}/roles',
1938
+ pathParams: [
1939
+ {
1940
+ name: 'organizationId',
1941
+ type: { kind: 'primitive' as const, type: 'string' as const },
1942
+ required: true,
1943
+ },
1944
+ ],
1945
+ queryParams: [],
1946
+ headerParams: [],
1947
+ response: { kind: 'model' as const, name: 'RoleList' },
1948
+ errors: [],
1949
+ pagination: {
1950
+ strategy: 'cursor' as const,
1951
+ param: 'after',
1952
+ itemType: { kind: 'model' as const, name: 'RoleList' },
1953
+ },
1954
+ injectIdempotencyKey: false,
1955
+ };
1675
1956
  const services: Service[] = [
1676
1957
  {
1677
1958
  name: 'Authorization',
1678
- operations: [
1679
- {
1680
- name: 'listRolesOrganizations',
1681
- httpMethod: 'get',
1682
- path: '/authorization/organizations/{organizationId}/roles',
1683
- pathParams: [
1684
- {
1685
- name: 'organizationId',
1686
- type: { kind: 'primitive', type: 'string' },
1687
- required: true,
1688
- },
1689
- ],
1690
- queryParams: [],
1691
- headerParams: [],
1692
- response: { kind: 'model', name: 'RoleList' },
1693
- errors: [],
1694
- pagination: {
1695
- strategy: 'cursor' as const,
1696
- param: 'after',
1697
- itemType: { kind: 'model' as const, name: 'RoleList' },
1698
- },
1699
- injectIdempotencyKey: false,
1700
- },
1701
- ],
1959
+ operations: [op],
1702
1960
  },
1703
1961
  ];
1704
1962
 
1705
- const ctxRecon: EmitterContext = {
1963
+ const ctxResolved: EmitterContext = {
1706
1964
  ...ctx,
1707
1965
  spec: {
1708
1966
  ...emptySpec,
1709
1967
  services,
1710
1968
  models: [{ name: 'RoleList', fields: [] }],
1711
1969
  },
1712
- overlayLookup: {
1713
- methodByOperation: new Map(), // no overlay mapping
1714
- httpKeyByMethod: new Map(),
1715
- interfaceByName: new Map(),
1716
- typeAliasByName: new Map(),
1717
- requiredExports: new Map(),
1718
- modelNameByIR: new Map(),
1719
- fileBySymbol: new Map(),
1720
- },
1721
- apiSurface: {
1722
- language: 'node',
1723
- extractedFrom: 'test',
1724
- extractedAt: '2024-01-01',
1725
- classes: {
1726
- Authorization: {
1727
- name: 'Authorization',
1728
- methods: {
1729
- listOrganizationRoles: [
1730
- {
1731
- name: 'listOrganizationRoles',
1732
- params: [],
1733
- returnType: 'void',
1734
- async: true,
1735
- },
1736
- ],
1737
- },
1738
- properties: {},
1739
- constructorParams: [{ name: 'workos', type: 'WorkOS', optional: false }],
1740
- },
1741
- },
1742
- interfaces: {},
1743
- typeAliases: {},
1744
- enums: {},
1745
- exports: {},
1746
- },
1970
+ resolvedOperations: [
1971
+ {
1972
+ operation: op,
1973
+ service: services[0],
1974
+ methodName: 'list_organization_roles',
1975
+ mountOn: 'Authorization',
1976
+ } as any,
1977
+ ],
1747
1978
  };
1748
1979
 
1749
- const files = generateResources(services, ctxRecon);
1980
+ const files = generateResources(services, ctxResolved);
1750
1981
  expect(files.length).toBe(1);
1751
1982
  const content = files[0].content;
1752
- // Should use reconciled name from api-surface, not spec-derived name
1983
+ // Should use the resolved operation name (converted to camelCase)
1753
1984
  expect(content).toContain('async listOrganizationRoles');
1754
1985
  expect(content).not.toContain('async listRolesOrganizations');
1755
1986
  });
@@ -1,6 +1,7 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { generateSerializers } from '../../src/node/serializers.js';
2
+ import { generateSerializers } from '../../src/node/models.js';
3
3
  import type { EmitterContext, ApiSpec, Model, Service } from '@workos/oagen';
4
+ import { defaultSdkBehavior } from '@workos/oagen';
4
5
 
5
6
  const emptySpec: ApiSpec = {
6
7
  name: 'Test',
@@ -9,6 +10,7 @@ const emptySpec: ApiSpec = {
9
10
  services: [],
10
11
  models: [],
11
12
  enums: [],
13
+ sdk: defaultSdkBehavior(),
12
14
  };
13
15
 
14
16
  const ctx: EmitterContext = {
@@ -60,6 +60,17 @@ describe('mapTypeRef', () => {
60
60
  expect(mapTypeRef(ref)).toBe('string | number');
61
61
  });
62
62
 
63
+ it('deduplicates union variants', () => {
64
+ const ref: TypeRef = {
65
+ kind: 'union',
66
+ variants: [
67
+ { kind: 'model', name: 'AuthenticationFactorTotp' },
68
+ { kind: 'model', name: 'AuthenticationFactorTotp' },
69
+ ],
70
+ };
71
+ expect(mapTypeRef(ref)).toBe('AuthenticationFactorTotp');
72
+ });
73
+
63
74
  it('maps map type', () => {
64
75
  const ref: TypeRef = {
65
76
  kind: 'map',
@@ -0,0 +1,95 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import type { EmitterContext, ApiSpec, Service, Model } from '@workos/oagen';
3
+ import { defaultSdkBehavior } from '@workos/oagen';
4
+ import { generateClient } from '../../src/php/client.js';
5
+
6
+ const models: Model[] = [
7
+ {
8
+ name: 'Organization',
9
+ fields: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
10
+ },
11
+ ];
12
+
13
+ const services: Service[] = [
14
+ {
15
+ name: 'Organizations',
16
+ operations: [
17
+ {
18
+ name: 'listOrganizations',
19
+ httpMethod: 'get',
20
+ path: '/organizations',
21
+ pathParams: [],
22
+ queryParams: [],
23
+ headerParams: [],
24
+ response: { kind: 'model', name: 'Organization' },
25
+ errors: [],
26
+ injectIdempotencyKey: false,
27
+ },
28
+ ],
29
+ },
30
+ ];
31
+
32
+ const emptySpec: ApiSpec = {
33
+ name: 'Test',
34
+ version: '1.0.0',
35
+ baseUrl: 'https://api.example.com',
36
+ services,
37
+ models,
38
+ enums: [],
39
+ sdk: defaultSdkBehavior(),
40
+ };
41
+
42
+ const ctx: EmitterContext = {
43
+ namespace: 'workos',
44
+ namespacePascal: 'WorkOS',
45
+ spec: emptySpec,
46
+ };
47
+
48
+ describe('generateClient', () => {
49
+ it('only generates the main client file', () => {
50
+ const result = generateClient(emptySpec, ctx);
51
+
52
+ expect(result).toHaveLength(1);
53
+ expect(result[0].path).toBe('lib/WorkOS.php');
54
+ });
55
+
56
+ it('generates main client class with namespace', () => {
57
+ const result = generateClient(emptySpec, ctx);
58
+
59
+ expect(result[0].content).toContain('class WorkOS');
60
+ expect(result[0].content).toContain('namespace WorkOS;');
61
+ });
62
+
63
+ it('generates resource accessor methods', () => {
64
+ const result = generateClient(emptySpec, ctx);
65
+
66
+ expect(result[0].content).toContain('public function organizations(): Organizations');
67
+ });
68
+
69
+ it('includes constructor with config options', () => {
70
+ const result = generateClient(emptySpec, ctx);
71
+
72
+ expect(result[0].content).toContain('?string $apiKey = null');
73
+ expect(result[0].content).toContain('?string $clientId = null');
74
+ expect(result[0].content).toContain("string $baseUrl = 'https://api.example.com'");
75
+ expect(result[0].content).toContain('int $timeout = 60');
76
+ expect(result[0].content).toContain('int $maxRetries = 3');
77
+ expect(result[0].content).toContain('?string $userAgent = null');
78
+ expect(result[0].content).toContain(
79
+ 'new HttpClient($apiKey, $clientId, $baseUrl, $timeout, $maxRetries, $handler, $userAgent)',
80
+ );
81
+ expect(result[0].content).not.toContain('self::$apiKey = $apiKey;');
82
+ expect(result[0].content).not.toContain('self::$clientId = $clientId;');
83
+ });
84
+
85
+ it('includes non-spec service accessors', () => {
86
+ const result = generateClient(emptySpec, ctx);
87
+
88
+ expect(result[0].content).toContain('public function passwordless(): Passwordless');
89
+ expect(result[0].content).toContain('public function vault(): Vault');
90
+ expect(result[0].content).toContain('public function webhookVerification(): WebhookVerification');
91
+ expect(result[0].content).toContain('public function actions(): Actions');
92
+ expect(result[0].content).toContain('public function sessionManager(): SessionManager');
93
+ expect(result[0].content).toContain('public function pkce(): PKCEHelper');
94
+ });
95
+ });