@workos/oagen-emitters 0.0.1 → 0.2.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 (41) hide show
  1. package/.github/workflows/release-please.yml +9 -1
  2. package/.husky/commit-msg +0 -0
  3. package/.husky/pre-commit +1 -0
  4. package/.husky/pre-push +1 -0
  5. package/.prettierignore +1 -0
  6. package/.release-please-manifest.json +3 -0
  7. package/.vscode/settings.json +3 -0
  8. package/CHANGELOG.md +54 -0
  9. package/README.md +2 -2
  10. package/dist/index.d.mts +7 -0
  11. package/dist/index.d.mts.map +1 -0
  12. package/dist/index.mjs +3522 -0
  13. package/dist/index.mjs.map +1 -0
  14. package/package.json +14 -18
  15. package/release-please-config.json +11 -0
  16. package/src/node/client.ts +437 -204
  17. package/src/node/common.ts +74 -4
  18. package/src/node/config.ts +1 -0
  19. package/src/node/enums.ts +50 -6
  20. package/src/node/errors.ts +78 -3
  21. package/src/node/fixtures.ts +84 -15
  22. package/src/node/index.ts +2 -2
  23. package/src/node/manifest.ts +4 -2
  24. package/src/node/models.ts +195 -79
  25. package/src/node/naming.ts +16 -1
  26. package/src/node/resources.ts +721 -106
  27. package/src/node/serializers.ts +510 -52
  28. package/src/node/tests.ts +621 -105
  29. package/src/node/type-map.ts +89 -11
  30. package/src/node/utils.ts +377 -114
  31. package/test/node/client.test.ts +979 -15
  32. package/test/node/enums.test.ts +0 -1
  33. package/test/node/errors.test.ts +4 -21
  34. package/test/node/models.test.ts +409 -2
  35. package/test/node/naming.test.ts +0 -3
  36. package/test/node/resources.test.ts +964 -7
  37. package/test/node/serializers.test.ts +212 -3
  38. package/tsconfig.json +2 -3
  39. package/{tsup.config.ts → tsdown.config.ts} +1 -1
  40. package/dist/index.d.ts +0 -5
  41. package/dist/index.js +0 -2158
@@ -15,7 +15,6 @@ const ctx: EmitterContext = {
15
15
  namespace: 'workos',
16
16
  namespacePascal: 'WorkOS',
17
17
  spec: emptySpec,
18
- irVersion: 6,
19
18
  };
20
19
 
21
20
  describe('generateEnums', () => {
@@ -1,26 +1,9 @@
1
1
  import { describe, it, expect } from 'vitest';
2
2
  import { generateErrors } from '../../src/node/errors.js';
3
- import type { EmitterContext, ApiSpec } from '@workos/oagen';
4
-
5
- const emptySpec: ApiSpec = {
6
- name: 'Test',
7
- version: '1.0.0',
8
- baseUrl: '',
9
- services: [],
10
- models: [],
11
- enums: [],
12
- };
13
-
14
- const ctx: EmitterContext = {
15
- namespace: 'workos',
16
- namespacePascal: 'WorkOS',
17
- spec: emptySpec,
18
- irVersion: 6,
19
- };
20
3
 
21
4
  describe('generateErrors', () => {
22
5
  it('generates all exception classes', () => {
23
- const files = generateErrors(ctx);
6
+ const files = generateErrors();
24
7
 
25
8
  const names = files.map((f) => f.path);
26
9
  expect(names).toContain('src/common/exceptions/bad-request.exception.ts');
@@ -35,7 +18,7 @@ describe('generateErrors', () => {
35
18
  });
36
19
 
37
20
  it('generates NotFoundException with correct status', () => {
38
- const files = generateErrors(ctx);
21
+ const files = generateErrors();
39
22
  const notFoundFile = files.find((f) => f.path.includes('not-found.exception.ts'))!;
40
23
 
41
24
  expect(notFoundFile.content).toContain('export class NotFoundException extends Error');
@@ -44,7 +27,7 @@ describe('generateErrors', () => {
44
27
  });
45
28
 
46
29
  it('generates RateLimitExceededException with retryAfter', () => {
47
- const files = generateErrors(ctx);
30
+ const files = generateErrors();
48
31
  const rateLimitFile = files.find((f) => f.path.includes('rate-limit-exceeded.exception.ts'))!;
49
32
 
50
33
  expect(rateLimitFile.content).toContain('export class RateLimitExceededException extends Error');
@@ -53,7 +36,7 @@ describe('generateErrors', () => {
53
36
  });
54
37
 
55
38
  it('generates exception barrel with all exports', () => {
56
- const files = generateErrors(ctx);
39
+ const files = generateErrors();
57
40
  const barrel = files.find((f) => f.path === 'src/common/exceptions/index.ts')!;
58
41
 
59
42
  expect(barrel.content).toContain('export { BadRequestException }');
@@ -15,7 +15,6 @@ const ctx: EmitterContext = {
15
15
  namespace: 'workos',
16
16
  namespacePascal: 'WorkOS',
17
17
  spec: emptySpec,
18
- irVersion: 6,
19
18
  };
20
19
 
21
20
  describe('generateModels', () => {
@@ -85,7 +84,7 @@ describe('generateModels', () => {
85
84
  expect(files[0].content).toContain('export interface Organization {');
86
85
  expect(files[0].content).toContain(' id: string;');
87
86
  expect(files[0].content).toContain(' name: string;');
88
- expect(files[0].content).toContain(' createdAt: string;');
87
+ expect(files[0].content).toContain(' createdAt: Date;');
89
88
  expect(files[0].content).toContain(' externalId?: string | null;');
90
89
 
91
90
  // Response interface has snake_case fields
@@ -298,4 +297,412 @@ describe('generateModels', () => {
298
297
  // Field with only deprecated gets single-line JSDoc
299
298
  expect(content).toContain(' /** @deprecated */');
300
299
  });
300
+
301
+ it('renders field-level JSDoc from OpenAPI descriptions', () => {
302
+ const service: Service = {
303
+ name: 'Organizations',
304
+ operations: [
305
+ {
306
+ name: 'getOrganization',
307
+ httpMethod: 'get',
308
+ path: '/organizations/{id}',
309
+ pathParams: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
310
+ queryParams: [],
311
+ headerParams: [],
312
+ response: { kind: 'model', name: 'Organization' },
313
+ errors: [],
314
+ injectIdempotencyKey: false,
315
+ },
316
+ ],
317
+ };
318
+
319
+ const models: Model[] = [
320
+ {
321
+ name: 'Organization',
322
+ description: 'An organization in the WorkOS system.',
323
+ fields: [
324
+ {
325
+ name: 'id',
326
+ type: { kind: 'primitive', type: 'string' },
327
+ required: true,
328
+ description: 'Unique identifier for the organization.',
329
+ },
330
+ {
331
+ name: 'name',
332
+ type: { kind: 'primitive', type: 'string' },
333
+ required: true,
334
+ description: 'The display name of the organization.',
335
+ },
336
+ {
337
+ name: 'created_at',
338
+ type: { kind: 'primitive', type: 'string', format: 'date-time' },
339
+ required: true,
340
+ // No description — should not get JSDoc
341
+ },
342
+ {
343
+ name: 'allow_profiles_outside_organization',
344
+ type: { kind: 'primitive', type: 'boolean' },
345
+ required: false,
346
+ description:
347
+ 'Whether connections within the organization allow profiles\nthat do not have a domain that is verified by the organization.',
348
+ },
349
+ ],
350
+ },
351
+ ];
352
+
353
+ const ctxWithServices: EmitterContext = {
354
+ ...ctx,
355
+ spec: { ...emptySpec, services: [service], models },
356
+ };
357
+
358
+ const files = generateModels(models, ctxWithServices);
359
+ const content = files[0].content;
360
+
361
+ // Model-level JSDoc is emitted
362
+ expect(content).toContain('/** An organization in the WorkOS system. */');
363
+
364
+ // Fields with description get per-field JSDoc
365
+ expect(content).toContain('/** Unique identifier for the organization. */');
366
+ expect(content).toContain('/** The display name of the organization. */');
367
+
368
+ // Multiline description renders correctly
369
+ expect(content).toContain(
370
+ ' /**\n * Whether connections within the organization allow profiles\n * that do not have a domain that is verified by the organization.\n */',
371
+ );
372
+
373
+ // Field without description does NOT get JSDoc
374
+ const lines = content.split('\n');
375
+ const createdAtIdx = lines.findIndex((l) => l.includes('createdAt'));
376
+ expect(createdAtIdx).toBeGreaterThan(0);
377
+ // The line before createdAt should not be a JSDoc closing tag
378
+ expect(lines[createdAtIdx - 1].trim()).not.toBe('*/');
379
+ });
380
+
381
+ it('renders readOnly/writeOnly/default annotations', () => {
382
+ const service: Service = {
383
+ name: 'Organizations',
384
+ operations: [
385
+ {
386
+ name: 'getOrganization',
387
+ httpMethod: 'get',
388
+ path: '/organizations/{id}',
389
+ pathParams: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
390
+ queryParams: [],
391
+ headerParams: [],
392
+ response: { kind: 'model', name: 'Organization' },
393
+ errors: [],
394
+ injectIdempotencyKey: false,
395
+ },
396
+ ],
397
+ };
398
+
399
+ const models: Model[] = [
400
+ {
401
+ name: 'Organization',
402
+ fields: [
403
+ {
404
+ name: 'id',
405
+ type: { kind: 'primitive', type: 'string' },
406
+ required: true,
407
+ readOnly: true,
408
+ },
409
+ {
410
+ name: 'secret_key',
411
+ type: { kind: 'primitive', type: 'string' },
412
+ required: true,
413
+ writeOnly: true,
414
+ },
415
+ {
416
+ name: 'status',
417
+ type: { kind: 'primitive', type: 'string' },
418
+ required: false,
419
+ default: 'active',
420
+ },
421
+ ],
422
+ },
423
+ ];
424
+
425
+ const ctxWithServices: EmitterContext = {
426
+ ...ctx,
427
+ spec: { ...emptySpec, services: [service], models },
428
+ };
429
+
430
+ const files = generateModels(models, ctxWithServices);
431
+ const content = files[0].content;
432
+
433
+ // readOnly field gets @readonly JSDoc and readonly TS modifier
434
+ expect(content).toContain('/** @readonly */');
435
+ expect(content).toContain(' readonly id: string;');
436
+
437
+ // writeOnly field gets @writeonly JSDoc
438
+ expect(content).toContain('/** @writeonly */');
439
+
440
+ // default field gets @default JSDoc
441
+ expect(content).toContain('@default "active"');
442
+ });
443
+
444
+ it('skips per-domain ListMetadata models (Fix #4)', () => {
445
+ const service: Service = {
446
+ name: 'Connections',
447
+ operations: [
448
+ {
449
+ name: 'listConnections',
450
+ httpMethod: 'get',
451
+ path: '/connections',
452
+ pathParams: [],
453
+ queryParams: [],
454
+ headerParams: [],
455
+ response: { kind: 'model', name: 'ConnectionList' },
456
+ errors: [],
457
+ injectIdempotencyKey: false,
458
+ pagination: {
459
+ strategy: 'cursor',
460
+ param: 'after',
461
+ itemType: { kind: 'model', name: 'Connection' },
462
+ },
463
+ },
464
+ ],
465
+ };
466
+
467
+ const models: Model[] = [
468
+ {
469
+ name: 'ConnectionListListMetadata',
470
+ fields: [
471
+ {
472
+ name: 'before',
473
+ type: { kind: 'nullable', inner: { kind: 'primitive', type: 'string' } },
474
+ required: false,
475
+ },
476
+ {
477
+ name: 'after',
478
+ type: { kind: 'nullable', inner: { kind: 'primitive', type: 'string' } },
479
+ required: false,
480
+ },
481
+ ],
482
+ },
483
+ {
484
+ name: 'Connection',
485
+ fields: [
486
+ {
487
+ name: 'id',
488
+ type: { kind: 'primitive', type: 'string' },
489
+ required: true,
490
+ },
491
+ ],
492
+ },
493
+ ];
494
+
495
+ const ctxWithServices: EmitterContext = {
496
+ ...ctx,
497
+ spec: { ...emptySpec, services: [service], models },
498
+ };
499
+
500
+ const files = generateModels(models, ctxWithServices);
501
+
502
+ // The ListMetadata model should be skipped entirely
503
+ const listMetadataFile = files.find((f) => f.path.includes('list-metadata'));
504
+ expect(listMetadataFile).toBeUndefined();
505
+
506
+ // The Connection model should still be generated
507
+ const connectionFile = files.find((f) => f.path.includes('connection.interface.ts'));
508
+ expect(connectionFile).toBeDefined();
509
+ });
510
+
511
+ it('skips per-domain list wrapper models (Fix #6)', () => {
512
+ const service: Service = {
513
+ name: 'Connections',
514
+ operations: [
515
+ {
516
+ name: 'listConnections',
517
+ httpMethod: 'get',
518
+ path: '/connections',
519
+ pathParams: [],
520
+ queryParams: [],
521
+ headerParams: [],
522
+ response: { kind: 'model', name: 'ConnectionList' },
523
+ errors: [],
524
+ injectIdempotencyKey: false,
525
+ pagination: {
526
+ strategy: 'cursor',
527
+ param: 'after',
528
+ itemType: { kind: 'model', name: 'Connection' },
529
+ },
530
+ },
531
+ ],
532
+ };
533
+
534
+ const models: Model[] = [
535
+ {
536
+ name: 'ConnectionList',
537
+ fields: [
538
+ {
539
+ name: 'object',
540
+ type: { kind: 'literal', value: 'list' },
541
+ required: true,
542
+ },
543
+ {
544
+ name: 'data',
545
+ type: { kind: 'array', items: { kind: 'model', name: 'Connection' } },
546
+ required: true,
547
+ },
548
+ {
549
+ name: 'list_metadata',
550
+ type: { kind: 'model', name: 'ConnectionListListMetadata' },
551
+ required: true,
552
+ },
553
+ ],
554
+ },
555
+ {
556
+ name: 'ConnectionListListMetadata',
557
+ fields: [
558
+ {
559
+ name: 'before',
560
+ type: { kind: 'nullable', inner: { kind: 'primitive', type: 'string' } },
561
+ required: false,
562
+ },
563
+ {
564
+ name: 'after',
565
+ type: { kind: 'nullable', inner: { kind: 'primitive', type: 'string' } },
566
+ required: false,
567
+ },
568
+ ],
569
+ },
570
+ {
571
+ name: 'Connection',
572
+ fields: [
573
+ {
574
+ name: 'id',
575
+ type: { kind: 'primitive', type: 'string' },
576
+ required: true,
577
+ },
578
+ ],
579
+ },
580
+ ];
581
+
582
+ const ctxWithServices: EmitterContext = {
583
+ ...ctx,
584
+ spec: { ...emptySpec, services: [service], models },
585
+ };
586
+
587
+ const files = generateModels(models, ctxWithServices);
588
+
589
+ // The list wrapper model should be skipped
590
+ const listFile = files.find((f) => f.path.includes('connection-list.interface.ts'));
591
+ expect(listFile).toBeUndefined();
592
+
593
+ // The ListMetadata model should also be skipped
594
+ const listMetadataFile = files.find((f) => f.path.includes('list-metadata'));
595
+ expect(listMetadataFile).toBeUndefined();
596
+
597
+ // The Connection model should still be generated
598
+ const connectionFile = files.find((f) => f.path.includes('connection.interface.ts'));
599
+ expect(connectionFile).toBeDefined();
600
+ });
601
+
602
+ it('does not skip models that only partially match list-metadata shape', () => {
603
+ const service: Service = {
604
+ name: 'Organizations',
605
+ operations: [
606
+ {
607
+ name: 'getOrganization',
608
+ httpMethod: 'get',
609
+ path: '/organizations/{id}',
610
+ pathParams: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
611
+ queryParams: [],
612
+ headerParams: [],
613
+ response: { kind: 'model', name: 'Pagination' },
614
+ errors: [],
615
+ injectIdempotencyKey: false,
616
+ },
617
+ ],
618
+ };
619
+
620
+ const models: Model[] = [
621
+ {
622
+ name: 'Pagination',
623
+ fields: [
624
+ {
625
+ name: 'before',
626
+ type: { kind: 'nullable', inner: { kind: 'primitive', type: 'string' } },
627
+ required: false,
628
+ },
629
+ {
630
+ name: 'after',
631
+ type: { kind: 'nullable', inner: { kind: 'primitive', type: 'string' } },
632
+ required: false,
633
+ },
634
+ {
635
+ name: 'total',
636
+ type: { kind: 'primitive', type: 'integer' },
637
+ required: true,
638
+ },
639
+ ],
640
+ },
641
+ ];
642
+
643
+ const ctxWithServices: EmitterContext = {
644
+ ...ctx,
645
+ spec: { ...emptySpec, services: [service], models },
646
+ };
647
+
648
+ const files = generateModels(models, ctxWithServices);
649
+ // Model with 3 fields should NOT be skipped even if it has before/after
650
+ expect(files.length).toBe(1);
651
+ expect(files[0].path).toContain('pagination.interface.ts');
652
+ });
653
+ });
654
+
655
+ describe('model deduplication', () => {
656
+ it('emits type alias for structurally identical models', () => {
657
+ const service: Service = {
658
+ name: 'Roles',
659
+ operations: [
660
+ {
661
+ name: 'getRole',
662
+ httpMethod: 'get',
663
+ path: '/roles/{id}',
664
+ pathParams: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
665
+ queryParams: [],
666
+ headerParams: [],
667
+ response: { kind: 'model', name: 'EnvironmentRole' },
668
+ errors: [],
669
+ injectIdempotencyKey: false,
670
+ },
671
+ ],
672
+ };
673
+
674
+ const models: Model[] = [
675
+ {
676
+ name: 'EnvironmentRole',
677
+ fields: [
678
+ { name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
679
+ { name: 'name', type: { kind: 'primitive', type: 'string' }, required: true },
680
+ { name: 'type', type: { kind: 'literal', value: 'environment_role' }, required: true },
681
+ ],
682
+ },
683
+ {
684
+ name: 'OrganizationRole',
685
+ fields: [
686
+ { name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
687
+ { name: 'name', type: { kind: 'primitive', type: 'string' }, required: true },
688
+ { name: 'type', type: { kind: 'literal', value: 'environment_role' }, required: true },
689
+ ],
690
+ },
691
+ ];
692
+
693
+ const ctxWithServices: EmitterContext = {
694
+ ...ctx,
695
+ spec: { ...emptySpec, services: [service], models },
696
+ };
697
+
698
+ const files = generateModels(models, ctxWithServices);
699
+ expect(files.length).toBe(2);
700
+
701
+ // First model: full interface
702
+ expect(files[0].content).toContain('export interface EnvironmentRole');
703
+
704
+ // Second model: type alias referencing canonical
705
+ expect(files[1].content).toContain('export type OrganizationRole = EnvironmentRole');
706
+ expect(files[1].content).toContain('export type OrganizationRoleResponse = EnvironmentRoleResponse');
707
+ });
301
708
  });
@@ -112,7 +112,6 @@ describe('naming', () => {
112
112
  namespace: 'workos',
113
113
  namespacePascal: 'WorkOS',
114
114
  spec: emptySpec,
115
- irVersion: 6,
116
115
  overlayLookup: {
117
116
  methodByOperation: new Map([
118
117
  [
@@ -142,7 +141,6 @@ describe('naming', () => {
142
141
  namespace: 'workos',
143
142
  namespacePascal: 'WorkOS',
144
143
  spec: emptySpec,
145
- irVersion: 6,
146
144
  };
147
145
 
148
146
  expect(resolveServiceName(service, ctx)).toBe('MultiFactorAuth');
@@ -187,7 +185,6 @@ describe('naming', () => {
187
185
  namespace: 'workos',
188
186
  namespacePascal: 'WorkOS',
189
187
  spec: emptySpec,
190
- irVersion: 6,
191
188
  overlayLookup: {
192
189
  methodByOperation: new Map([
193
190
  [