@workos/oagen-emitters 0.2.0 → 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 (110) hide show
  1. package/.husky/pre-commit +1 -0
  2. package/.oxfmtrc.json +8 -1
  3. package/.release-please-manifest.json +1 -1
  4. package/CHANGELOG.md +15 -0
  5. package/README.md +129 -0
  6. package/dist/index.d.mts +10 -1
  7. package/dist/index.d.mts.map +1 -1
  8. package/dist/index.mjs +11943 -2728
  9. package/dist/index.mjs.map +1 -1
  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 +298 -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 +17 -3
  18. package/smoke/sdk-elixir.ts +17 -3
  19. package/smoke/sdk-go.ts +137 -46
  20. package/smoke/sdk-kotlin.ts +23 -4
  21. package/smoke/sdk-node.ts +15 -3
  22. package/smoke/sdk-php.ts +28 -26
  23. package/smoke/sdk-python.ts +5 -2
  24. package/smoke/sdk-ruby.ts +17 -3
  25. package/smoke/sdk-rust.ts +16 -3
  26. package/src/go/client.ts +141 -0
  27. package/src/go/enums.ts +196 -0
  28. package/src/go/fixtures.ts +212 -0
  29. package/src/go/index.ts +81 -0
  30. package/src/go/manifest.ts +36 -0
  31. package/src/go/models.ts +254 -0
  32. package/src/go/naming.ts +191 -0
  33. package/src/go/resources.ts +827 -0
  34. package/src/go/tests.ts +751 -0
  35. package/src/go/type-map.ts +82 -0
  36. package/src/go/wrappers.ts +261 -0
  37. package/src/index.ts +3 -0
  38. package/src/node/client.ts +167 -122
  39. package/src/node/enums.ts +13 -4
  40. package/src/node/errors.ts +42 -233
  41. package/src/node/field-plan.ts +726 -0
  42. package/src/node/fixtures.ts +15 -5
  43. package/src/node/index.ts +65 -16
  44. package/src/node/models.ts +264 -96
  45. package/src/node/naming.ts +52 -25
  46. package/src/node/resources.ts +621 -172
  47. package/src/node/sdk-errors.ts +41 -0
  48. package/src/node/tests.ts +71 -27
  49. package/src/node/type-map.ts +4 -2
  50. package/src/node/utils.ts +56 -64
  51. package/src/node/wrappers.ts +151 -0
  52. package/src/php/client.ts +171 -0
  53. package/src/php/enums.ts +67 -0
  54. package/src/php/errors.ts +9 -0
  55. package/src/php/fixtures.ts +181 -0
  56. package/src/php/index.ts +96 -0
  57. package/src/php/manifest.ts +36 -0
  58. package/src/php/models.ts +310 -0
  59. package/src/php/naming.ts +298 -0
  60. package/src/php/resources.ts +561 -0
  61. package/src/php/tests.ts +533 -0
  62. package/src/php/type-map.ts +90 -0
  63. package/src/php/utils.ts +18 -0
  64. package/src/php/wrappers.ts +151 -0
  65. package/src/python/client.ts +337 -0
  66. package/src/python/enums.ts +313 -0
  67. package/src/python/fixtures.ts +196 -0
  68. package/src/python/index.ts +95 -0
  69. package/src/python/manifest.ts +38 -0
  70. package/src/python/models.ts +688 -0
  71. package/src/python/naming.ts +209 -0
  72. package/src/python/resources.ts +1322 -0
  73. package/src/python/tests.ts +1335 -0
  74. package/src/python/type-map.ts +93 -0
  75. package/src/python/wrappers.ts +191 -0
  76. package/src/shared/model-utils.ts +255 -0
  77. package/src/shared/naming-utils.ts +107 -0
  78. package/src/shared/non-spec-services.ts +54 -0
  79. package/src/shared/resolved-ops.ts +109 -0
  80. package/src/shared/wrapper-utils.ts +59 -0
  81. package/test/go/client.test.ts +92 -0
  82. package/test/go/enums.test.ts +132 -0
  83. package/test/go/errors.test.ts +9 -0
  84. package/test/go/models.test.ts +265 -0
  85. package/test/go/resources.test.ts +408 -0
  86. package/test/go/tests.test.ts +143 -0
  87. package/test/node/client.test.ts +199 -94
  88. package/test/node/enums.test.ts +75 -3
  89. package/test/node/errors.test.ts +2 -41
  90. package/test/node/models.test.ts +109 -20
  91. package/test/node/naming.test.ts +37 -4
  92. package/test/node/resources.test.ts +662 -30
  93. package/test/node/serializers.test.ts +36 -7
  94. package/test/node/type-map.test.ts +11 -0
  95. package/test/php/client.test.ts +94 -0
  96. package/test/php/enums.test.ts +173 -0
  97. package/test/php/errors.test.ts +9 -0
  98. package/test/php/models.test.ts +497 -0
  99. package/test/php/resources.test.ts +644 -0
  100. package/test/php/tests.test.ts +118 -0
  101. package/test/python/client.test.ts +200 -0
  102. package/test/python/enums.test.ts +228 -0
  103. package/test/python/errors.test.ts +16 -0
  104. package/test/python/manifest.test.ts +74 -0
  105. package/test/python/models.test.ts +716 -0
  106. package/test/python/resources.test.ts +617 -0
  107. package/test/python/tests.test.ts +202 -0
  108. package/src/node/common.ts +0 -273
  109. package/src/node/config.ts +0 -71
  110. package/src/node/serializers.ts +0 -744
@@ -0,0 +1,716 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { generateModels } from '../../src/python/models.js';
3
+ import type { EmitterContext, ApiSpec, Model, Service } 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('generateModels', () => {
23
+ it('returns empty for no models', () => {
24
+ expect(generateModels([], ctx)).toEqual([]);
25
+ });
26
+
27
+ it('generates a dataclass for a model', () => {
28
+ const service: Service = {
29
+ name: 'Organizations',
30
+ operations: [
31
+ {
32
+ name: 'getOrganization',
33
+ httpMethod: 'get',
34
+ path: '/organizations/{id}',
35
+ pathParams: [
36
+ {
37
+ name: 'id',
38
+ type: { kind: 'primitive', type: 'string' },
39
+ required: true,
40
+ },
41
+ ],
42
+ queryParams: [],
43
+ headerParams: [],
44
+ response: { kind: 'model', name: 'Organization' },
45
+ errors: [],
46
+ injectIdempotencyKey: false,
47
+ },
48
+ ],
49
+ };
50
+
51
+ const models: Model[] = [
52
+ {
53
+ name: 'Organization',
54
+ fields: [
55
+ {
56
+ name: 'id',
57
+ type: { kind: 'primitive', type: 'string' },
58
+ required: true,
59
+ },
60
+ {
61
+ name: 'name',
62
+ type: { kind: 'primitive', type: 'string' },
63
+ required: true,
64
+ },
65
+ {
66
+ name: 'created_at',
67
+ type: { kind: 'primitive', type: 'string', format: 'date-time' },
68
+ required: true,
69
+ },
70
+ {
71
+ name: 'external_id',
72
+ type: {
73
+ kind: 'nullable',
74
+ inner: { kind: 'primitive', type: 'string' },
75
+ },
76
+ required: false,
77
+ },
78
+ ],
79
+ },
80
+ ];
81
+
82
+ const ctxWithServices: EmitterContext = {
83
+ ...ctx,
84
+ spec: { ...emptySpec, services: [service], models },
85
+ };
86
+
87
+ const files = generateModels(models, ctxWithServices);
88
+ // Model file + barrel __init__.py (parent __init__.py is generated by generateServiceInits)
89
+ expect(files.length).toBe(2);
90
+
91
+ const modelFile = files.find((f) => f.path === 'src/workos/organizations/models/organization.py')!;
92
+ expect(modelFile).toBeDefined();
93
+
94
+ // Has dataclass decorator
95
+ expect(modelFile.content).toContain('@dataclass(slots=True)');
96
+ expect(modelFile.content).toContain('class Organization:');
97
+
98
+ // Required fields
99
+ expect(modelFile.content).toContain(' id: str');
100
+ expect(modelFile.content).toContain(' name: str');
101
+ expect(modelFile.content).toContain('from datetime import datetime');
102
+ expect(modelFile.content).toContain(' created_at: datetime');
103
+
104
+ // Optional/nullable field
105
+ expect(modelFile.content).toContain(' external_id: Optional[str] = None');
106
+
107
+ // from_dict method
108
+ expect(modelFile.content).toContain('def from_dict(cls, data: Dict[str, Any])');
109
+ expect(modelFile.content).toContain('_parse_datetime(data["created_at"])');
110
+ expect(modelFile.content).toContain('_raise_deserialize_error("Organization", e)');
111
+ expect(modelFile.content).toContain('from workos._types import _raise_deserialize_error');
112
+
113
+ // to_dict method
114
+ expect(modelFile.content).toContain('def to_dict(self) -> Dict[str, Any]:');
115
+ expect(modelFile.content).toContain('result["created_at"] = _format_datetime(self.created_at)');
116
+ expect(modelFile.content).toContain('from workos._types import _format_datetime, _parse_datetime');
117
+ expect(modelFile.content).toContain('result["external_id"] = None');
118
+ });
119
+
120
+ it('handles array fields with model refs', () => {
121
+ const service: Service = {
122
+ name: 'Organizations',
123
+ operations: [
124
+ {
125
+ name: 'getOrganization',
126
+ httpMethod: 'get',
127
+ path: '/organizations/{id}',
128
+ pathParams: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
129
+ queryParams: [],
130
+ headerParams: [],
131
+ response: { kind: 'model', name: 'Organization' },
132
+ errors: [],
133
+ injectIdempotencyKey: false,
134
+ },
135
+ ],
136
+ };
137
+
138
+ const models: Model[] = [
139
+ {
140
+ name: 'Organization',
141
+ fields: [
142
+ {
143
+ name: 'id',
144
+ type: { kind: 'primitive', type: 'string' },
145
+ required: true,
146
+ },
147
+ {
148
+ name: 'domains',
149
+ type: {
150
+ kind: 'array',
151
+ items: { kind: 'model', name: 'OrganizationDomain' },
152
+ },
153
+ required: true,
154
+ },
155
+ ],
156
+ },
157
+ {
158
+ name: 'OrganizationDomain',
159
+ fields: [
160
+ {
161
+ name: 'id',
162
+ type: { kind: 'primitive', type: 'string' },
163
+ required: true,
164
+ },
165
+ {
166
+ name: 'domain',
167
+ type: { kind: 'primitive', type: 'string' },
168
+ required: true,
169
+ },
170
+ ],
171
+ },
172
+ ];
173
+
174
+ const ctxWithServices: EmitterContext = {
175
+ ...ctx,
176
+ spec: { ...emptySpec, services: [service], models },
177
+ };
178
+
179
+ const files = generateModels(models, ctxWithServices);
180
+ // 2 model files + 1 barrel __init__.py (parent __init__.py is generated by generateServiceInits)
181
+ expect(files.length).toBe(3);
182
+
183
+ const orgFile = files.find((f) => f.path.includes('organization.py') && !f.path.includes('organization_domain'));
184
+ expect(orgFile).toBeDefined();
185
+ expect(orgFile!.content).toContain('domains: List["OrganizationDomain"]');
186
+ expect(orgFile!.content).toContain('OrganizationDomain.from_dict(cast(Dict[str, Any], item))');
187
+ expect(orgFile!.content).not.toContain('or []');
188
+ });
189
+
190
+ it('omits optional non-nullable fields and preserves required nullable fields in to_dict', () => {
191
+ const service: Service = {
192
+ name: 'Organizations',
193
+ operations: [
194
+ {
195
+ name: 'getOrganization',
196
+ httpMethod: 'get',
197
+ path: '/organizations/{id}',
198
+ pathParams: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
199
+ queryParams: [],
200
+ headerParams: [],
201
+ response: { kind: 'model', name: 'Organization' },
202
+ errors: [],
203
+ injectIdempotencyKey: false,
204
+ },
205
+ ],
206
+ };
207
+
208
+ const models: Model[] = [
209
+ {
210
+ name: 'Organization',
211
+ fields: [
212
+ { name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
213
+ {
214
+ name: 'nickname',
215
+ type: { kind: 'primitive', type: 'string' },
216
+ required: false,
217
+ },
218
+ {
219
+ name: 'external_id',
220
+ type: { kind: 'nullable', inner: { kind: 'primitive', type: 'string' } },
221
+ required: true,
222
+ },
223
+ ],
224
+ },
225
+ ];
226
+
227
+ const files = generateModels(models, {
228
+ ...ctx,
229
+ spec: { ...emptySpec, services: [service], models },
230
+ });
231
+ const modelFile = files.find((f) => f.path === 'src/workos/organizations/models/organization.py')!;
232
+ expect(modelFile.content).toContain('if self.nickname is not None:');
233
+ expect(modelFile.content).not.toContain('result["nickname"] = None');
234
+ expect(modelFile.content).toContain('result["external_id"] = None');
235
+ });
236
+
237
+ it('skips list wrapper and list metadata models', () => {
238
+ const service: Service = {
239
+ name: 'Organizations',
240
+ operations: [
241
+ {
242
+ name: 'listOrganizations',
243
+ httpMethod: 'get',
244
+ path: '/organizations',
245
+ pathParams: [],
246
+ queryParams: [],
247
+ headerParams: [],
248
+ response: { kind: 'model', name: 'OrganizationList' },
249
+ errors: [],
250
+ injectIdempotencyKey: false,
251
+ pagination: {
252
+ strategy: 'cursor',
253
+ param: 'after',
254
+ dataPath: 'data',
255
+ itemType: { kind: 'model', name: 'Organization' },
256
+ },
257
+ },
258
+ ],
259
+ };
260
+
261
+ const models: Model[] = [
262
+ {
263
+ name: 'Organization',
264
+ fields: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
265
+ },
266
+ {
267
+ name: 'OrganizationList',
268
+ fields: [
269
+ {
270
+ name: 'data',
271
+ type: { kind: 'array', items: { kind: 'model', name: 'Organization' } },
272
+ required: true,
273
+ },
274
+ {
275
+ name: 'list_metadata',
276
+ type: { kind: 'model', name: 'ListMetadata' },
277
+ required: true,
278
+ },
279
+ ],
280
+ },
281
+ {
282
+ name: 'ListMetadata',
283
+ fields: [
284
+ { name: 'before', type: { kind: 'primitive', type: 'string' }, required: false },
285
+ { name: 'after', type: { kind: 'primitive', type: 'string' }, required: false },
286
+ ],
287
+ },
288
+ ];
289
+
290
+ const ctxWithServices: EmitterContext = {
291
+ ...ctx,
292
+ spec: { ...emptySpec, services: [service], models },
293
+ };
294
+
295
+ const files = generateModels(models, ctxWithServices);
296
+ const filePaths = files.map((f) => f.path);
297
+
298
+ // Should generate Organization but NOT OrganizationList or ListMetadata
299
+ expect(filePaths.some((p) => p.includes('organization.py'))).toBe(true);
300
+ expect(filePaths.some((p) => p.includes('organization_list.py'))).toBe(false);
301
+ expect(filePaths.some((p) => p.includes('list_metadata.py'))).toBe(false);
302
+
303
+ // Barrel should not export the skipped models
304
+ const barrel = files.find((f) => f.path.endsWith('__init__.py') && f.path.includes('models/'));
305
+ expect(barrel).toBeDefined();
306
+ expect(barrel!.content).not.toContain('OrganizationList');
307
+ expect(barrel!.content).not.toContain('ListMetadata');
308
+ });
309
+
310
+ it('generates type alias for structurally identical models', () => {
311
+ const service: Service = {
312
+ name: 'Organizations',
313
+ operations: [
314
+ {
315
+ name: 'getOrganization',
316
+ httpMethod: 'get',
317
+ path: '/organizations/{id}',
318
+ pathParams: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
319
+ queryParams: [],
320
+ headerParams: [],
321
+ response: { kind: 'model', name: 'OrganizationDomain' },
322
+ errors: [],
323
+ injectIdempotencyKey: false,
324
+ },
325
+ ],
326
+ };
327
+
328
+ const models: Model[] = [
329
+ {
330
+ name: 'OrganizationDomain',
331
+ fields: [
332
+ { name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
333
+ { name: 'domain', type: { kind: 'primitive', type: 'string' }, required: true },
334
+ ],
335
+ },
336
+ {
337
+ name: 'OrganizationDomainStandAlone',
338
+ fields: [
339
+ { name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
340
+ { name: 'domain', type: { kind: 'primitive', type: 'string' }, required: true },
341
+ ],
342
+ },
343
+ ];
344
+
345
+ const ctxWithServices: EmitterContext = {
346
+ ...ctx,
347
+ spec: { ...emptySpec, services: [service], models },
348
+ };
349
+
350
+ const files = generateModels(models, ctxWithServices);
351
+
352
+ // Canonical model should have a full dataclass
353
+ const canonicalFile = files.find(
354
+ (f) => f.path.includes('organization_domain.py') && !f.path.includes('stand_alone'),
355
+ )!;
356
+ expect(canonicalFile).toBeDefined();
357
+ expect(canonicalFile.content).toContain('@dataclass');
358
+ expect(canonicalFile.content).toContain('class OrganizationDomain:');
359
+
360
+ // Alias model should be a type alias
361
+ const aliasFile = files.find((f) => f.path.includes('organization_domain_stand_alone.py'))!;
362
+ expect(aliasFile).toBeDefined();
363
+ expect(aliasFile.content).toContain('OrganizationDomainStandAlone: TypeAlias = OrganizationDomain');
364
+ expect(aliasFile.content).not.toContain('@dataclass');
365
+ });
366
+
367
+ it('does not alias response models to request-only dto models', () => {
368
+ const service: Service = {
369
+ name: 'Pipes',
370
+ operations: [
371
+ {
372
+ name: 'getUserlandUserToken',
373
+ httpMethod: 'post',
374
+ path: '/data-integrations/{slug}/token',
375
+ pathParams: [{ name: 'slug', type: { kind: 'primitive', type: 'string' }, required: true }],
376
+ queryParams: [],
377
+ headerParams: [],
378
+ requestBody: { kind: 'model', name: 'CreateApplicationSecret' },
379
+ response: { kind: 'model', name: 'DataIntegrationAccessTokenResponse' },
380
+ errors: [],
381
+ injectIdempotencyKey: false,
382
+ },
383
+ ],
384
+ };
385
+
386
+ const models: Model[] = [
387
+ {
388
+ name: 'CreateApplicationSecret',
389
+ fields: [{ name: 'access_token', type: { kind: 'primitive', type: 'string' }, required: true }],
390
+ },
391
+ {
392
+ name: 'DataIntegrationAccessTokenResponse',
393
+ fields: [{ name: 'access_token', type: { kind: 'primitive', type: 'string' }, required: true }],
394
+ },
395
+ ];
396
+
397
+ const files = generateModels(models, { ...ctx, spec: { ...emptySpec, services: [service], models } });
398
+ const responseFile = files.find((f) => f.path.includes('data_integration_access_token_response.py'))!;
399
+ expect(responseFile.content).toContain('@dataclass(slots=True)');
400
+ expect(responseFile.content).not.toContain('= CreateApplicationSecret');
401
+ // Dto stripping now happens at IR level (schemaNameTransform in oagen.config.ts),
402
+ // so model names arrive at the emitter already stripped
403
+ const dtoFile = files.find((f) => f.path.includes('create_application_secret.py'));
404
+ expect(dtoFile).toBeDefined();
405
+ expect(dtoFile!.content).toContain('class CreateApplicationSecret:');
406
+ expect(files.every((f) => !f.path.includes('_dto'))).toBe(true);
407
+ });
408
+
409
+ it('treats deprecated or baseline-optional fields as optional', () => {
410
+ const service: Service = {
411
+ name: 'Events',
412
+ operations: [
413
+ {
414
+ name: 'getOrganizationEvent',
415
+ httpMethod: 'get',
416
+ path: '/events/{id}',
417
+ pathParams: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
418
+ queryParams: [],
419
+ headerParams: [],
420
+ response: { kind: 'model', name: 'OrganizationEvent' },
421
+ errors: [],
422
+ injectIdempotencyKey: false,
423
+ },
424
+ ],
425
+ };
426
+
427
+ const models: Model[] = [
428
+ {
429
+ name: 'OrganizationEvent',
430
+ fields: [
431
+ { name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
432
+ {
433
+ name: 'allow_profiles_outside_organization',
434
+ type: { kind: 'primitive', type: 'boolean' },
435
+ required: true,
436
+ deprecated: true,
437
+ },
438
+ ],
439
+ },
440
+ ];
441
+
442
+ const files = generateModels(models, { ...ctx, spec: { ...emptySpec, services: [service], models } });
443
+ const eventFile = files.find((f) => f.path.includes('organization_event.py'))!;
444
+ expect(eventFile.content).toContain('allow_profiles_outside_organization: Optional[bool] = None');
445
+ expect(eventFile.content).toContain('data.get("allow_profiles_outside_organization")');
446
+ });
447
+
448
+ it('handles union fields with identical model variants in from_dict', () => {
449
+ const service: Service = {
450
+ name: 'Auth',
451
+ operations: [
452
+ {
453
+ name: 'getFactor',
454
+ httpMethod: 'get',
455
+ path: '/auth/factors/{id}',
456
+ pathParams: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
457
+ queryParams: [],
458
+ headerParams: [],
459
+ response: { kind: 'model', name: 'AuthenticationFactor' },
460
+ errors: [],
461
+ injectIdempotencyKey: false,
462
+ },
463
+ ],
464
+ };
465
+
466
+ const models: Model[] = [
467
+ {
468
+ name: 'AuthenticationFactor',
469
+ fields: [
470
+ { name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
471
+ {
472
+ name: 'totp',
473
+ type: {
474
+ kind: 'union',
475
+ variants: [
476
+ { kind: 'model', name: 'AuthenticationFactorTotp' },
477
+ { kind: 'model', name: 'AuthenticationFactorTotp' },
478
+ ],
479
+ },
480
+ required: false,
481
+ },
482
+ ],
483
+ },
484
+ {
485
+ name: 'AuthenticationFactorTotp',
486
+ fields: [
487
+ { name: 'issuer', type: { kind: 'primitive', type: 'string' }, required: true },
488
+ { name: 'user', type: { kind: 'primitive', type: 'string' }, required: true },
489
+ ],
490
+ },
491
+ ];
492
+
493
+ const ctxWithServices: EmitterContext = {
494
+ ...ctx,
495
+ spec: { ...emptySpec, services: [service], models },
496
+ };
497
+
498
+ const files = generateModels(models, ctxWithServices);
499
+ const factorFile = files.find((f) => f.path.includes('authentication_factor.py') && !f.path.includes('totp'))!;
500
+ expect(factorFile).toBeDefined();
501
+
502
+ // Union of identical model refs should collapse in from_dict
503
+ expect(factorFile.content).toContain('AuthenticationFactorTotp.from_dict');
504
+ // Type annotation should be collapsed (no Union[Foo, Foo])
505
+ expect(factorFile.content).toContain('Optional["AuthenticationFactorTotp"]');
506
+ expect(factorFile.content).not.toContain('Union["AuthenticationFactorTotp", "AuthenticationFactorTotp"]');
507
+ });
508
+
509
+ it('handles map fields', () => {
510
+ const service: Service = {
511
+ name: 'Organizations',
512
+ operations: [
513
+ {
514
+ name: 'getOrganization',
515
+ httpMethod: 'get',
516
+ path: '/organizations/{id}',
517
+ pathParams: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
518
+ queryParams: [],
519
+ headerParams: [],
520
+ response: { kind: 'model', name: 'Organization' },
521
+ errors: [],
522
+ injectIdempotencyKey: false,
523
+ },
524
+ ],
525
+ };
526
+
527
+ const models: Model[] = [
528
+ {
529
+ name: 'Organization',
530
+ fields: [
531
+ {
532
+ name: 'id',
533
+ type: { kind: 'primitive', type: 'string' },
534
+ required: true,
535
+ },
536
+ {
537
+ name: 'metadata',
538
+ type: {
539
+ kind: 'map',
540
+ valueType: { kind: 'primitive', type: 'string' },
541
+ },
542
+ required: false,
543
+ },
544
+ ],
545
+ },
546
+ ];
547
+
548
+ const ctxWithServices: EmitterContext = {
549
+ ...ctx,
550
+ spec: { ...emptySpec, services: [service], models },
551
+ };
552
+
553
+ const files = generateModels(models, ctxWithServices);
554
+ // 1 model file + 1 barrel __init__.py (parent __init__.py is generated by generateServiceInits)
555
+ expect(files.length).toBe(2);
556
+ const modelFile = files.find((f) => f.path.endsWith('organization.py'))!;
557
+ expect(modelFile.content).toContain('metadata: Optional[Dict[str, str]] = None');
558
+ });
559
+
560
+ it('adds .. deprecated:: docstring for deprecated fields', () => {
561
+ const service: Service = {
562
+ name: 'Organizations',
563
+ operations: [
564
+ {
565
+ name: 'getOrganization',
566
+ httpMethod: 'get',
567
+ path: '/organizations/{id}',
568
+ pathParams: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
569
+ queryParams: [],
570
+ headerParams: [],
571
+ response: { kind: 'model', name: 'Organization' },
572
+ errors: [],
573
+ injectIdempotencyKey: false,
574
+ },
575
+ ],
576
+ };
577
+
578
+ const models: Model[] = [
579
+ {
580
+ name: 'Organization',
581
+ fields: [
582
+ { name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
583
+ {
584
+ name: 'old_field',
585
+ type: { kind: 'primitive', type: 'string' },
586
+ required: true,
587
+ deprecated: true,
588
+ description: 'Legacy field',
589
+ },
590
+ {
591
+ name: 'old_no_desc',
592
+ type: { kind: 'primitive', type: 'string' },
593
+ required: false,
594
+ deprecated: true,
595
+ },
596
+ ],
597
+ },
598
+ ];
599
+
600
+ const files = generateModels(models, {
601
+ ...ctx,
602
+ spec: { ...emptySpec, services: [service], models },
603
+ });
604
+ const modelFile = files.find((f) => f.path.includes('organization.py'))!;
605
+
606
+ // Deprecated required field with description gets both description and .. deprecated::
607
+ expect(modelFile.content).toContain('"""Legacy field\n\n .. deprecated:: This field is deprecated."""');
608
+
609
+ // Deprecated optional field without description gets just .. deprecated::
610
+ expect(modelFile.content).toContain('""".. deprecated:: This field is deprecated."""');
611
+ });
612
+
613
+ it('deduplicates models with recursively identical sub-model references', () => {
614
+ const service: Service = {
615
+ name: 'Events',
616
+ operations: [
617
+ {
618
+ name: 'listEvents',
619
+ httpMethod: 'get',
620
+ path: '/events',
621
+ pathParams: [],
622
+ queryParams: [],
623
+ headerParams: [],
624
+ response: {
625
+ kind: 'union',
626
+ variants: [
627
+ { kind: 'model', name: 'EventA' },
628
+ { kind: 'model', name: 'EventB' },
629
+ ],
630
+ },
631
+ errors: [],
632
+ injectIdempotencyKey: false,
633
+ },
634
+ ],
635
+ };
636
+
637
+ const models: Model[] = [
638
+ {
639
+ name: 'EventA',
640
+ fields: [
641
+ { name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
642
+ { name: 'event', type: { kind: 'literal', value: 'event_a' }, required: true },
643
+ { name: 'context', type: { kind: 'model', name: 'EventAContext' }, required: false },
644
+ ],
645
+ },
646
+ {
647
+ name: 'EventAContext',
648
+ fields: [
649
+ { name: 'actor_id', type: { kind: 'primitive', type: 'string' }, required: false },
650
+ { name: 'detail', type: { kind: 'model', name: 'EventAContextDetail' }, required: false },
651
+ ],
652
+ },
653
+ {
654
+ name: 'EventAContextDetail',
655
+ fields: [{ name: 'ip', type: { kind: 'primitive', type: 'string' }, required: false }],
656
+ },
657
+ {
658
+ name: 'EventB',
659
+ fields: [
660
+ { name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
661
+ { name: 'event', type: { kind: 'literal', value: 'event_b' }, required: true },
662
+ { name: 'context', type: { kind: 'model', name: 'EventBContext' }, required: false },
663
+ ],
664
+ },
665
+ {
666
+ name: 'EventBContext',
667
+ fields: [
668
+ { name: 'actor_id', type: { kind: 'primitive', type: 'string' }, required: false },
669
+ { name: 'detail', type: { kind: 'model', name: 'EventBContextDetail' }, required: false },
670
+ ],
671
+ },
672
+ {
673
+ name: 'EventBContextDetail',
674
+ fields: [{ name: 'ip', type: { kind: 'primitive', type: 'string' }, required: false }],
675
+ },
676
+ ];
677
+
678
+ const ctxWithServices: EmitterContext = {
679
+ ...ctx,
680
+ spec: { ...emptySpec, services: [service], models },
681
+ };
682
+
683
+ const files = generateModels(models, ctxWithServices);
684
+
685
+ // Top-level events differ (different literal values) → both get full dataclasses
686
+ const eventAFile = files.find((f) => f.path.includes('/event_a.py'))!;
687
+ expect(eventAFile).toBeDefined();
688
+ expect(eventAFile.content).toContain('@dataclass');
689
+ expect(eventAFile.content).toContain('class EventA:');
690
+
691
+ const eventBFile = files.find((f) => f.path.includes('/event_b.py'))!;
692
+ expect(eventBFile).toBeDefined();
693
+ expect(eventBFile.content).toContain('@dataclass');
694
+ expect(eventBFile.content).toContain('class EventB:');
695
+
696
+ // Leaf models are structurally identical → one canonical, one alias
697
+ const detailAFile = files.find((f) => f.path.includes('event_a_context_detail.py'))!;
698
+ expect(detailAFile).toBeDefined();
699
+ expect(detailAFile.content).toContain('@dataclass');
700
+
701
+ const detailBFile = files.find((f) => f.path.includes('event_b_context_detail.py'))!;
702
+ expect(detailBFile).toBeDefined();
703
+ expect(detailBFile.content).toContain('TypeAlias');
704
+ expect(detailBFile.content).not.toContain('@dataclass');
705
+
706
+ // Context models reference recursively-identical sub-models → also deduplicated
707
+ const contextAFile = files.find((f) => f.path.includes('/event_a_context.py'))!;
708
+ expect(contextAFile).toBeDefined();
709
+ expect(contextAFile.content).toContain('@dataclass');
710
+
711
+ const contextBFile = files.find((f) => f.path.includes('/event_b_context.py'))!;
712
+ expect(contextBFile).toBeDefined();
713
+ expect(contextBFile.content).toContain('TypeAlias');
714
+ expect(contextBFile.content).not.toContain('@dataclass');
715
+ });
716
+ });