@workos/oagen-emitters 0.12.0 → 0.12.1

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.
@@ -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
  });