@workos/oagen-emitters 0.12.0 → 0.12.2

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 (53) hide show
  1. package/.github/workflows/ci.yml +1 -1
  2. package/.github/workflows/lint-pr-title.yml +1 -1
  3. package/.github/workflows/lint.yml +1 -1
  4. package/.github/workflows/release-please.yml +2 -2
  5. package/.github/workflows/release.yml +1 -1
  6. package/.node-version +1 -1
  7. package/.release-please-manifest.json +1 -1
  8. package/CHANGELOG.md +14 -0
  9. package/dist/index.d.mts.map +1 -1
  10. package/dist/index.mjs +1 -1
  11. package/dist/{plugin-C408Wh-o.mjs → plugin-eCuvoL1T.mjs} +3914 -2121
  12. package/dist/plugin-eCuvoL1T.mjs.map +1 -0
  13. package/dist/plugin.d.mts.map +1 -1
  14. package/dist/plugin.mjs +1 -1
  15. package/package.json +10 -10
  16. package/renovate.json +46 -6
  17. package/src/node/client.ts +19 -32
  18. package/src/node/enums.ts +67 -30
  19. package/src/node/errors.ts +2 -8
  20. package/src/node/field-plan.ts +188 -52
  21. package/src/node/fixtures.ts +11 -33
  22. package/src/node/index.ts +345 -20
  23. package/src/node/live-surface.ts +378 -0
  24. package/src/node/models.ts +540 -351
  25. package/src/node/naming.ts +119 -25
  26. package/src/node/node-overrides.ts +77 -0
  27. package/src/node/options.ts +41 -0
  28. package/src/node/resources.ts +455 -46
  29. package/src/node/sdk-errors.ts +0 -16
  30. package/src/node/tests.ts +108 -83
  31. package/src/node/type-map.ts +40 -18
  32. package/src/node/utils.ts +89 -102
  33. package/src/node/wrappers.ts +0 -20
  34. package/src/rust/fixtures.ts +87 -1
  35. package/src/rust/models.ts +17 -2
  36. package/src/rust/resources.ts +697 -62
  37. package/src/rust/tests.ts +540 -20
  38. package/test/node/client.test.ts +106 -1201
  39. package/test/node/enums.test.ts +59 -130
  40. package/test/node/errors.test.ts +2 -3
  41. package/test/node/live-surface.test.ts +240 -0
  42. package/test/node/models.test.ts +396 -765
  43. package/test/node/naming.test.ts +69 -234
  44. package/test/node/resources.test.ts +376 -2036
  45. package/test/node/tests.test.ts +119 -0
  46. package/test/node/type-map.test.ts +49 -54
  47. package/test/node/utils.test.ts +29 -80
  48. package/test/rust/fixtures.test.ts +227 -0
  49. package/test/rust/models.test.ts +38 -0
  50. package/test/rust/resources.test.ts +505 -2
  51. package/test/rust/tests.test.ts +504 -0
  52. package/dist/plugin-C408Wh-o.mjs.map +0 -1
  53. package/test/node/serializers.test.ts +0 -444
@@ -119,6 +119,44 @@ describe('rust/models', () => {
119
119
  expect(unions.content).toContain('pub enum EventPayloadOneOf {');
120
120
  });
121
121
 
122
+ it('documents Field.default as a "Defaults to" doc comment', () => {
123
+ const models: Model[] = [
124
+ {
125
+ name: 'Pagination',
126
+ fields: [
127
+ {
128
+ name: 'limit',
129
+ type: { kind: 'primitive', type: 'integer' },
130
+ required: false,
131
+ description: 'Page size.',
132
+ default: 10,
133
+ },
134
+ {
135
+ name: 'order',
136
+ type: { kind: 'primitive', type: 'string' },
137
+ required: false,
138
+ default: 'desc',
139
+ },
140
+ {
141
+ name: 'verbose',
142
+ type: { kind: 'primitive', type: 'boolean' },
143
+ required: false,
144
+ default: true,
145
+ },
146
+ ],
147
+ },
148
+ ];
149
+ const files = generateModels(models, ctx, new UnionRegistry());
150
+ const f = files.find((x) => x.path === 'src/models/pagination.rs')!;
151
+ // Number default with description: description first, blank `///`, then defaults.
152
+ expect(f.content).toContain('/// Page size.');
153
+ expect(f.content).toContain('/// Defaults to `10`.');
154
+ // String default renders bare (no JSON quotes).
155
+ expect(f.content).toContain('/// Defaults to `desc`.');
156
+ // Boolean default uses JSON encoding (`true`, not `"true"`).
157
+ expect(f.content).toContain('/// Defaults to `true`.');
158
+ });
159
+
122
160
  it('emits a barrel re-exporting each module', () => {
123
161
  const models: Model[] = [
124
162
  {
@@ -118,7 +118,13 @@ describe('rust/resources', () => {
118
118
  name: 'updateIssue',
119
119
  httpMethod: 'patch',
120
120
  path: '/issues/{id}',
121
- pathParams: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
121
+ pathParams: [
122
+ {
123
+ name: 'id',
124
+ type: { kind: 'primitive', type: 'string' },
125
+ required: true,
126
+ },
127
+ ],
122
128
  queryParams: [],
123
129
  headerParams: [],
124
130
  requestBody: {
@@ -217,6 +223,54 @@ describe('rust/resources', () => {
217
223
  expect(f.content).toContain('"grant_type": "authorization_code"');
218
224
  });
219
225
 
226
+ it('renders spec-level parameter defaults as doc comments', () => {
227
+ const services: Service[] = [
228
+ {
229
+ name: 'Events',
230
+ operations: [
231
+ {
232
+ name: 'listEvents',
233
+ httpMethod: 'get',
234
+ path: '/events',
235
+ pathParams: [],
236
+ queryParams: [
237
+ {
238
+ name: 'limit',
239
+ type: { kind: 'primitive', type: 'integer' },
240
+ required: false,
241
+ description: 'Upper limit.',
242
+ default: 10,
243
+ },
244
+ {
245
+ name: 'order',
246
+ type: { kind: 'enum', name: 'PaginationOrder' },
247
+ required: false,
248
+ description: 'Order the results.',
249
+ default: 'desc',
250
+ },
251
+ {
252
+ name: 'enabled',
253
+ type: { kind: 'primitive', type: 'boolean' },
254
+ required: false,
255
+ default: true,
256
+ },
257
+ ],
258
+ headerParams: [],
259
+ response: { kind: 'model', name: 'EventsList' },
260
+ errors: [],
261
+ injectIdempotencyKey: false,
262
+ },
263
+ ],
264
+ },
265
+ ];
266
+ const f = generateResources(services, ctxWithResolved(services), new UnionRegistry()).find(
267
+ (x) => x.path === 'src/resources/events.rs',
268
+ )!;
269
+ expect(f.content).toContain(' /// Upper limit.\n ///\n /// Defaults to `10`.');
270
+ expect(f.content).toContain(' /// Order the results.\n ///\n /// Defaults to `desc`.');
271
+ expect(f.content).toContain(' /// Defaults to `true`.');
272
+ });
273
+
220
274
  it('interpolates path parameters via format!', () => {
221
275
  const services: Service[] = [
222
276
  {
@@ -226,7 +280,13 @@ describe('rust/resources', () => {
226
280
  name: 'getUser',
227
281
  httpMethod: 'get',
228
282
  path: '/users/{id}',
229
- pathParams: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
283
+ pathParams: [
284
+ {
285
+ name: 'id',
286
+ type: { kind: 'primitive', type: 'string' },
287
+ required: true,
288
+ },
289
+ ],
230
290
  queryParams: [],
231
291
  headerParams: [],
232
292
  response: { kind: 'model', name: 'User' },
@@ -242,4 +302,447 @@ describe('rust/resources', () => {
242
302
  expect(f.content).toContain('let path = format!("/users/{id}");');
243
303
  expect(f.content).toContain('pub async fn get_user(&self, id: &str');
244
304
  });
305
+
306
+ it('emits a URL-builder method when resolved.urlBuilder is true', () => {
307
+ const services: Service[] = [
308
+ {
309
+ name: 'SSO',
310
+ operations: [
311
+ {
312
+ name: 'getAuthorizationUrl',
313
+ httpMethod: 'get',
314
+ path: '/sso/authorize',
315
+ pathParams: [],
316
+ queryParams: [
317
+ {
318
+ name: 'redirect_uri',
319
+ type: { kind: 'primitive', type: 'string' },
320
+ required: true,
321
+ },
322
+ {
323
+ name: 'state',
324
+ type: { kind: 'primitive', type: 'string' },
325
+ required: false,
326
+ },
327
+ ],
328
+ headerParams: [],
329
+ response: { kind: 'model', name: 'SsoAuthorizeUrlResponse' },
330
+ errors: [],
331
+ injectIdempotencyKey: false,
332
+ },
333
+ ],
334
+ },
335
+ ];
336
+ const baseCtx = ctxWithResolved(services);
337
+ const ctx: EmitterContext = {
338
+ ...baseCtx,
339
+ resolvedOperations: baseCtx.resolvedOperations!.map((r) => ({
340
+ ...r,
341
+ urlBuilder: true,
342
+ })),
343
+ };
344
+ const f = generateResources(services, ctx, new UnionRegistry()).find((x) => x.path === 'src/resources/sso.rs')!;
345
+ // URL builders are sync `pub fn`, return `Result<String, Error>`, and
346
+ // never emit an `_with_options` variant or an HTTP issuer.
347
+ expect(f.content).toContain('pub fn get_authorization_url(');
348
+ expect(f.content).toContain('-> Result<String, Error>');
349
+ expect(f.content).toContain('let qs = crate::query::encode_query');
350
+ expect(f.content).not.toContain('get_authorization_url_with_options');
351
+ expect(f.content).not.toContain('request_with_query_opts');
352
+ });
353
+
354
+ it('emits a bearer-override token parameter when op.security has a non-bearer scheme', () => {
355
+ const services: Service[] = [
356
+ {
357
+ name: 'SSO',
358
+ operations: [
359
+ {
360
+ name: 'getProfile',
361
+ httpMethod: 'get',
362
+ path: '/sso/profile',
363
+ pathParams: [],
364
+ queryParams: [],
365
+ headerParams: [],
366
+ response: { kind: 'model', name: 'Profile' },
367
+ errors: [],
368
+ injectIdempotencyKey: false,
369
+ security: [{ schemeName: 'access_token', scopes: [] }],
370
+ },
371
+ ],
372
+ },
373
+ ];
374
+ const f = generateResources(services, ctxWithResolved(services), new UnionRegistry()).find(
375
+ (x) => x.path === 'src/resources/sso.rs',
376
+ )!;
377
+ // The method takes `access_token: impl Into<String>` and overrides the
378
+ // Authorization header in-place via a merged RequestOptions clone.
379
+ expect(f.content).toContain('access_token: impl Into<String>');
380
+ expect(f.content).toContain('let access_token: String = access_token.into();');
381
+ expect(f.content).toContain('http::header::AUTHORIZATION');
382
+ });
383
+
384
+ it('emits a parameter-group enum and a single flattened field on the params struct', () => {
385
+ const services: Service[] = [
386
+ {
387
+ name: 'Authorization',
388
+ operations: [
389
+ {
390
+ name: 'check',
391
+ httpMethod: 'post',
392
+ path: '/authorization/check',
393
+ pathParams: [],
394
+ queryParams: [],
395
+ headerParams: [],
396
+ requestBody: { kind: 'model', name: 'CheckAuthorization' },
397
+ response: { kind: 'model', name: 'AuthorizationCheck' },
398
+ errors: [],
399
+ injectIdempotencyKey: false,
400
+ parameterGroups: [
401
+ {
402
+ name: 'resource_target',
403
+ optional: false,
404
+ variants: [
405
+ {
406
+ name: 'by_id',
407
+ parameters: [
408
+ {
409
+ name: 'resource_id',
410
+ type: { kind: 'primitive', type: 'string' },
411
+ required: false,
412
+ },
413
+ ],
414
+ },
415
+ {
416
+ name: 'by_external_id',
417
+ parameters: [
418
+ {
419
+ name: 'resource_external_id',
420
+ type: { kind: 'primitive', type: 'string' },
421
+ required: false,
422
+ },
423
+ {
424
+ name: 'resource_type_slug',
425
+ type: { kind: 'primitive', type: 'string' },
426
+ required: false,
427
+ },
428
+ ],
429
+ },
430
+ ],
431
+ },
432
+ ],
433
+ },
434
+ ],
435
+ },
436
+ ];
437
+ const baseCtx = ctxWithResolved(services);
438
+ const ctx: EmitterContext = {
439
+ ...baseCtx,
440
+ spec: {
441
+ ...baseCtx.spec,
442
+ models: [
443
+ {
444
+ name: 'CheckAuthorization',
445
+ fields: [
446
+ {
447
+ name: 'permission_slug',
448
+ type: { kind: 'primitive', type: 'string' },
449
+ required: true,
450
+ },
451
+ {
452
+ name: 'resource_id',
453
+ type: { kind: 'primitive', type: 'string' },
454
+ required: false,
455
+ },
456
+ {
457
+ name: 'resource_external_id',
458
+ type: { kind: 'primitive', type: 'string' },
459
+ required: false,
460
+ },
461
+ {
462
+ name: 'resource_type_slug',
463
+ type: { kind: 'primitive', type: 'string' },
464
+ required: false,
465
+ },
466
+ ],
467
+ },
468
+ ],
469
+ },
470
+ };
471
+ const f = generateResources(services, ctx, new UnionRegistry()).find(
472
+ (x) => x.path === 'src/resources/authorization.rs',
473
+ )!;
474
+ // Enum is generated with untagged variants whose fields flatten cleanly.
475
+ expect(f.content).toContain('pub enum ResourceTarget {');
476
+ expect(f.content).toContain('#[serde(untagged)]');
477
+ expect(f.content).toContain('ById {');
478
+ expect(f.content).toContain('resource_id: String,');
479
+ expect(f.content).toContain('ByExternalId {');
480
+ // The synthetic body keeps non-grouped fields flat and folds the enum
481
+ // in via `serde(flatten)`.
482
+ expect(f.content).toContain('pub struct CheckParamsBody {');
483
+ expect(f.content).toContain('pub permission_slug: String,');
484
+ expect(f.content).toContain('#[serde(flatten)]\n pub resource_target: ResourceTarget,');
485
+ // The params struct's `body` field points at the synthetic type, not the
486
+ // original model.
487
+ expect(f.content).toContain('pub body: CheckParamsBody,');
488
+ });
489
+
490
+ it('drives auto-paging from op.pagination and uses the IR cursor param name', () => {
491
+ const services: Service[] = [
492
+ {
493
+ name: 'Widgets',
494
+ operations: [
495
+ {
496
+ name: 'listWidgets',
497
+ httpMethod: 'get',
498
+ path: '/widgets',
499
+ pathParams: [],
500
+ queryParams: [
501
+ {
502
+ name: 'before',
503
+ type: { kind: 'primitive', type: 'string' },
504
+ required: false,
505
+ },
506
+ {
507
+ name: 'after',
508
+ type: { kind: 'primitive', type: 'string' },
509
+ required: false,
510
+ },
511
+ {
512
+ name: 'limit',
513
+ type: { kind: 'primitive', type: 'integer' },
514
+ required: false,
515
+ },
516
+ ],
517
+ headerParams: [],
518
+ response: { kind: 'model', name: 'WidgetList' },
519
+ errors: [],
520
+ injectIdempotencyKey: false,
521
+ pagination: {
522
+ strategy: 'cursor',
523
+ param: 'before',
524
+ itemType: { kind: 'model', name: 'WidgetList' },
525
+ },
526
+ },
527
+ ],
528
+ },
529
+ ];
530
+ const baseCtx = ctxWithResolved(services);
531
+ const ctx: EmitterContext = {
532
+ ...baseCtx,
533
+ spec: {
534
+ ...baseCtx.spec,
535
+ models: [
536
+ {
537
+ name: 'WidgetList',
538
+ fields: [
539
+ {
540
+ name: 'data',
541
+ type: {
542
+ kind: 'array',
543
+ items: { kind: 'model', name: 'Widget' },
544
+ },
545
+ required: true,
546
+ },
547
+ {
548
+ name: 'list_metadata',
549
+ type: { kind: 'model', name: 'WidgetListListMetadata' },
550
+ required: true,
551
+ },
552
+ ],
553
+ },
554
+ {
555
+ name: 'WidgetListListMetadata',
556
+ fields: [
557
+ {
558
+ name: 'before',
559
+ type: { kind: 'primitive', type: 'string' },
560
+ required: false,
561
+ },
562
+ {
563
+ name: 'after',
564
+ type: { kind: 'primitive', type: 'string' },
565
+ required: false,
566
+ },
567
+ ],
568
+ },
569
+ { name: 'Widget', fields: [] },
570
+ ],
571
+ },
572
+ };
573
+ const f = generateResources(services, ctx, new UnionRegistry()).find((x) => x.path === 'src/resources/widgets.rs')!;
574
+ // The IR's cursor param wins over the old hardcoded `after` — both the
575
+ // params side and the list-metadata side reference `before`.
576
+ expect(f.content).toContain('list_widgets_auto_paging');
577
+ expect(f.content).toContain('params.before = after;');
578
+ expect(f.content).toContain('page.list_metadata.before');
579
+ });
580
+
581
+ it('skips auto-paging when the IR cursor field is missing from the response metadata', () => {
582
+ // If the spec/IR is internally inconsistent (request says cursor is
583
+ // `weird_cursor` but the list-metadata model has no such field) we'd emit
584
+ // code that references a nonexistent field. Bail out instead — callers
585
+ // can paginate manually.
586
+ const services: Service[] = [
587
+ {
588
+ name: 'Events',
589
+ operations: [
590
+ {
591
+ name: 'listEvents',
592
+ httpMethod: 'get',
593
+ path: '/events',
594
+ pathParams: [],
595
+ queryParams: [
596
+ {
597
+ name: 'weird_cursor',
598
+ type: { kind: 'primitive', type: 'string' },
599
+ required: false,
600
+ },
601
+ ],
602
+ headerParams: [],
603
+ response: { kind: 'model', name: 'EventList' },
604
+ errors: [],
605
+ injectIdempotencyKey: false,
606
+ pagination: {
607
+ strategy: 'cursor',
608
+ param: 'weird_cursor',
609
+ itemType: { kind: 'model', name: 'EventList' },
610
+ },
611
+ },
612
+ ],
613
+ },
614
+ ];
615
+ const baseCtx = ctxWithResolved(services);
616
+ const ctx: EmitterContext = {
617
+ ...baseCtx,
618
+ spec: {
619
+ ...baseCtx.spec,
620
+ models: [
621
+ {
622
+ name: 'EventList',
623
+ fields: [
624
+ {
625
+ name: 'data',
626
+ type: {
627
+ kind: 'array',
628
+ items: { kind: 'model', name: 'Event' },
629
+ },
630
+ required: true,
631
+ },
632
+ {
633
+ name: 'list_metadata',
634
+ type: { kind: 'model', name: 'EventListListMetadata' },
635
+ required: true,
636
+ },
637
+ ],
638
+ },
639
+ {
640
+ name: 'EventListListMetadata',
641
+ fields: [
642
+ {
643
+ name: 'after',
644
+ type: { kind: 'primitive', type: 'string' },
645
+ required: false,
646
+ },
647
+ ],
648
+ },
649
+ { name: 'Event', fields: [] },
650
+ ],
651
+ },
652
+ };
653
+ const f = generateResources(services, ctx, new UnionRegistry()).find((x) => x.path === 'src/resources/events.rs')!;
654
+ expect(f.content).not.toContain('list_events_auto_paging');
655
+ });
656
+
657
+ it('adds serialize_with attribute on Vec query params with explode=false', () => {
658
+ const services: Service[] = [
659
+ {
660
+ name: 'Events',
661
+ operations: [
662
+ {
663
+ name: 'listEvents',
664
+ httpMethod: 'get',
665
+ path: '/events',
666
+ pathParams: [],
667
+ queryParams: [
668
+ {
669
+ name: 'events',
670
+ type: {
671
+ kind: 'array',
672
+ items: { kind: 'primitive', type: 'string' },
673
+ },
674
+ required: false,
675
+ style: 'form',
676
+ explode: false,
677
+ },
678
+ {
679
+ name: 'tags',
680
+ type: {
681
+ kind: 'array',
682
+ items: { kind: 'primitive', type: 'string' },
683
+ },
684
+ required: false,
685
+ style: 'form',
686
+ explode: true,
687
+ },
688
+ ],
689
+ headerParams: [],
690
+ response: { kind: 'model', name: 'EventList' },
691
+ errors: [],
692
+ injectIdempotencyKey: false,
693
+ },
694
+ ],
695
+ },
696
+ ];
697
+ const f = generateResources(services, ctxWithResolved(services), new UnionRegistry()).find(
698
+ (x) => x.path === 'src/resources/events.rs',
699
+ )!;
700
+ // explode=false → comma-joined serializer; explode=true (default) leaves
701
+ // the field alone so the runtime query encoder unrolls it to repeated keys.
702
+ expect(f.content).toContain(
703
+ '#[serde(serialize_with = "crate::query::serialize_comma_separated_opt")]\n pub events:',
704
+ );
705
+ expect(f.content).not.toContain('"crate::query::serialize_comma_separated_opt")]\n pub tags:');
706
+ });
707
+
708
+ it('iterates cookieParams alongside path/query/header params', () => {
709
+ // Forward-compatibility: ensure the iteration site picks up cookie
710
+ // params so a future spec doesn't silently drop them.
711
+ const services: Service[] = [
712
+ {
713
+ name: 'Widgets',
714
+ operations: [
715
+ {
716
+ name: 'getWidget',
717
+ httpMethod: 'get',
718
+ path: '/widgets/{id}',
719
+ pathParams: [
720
+ {
721
+ name: 'id',
722
+ type: { kind: 'primitive', type: 'string' },
723
+ required: true,
724
+ },
725
+ ],
726
+ queryParams: [],
727
+ headerParams: [],
728
+ cookieParams: [
729
+ {
730
+ name: 'session_id',
731
+ type: { kind: 'primitive', type: 'string' },
732
+ required: false,
733
+ description: 'Tracking cookie.',
734
+ },
735
+ ],
736
+ response: { kind: 'model', name: 'Widget' },
737
+ errors: [],
738
+ injectIdempotencyKey: false,
739
+ },
740
+ ],
741
+ },
742
+ ];
743
+ const f = generateResources(services, ctxWithResolved(services), new UnionRegistry()).find(
744
+ (x) => x.path === 'src/resources/widgets.rs',
745
+ )!;
746
+ expect(f.content).toContain('pub session_id: Option<String>,');
747
+ });
245
748
  });