@workos/oagen-emitters 0.2.1 → 0.3.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 (103) hide show
  1. package/.husky/pre-commit +1 -0
  2. package/.release-please-manifest.json +1 -1
  3. package/CHANGELOG.md +8 -0
  4. package/README.md +129 -0
  5. package/dist/index.d.mts +10 -1
  6. package/dist/index.d.mts.map +1 -1
  7. package/dist/index.mjs +11893 -3226
  8. package/dist/index.mjs.map +1 -1
  9. package/docs/sdk-architecture/go.md +338 -0
  10. package/docs/sdk-architecture/php.md +315 -0
  11. package/docs/sdk-architecture/python.md +511 -0
  12. package/oagen.config.ts +298 -2
  13. package/package.json +9 -5
  14. package/scripts/generate-php.js +13 -0
  15. package/scripts/git-push-with-published-oagen.sh +21 -0
  16. package/smoke/sdk-go.ts +116 -42
  17. package/smoke/sdk-php.ts +28 -26
  18. package/smoke/sdk-python.ts +5 -2
  19. package/src/go/client.ts +141 -0
  20. package/src/go/enums.ts +196 -0
  21. package/src/go/fixtures.ts +212 -0
  22. package/src/go/index.ts +81 -0
  23. package/src/go/manifest.ts +36 -0
  24. package/src/go/models.ts +254 -0
  25. package/src/go/naming.ts +191 -0
  26. package/src/go/resources.ts +827 -0
  27. package/src/go/tests.ts +751 -0
  28. package/src/go/type-map.ts +82 -0
  29. package/src/go/wrappers.ts +261 -0
  30. package/src/index.ts +3 -0
  31. package/src/node/client.ts +78 -115
  32. package/src/node/enums.ts +9 -0
  33. package/src/node/errors.ts +37 -232
  34. package/src/node/field-plan.ts +726 -0
  35. package/src/node/fixtures.ts +9 -1
  36. package/src/node/index.ts +2 -9
  37. package/src/node/models.ts +178 -21
  38. package/src/node/naming.ts +49 -111
  39. package/src/node/resources.ts +374 -364
  40. package/src/node/sdk-errors.ts +41 -0
  41. package/src/node/tests.ts +32 -12
  42. package/src/node/type-map.ts +4 -2
  43. package/src/node/utils.ts +13 -71
  44. package/src/node/wrappers.ts +151 -0
  45. package/src/php/client.ts +171 -0
  46. package/src/php/enums.ts +67 -0
  47. package/src/php/errors.ts +9 -0
  48. package/src/php/fixtures.ts +181 -0
  49. package/src/php/index.ts +96 -0
  50. package/src/php/manifest.ts +36 -0
  51. package/src/php/models.ts +310 -0
  52. package/src/php/naming.ts +298 -0
  53. package/src/php/resources.ts +561 -0
  54. package/src/php/tests.ts +533 -0
  55. package/src/php/type-map.ts +90 -0
  56. package/src/php/utils.ts +18 -0
  57. package/src/php/wrappers.ts +151 -0
  58. package/src/python/client.ts +337 -0
  59. package/src/python/enums.ts +313 -0
  60. package/src/python/fixtures.ts +196 -0
  61. package/src/python/index.ts +95 -0
  62. package/src/python/manifest.ts +38 -0
  63. package/src/python/models.ts +688 -0
  64. package/src/python/naming.ts +209 -0
  65. package/src/python/resources.ts +1322 -0
  66. package/src/python/tests.ts +1335 -0
  67. package/src/python/type-map.ts +93 -0
  68. package/src/python/wrappers.ts +191 -0
  69. package/src/shared/model-utils.ts +255 -0
  70. package/src/shared/naming-utils.ts +107 -0
  71. package/src/shared/non-spec-services.ts +54 -0
  72. package/src/shared/resolved-ops.ts +109 -0
  73. package/src/shared/wrapper-utils.ts +59 -0
  74. package/test/go/client.test.ts +92 -0
  75. package/test/go/enums.test.ts +132 -0
  76. package/test/go/errors.test.ts +9 -0
  77. package/test/go/models.test.ts +265 -0
  78. package/test/go/resources.test.ts +408 -0
  79. package/test/go/tests.test.ts +143 -0
  80. package/test/node/client.test.ts +18 -12
  81. package/test/node/enums.test.ts +2 -0
  82. package/test/node/errors.test.ts +2 -41
  83. package/test/node/models.test.ts +2 -0
  84. package/test/node/naming.test.ts +23 -0
  85. package/test/node/resources.test.ts +99 -69
  86. package/test/node/serializers.test.ts +3 -1
  87. package/test/node/type-map.test.ts +11 -0
  88. package/test/php/client.test.ts +94 -0
  89. package/test/php/enums.test.ts +173 -0
  90. package/test/php/errors.test.ts +9 -0
  91. package/test/php/models.test.ts +497 -0
  92. package/test/php/resources.test.ts +644 -0
  93. package/test/php/tests.test.ts +118 -0
  94. package/test/python/client.test.ts +200 -0
  95. package/test/python/enums.test.ts +228 -0
  96. package/test/python/errors.test.ts +16 -0
  97. package/test/python/manifest.test.ts +74 -0
  98. package/test/python/models.test.ts +716 -0
  99. package/test/python/resources.test.ts +617 -0
  100. package/test/python/tests.test.ts +202 -0
  101. package/src/node/common.ts +0 -273
  102. package/src/node/config.ts +0 -71
  103. package/src/node/serializers.ts +0 -746
@@ -2,47 +2,8 @@ import { describe, it, expect } from 'vitest';
2
2
  import { generateErrors } from '../../src/node/errors.js';
3
3
 
4
4
  describe('generateErrors', () => {
5
- it('generates all exception classes', () => {
5
+ it('returns empty array without context (static exceptions now hand-maintained)', () => {
6
6
  const files = generateErrors();
7
-
8
- const names = files.map((f) => f.path);
9
- expect(names).toContain('src/common/exceptions/bad-request.exception.ts');
10
- expect(names).toContain('src/common/exceptions/unauthorized.exception.ts');
11
- expect(names).toContain('src/common/exceptions/not-found.exception.ts');
12
- expect(names).toContain('src/common/exceptions/conflict.exception.ts');
13
- expect(names).toContain('src/common/exceptions/unprocessable-entity.exception.ts');
14
- expect(names).toContain('src/common/exceptions/rate-limit-exceeded.exception.ts');
15
- expect(names).toContain('src/common/exceptions/generic-server.exception.ts');
16
- expect(names).toContain('src/common/exceptions/no-api-key-provided.exception.ts');
17
- expect(names).toContain('src/common/exceptions/index.ts');
18
- });
19
-
20
- it('generates NotFoundException with correct status', () => {
21
- const files = generateErrors();
22
- const notFoundFile = files.find((f) => f.path.includes('not-found.exception.ts'))!;
23
-
24
- expect(notFoundFile.content).toContain('export class NotFoundException extends Error');
25
- expect(notFoundFile.content).toContain('readonly status = 404;');
26
- expect(notFoundFile.content).toContain('requestID: string');
27
- });
28
-
29
- it('generates RateLimitExceededException with retryAfter', () => {
30
- const files = generateErrors();
31
- const rateLimitFile = files.find((f) => f.path.includes('rate-limit-exceeded.exception.ts'))!;
32
-
33
- expect(rateLimitFile.content).toContain('export class RateLimitExceededException extends Error');
34
- expect(rateLimitFile.content).toContain('readonly status = 429;');
35
- expect(rateLimitFile.content).toContain('retryAfter?: number');
36
- });
37
-
38
- it('generates exception barrel with all exports', () => {
39
- const files = generateErrors();
40
- const barrel = files.find((f) => f.path === 'src/common/exceptions/index.ts')!;
41
-
42
- expect(barrel.content).toContain('export { BadRequestException }');
43
- expect(barrel.content).toContain('export { UnauthorizedException }');
44
- expect(barrel.content).toContain('export { NotFoundException }');
45
- expect(barrel.content).toContain('export { RateLimitExceededException }');
46
- expect(barrel.content).toContain('export { NoApiKeyProvidedException }');
7
+ expect(files).toEqual([]);
47
8
  });
48
9
  });
@@ -1,6 +1,7 @@
1
1
  import { describe, it, expect } from 'vitest';
2
2
  import { generateModels } 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 = {
@@ -10,10 +10,31 @@ import {
10
10
  servicePropertyName,
11
11
  resolveServiceName,
12
12
  buildServiceNameMap,
13
+ stripNoiseSuffixes,
13
14
  } from '../../src/node/naming.js';
14
15
  import type { EmitterContext, ApiSpec, Service } from '@workos/oagen';
16
+ import { defaultSdkBehavior } from '@workos/oagen';
15
17
 
16
18
  describe('naming', () => {
19
+ describe('stripNoiseSuffixes', () => {
20
+ it('strips trailing Dto', () => {
21
+ expect(stripNoiseSuffixes('OrganizationDto')).toBe('Organization');
22
+ expect(stripNoiseSuffixes('UpdateOrganizationDto')).toBe('UpdateOrganization');
23
+ });
24
+
25
+ it('is case-insensitive for the Dto suffix', () => {
26
+ expect(stripNoiseSuffixes('OrganizationDTO')).toBe('Organization');
27
+ });
28
+
29
+ it('does not strip Dto from the middle of a name', () => {
30
+ expect(stripNoiseSuffixes('DtoFactory')).toBe('DtoFactory');
31
+ });
32
+
33
+ it('leaves names without Dto unchanged', () => {
34
+ expect(stripNoiseSuffixes('Organization')).toBe('Organization');
35
+ });
36
+ });
37
+
17
38
  describe('className', () => {
18
39
  it('converts to PascalCase', () => {
19
40
  expect(className('organizations')).toBe('Organizations');
@@ -88,6 +109,7 @@ describe('naming', () => {
88
109
  services: [],
89
110
  models: [],
90
111
  enums: [],
112
+ sdk: defaultSdkBehavior(),
91
113
  };
92
114
 
93
115
  it('returns overlay class name when available', () => {
@@ -160,6 +182,7 @@ describe('naming', () => {
160
182
  services: [],
161
183
  models: [],
162
184
  enums: [],
185
+ sdk: defaultSdkBehavior(),
163
186
  };
164
187
 
165
188
  it('maps IR names to resolved names', () => {
@@ -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 = {
@@ -309,7 +311,7 @@ describe('generateResources', () => {
309
311
  expect(content).toContain(' *');
310
312
  expect(content).toContain(' * You may optionally inform Radar that an attempt was successful.');
311
313
  expect(content).toContain(' * @param id - The unique identifier of the attempt.');
312
- expect(content).toContain(' * @returns {RadarAttempt}');
314
+ expect(content).toContain(' * @returns {Promise<RadarAttempt>}');
313
315
  expect(content).toContain(' * @deprecated');
314
316
  expect(content).toContain(' */');
315
317
  });
@@ -342,7 +344,61 @@ describe('generateResources', () => {
342
344
 
343
345
  const files = generateResources(services, ctx);
344
346
  const content = files[0].content;
345
- expect(content).toContain('@returns {Organization}');
347
+ expect(content).toContain('@returns {Promise<Organization>}');
348
+ });
349
+
350
+ it('renders @returns from overlay return type when available', () => {
351
+ const services: Service[] = [
352
+ {
353
+ name: 'Authorization',
354
+ operations: [
355
+ {
356
+ name: 'createEnvironmentRole',
357
+ httpMethod: 'post',
358
+ path: '/authorization/roles',
359
+ pathParams: [],
360
+ queryParams: [],
361
+ headerParams: [],
362
+ requestBody: { kind: 'model', name: 'CreateRoleInput' },
363
+ response: { kind: 'model', name: 'Role' },
364
+ errors: [],
365
+ injectIdempotencyKey: false,
366
+ },
367
+ ],
368
+ },
369
+ ];
370
+
371
+ const overlayCtx: EmitterContext = {
372
+ namespace: 'workos',
373
+ namespacePascal: 'WorkOS',
374
+ spec: { ...emptySpec, services, models: [] },
375
+ overlayLookup: {
376
+ methodByOperation: new Map([
377
+ [
378
+ 'POST /authorization/roles',
379
+ {
380
+ className: 'Authorization',
381
+ methodName: 'createEnvironmentRole',
382
+ params: [{ name: 'payload', type: 'CreateRoleInput', optional: false }],
383
+ returnType: 'Promise<EnvironmentRole>',
384
+ },
385
+ ],
386
+ ]),
387
+ httpKeyByMethod: new Map(),
388
+ interfaceByName: new Map(),
389
+ typeAliasByName: new Map(),
390
+ requiredExports: new Map(),
391
+ modelNameByIR: new Map(),
392
+ fileBySymbol: new Map(),
393
+ },
394
+ };
395
+
396
+ const files = generateResources(services, overlayCtx);
397
+ const content = files[0].content;
398
+ // JSDoc should use the overlay return type, not the spec schema name
399
+ expect(content).toContain('@returns {Promise<EnvironmentRole>}');
400
+ expect(content).not.toContain('@returns {Role}');
401
+ expect(content).not.toContain('@returns {Promise<Role>}');
346
402
  });
347
403
 
348
404
  it('renders query param docs for non-paginated operations', () => {
@@ -466,9 +522,9 @@ describe('generateResources', () => {
466
522
  const files = generateResources(services, ctx);
467
523
  const content = files[0].content;
468
524
  // 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');
525
+ expect(content).toContain('@returns {Promise<Organization>}');
526
+ expect(content).not.toContain('@returns {Promise<Organization>} 200');
527
+ expect(content).not.toContain('@returns {Promise<Organization>} 201');
472
528
  });
473
529
 
474
530
  it('generates DELETE-with-body method using deleteWithBody', () => {
@@ -1665,91 +1721,65 @@ describe('partial service coverage', () => {
1665
1721
  const files = generateResources(services, ctxCovered);
1666
1722
  expect(files.length).toBe(1);
1667
1723
  const content = files[0].content;
1668
- // Should contain JSDoc with description from the spec
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.
1669
1727
  expect(content).toContain('List all permissions.');
1670
1728
  // skipIfExists should remain true for covered services
1671
1729
  expect(files[0].skipIfExists).toBe(true);
1672
1730
  });
1673
1731
 
1674
- it('reconciles method names against api-surface using word-set matching', () => {
1732
+ it('uses resolved operation method names when provided', () => {
1733
+ const op = {
1734
+ name: 'listRolesOrganizations',
1735
+ httpMethod: 'get' as const,
1736
+ path: '/authorization/organizations/{organizationId}/roles',
1737
+ pathParams: [
1738
+ {
1739
+ name: 'organizationId',
1740
+ type: { kind: 'primitive' as const, type: 'string' as const },
1741
+ required: true,
1742
+ },
1743
+ ],
1744
+ queryParams: [],
1745
+ headerParams: [],
1746
+ response: { kind: 'model' as const, name: 'RoleList' },
1747
+ errors: [],
1748
+ pagination: {
1749
+ strategy: 'cursor' as const,
1750
+ param: 'after',
1751
+ itemType: { kind: 'model' as const, name: 'RoleList' },
1752
+ },
1753
+ injectIdempotencyKey: false,
1754
+ };
1675
1755
  const services: Service[] = [
1676
1756
  {
1677
1757
  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
- ],
1758
+ operations: [op],
1702
1759
  },
1703
1760
  ];
1704
1761
 
1705
- const ctxRecon: EmitterContext = {
1762
+ const ctxResolved: EmitterContext = {
1706
1763
  ...ctx,
1707
1764
  spec: {
1708
1765
  ...emptySpec,
1709
1766
  services,
1710
1767
  models: [{ name: 'RoleList', fields: [] }],
1711
1768
  },
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
- },
1769
+ resolvedOperations: [
1770
+ {
1771
+ operation: op,
1772
+ service: services[0],
1773
+ methodName: 'list_organization_roles',
1774
+ mountOn: 'Authorization',
1775
+ } as any,
1776
+ ],
1747
1777
  };
1748
1778
 
1749
- const files = generateResources(services, ctxRecon);
1779
+ const files = generateResources(services, ctxResolved);
1750
1780
  expect(files.length).toBe(1);
1751
1781
  const content = files[0].content;
1752
- // Should use reconciled name from api-surface, not spec-derived name
1782
+ // Should use the resolved operation name (converted to camelCase)
1753
1783
  expect(content).toContain('async listOrganizationRoles');
1754
1784
  expect(content).not.toContain('async listRolesOrganizations');
1755
1785
  });
@@ -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,94 @@
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(
78
+ 'new HttpClient($apiKey, $clientId, $baseUrl, $timeout, $maxRetries, $handler)',
79
+ );
80
+ expect(result[0].content).not.toContain('self::$apiKey = $apiKey;');
81
+ expect(result[0].content).not.toContain('self::$clientId = $clientId;');
82
+ });
83
+
84
+ it('includes non-spec service accessors', () => {
85
+ const result = generateClient(emptySpec, ctx);
86
+
87
+ expect(result[0].content).toContain('public function passwordless(): Passwordless');
88
+ expect(result[0].content).toContain('public function vault(): Vault');
89
+ expect(result[0].content).toContain('public function webhookVerification(): WebhookVerification');
90
+ expect(result[0].content).toContain('public function actions(): Actions');
91
+ expect(result[0].content).toContain('public function sessionManager(): SessionManager');
92
+ expect(result[0].content).toContain('public function pkce(): PKCEHelper');
93
+ });
94
+ });
@@ -0,0 +1,173 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import type { EmitterContext, ApiSpec, Enum } from '@workos/oagen';
3
+ import { defaultSdkBehavior } from '@workos/oagen';
4
+ import { generateEnums } from '../../src/php/enums.js';
5
+ import { initializeEnumDedup } from '../../src/php/naming.js';
6
+
7
+ const emptySpec: ApiSpec = {
8
+ name: 'Test',
9
+ version: '1.0.0',
10
+ baseUrl: '',
11
+ services: [],
12
+ models: [],
13
+ enums: [],
14
+ sdk: defaultSdkBehavior(),
15
+ };
16
+
17
+ const ctx: EmitterContext = {
18
+ namespace: 'workos',
19
+ namespacePascal: 'WorkOS',
20
+ spec: emptySpec,
21
+ };
22
+
23
+ describe('generateEnums', () => {
24
+ it('returns empty array for no enums', () => {
25
+ expect(generateEnums([], ctx)).toEqual([]);
26
+ });
27
+
28
+ it('generates a string-backed enum', () => {
29
+ const enums: Enum[] = [
30
+ {
31
+ name: 'OrganizationStatus',
32
+ values: [
33
+ { name: 'ACTIVE', value: 'active' },
34
+ { name: 'INACTIVE', value: 'inactive' },
35
+ ],
36
+ },
37
+ ];
38
+
39
+ const result = generateEnums(enums, ctx);
40
+
41
+ expect(result).toHaveLength(1);
42
+ expect(result[0].path).toBe('lib/Resource/OrganizationStatus.php');
43
+ expect(result[0].content).toContain('enum OrganizationStatus: string');
44
+ expect(result[0].content).toContain("case Active = 'active';");
45
+ expect(result[0].content).toContain("case Inactive = 'inactive';");
46
+ });
47
+
48
+ it('generates an int-backed enum', () => {
49
+ const enums: Enum[] = [
50
+ {
51
+ name: 'Priority',
52
+ values: [
53
+ { name: 'LOW', value: 1 },
54
+ { name: 'MEDIUM', value: 2 },
55
+ { name: 'HIGH', value: 3 },
56
+ ],
57
+ },
58
+ ];
59
+
60
+ const result = generateEnums(enums, ctx);
61
+
62
+ expect(result[0].content).toContain('enum Priority: int');
63
+ expect(result[0].content).toContain('case Low = 1;');
64
+ expect(result[0].content).toContain('case Medium = 2;');
65
+ expect(result[0].content).toContain('case High = 3;');
66
+ });
67
+
68
+ it('generates correct namespace', () => {
69
+ const enums: Enum[] = [
70
+ {
71
+ name: 'Status',
72
+ values: [{ name: 'ACTIVE', value: 'active' }],
73
+ },
74
+ ];
75
+
76
+ const result = generateEnums(enums, ctx);
77
+
78
+ expect(result[0].content).toContain('namespace WorkOS\\Resource;');
79
+ });
80
+
81
+ it('collapses duplicate enums with identical values into one file', () => {
82
+ const enums: Enum[] = [
83
+ {
84
+ name: 'Order',
85
+ values: [
86
+ { name: 'ASC', value: 'asc' },
87
+ { name: 'DESC', value: 'desc' },
88
+ ],
89
+ },
90
+ {
91
+ name: 'ConnectionOrder',
92
+ values: [
93
+ { name: 'ASC', value: 'asc' },
94
+ { name: 'DESC', value: 'desc' },
95
+ ],
96
+ },
97
+ {
98
+ name: 'ApiKeyOrder',
99
+ values: [
100
+ { name: 'ASC', value: 'asc' },
101
+ { name: 'DESC', value: 'desc' },
102
+ ],
103
+ },
104
+ ];
105
+
106
+ // Initialize dedup before generating
107
+ initializeEnumDedup(enums);
108
+ const result = generateEnums(enums, ctx);
109
+
110
+ // Should produce only one file (the shortest name: Order)
111
+ expect(result).toHaveLength(1);
112
+ expect(result[0].path).toBe('lib/Resource/Order.php');
113
+ });
114
+
115
+ it('adds PHPDoc @deprecated for deprecated enum values', () => {
116
+ const enums: Enum[] = [
117
+ {
118
+ name: 'ConnectionType',
119
+ values: [
120
+ { name: 'SAML', value: 'saml' },
121
+ { name: 'OAUTH', value: 'oauth', deprecated: true },
122
+ ],
123
+ },
124
+ ];
125
+
126
+ const result = generateEnums(enums, ctx);
127
+
128
+ expect(result).toHaveLength(1);
129
+ // The non-deprecated value should not have a PHPDoc
130
+ expect(result[0].content).not.toContain('/** @deprecated */\n case Saml');
131
+ // The deprecated value should have a PHPDoc
132
+ expect(result[0].content).toContain('/** @deprecated */');
133
+ // Verify the deprecated case follows the PHPDoc
134
+ const lines = result[0].content.split('\n');
135
+ const deprecatedIdx = lines.findIndex((l: string) => l.includes('@deprecated'));
136
+ expect(deprecatedIdx).toBeGreaterThan(-1);
137
+ expect(lines[deprecatedIdx + 1]).toContain("= 'oauth';");
138
+ });
139
+
140
+ it('adds PHPDoc with description and @deprecated for enum values', () => {
141
+ const enums: Enum[] = [
142
+ {
143
+ name: 'ConnectionType',
144
+ values: [
145
+ { name: 'SAML', value: 'saml' },
146
+ { name: 'OAUTH', value: 'oauth', description: 'Use OIDC instead', deprecated: true },
147
+ ],
148
+ },
149
+ ];
150
+
151
+ const result = generateEnums(enums, ctx);
152
+
153
+ expect(result[0].content).toContain('Use OIDC instead');
154
+ expect(result[0].content).toContain('@deprecated');
155
+ });
156
+
157
+ it('deduplicates case names', () => {
158
+ const enums: Enum[] = [
159
+ {
160
+ name: 'DupEnum',
161
+ values: [
162
+ { name: 'FOO_BAR', value: 'foo_bar' },
163
+ { name: 'FOO__BAR', value: 'foo__bar' },
164
+ ],
165
+ },
166
+ ];
167
+
168
+ const result = generateEnums(enums, ctx);
169
+
170
+ expect(result[0].content).toContain('case FooBar =');
171
+ expect(result[0].content).toContain('case FooBar2 =');
172
+ });
173
+ });
@@ -0,0 +1,9 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { generateErrors } from '../../src/php/errors.js';
3
+
4
+ describe('generateErrors', () => {
5
+ it('returns empty array (errors are now hand-maintained)', () => {
6
+ const result = generateErrors();
7
+ expect(result).toEqual([]);
8
+ });
9
+ });