@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.
- package/.husky/pre-commit +1 -0
- package/.oxfmtrc.json +8 -1
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +15 -0
- package/README.md +129 -0
- package/dist/index.d.mts +10 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +11943 -2728
- package/dist/index.mjs.map +1 -1
- package/docs/sdk-architecture/go.md +338 -0
- package/docs/sdk-architecture/php.md +315 -0
- package/docs/sdk-architecture/python.md +511 -0
- package/oagen.config.ts +298 -2
- package/package.json +9 -5
- package/scripts/generate-php.js +13 -0
- package/scripts/git-push-with-published-oagen.sh +21 -0
- package/smoke/sdk-dotnet.ts +17 -3
- package/smoke/sdk-elixir.ts +17 -3
- package/smoke/sdk-go.ts +137 -46
- package/smoke/sdk-kotlin.ts +23 -4
- package/smoke/sdk-node.ts +15 -3
- package/smoke/sdk-php.ts +28 -26
- package/smoke/sdk-python.ts +5 -2
- package/smoke/sdk-ruby.ts +17 -3
- package/smoke/sdk-rust.ts +16 -3
- package/src/go/client.ts +141 -0
- package/src/go/enums.ts +196 -0
- package/src/go/fixtures.ts +212 -0
- package/src/go/index.ts +81 -0
- package/src/go/manifest.ts +36 -0
- package/src/go/models.ts +254 -0
- package/src/go/naming.ts +191 -0
- package/src/go/resources.ts +827 -0
- package/src/go/tests.ts +751 -0
- package/src/go/type-map.ts +82 -0
- package/src/go/wrappers.ts +261 -0
- package/src/index.ts +3 -0
- package/src/node/client.ts +167 -122
- package/src/node/enums.ts +13 -4
- package/src/node/errors.ts +42 -233
- package/src/node/field-plan.ts +726 -0
- package/src/node/fixtures.ts +15 -5
- package/src/node/index.ts +65 -16
- package/src/node/models.ts +264 -96
- package/src/node/naming.ts +52 -25
- package/src/node/resources.ts +621 -172
- package/src/node/sdk-errors.ts +41 -0
- package/src/node/tests.ts +71 -27
- package/src/node/type-map.ts +4 -2
- package/src/node/utils.ts +56 -64
- package/src/node/wrappers.ts +151 -0
- package/src/php/client.ts +171 -0
- package/src/php/enums.ts +67 -0
- package/src/php/errors.ts +9 -0
- package/src/php/fixtures.ts +181 -0
- package/src/php/index.ts +96 -0
- package/src/php/manifest.ts +36 -0
- package/src/php/models.ts +310 -0
- package/src/php/naming.ts +298 -0
- package/src/php/resources.ts +561 -0
- package/src/php/tests.ts +533 -0
- package/src/php/type-map.ts +90 -0
- package/src/php/utils.ts +18 -0
- package/src/php/wrappers.ts +151 -0
- package/src/python/client.ts +337 -0
- package/src/python/enums.ts +313 -0
- package/src/python/fixtures.ts +196 -0
- package/src/python/index.ts +95 -0
- package/src/python/manifest.ts +38 -0
- package/src/python/models.ts +688 -0
- package/src/python/naming.ts +209 -0
- package/src/python/resources.ts +1322 -0
- package/src/python/tests.ts +1335 -0
- package/src/python/type-map.ts +93 -0
- package/src/python/wrappers.ts +191 -0
- package/src/shared/model-utils.ts +255 -0
- package/src/shared/naming-utils.ts +107 -0
- package/src/shared/non-spec-services.ts +54 -0
- package/src/shared/resolved-ops.ts +109 -0
- package/src/shared/wrapper-utils.ts +59 -0
- package/test/go/client.test.ts +92 -0
- package/test/go/enums.test.ts +132 -0
- package/test/go/errors.test.ts +9 -0
- package/test/go/models.test.ts +265 -0
- package/test/go/resources.test.ts +408 -0
- package/test/go/tests.test.ts +143 -0
- package/test/node/client.test.ts +199 -94
- package/test/node/enums.test.ts +75 -3
- package/test/node/errors.test.ts +2 -41
- package/test/node/models.test.ts +109 -20
- package/test/node/naming.test.ts +37 -4
- package/test/node/resources.test.ts +662 -30
- package/test/node/serializers.test.ts +36 -7
- package/test/node/type-map.test.ts +11 -0
- package/test/php/client.test.ts +94 -0
- package/test/php/enums.test.ts +173 -0
- package/test/php/errors.test.ts +9 -0
- package/test/php/models.test.ts +497 -0
- package/test/php/resources.test.ts +644 -0
- package/test/php/tests.test.ts +118 -0
- package/test/python/client.test.ts +200 -0
- package/test/python/enums.test.ts +228 -0
- package/test/python/errors.test.ts +16 -0
- package/test/python/manifest.test.ts +74 -0
- package/test/python/models.test.ts +716 -0
- package/test/python/resources.test.ts +617 -0
- package/test/python/tests.test.ts +202 -0
- package/src/node/common.ts +0 -273
- package/src/node/config.ts +0 -71
- package/src/node/serializers.ts +0 -744
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
2
|
import { generateResources, resolveResourceClassName, hasCompatibleConstructor } from '../../src/node/resources.js';
|
|
3
3
|
import type { EmitterContext, ApiSpec, Service } from '@workos/oagen';
|
|
4
|
+
import { defaultSdkBehavior } from '@workos/oagen';
|
|
4
5
|
|
|
5
6
|
const emptySpec: ApiSpec = {
|
|
6
7
|
name: 'Test',
|
|
@@ -9,6 +10,7 @@ const emptySpec: ApiSpec = {
|
|
|
9
10
|
services: [],
|
|
10
11
|
models: [],
|
|
11
12
|
enums: [],
|
|
13
|
+
sdk: defaultSdkBehavior(),
|
|
12
14
|
};
|
|
13
15
|
|
|
14
16
|
const ctx: EmitterContext = {
|
|
@@ -72,7 +74,10 @@ describe('generateResources', () => {
|
|
|
72
74
|
queryParams: [
|
|
73
75
|
{
|
|
74
76
|
name: 'domains',
|
|
75
|
-
type: {
|
|
77
|
+
type: {
|
|
78
|
+
kind: 'array',
|
|
79
|
+
items: { kind: 'primitive', type: 'string' },
|
|
80
|
+
},
|
|
76
81
|
required: false,
|
|
77
82
|
},
|
|
78
83
|
],
|
|
@@ -243,7 +248,12 @@ describe('generateResources', () => {
|
|
|
243
248
|
methodByOperation: new Map([
|
|
244
249
|
[
|
|
245
250
|
'POST /auth/factors/enroll',
|
|
246
|
-
{
|
|
251
|
+
{
|
|
252
|
+
className: 'Mfa',
|
|
253
|
+
methodName: 'enrollFactor',
|
|
254
|
+
params: [],
|
|
255
|
+
returnType: 'void',
|
|
256
|
+
},
|
|
247
257
|
],
|
|
248
258
|
]),
|
|
249
259
|
httpKeyByMethod: new Map(),
|
|
@@ -301,7 +311,7 @@ describe('generateResources', () => {
|
|
|
301
311
|
expect(content).toContain(' *');
|
|
302
312
|
expect(content).toContain(' * You may optionally inform Radar that an attempt was successful.');
|
|
303
313
|
expect(content).toContain(' * @param id - The unique identifier of the attempt.');
|
|
304
|
-
expect(content).toContain(' * @returns {RadarAttempt}');
|
|
314
|
+
expect(content).toContain(' * @returns {Promise<RadarAttempt>}');
|
|
305
315
|
expect(content).toContain(' * @deprecated');
|
|
306
316
|
expect(content).toContain(' */');
|
|
307
317
|
});
|
|
@@ -315,7 +325,13 @@ describe('generateResources', () => {
|
|
|
315
325
|
name: 'getOrganization',
|
|
316
326
|
httpMethod: 'get',
|
|
317
327
|
path: '/organizations/{id}',
|
|
318
|
-
pathParams: [
|
|
328
|
+
pathParams: [
|
|
329
|
+
{
|
|
330
|
+
name: 'id',
|
|
331
|
+
type: { kind: 'primitive', type: 'string' },
|
|
332
|
+
required: true,
|
|
333
|
+
},
|
|
334
|
+
],
|
|
319
335
|
queryParams: [],
|
|
320
336
|
headerParams: [],
|
|
321
337
|
response: { kind: 'model', name: 'Organization' },
|
|
@@ -328,7 +344,61 @@ describe('generateResources', () => {
|
|
|
328
344
|
|
|
329
345
|
const files = generateResources(services, ctx);
|
|
330
346
|
const content = files[0].content;
|
|
331
|
-
expect(content).toContain('@returns {Organization}');
|
|
347
|
+
expect(content).toContain('@returns {Promise<Organization>}');
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it('renders @returns from overlay return type when available', () => {
|
|
351
|
+
const services: Service[] = [
|
|
352
|
+
{
|
|
353
|
+
name: 'Authorization',
|
|
354
|
+
operations: [
|
|
355
|
+
{
|
|
356
|
+
name: 'createEnvironmentRole',
|
|
357
|
+
httpMethod: 'post',
|
|
358
|
+
path: '/authorization/roles',
|
|
359
|
+
pathParams: [],
|
|
360
|
+
queryParams: [],
|
|
361
|
+
headerParams: [],
|
|
362
|
+
requestBody: { kind: 'model', name: 'CreateRoleInput' },
|
|
363
|
+
response: { kind: 'model', name: 'Role' },
|
|
364
|
+
errors: [],
|
|
365
|
+
injectIdempotencyKey: false,
|
|
366
|
+
},
|
|
367
|
+
],
|
|
368
|
+
},
|
|
369
|
+
];
|
|
370
|
+
|
|
371
|
+
const overlayCtx: EmitterContext = {
|
|
372
|
+
namespace: 'workos',
|
|
373
|
+
namespacePascal: 'WorkOS',
|
|
374
|
+
spec: { ...emptySpec, services, models: [] },
|
|
375
|
+
overlayLookup: {
|
|
376
|
+
methodByOperation: new Map([
|
|
377
|
+
[
|
|
378
|
+
'POST /authorization/roles',
|
|
379
|
+
{
|
|
380
|
+
className: 'Authorization',
|
|
381
|
+
methodName: 'createEnvironmentRole',
|
|
382
|
+
params: [{ name: 'payload', type: 'CreateRoleInput', optional: false }],
|
|
383
|
+
returnType: 'Promise<EnvironmentRole>',
|
|
384
|
+
},
|
|
385
|
+
],
|
|
386
|
+
]),
|
|
387
|
+
httpKeyByMethod: new Map(),
|
|
388
|
+
interfaceByName: new Map(),
|
|
389
|
+
typeAliasByName: new Map(),
|
|
390
|
+
requiredExports: new Map(),
|
|
391
|
+
modelNameByIR: new Map(),
|
|
392
|
+
fileBySymbol: new Map(),
|
|
393
|
+
},
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
const files = generateResources(services, overlayCtx);
|
|
397
|
+
const content = files[0].content;
|
|
398
|
+
// JSDoc should use the overlay return type, not the spec schema name
|
|
399
|
+
expect(content).toContain('@returns {Promise<EnvironmentRole>}');
|
|
400
|
+
expect(content).not.toContain('@returns {Role}');
|
|
401
|
+
expect(content).not.toContain('@returns {Promise<Role>}');
|
|
332
402
|
});
|
|
333
403
|
|
|
334
404
|
it('renders query param docs for non-paginated operations', () => {
|
|
@@ -340,7 +410,13 @@ describe('generateResources', () => {
|
|
|
340
410
|
name: 'getOrganization',
|
|
341
411
|
httpMethod: 'get',
|
|
342
412
|
path: '/organizations/{id}',
|
|
343
|
-
pathParams: [
|
|
413
|
+
pathParams: [
|
|
414
|
+
{
|
|
415
|
+
name: 'id',
|
|
416
|
+
type: { kind: 'primitive', type: 'string' },
|
|
417
|
+
required: true,
|
|
418
|
+
},
|
|
419
|
+
],
|
|
344
420
|
queryParams: [
|
|
345
421
|
{
|
|
346
422
|
name: 'include_fields',
|
|
@@ -372,7 +448,13 @@ describe('generateResources', () => {
|
|
|
372
448
|
name: 'getSession',
|
|
373
449
|
httpMethod: 'get',
|
|
374
450
|
path: '/sessions/{id}',
|
|
375
|
-
pathParams: [
|
|
451
|
+
pathParams: [
|
|
452
|
+
{
|
|
453
|
+
name: 'id',
|
|
454
|
+
type: { kind: 'primitive', type: 'string' },
|
|
455
|
+
required: true,
|
|
456
|
+
},
|
|
457
|
+
],
|
|
376
458
|
queryParams: [],
|
|
377
459
|
headerParams: [
|
|
378
460
|
{
|
|
@@ -421,8 +503,14 @@ describe('generateResources', () => {
|
|
|
421
503
|
requestBody: { kind: 'model', name: 'CreateOrganizationInput' },
|
|
422
504
|
response: { kind: 'model', name: 'Organization' },
|
|
423
505
|
successResponses: [
|
|
424
|
-
{
|
|
425
|
-
|
|
506
|
+
{
|
|
507
|
+
statusCode: 200,
|
|
508
|
+
type: { kind: 'model', name: 'Organization' },
|
|
509
|
+
},
|
|
510
|
+
{
|
|
511
|
+
statusCode: 201,
|
|
512
|
+
type: { kind: 'model', name: 'Organization' },
|
|
513
|
+
},
|
|
426
514
|
],
|
|
427
515
|
errors: [],
|
|
428
516
|
injectIdempotencyKey: false,
|
|
@@ -434,9 +522,9 @@ describe('generateResources', () => {
|
|
|
434
522
|
const files = generateResources(services, ctx);
|
|
435
523
|
const content = files[0].content;
|
|
436
524
|
// Only emit a single @returns for the primary response model (no status-code variants)
|
|
437
|
-
expect(content).toContain('@returns {Organization}');
|
|
438
|
-
expect(content).not.toContain('@returns {Organization} 200');
|
|
439
|
-
expect(content).not.toContain('@returns {Organization} 201');
|
|
525
|
+
expect(content).toContain('@returns {Promise<Organization>}');
|
|
526
|
+
expect(content).not.toContain('@returns {Promise<Organization>} 200');
|
|
527
|
+
expect(content).not.toContain('@returns {Promise<Organization>} 201');
|
|
440
528
|
});
|
|
441
529
|
|
|
442
530
|
it('generates DELETE-with-body method using deleteWithBody', () => {
|
|
@@ -517,7 +605,13 @@ describe('generateResources', () => {
|
|
|
517
605
|
name: 'getOrganization',
|
|
518
606
|
httpMethod: 'get',
|
|
519
607
|
path: '/organizations/{id}',
|
|
520
|
-
pathParams: [
|
|
608
|
+
pathParams: [
|
|
609
|
+
{
|
|
610
|
+
name: 'id',
|
|
611
|
+
type: { kind: 'primitive', type: 'string' },
|
|
612
|
+
required: true,
|
|
613
|
+
},
|
|
614
|
+
],
|
|
521
615
|
queryParams: [
|
|
522
616
|
{
|
|
523
617
|
name: 'include_fields',
|
|
@@ -603,7 +697,7 @@ describe('generateResources', () => {
|
|
|
603
697
|
expect(content).toContain('query: options');
|
|
604
698
|
});
|
|
605
699
|
|
|
606
|
-
it('
|
|
700
|
+
it('falls back to pass-through for non-discriminated union when models not in spec', () => {
|
|
607
701
|
const services: Service[] = [
|
|
608
702
|
{
|
|
609
703
|
name: 'Auth',
|
|
@@ -640,7 +734,7 @@ describe('generateResources', () => {
|
|
|
640
734
|
// Should NOT use Record<string, unknown>
|
|
641
735
|
expect(content).not.toContain('Record<string, unknown>');
|
|
642
736
|
|
|
643
|
-
//
|
|
737
|
+
// Models not in spec → falls back to pass-through
|
|
644
738
|
expect(content).toContain("'/user_management/authenticate',");
|
|
645
739
|
expect(content).toContain('payload,');
|
|
646
740
|
|
|
@@ -650,6 +744,111 @@ describe('generateResources', () => {
|
|
|
650
744
|
expect(content).toContain('AuthByMagicAuth');
|
|
651
745
|
});
|
|
652
746
|
|
|
747
|
+
it('generates field-guard serializer dispatch for non-discriminated union with models', () => {
|
|
748
|
+
const services: Service[] = [
|
|
749
|
+
{
|
|
750
|
+
name: 'Applications',
|
|
751
|
+
operations: [
|
|
752
|
+
{
|
|
753
|
+
name: 'createApplication',
|
|
754
|
+
httpMethod: 'post',
|
|
755
|
+
path: '/connect/applications',
|
|
756
|
+
pathParams: [],
|
|
757
|
+
queryParams: [],
|
|
758
|
+
headerParams: [],
|
|
759
|
+
requestBody: {
|
|
760
|
+
kind: 'union',
|
|
761
|
+
variants: [
|
|
762
|
+
{ kind: 'model', name: 'CreateOAuthApplication' },
|
|
763
|
+
{ kind: 'model', name: 'CreateM2MApplication' },
|
|
764
|
+
],
|
|
765
|
+
},
|
|
766
|
+
response: { kind: 'model', name: 'ConnectApplication' },
|
|
767
|
+
errors: [],
|
|
768
|
+
injectIdempotencyKey: false,
|
|
769
|
+
},
|
|
770
|
+
],
|
|
771
|
+
},
|
|
772
|
+
];
|
|
773
|
+
|
|
774
|
+
const testCtx: EmitterContext = {
|
|
775
|
+
namespace: 'workos',
|
|
776
|
+
namespacePascal: 'WorkOS',
|
|
777
|
+
spec: {
|
|
778
|
+
...emptySpec,
|
|
779
|
+
services,
|
|
780
|
+
models: [
|
|
781
|
+
{
|
|
782
|
+
name: 'CreateOAuthApplication',
|
|
783
|
+
fields: [
|
|
784
|
+
{
|
|
785
|
+
name: 'name',
|
|
786
|
+
type: { kind: 'primitive', type: 'string' },
|
|
787
|
+
required: true,
|
|
788
|
+
},
|
|
789
|
+
{
|
|
790
|
+
name: 'redirect_uris',
|
|
791
|
+
type: {
|
|
792
|
+
kind: 'array',
|
|
793
|
+
items: { kind: 'primitive', type: 'string' },
|
|
794
|
+
},
|
|
795
|
+
required: true,
|
|
796
|
+
},
|
|
797
|
+
{
|
|
798
|
+
name: 'uses_pkce',
|
|
799
|
+
type: { kind: 'primitive', type: 'boolean' },
|
|
800
|
+
required: false,
|
|
801
|
+
},
|
|
802
|
+
],
|
|
803
|
+
},
|
|
804
|
+
{
|
|
805
|
+
name: 'CreateM2MApplication',
|
|
806
|
+
fields: [
|
|
807
|
+
{
|
|
808
|
+
name: 'name',
|
|
809
|
+
type: { kind: 'primitive', type: 'string' },
|
|
810
|
+
required: true,
|
|
811
|
+
},
|
|
812
|
+
{
|
|
813
|
+
name: 'scopes',
|
|
814
|
+
type: {
|
|
815
|
+
kind: 'array',
|
|
816
|
+
items: { kind: 'primitive', type: 'string' },
|
|
817
|
+
},
|
|
818
|
+
required: true,
|
|
819
|
+
},
|
|
820
|
+
],
|
|
821
|
+
},
|
|
822
|
+
{
|
|
823
|
+
name: 'ConnectApplication',
|
|
824
|
+
fields: [
|
|
825
|
+
{
|
|
826
|
+
name: 'id',
|
|
827
|
+
type: { kind: 'primitive', type: 'string' },
|
|
828
|
+
required: true,
|
|
829
|
+
},
|
|
830
|
+
],
|
|
831
|
+
},
|
|
832
|
+
],
|
|
833
|
+
},
|
|
834
|
+
};
|
|
835
|
+
|
|
836
|
+
const files = generateResources(services, testCtx);
|
|
837
|
+
const content = files[0].content;
|
|
838
|
+
|
|
839
|
+
// Should use the union type for the payload parameter
|
|
840
|
+
expect(content).toContain('payload: CreateOAuthApplication | CreateM2MApplication');
|
|
841
|
+
|
|
842
|
+
// Should dispatch via unique required field guards
|
|
843
|
+
expect(content).toContain("'redirectUris' in payload");
|
|
844
|
+
expect(content).toContain('serializeCreateOAuthApplication(payload as any)');
|
|
845
|
+
expect(content).toContain('serializeCreateM2MApplication(payload as any)');
|
|
846
|
+
|
|
847
|
+
// Should import serializers for all union variants
|
|
848
|
+
expect(content).toContain('serializeCreateOAuthApplication');
|
|
849
|
+
expect(content).toContain('serializeCreateM2MApplication');
|
|
850
|
+
});
|
|
851
|
+
|
|
653
852
|
it('generates discriminated union serializer dispatch for request body', () => {
|
|
654
853
|
const services: Service[] = [
|
|
655
854
|
{
|
|
@@ -790,12 +989,12 @@ describe('generateResources', () => {
|
|
|
790
989
|
it('prefixes ListOptions with service name when method is "list"', () => {
|
|
791
990
|
const services: Service[] = [
|
|
792
991
|
{
|
|
793
|
-
name: '
|
|
992
|
+
name: 'Payments',
|
|
794
993
|
operations: [
|
|
795
994
|
{
|
|
796
995
|
name: 'list',
|
|
797
996
|
httpMethod: 'get',
|
|
798
|
-
path: '/
|
|
997
|
+
path: '/payments',
|
|
799
998
|
pathParams: [],
|
|
800
999
|
queryParams: [
|
|
801
1000
|
{
|
|
@@ -826,7 +1025,15 @@ describe('generateResources', () => {
|
|
|
826
1025
|
spec: { ...emptySpec, services, models: [] },
|
|
827
1026
|
overlayLookup: {
|
|
828
1027
|
methodByOperation: new Map([
|
|
829
|
-
[
|
|
1028
|
+
[
|
|
1029
|
+
'GET /payments',
|
|
1030
|
+
{
|
|
1031
|
+
className: 'Payments',
|
|
1032
|
+
methodName: 'list',
|
|
1033
|
+
params: [],
|
|
1034
|
+
returnType: 'void',
|
|
1035
|
+
},
|
|
1036
|
+
],
|
|
830
1037
|
]),
|
|
831
1038
|
httpKeyByMethod: new Map(),
|
|
832
1039
|
interfaceByName: new Map(),
|
|
@@ -841,8 +1048,8 @@ describe('generateResources', () => {
|
|
|
841
1048
|
const content = files[0].content;
|
|
842
1049
|
|
|
843
1050
|
// Should use service-prefixed options name instead of generic "ListOptions"
|
|
844
|
-
expect(content).toContain('export interface
|
|
845
|
-
expect(content).toContain('Promise<AutoPaginatable<Connection,
|
|
1051
|
+
expect(content).toContain('export interface PaymentsListOptions extends PaginationOptions {');
|
|
1052
|
+
expect(content).toContain('Promise<AutoPaginatable<Connection, PaymentsListOptions>>');
|
|
846
1053
|
// Should NOT use the generic "ListOptions"
|
|
847
1054
|
expect(content).not.toContain('export interface ListOptions ');
|
|
848
1055
|
});
|
|
@@ -860,7 +1067,10 @@ describe('generateResources', () => {
|
|
|
860
1067
|
queryParams: [
|
|
861
1068
|
{
|
|
862
1069
|
name: 'domains',
|
|
863
|
-
type: {
|
|
1070
|
+
type: {
|
|
1071
|
+
kind: 'array',
|
|
1072
|
+
items: { kind: 'primitive', type: 'string' },
|
|
1073
|
+
},
|
|
864
1074
|
required: false,
|
|
865
1075
|
},
|
|
866
1076
|
],
|
|
@@ -885,6 +1095,185 @@ describe('generateResources', () => {
|
|
|
885
1095
|
// Method is "listOrganizations", not "list", so options name should be normal
|
|
886
1096
|
expect(content).toContain('export interface ListOrganizationsOptions extends PaginationOptions {');
|
|
887
1097
|
});
|
|
1098
|
+
|
|
1099
|
+
it('removes skipIfExists when fully-covered service has methods absent from baseline', () => {
|
|
1100
|
+
const services: Service[] = [
|
|
1101
|
+
{
|
|
1102
|
+
name: 'SSOService',
|
|
1103
|
+
operations: [
|
|
1104
|
+
{
|
|
1105
|
+
name: 'getAuthorizationUrl',
|
|
1106
|
+
httpMethod: 'get',
|
|
1107
|
+
path: '/sso/authorize',
|
|
1108
|
+
pathParams: [],
|
|
1109
|
+
queryParams: [],
|
|
1110
|
+
headerParams: [],
|
|
1111
|
+
response: { kind: 'model', name: 'AuthorizationUrl' },
|
|
1112
|
+
errors: [],
|
|
1113
|
+
injectIdempotencyKey: false,
|
|
1114
|
+
},
|
|
1115
|
+
{
|
|
1116
|
+
name: 'logout',
|
|
1117
|
+
httpMethod: 'get',
|
|
1118
|
+
path: '/sso/logout',
|
|
1119
|
+
pathParams: [],
|
|
1120
|
+
queryParams: [],
|
|
1121
|
+
headerParams: [],
|
|
1122
|
+
response: { kind: 'model', name: 'LogoutResult' },
|
|
1123
|
+
errors: [],
|
|
1124
|
+
injectIdempotencyKey: false,
|
|
1125
|
+
},
|
|
1126
|
+
],
|
|
1127
|
+
},
|
|
1128
|
+
];
|
|
1129
|
+
|
|
1130
|
+
// Overlay maps both operations to SSO class
|
|
1131
|
+
// Baseline SSO class exists but only has getAuthorizationUrl (logout is missing)
|
|
1132
|
+
const overlayCtx: EmitterContext = {
|
|
1133
|
+
namespace: 'workos',
|
|
1134
|
+
namespacePascal: 'WorkOS',
|
|
1135
|
+
spec: { ...emptySpec, services, models: [] },
|
|
1136
|
+
overlayLookup: {
|
|
1137
|
+
methodByOperation: new Map([
|
|
1138
|
+
[
|
|
1139
|
+
'GET /sso/authorize',
|
|
1140
|
+
{
|
|
1141
|
+
className: 'SSO',
|
|
1142
|
+
methodName: 'getAuthorizationUrl',
|
|
1143
|
+
params: [],
|
|
1144
|
+
returnType: 'void',
|
|
1145
|
+
},
|
|
1146
|
+
],
|
|
1147
|
+
[
|
|
1148
|
+
'GET /sso/logout',
|
|
1149
|
+
{
|
|
1150
|
+
className: 'SSO',
|
|
1151
|
+
methodName: 'logout',
|
|
1152
|
+
params: [],
|
|
1153
|
+
returnType: 'void',
|
|
1154
|
+
},
|
|
1155
|
+
],
|
|
1156
|
+
]),
|
|
1157
|
+
httpKeyByMethod: new Map(),
|
|
1158
|
+
interfaceByName: new Map(),
|
|
1159
|
+
typeAliasByName: new Map(),
|
|
1160
|
+
requiredExports: new Map(),
|
|
1161
|
+
modelNameByIR: new Map(),
|
|
1162
|
+
fileBySymbol: new Map(),
|
|
1163
|
+
},
|
|
1164
|
+
apiSurface: {
|
|
1165
|
+
language: 'node',
|
|
1166
|
+
extractedFrom: 'test',
|
|
1167
|
+
extractedAt: '2024-01-01',
|
|
1168
|
+
classes: {
|
|
1169
|
+
SSO: {
|
|
1170
|
+
name: 'SSO',
|
|
1171
|
+
methods: {
|
|
1172
|
+
getAuthorizationUrl: [
|
|
1173
|
+
{
|
|
1174
|
+
name: 'getAuthorizationUrl',
|
|
1175
|
+
params: [],
|
|
1176
|
+
returnType: 'void',
|
|
1177
|
+
async: true,
|
|
1178
|
+
},
|
|
1179
|
+
],
|
|
1180
|
+
// logout method is intentionally ABSENT
|
|
1181
|
+
},
|
|
1182
|
+
properties: {},
|
|
1183
|
+
constructorParams: [{ name: 'workos', type: 'WorkOS', optional: false }],
|
|
1184
|
+
},
|
|
1185
|
+
},
|
|
1186
|
+
interfaces: {},
|
|
1187
|
+
typeAliases: {},
|
|
1188
|
+
enums: {},
|
|
1189
|
+
exports: {},
|
|
1190
|
+
},
|
|
1191
|
+
};
|
|
1192
|
+
|
|
1193
|
+
const files = generateResources(services, overlayCtx);
|
|
1194
|
+
expect(files.length).toBe(1);
|
|
1195
|
+
|
|
1196
|
+
// skipIfExists should be removed because 'logout' is absent from baseline
|
|
1197
|
+
expect(files[0].skipIfExists).toBeUndefined();
|
|
1198
|
+
});
|
|
1199
|
+
|
|
1200
|
+
it('keeps skipIfExists when fully-covered service has all methods in baseline', () => {
|
|
1201
|
+
const services: Service[] = [
|
|
1202
|
+
{
|
|
1203
|
+
name: 'SSOService',
|
|
1204
|
+
operations: [
|
|
1205
|
+
{
|
|
1206
|
+
name: 'getAuthorizationUrl',
|
|
1207
|
+
httpMethod: 'get',
|
|
1208
|
+
path: '/sso/authorize',
|
|
1209
|
+
pathParams: [],
|
|
1210
|
+
queryParams: [],
|
|
1211
|
+
headerParams: [],
|
|
1212
|
+
response: { kind: 'model', name: 'AuthorizationUrl' },
|
|
1213
|
+
errors: [],
|
|
1214
|
+
injectIdempotencyKey: false,
|
|
1215
|
+
},
|
|
1216
|
+
],
|
|
1217
|
+
},
|
|
1218
|
+
];
|
|
1219
|
+
|
|
1220
|
+
const overlayCtx: EmitterContext = {
|
|
1221
|
+
namespace: 'workos',
|
|
1222
|
+
namespacePascal: 'WorkOS',
|
|
1223
|
+
spec: { ...emptySpec, services, models: [] },
|
|
1224
|
+
overlayLookup: {
|
|
1225
|
+
methodByOperation: new Map([
|
|
1226
|
+
[
|
|
1227
|
+
'GET /sso/authorize',
|
|
1228
|
+
{
|
|
1229
|
+
className: 'SSO',
|
|
1230
|
+
methodName: 'getAuthorizationUrl',
|
|
1231
|
+
params: [],
|
|
1232
|
+
returnType: 'void',
|
|
1233
|
+
},
|
|
1234
|
+
],
|
|
1235
|
+
]),
|
|
1236
|
+
httpKeyByMethod: new Map(),
|
|
1237
|
+
interfaceByName: new Map(),
|
|
1238
|
+
typeAliasByName: new Map(),
|
|
1239
|
+
requiredExports: new Map(),
|
|
1240
|
+
modelNameByIR: new Map(),
|
|
1241
|
+
fileBySymbol: new Map(),
|
|
1242
|
+
},
|
|
1243
|
+
apiSurface: {
|
|
1244
|
+
language: 'node',
|
|
1245
|
+
extractedFrom: 'test',
|
|
1246
|
+
extractedAt: '2024-01-01',
|
|
1247
|
+
classes: {
|
|
1248
|
+
SSO: {
|
|
1249
|
+
name: 'SSO',
|
|
1250
|
+
methods: {
|
|
1251
|
+
getAuthorizationUrl: [
|
|
1252
|
+
{
|
|
1253
|
+
name: 'getAuthorizationUrl',
|
|
1254
|
+
params: [],
|
|
1255
|
+
returnType: 'void',
|
|
1256
|
+
async: true,
|
|
1257
|
+
},
|
|
1258
|
+
],
|
|
1259
|
+
},
|
|
1260
|
+
properties: {},
|
|
1261
|
+
constructorParams: [{ name: 'workos', type: 'WorkOS', optional: false }],
|
|
1262
|
+
},
|
|
1263
|
+
},
|
|
1264
|
+
interfaces: {},
|
|
1265
|
+
typeAliases: {},
|
|
1266
|
+
enums: {},
|
|
1267
|
+
exports: {},
|
|
1268
|
+
},
|
|
1269
|
+
};
|
|
1270
|
+
|
|
1271
|
+
const files = generateResources(services, overlayCtx);
|
|
1272
|
+
expect(files.length).toBe(1);
|
|
1273
|
+
|
|
1274
|
+
// skipIfExists should stay true because all methods exist in baseline
|
|
1275
|
+
expect(files[0].skipIfExists).toBe(true);
|
|
1276
|
+
});
|
|
888
1277
|
});
|
|
889
1278
|
|
|
890
1279
|
describe('resolveResourceClassName', () => {
|
|
@@ -914,7 +1303,12 @@ describe('resolveResourceClassName', () => {
|
|
|
914
1303
|
methodByOperation: new Map([
|
|
915
1304
|
[
|
|
916
1305
|
'GET /webhook_events',
|
|
917
|
-
{
|
|
1306
|
+
{
|
|
1307
|
+
className: 'Webhooks',
|
|
1308
|
+
methodName: 'listWebhookEvents',
|
|
1309
|
+
params: [],
|
|
1310
|
+
returnType: 'void',
|
|
1311
|
+
},
|
|
918
1312
|
],
|
|
919
1313
|
]),
|
|
920
1314
|
httpKeyByMethod: new Map(),
|
|
@@ -933,7 +1327,13 @@ describe('resolveResourceClassName', () => {
|
|
|
933
1327
|
name: 'Webhooks',
|
|
934
1328
|
methods: {},
|
|
935
1329
|
properties: {},
|
|
936
|
-
constructorParams: [
|
|
1330
|
+
constructorParams: [
|
|
1331
|
+
{
|
|
1332
|
+
name: 'cryptoProvider',
|
|
1333
|
+
type: 'CryptoProvider',
|
|
1334
|
+
optional: false,
|
|
1335
|
+
},
|
|
1336
|
+
],
|
|
937
1337
|
},
|
|
938
1338
|
},
|
|
939
1339
|
interfaces: {},
|
|
@@ -957,7 +1357,12 @@ describe('resolveResourceClassName', () => {
|
|
|
957
1357
|
methodByOperation: new Map([
|
|
958
1358
|
[
|
|
959
1359
|
'GET /webhook_events',
|
|
960
|
-
{
|
|
1360
|
+
{
|
|
1361
|
+
className: 'Webhooks',
|
|
1362
|
+
methodName: 'listWebhookEvents',
|
|
1363
|
+
params: [],
|
|
1364
|
+
returnType: 'void',
|
|
1365
|
+
},
|
|
961
1366
|
],
|
|
962
1367
|
]),
|
|
963
1368
|
httpKeyByMethod: new Map(),
|
|
@@ -1014,7 +1419,15 @@ describe('resolveResourceClassName', () => {
|
|
|
1014
1419
|
spec: { ...emptySpec, services: [collisionService] },
|
|
1015
1420
|
overlayLookup: {
|
|
1016
1421
|
methodByOperation: new Map([
|
|
1017
|
-
[
|
|
1422
|
+
[
|
|
1423
|
+
'GET /webhooks',
|
|
1424
|
+
{
|
|
1425
|
+
className: 'Webhooks',
|
|
1426
|
+
methodName: 'listWebhooks',
|
|
1427
|
+
params: [],
|
|
1428
|
+
returnType: 'void',
|
|
1429
|
+
},
|
|
1430
|
+
],
|
|
1018
1431
|
]),
|
|
1019
1432
|
httpKeyByMethod: new Map(),
|
|
1020
1433
|
interfaceByName: new Map(),
|
|
@@ -1032,7 +1445,13 @@ describe('resolveResourceClassName', () => {
|
|
|
1032
1445
|
name: 'Webhooks',
|
|
1033
1446
|
methods: {},
|
|
1034
1447
|
properties: {},
|
|
1035
|
-
constructorParams: [
|
|
1448
|
+
constructorParams: [
|
|
1449
|
+
{
|
|
1450
|
+
name: 'cryptoProvider',
|
|
1451
|
+
type: 'CryptoProvider',
|
|
1452
|
+
optional: false,
|
|
1453
|
+
},
|
|
1454
|
+
],
|
|
1036
1455
|
},
|
|
1037
1456
|
},
|
|
1038
1457
|
interfaces: {},
|
|
@@ -1090,7 +1509,13 @@ describe('hasCompatibleConstructor', () => {
|
|
|
1090
1509
|
name: 'Webhooks',
|
|
1091
1510
|
methods: {},
|
|
1092
1511
|
properties: {},
|
|
1093
|
-
constructorParams: [
|
|
1512
|
+
constructorParams: [
|
|
1513
|
+
{
|
|
1514
|
+
name: 'cryptoProvider',
|
|
1515
|
+
type: 'CryptoProvider',
|
|
1516
|
+
optional: false,
|
|
1517
|
+
},
|
|
1518
|
+
],
|
|
1094
1519
|
},
|
|
1095
1520
|
},
|
|
1096
1521
|
interfaces: {},
|
|
@@ -1192,7 +1617,14 @@ describe('partial service coverage', () => {
|
|
|
1192
1617
|
AuditLogs: {
|
|
1193
1618
|
name: 'AuditLogs',
|
|
1194
1619
|
methods: {
|
|
1195
|
-
createEvent: [
|
|
1620
|
+
createEvent: [
|
|
1621
|
+
{
|
|
1622
|
+
name: 'createEvent',
|
|
1623
|
+
params: [],
|
|
1624
|
+
returnType: 'AuditLogEvent',
|
|
1625
|
+
async: true,
|
|
1626
|
+
},
|
|
1627
|
+
],
|
|
1196
1628
|
},
|
|
1197
1629
|
properties: {},
|
|
1198
1630
|
constructorParams: [{ name: 'workos', type: 'WorkOS', optional: false }],
|
|
@@ -1211,7 +1643,207 @@ describe('partial service coverage', () => {
|
|
|
1211
1643
|
|
|
1212
1644
|
// Should generate method for uncovered operation
|
|
1213
1645
|
expect(content).toContain('async getRetention');
|
|
1214
|
-
// Should
|
|
1215
|
-
expect(content).
|
|
1646
|
+
// Should also generate covered operation so the merger can apply JSDoc
|
|
1647
|
+
expect(content).toContain('async createEvent');
|
|
1648
|
+
});
|
|
1649
|
+
|
|
1650
|
+
it('generates resource class for fully covered services to provide JSDoc', () => {
|
|
1651
|
+
const services: Service[] = [
|
|
1652
|
+
{
|
|
1653
|
+
name: 'Permissions',
|
|
1654
|
+
operations: [
|
|
1655
|
+
{
|
|
1656
|
+
name: 'listPermissions',
|
|
1657
|
+
description: 'List all permissions.',
|
|
1658
|
+
httpMethod: 'get',
|
|
1659
|
+
path: '/authorization/permissions',
|
|
1660
|
+
pathParams: [],
|
|
1661
|
+
queryParams: [],
|
|
1662
|
+
headerParams: [],
|
|
1663
|
+
response: { kind: 'model', name: 'PermissionList' },
|
|
1664
|
+
errors: [{ statusCode: 404 }],
|
|
1665
|
+
injectIdempotencyKey: false,
|
|
1666
|
+
},
|
|
1667
|
+
],
|
|
1668
|
+
},
|
|
1669
|
+
];
|
|
1670
|
+
|
|
1671
|
+
const ctxCovered: EmitterContext = {
|
|
1672
|
+
...ctx,
|
|
1673
|
+
spec: { ...emptySpec, services, models: [] },
|
|
1674
|
+
overlayLookup: {
|
|
1675
|
+
methodByOperation: new Map([
|
|
1676
|
+
[
|
|
1677
|
+
'GET /authorization/permissions',
|
|
1678
|
+
{
|
|
1679
|
+
className: 'Permissions',
|
|
1680
|
+
methodName: 'listPermissions',
|
|
1681
|
+
params: [],
|
|
1682
|
+
returnType: 'void',
|
|
1683
|
+
},
|
|
1684
|
+
],
|
|
1685
|
+
]),
|
|
1686
|
+
httpKeyByMethod: new Map(),
|
|
1687
|
+
interfaceByName: new Map(),
|
|
1688
|
+
typeAliasByName: new Map(),
|
|
1689
|
+
requiredExports: new Map(),
|
|
1690
|
+
modelNameByIR: new Map(),
|
|
1691
|
+
fileBySymbol: new Map(),
|
|
1692
|
+
},
|
|
1693
|
+
apiSurface: {
|
|
1694
|
+
language: 'node',
|
|
1695
|
+
extractedFrom: 'test',
|
|
1696
|
+
extractedAt: '2024-01-01',
|
|
1697
|
+
classes: {
|
|
1698
|
+
Permissions: {
|
|
1699
|
+
name: 'Permissions',
|
|
1700
|
+
methods: {
|
|
1701
|
+
listPermissions: [
|
|
1702
|
+
{
|
|
1703
|
+
name: 'listPermissions',
|
|
1704
|
+
params: [],
|
|
1705
|
+
returnType: 'void',
|
|
1706
|
+
async: true,
|
|
1707
|
+
},
|
|
1708
|
+
],
|
|
1709
|
+
},
|
|
1710
|
+
properties: {},
|
|
1711
|
+
constructorParams: [{ name: 'workos', type: 'WorkOS', optional: false }],
|
|
1712
|
+
},
|
|
1713
|
+
},
|
|
1714
|
+
interfaces: {},
|
|
1715
|
+
typeAliases: {},
|
|
1716
|
+
enums: {},
|
|
1717
|
+
exports: {},
|
|
1718
|
+
},
|
|
1719
|
+
};
|
|
1720
|
+
|
|
1721
|
+
const files = generateResources(services, ctxCovered);
|
|
1722
|
+
expect(files.length).toBe(1);
|
|
1723
|
+
const content = files[0].content;
|
|
1724
|
+
// All methods should have generated JSDoc — the merger matches by name
|
|
1725
|
+
// and handles @deprecated preservation, so the emitter always provides
|
|
1726
|
+
// docstrings for the merger to work with.
|
|
1727
|
+
expect(content).toContain('List all permissions.');
|
|
1728
|
+
// skipIfExists should remain true for covered services
|
|
1729
|
+
expect(files[0].skipIfExists).toBe(true);
|
|
1730
|
+
});
|
|
1731
|
+
|
|
1732
|
+
it('uses resolved operation method names when provided', () => {
|
|
1733
|
+
const op = {
|
|
1734
|
+
name: 'listRolesOrganizations',
|
|
1735
|
+
httpMethod: 'get' as const,
|
|
1736
|
+
path: '/authorization/organizations/{organizationId}/roles',
|
|
1737
|
+
pathParams: [
|
|
1738
|
+
{
|
|
1739
|
+
name: 'organizationId',
|
|
1740
|
+
type: { kind: 'primitive' as const, type: 'string' as const },
|
|
1741
|
+
required: true,
|
|
1742
|
+
},
|
|
1743
|
+
],
|
|
1744
|
+
queryParams: [],
|
|
1745
|
+
headerParams: [],
|
|
1746
|
+
response: { kind: 'model' as const, name: 'RoleList' },
|
|
1747
|
+
errors: [],
|
|
1748
|
+
pagination: {
|
|
1749
|
+
strategy: 'cursor' as const,
|
|
1750
|
+
param: 'after',
|
|
1751
|
+
itemType: { kind: 'model' as const, name: 'RoleList' },
|
|
1752
|
+
},
|
|
1753
|
+
injectIdempotencyKey: false,
|
|
1754
|
+
};
|
|
1755
|
+
const services: Service[] = [
|
|
1756
|
+
{
|
|
1757
|
+
name: 'Authorization',
|
|
1758
|
+
operations: [op],
|
|
1759
|
+
},
|
|
1760
|
+
];
|
|
1761
|
+
|
|
1762
|
+
const ctxResolved: EmitterContext = {
|
|
1763
|
+
...ctx,
|
|
1764
|
+
spec: {
|
|
1765
|
+
...emptySpec,
|
|
1766
|
+
services,
|
|
1767
|
+
models: [{ name: 'RoleList', fields: [] }],
|
|
1768
|
+
},
|
|
1769
|
+
resolvedOperations: [
|
|
1770
|
+
{
|
|
1771
|
+
operation: op,
|
|
1772
|
+
service: services[0],
|
|
1773
|
+
methodName: 'list_organization_roles',
|
|
1774
|
+
mountOn: 'Authorization',
|
|
1775
|
+
} as any,
|
|
1776
|
+
],
|
|
1777
|
+
};
|
|
1778
|
+
|
|
1779
|
+
const files = generateResources(services, ctxResolved);
|
|
1780
|
+
expect(files.length).toBe(1);
|
|
1781
|
+
const content = files[0].content;
|
|
1782
|
+
// Should use the resolved operation name (converted to camelCase)
|
|
1783
|
+
expect(content).toContain('async listOrganizationRoles');
|
|
1784
|
+
expect(content).not.toContain('async listRolesOrganizations');
|
|
1785
|
+
});
|
|
1786
|
+
|
|
1787
|
+
it('deduplicates method names for operations on different paths', () => {
|
|
1788
|
+
const services: Service[] = [
|
|
1789
|
+
{
|
|
1790
|
+
name: 'Organizations',
|
|
1791
|
+
operations: [
|
|
1792
|
+
{
|
|
1793
|
+
name: 'create',
|
|
1794
|
+
httpMethod: 'post',
|
|
1795
|
+
path: '/organization_domains',
|
|
1796
|
+
pathParams: [],
|
|
1797
|
+
queryParams: [],
|
|
1798
|
+
headerParams: [],
|
|
1799
|
+
requestBody: { kind: 'model', name: 'CreateOrgDomain' },
|
|
1800
|
+
response: { kind: 'model', name: 'OrgDomain' },
|
|
1801
|
+
errors: [],
|
|
1802
|
+
injectIdempotencyKey: false,
|
|
1803
|
+
},
|
|
1804
|
+
{
|
|
1805
|
+
name: 'create',
|
|
1806
|
+
httpMethod: 'post',
|
|
1807
|
+
path: '/organizations',
|
|
1808
|
+
pathParams: [],
|
|
1809
|
+
queryParams: [],
|
|
1810
|
+
headerParams: [],
|
|
1811
|
+
requestBody: { kind: 'model', name: 'CreateOrg' },
|
|
1812
|
+
response: { kind: 'model', name: 'Organization' },
|
|
1813
|
+
errors: [],
|
|
1814
|
+
injectIdempotencyKey: false,
|
|
1815
|
+
},
|
|
1816
|
+
],
|
|
1817
|
+
},
|
|
1818
|
+
];
|
|
1819
|
+
|
|
1820
|
+
const ctxDedup: EmitterContext = {
|
|
1821
|
+
...ctx,
|
|
1822
|
+
spec: {
|
|
1823
|
+
...emptySpec,
|
|
1824
|
+
services,
|
|
1825
|
+
models: [
|
|
1826
|
+
{ name: 'CreateOrgDomain', fields: [] },
|
|
1827
|
+
{ name: 'OrgDomain', fields: [] },
|
|
1828
|
+
{ name: 'CreateOrg', fields: [] },
|
|
1829
|
+
{ name: 'Organization', fields: [] },
|
|
1830
|
+
],
|
|
1831
|
+
},
|
|
1832
|
+
};
|
|
1833
|
+
|
|
1834
|
+
const files = generateResources(services, ctxDedup);
|
|
1835
|
+
expect(files.length).toBe(1);
|
|
1836
|
+
const content = files[0].content;
|
|
1837
|
+
// The best-scoring plan keeps the name; the other gets disambiguated.
|
|
1838
|
+
// "create" matches "organizations" path better (the word "create" doesn't
|
|
1839
|
+
// appear in either path, but scoring is equal — first wins).
|
|
1840
|
+
// The other gets a path suffix.
|
|
1841
|
+
const createMatches = content.match(/async create\b/g);
|
|
1842
|
+
// At most one un-suffixed "create"
|
|
1843
|
+
expect(createMatches?.length ?? 0).toBeLessThanOrEqual(1);
|
|
1844
|
+
// The two methods should have different names
|
|
1845
|
+
const methodNames = [...content.matchAll(/async (\w+)\(/g)].map((m) => m[1]);
|
|
1846
|
+
const createMethods = methodNames.filter((n) => n.toLowerCase().startsWith('create'));
|
|
1847
|
+
expect(new Set(createMethods).size).toBe(createMethods.length); // all unique
|
|
1216
1848
|
});
|
|
1217
1849
|
});
|