@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
@@ -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
+ });
@@ -0,0 +1,497 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import type { EmitterContext, ApiSpec, Model } from '@workos/oagen';
3
+ import { defaultSdkBehavior } from '@workos/oagen';
4
+ import { generateModels } from '../../src/php/models.js';
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
+ /** Find the model file for a given class name (skipping the trait file). */
23
+ function findModel(result: ReturnType<typeof generateModels>, name: string) {
24
+ return result.find((f) => f.path === `lib/Resource/${name}.php`);
25
+ }
26
+
27
+ describe('generateModels', () => {
28
+ it('returns empty array for no models', () => {
29
+ expect(generateModels([], ctx)).toEqual([]);
30
+ });
31
+
32
+ it('generates a readonly class with constructor promotion', () => {
33
+ const models: Model[] = [
34
+ {
35
+ name: 'Organization',
36
+ fields: [
37
+ { name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
38
+ { name: 'name', type: { kind: 'primitive', type: 'string' }, required: true },
39
+ { name: 'slug', type: { kind: 'primitive', type: 'string' }, required: false },
40
+ ],
41
+ },
42
+ ];
43
+
44
+ const specWithModels = { ...emptySpec, models };
45
+ const result = generateModels(models, { ...ctx, spec: specWithModels });
46
+
47
+ const file = findModel(result, 'Organization');
48
+ expect(file).toBeDefined();
49
+ expect(file!.content).toContain('readonly class Organization');
50
+ expect(file!.content).toContain('public string $id,');
51
+ expect(file!.content).toContain('public string $name,');
52
+ expect(file!.content).toContain('public ?string $slug = null,');
53
+ expect(file!.content).toContain('public static function fromArray(array $data): self');
54
+ expect(file!.content).toContain('public function toArray(): array');
55
+ expect(file!.content).toContain('implements \\JsonSerializable');
56
+ expect(file!.content).toContain('use JsonSerializableTrait;');
57
+ });
58
+
59
+ it('generates JsonSerializableTrait file', () => {
60
+ const models: Model[] = [
61
+ {
62
+ name: 'Item',
63
+ fields: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
64
+ },
65
+ ];
66
+
67
+ const specWithModels = { ...emptySpec, models };
68
+ const result = generateModels(models, { ...ctx, spec: specWithModels });
69
+
70
+ const trait = result.find((f) => f.path === 'lib/Resource/JsonSerializableTrait.php');
71
+ expect(trait).toBeDefined();
72
+ expect(trait!.content).toContain('trait JsonSerializableTrait');
73
+ expect(trait!.content).toContain('return $this->toArray();');
74
+ });
75
+
76
+ it('handles required date-time fields without fallback', () => {
77
+ const models: Model[] = [
78
+ {
79
+ name: 'Event',
80
+ fields: [
81
+ { name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
82
+ { name: 'created_at', type: { kind: 'primitive', type: 'string', format: 'date-time' }, required: true },
83
+ ],
84
+ },
85
+ ];
86
+
87
+ const specWithModels = { ...emptySpec, models };
88
+ const result = generateModels(models, { ...ctx, spec: specWithModels });
89
+
90
+ const file = findModel(result, 'Event');
91
+ expect(file).toBeDefined();
92
+ expect(file!.content).toContain('\\DateTimeImmutable $createdAt');
93
+ expect(file!.content).toContain("new \\DateTimeImmutable($data['created_at'])");
94
+ expect(file!.content).not.toContain("?? 'now'");
95
+ });
96
+
97
+ it('handles optional date-time fields with isset guard and no fallback', () => {
98
+ const models: Model[] = [
99
+ {
100
+ name: 'Session',
101
+ fields: [
102
+ { name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
103
+ {
104
+ name: 'last_sign_in_at',
105
+ type: { kind: 'primitive', type: 'string', format: 'date-time' },
106
+ required: false,
107
+ },
108
+ ],
109
+ },
110
+ ];
111
+
112
+ const specWithModels = { ...emptySpec, models };
113
+ const result = generateModels(models, { ...ctx, spec: specWithModels });
114
+
115
+ const file = findModel(result, 'Session');
116
+ expect(file).toBeDefined();
117
+ expect(file!.content).toContain(
118
+ "isset($data['last_sign_in_at']) ? new \\DateTimeImmutable($data['last_sign_in_at']) : null",
119
+ );
120
+ expect(file!.content).not.toContain("?? 'now'");
121
+ });
122
+
123
+ it('handles enum fields with ::from() not ::tryFrom()', () => {
124
+ const models: Model[] = [
125
+ {
126
+ name: 'Connection',
127
+ fields: [
128
+ { name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
129
+ { name: 'status', type: { kind: 'enum', name: 'ConnectionStatus' }, required: true },
130
+ ],
131
+ },
132
+ ];
133
+
134
+ const specWithModels = { ...emptySpec, models };
135
+ const result = generateModels(models, { ...ctx, spec: specWithModels });
136
+
137
+ const file = findModel(result, 'Connection');
138
+ expect(file).toBeDefined();
139
+ expect(file!.content).toContain("ConnectionStatus::from($data['status'])");
140
+ expect(file!.content).not.toContain('tryFrom');
141
+ });
142
+
143
+ it('handles model references in fromArray', () => {
144
+ const models: Model[] = [
145
+ {
146
+ name: 'User',
147
+ fields: [
148
+ { name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
149
+ { name: 'profile', type: { kind: 'model', name: 'Profile' }, required: false },
150
+ ],
151
+ },
152
+ {
153
+ name: 'Profile',
154
+ fields: [{ name: 'bio', type: { kind: 'primitive', type: 'string' }, required: true }],
155
+ },
156
+ ];
157
+
158
+ const specWithModels = { ...emptySpec, models };
159
+ const result = generateModels(models, { ...ctx, spec: specWithModels });
160
+
161
+ const userFile = findModel(result, 'User');
162
+ expect(userFile).toBeDefined();
163
+ expect(userFile!.content).toContain('Profile::fromArray');
164
+ });
165
+
166
+ it('handles required nullable model fields with isset guard', () => {
167
+ const models: Model[] = [
168
+ {
169
+ name: 'FeatureFlag',
170
+ fields: [
171
+ { name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
172
+ {
173
+ name: 'owner',
174
+ type: { kind: 'nullable', inner: { kind: 'model', name: 'Owner' } },
175
+ required: true,
176
+ },
177
+ ],
178
+ },
179
+ {
180
+ name: 'Owner',
181
+ fields: [{ name: 'name', type: { kind: 'primitive', type: 'string' }, required: true }],
182
+ },
183
+ ];
184
+
185
+ const specWithModels = { ...emptySpec, models };
186
+ const result = generateModels(models, { ...ctx, spec: specWithModels });
187
+
188
+ const flagFile = findModel(result, 'FeatureFlag');
189
+ expect(flagFile).toBeDefined();
190
+ expect(flagFile!.content).toContain("isset($data['owner']) ? Owner::fromArray($data['owner']) : null");
191
+ });
192
+
193
+ it('skips list wrapper models', () => {
194
+ const models: Model[] = [
195
+ {
196
+ name: 'OrganizationList',
197
+ fields: [
198
+ {
199
+ name: 'data',
200
+ type: { kind: 'array', items: { kind: 'model', name: 'Organization' } },
201
+ required: true,
202
+ },
203
+ {
204
+ name: 'list_metadata',
205
+ type: { kind: 'model', name: 'ListMetadata' },
206
+ required: true,
207
+ },
208
+ ],
209
+ },
210
+ ];
211
+
212
+ const specWithModels = { ...emptySpec, models };
213
+ const result = generateModels(models, { ...ctx, spec: specWithModels });
214
+
215
+ // Only the trait file should be present — no model files
216
+ const modelFiles = result.filter((f) => !f.path.includes('Trait'));
217
+ expect(modelFiles).toHaveLength(0);
218
+ });
219
+
220
+ it('skips prefixed list metadata models like ApiKeyListListMetadata', () => {
221
+ const models: Model[] = [
222
+ {
223
+ name: 'ApiKeyListListMetadata',
224
+ fields: [
225
+ { name: 'after', type: { kind: 'primitive', type: 'string' }, required: false },
226
+ { name: 'before', type: { kind: 'primitive', type: 'string' }, required: false },
227
+ ],
228
+ },
229
+ ];
230
+
231
+ const specWithModels = { ...emptySpec, models };
232
+ const result = generateModels(models, { ...ctx, spec: specWithModels });
233
+
234
+ // Only the trait file should be present — no model files
235
+ const modelFiles = result.filter((f) => !f.path.includes('Trait'));
236
+ expect(modelFiles).toHaveLength(0);
237
+ });
238
+
239
+ it('generates correct namespace', () => {
240
+ const models: Model[] = [
241
+ {
242
+ name: 'Item',
243
+ fields: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
244
+ },
245
+ ];
246
+
247
+ const specWithModels = { ...emptySpec, models };
248
+ const result = generateModels(models, { ...ctx, spec: specWithModels });
249
+
250
+ const file = findModel(result, 'Item');
251
+ expect(file).toBeDefined();
252
+ expect(file!.content).toContain('namespace WorkOS\\Resource;');
253
+ });
254
+
255
+ it('adds PHPDoc @deprecated for deprecated fields', () => {
256
+ const models: Model[] = [
257
+ {
258
+ name: 'Connection',
259
+ fields: [
260
+ { name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
261
+ { name: 'old_field', type: { kind: 'primitive', type: 'string' }, required: false, deprecated: true },
262
+ ],
263
+ },
264
+ ];
265
+
266
+ const specWithModels = { ...emptySpec, models };
267
+ const result = generateModels(models, { ...ctx, spec: specWithModels });
268
+
269
+ const file = findModel(result, 'Connection');
270
+ expect(file).toBeDefined();
271
+ expect(file!.content).toContain('/** @deprecated */');
272
+ // The deprecated PHPDoc should come before the property
273
+ const lines = file!.content.split('\n');
274
+ const deprecatedIdx = lines.findIndex((l: string) => l.includes('@deprecated'));
275
+ const propertyIdx = lines.findIndex((l: string) => l.includes('$oldField'));
276
+ expect(deprecatedIdx).toBeGreaterThan(-1);
277
+ expect(propertyIdx).toBeGreaterThan(-1);
278
+ expect(deprecatedIdx).toBeLessThan(propertyIdx);
279
+ });
280
+
281
+ it('adds PHPDoc with description and @deprecated for fields', () => {
282
+ const models: Model[] = [
283
+ {
284
+ name: 'Connection',
285
+ fields: [
286
+ { name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
287
+ {
288
+ name: 'legacy_name',
289
+ type: { kind: 'primitive', type: 'string' },
290
+ required: false,
291
+ description: 'Use name instead',
292
+ deprecated: true,
293
+ },
294
+ ],
295
+ },
296
+ ];
297
+
298
+ const specWithModels = { ...emptySpec, models };
299
+ const result = generateModels(models, { ...ctx, spec: specWithModels });
300
+
301
+ const file = findModel(result, 'Connection');
302
+ expect(file).toBeDefined();
303
+ expect(file!.content).toContain('Use name instead');
304
+ expect(file!.content).toContain('@deprecated');
305
+ });
306
+
307
+ it('adds @var PHPDoc for array-typed properties', () => {
308
+ const models: Model[] = [
309
+ {
310
+ name: 'Connection',
311
+ fields: [
312
+ { name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
313
+ {
314
+ name: 'domains',
315
+ type: { kind: 'array', items: { kind: 'model', name: 'ConnectionDomain' } },
316
+ required: true,
317
+ },
318
+ ],
319
+ },
320
+ {
321
+ name: 'ConnectionDomain',
322
+ fields: [{ name: 'domain', type: { kind: 'primitive', type: 'string' }, required: true }],
323
+ },
324
+ ];
325
+
326
+ const specWithModels = { ...emptySpec, models };
327
+ const result = generateModels(models, { ...ctx, spec: specWithModels });
328
+
329
+ const file = findModel(result, 'Connection');
330
+ expect(file).toBeDefined();
331
+ expect(file!.content).toContain('@var array<\\WorkOS\\Resource\\ConnectionDomain>');
332
+ });
333
+
334
+ it('adds @var PHPDoc for nullable array-typed properties', () => {
335
+ const models: Model[] = [
336
+ {
337
+ name: 'User',
338
+ fields: [
339
+ { name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
340
+ {
341
+ name: 'roles',
342
+ type: { kind: 'nullable', inner: { kind: 'array', items: { kind: 'primitive', type: 'string' } } },
343
+ required: false,
344
+ },
345
+ ],
346
+ },
347
+ ];
348
+
349
+ const specWithModels = { ...emptySpec, models };
350
+ const result = generateModels(models, { ...ctx, spec: specWithModels });
351
+
352
+ const file = findModel(result, 'User');
353
+ expect(file).toBeDefined();
354
+ expect(file!.content).toContain('@var array<string>|null');
355
+ });
356
+
357
+ it('hydrates optional array-of-model fields via array_map in fromArray', () => {
358
+ const models: Model[] = [
359
+ {
360
+ name: 'DirectoryUser',
361
+ fields: [
362
+ { name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
363
+ {
364
+ name: 'emails',
365
+ type: { kind: 'array', items: { kind: 'model', name: 'DirectoryUserEmail' } },
366
+ required: false,
367
+ },
368
+ ],
369
+ },
370
+ {
371
+ name: 'DirectoryUserEmail',
372
+ fields: [{ name: 'value', type: { kind: 'primitive', type: 'string' }, required: true }],
373
+ },
374
+ ];
375
+
376
+ const specWithModels = { ...emptySpec, models };
377
+ const result = generateModels(models, { ...ctx, spec: specWithModels });
378
+
379
+ const file = findModel(result, 'DirectoryUser');
380
+ expect(file).toBeDefined();
381
+ // fromArray should use isset guard + array_map for optional array-of-model
382
+ expect(file!.content).toContain(
383
+ "isset($data['emails']) ? array_map(fn ($item) => DirectoryUserEmail::fromArray($item), $data['emails']) : null",
384
+ );
385
+ // toArray should call ->toArray() on each item
386
+ expect(file!.content).toContain(
387
+ '$this->emails !== null ? array_map(fn ($item) => $item->toArray(), $this->emails) : null',
388
+ );
389
+ });
390
+
391
+ it('hydrates optional array-of-enum fields via array_map in fromArray', () => {
392
+ const models: Model[] = [
393
+ {
394
+ name: 'Profile',
395
+ fields: [
396
+ { name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
397
+ {
398
+ name: 'roles',
399
+ type: { kind: 'array', items: { kind: 'enum', name: 'RoleType' } },
400
+ required: false,
401
+ },
402
+ ],
403
+ },
404
+ ];
405
+
406
+ const specWithModels = { ...emptySpec, models };
407
+ const result = generateModels(models, { ...ctx, spec: specWithModels });
408
+
409
+ const file = findModel(result, 'Profile');
410
+ expect(file).toBeDefined();
411
+ expect(file!.content).toContain(
412
+ "isset($data['roles']) ? array_map(fn ($item) => RoleType::from($item), $data['roles']) : null",
413
+ );
414
+ expect(file!.content).toContain(
415
+ '$this->roles !== null ? array_map(fn ($item) => $item->value, $this->roles) : null',
416
+ );
417
+ });
418
+
419
+ it('uses nullsafe operator for nullable enum in toArray', () => {
420
+ const models: Model[] = [
421
+ {
422
+ name: 'Connection',
423
+ fields: [
424
+ { name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
425
+ { name: 'status', type: { kind: 'enum', name: 'ConnectionStatus' }, required: false },
426
+ ],
427
+ },
428
+ ];
429
+
430
+ const specWithModels = { ...emptySpec, models };
431
+ const result = generateModels(models, { ...ctx, spec: specWithModels });
432
+
433
+ const file = findModel(result, 'Connection');
434
+ expect(file).toBeDefined();
435
+ expect(file!.content).toContain('$this->status?->value');
436
+ expect(file!.content).not.toContain('instanceof \\BackedEnum');
437
+ });
438
+
439
+ it('deduplicates structurally identical models', () => {
440
+ const models: Model[] = [
441
+ {
442
+ name: 'FlagCreatedContextActor',
443
+ fields: [
444
+ { name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
445
+ { name: 'type', type: { kind: 'primitive', type: 'string' }, required: true },
446
+ ],
447
+ },
448
+ {
449
+ name: 'FlagUpdatedContextActor',
450
+ fields: [
451
+ { name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
452
+ { name: 'type', type: { kind: 'primitive', type: 'string' }, required: true },
453
+ ],
454
+ },
455
+ {
456
+ name: 'FlagDeletedContextActor',
457
+ fields: [
458
+ { name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
459
+ { name: 'type', type: { kind: 'primitive', type: 'string' }, required: true },
460
+ ],
461
+ },
462
+ ];
463
+
464
+ const specWithModels = { ...emptySpec, models };
465
+ const result = generateModels(models, { ...ctx, spec: specWithModels });
466
+
467
+ // Only the trait + one canonical model file should be emitted (not 3)
468
+ const modelFiles = result.filter((f) => !f.path.includes('Trait'));
469
+ expect(modelFiles).toHaveLength(1);
470
+ // Shortest class name wins as canonical
471
+ expect(modelFiles[0].path).toContain('FlagCreatedContextActor');
472
+ });
473
+
474
+ it('does not produce double |null in @var for nullable optional arrays', () => {
475
+ const models: Model[] = [
476
+ {
477
+ name: 'User',
478
+ fields: [
479
+ { name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
480
+ {
481
+ name: 'tags',
482
+ type: { kind: 'nullable', inner: { kind: 'array', items: { kind: 'primitive', type: 'string' } } },
483
+ required: false,
484
+ },
485
+ ],
486
+ },
487
+ ];
488
+
489
+ const specWithModels = { ...emptySpec, models };
490
+ const result = generateModels(models, { ...ctx, spec: specWithModels });
491
+
492
+ const file = findModel(result, 'User');
493
+ expect(file).toBeDefined();
494
+ expect(file!.content).toContain('@var array<string>|null');
495
+ expect(file!.content).not.toContain('|null|null');
496
+ });
497
+ });