@workos/oagen-emitters 0.0.1 → 0.2.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.
- package/.github/workflows/release-please.yml +9 -1
- package/.husky/commit-msg +0 -0
- package/.husky/pre-commit +1 -0
- package/.husky/pre-push +1 -0
- package/.oxfmtrc.json +8 -1
- package/.prettierignore +1 -0
- package/.release-please-manifest.json +3 -0
- package/.vscode/settings.json +3 -0
- package/CHANGELOG.md +61 -0
- package/README.md +2 -2
- package/dist/index.d.mts +7 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +4070 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +14 -18
- package/release-please-config.json +11 -0
- package/smoke/sdk-dotnet.ts +17 -3
- package/smoke/sdk-elixir.ts +17 -3
- package/smoke/sdk-go.ts +21 -4
- package/smoke/sdk-kotlin.ts +23 -4
- package/smoke/sdk-node.ts +15 -3
- package/smoke/sdk-ruby.ts +17 -3
- package/smoke/sdk-rust.ts +16 -3
- package/src/node/client.ts +521 -206
- package/src/node/common.ts +74 -4
- package/src/node/config.ts +1 -0
- package/src/node/enums.ts +53 -9
- package/src/node/errors.ts +82 -3
- package/src/node/fixtures.ts +87 -16
- package/src/node/index.ts +66 -10
- package/src/node/manifest.ts +4 -2
- package/src/node/models.ts +251 -124
- package/src/node/naming.ts +107 -3
- package/src/node/resources.ts +1162 -108
- package/src/node/serializers.ts +512 -52
- package/src/node/tests.ts +650 -110
- package/src/node/type-map.ts +89 -11
- package/src/node/utils.ts +426 -113
- package/test/node/client.test.ts +1083 -20
- package/test/node/enums.test.ts +73 -4
- package/test/node/errors.test.ts +4 -21
- package/test/node/models.test.ts +499 -5
- package/test/node/naming.test.ts +14 -7
- package/test/node/resources.test.ts +1568 -9
- package/test/node/serializers.test.ts +241 -5
- package/tsconfig.json +2 -3
- package/{tsup.config.ts → tsdown.config.ts} +1 -1
- package/dist/index.d.ts +0 -5
- package/dist/index.js +0 -2158
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { generateResources } from '../../src/node/resources.js';
|
|
2
|
+
import { generateResources, resolveResourceClassName, hasCompatibleConstructor } from '../../src/node/resources.js';
|
|
3
3
|
import type { EmitterContext, ApiSpec, Service } from '@workos/oagen';
|
|
4
4
|
|
|
5
5
|
const emptySpec: ApiSpec = {
|
|
@@ -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('generateResources', () => {
|
|
@@ -73,7 +72,10 @@ describe('generateResources', () => {
|
|
|
73
72
|
queryParams: [
|
|
74
73
|
{
|
|
75
74
|
name: 'domains',
|
|
76
|
-
type: {
|
|
75
|
+
type: {
|
|
76
|
+
kind: 'array',
|
|
77
|
+
items: { kind: 'primitive', type: 'string' },
|
|
78
|
+
},
|
|
77
79
|
required: false,
|
|
78
80
|
},
|
|
79
81
|
],
|
|
@@ -81,7 +83,8 @@ describe('generateResources', () => {
|
|
|
81
83
|
response: { kind: 'model', name: 'Organization' },
|
|
82
84
|
errors: [],
|
|
83
85
|
pagination: {
|
|
84
|
-
|
|
86
|
+
strategy: 'cursor',
|
|
87
|
+
param: 'after',
|
|
85
88
|
dataPath: 'data',
|
|
86
89
|
itemType: { kind: 'model', name: 'Organization' },
|
|
87
90
|
},
|
|
@@ -94,9 +97,9 @@ describe('generateResources', () => {
|
|
|
94
97
|
const files = generateResources(services, ctx);
|
|
95
98
|
const content = files[0].content;
|
|
96
99
|
|
|
97
|
-
// Should have AutoPaginatable
|
|
98
|
-
expect(content).toContain(
|
|
99
|
-
expect(content).toContain(
|
|
100
|
+
// Should have AutoPaginatable type import and createPaginatedList import
|
|
101
|
+
expect(content).toContain("import type { AutoPaginatable } from '../common/utils/pagination'");
|
|
102
|
+
expect(content).toContain("import { createPaginatedList } from '../common/utils/fetch-and-deserialize'");
|
|
100
103
|
|
|
101
104
|
// Should generate options interface
|
|
102
105
|
expect(content).toContain('export interface ListOrganizationsOptions extends PaginationOptions {');
|
|
@@ -106,6 +109,54 @@ describe('generateResources', () => {
|
|
|
106
109
|
expect(content).toContain('Promise<AutoPaginatable<Organization, ListOrganizationsOptions>>');
|
|
107
110
|
});
|
|
108
111
|
|
|
112
|
+
it('uses item type not list wrapper type for paginated methods', () => {
|
|
113
|
+
// The response model is the list wrapper (ConnectionList), but the pagination
|
|
114
|
+
// itemType is the actual item (Connection). The generated code should use the
|
|
115
|
+
// item type for fetchAndDeserialize, not the list wrapper.
|
|
116
|
+
const services: Service[] = [
|
|
117
|
+
{
|
|
118
|
+
name: 'SSO',
|
|
119
|
+
operations: [
|
|
120
|
+
{
|
|
121
|
+
name: 'listConnections',
|
|
122
|
+
httpMethod: 'get',
|
|
123
|
+
path: '/connections',
|
|
124
|
+
pathParams: [],
|
|
125
|
+
queryParams: [],
|
|
126
|
+
headerParams: [],
|
|
127
|
+
response: { kind: 'model', name: 'ConnectionList' },
|
|
128
|
+
errors: [],
|
|
129
|
+
pagination: {
|
|
130
|
+
strategy: 'cursor',
|
|
131
|
+
param: 'after',
|
|
132
|
+
dataPath: 'data',
|
|
133
|
+
itemType: { kind: 'model', name: 'Connection' },
|
|
134
|
+
},
|
|
135
|
+
injectIdempotencyKey: false,
|
|
136
|
+
},
|
|
137
|
+
],
|
|
138
|
+
},
|
|
139
|
+
];
|
|
140
|
+
|
|
141
|
+
const testCtx: EmitterContext = {
|
|
142
|
+
namespace: 'workos',
|
|
143
|
+
namespacePascal: 'WorkOS',
|
|
144
|
+
spec: { ...emptySpec, services, models: [] },
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const files = generateResources(services, testCtx);
|
|
148
|
+
const content = files[0].content;
|
|
149
|
+
|
|
150
|
+
// Should use item type (Connection) not list wrapper (ConnectionList)
|
|
151
|
+
expect(content).toContain('createPaginatedList<ConnectionResponse, Connection,');
|
|
152
|
+
expect(content).toContain('deserializeConnection');
|
|
153
|
+
expect(content).toContain('Promise<AutoPaginatable<Connection,');
|
|
154
|
+
|
|
155
|
+
// Should NOT reference the list wrapper type
|
|
156
|
+
expect(content).not.toContain('ConnectionList');
|
|
157
|
+
expect(content).not.toContain('deserializeConnectionList');
|
|
158
|
+
});
|
|
159
|
+
|
|
109
160
|
it('generates DELETE method returning void', () => {
|
|
110
161
|
const services: Service[] = [
|
|
111
162
|
{
|
|
@@ -191,12 +242,16 @@ describe('generateResources', () => {
|
|
|
191
242
|
namespace: 'workos',
|
|
192
243
|
namespacePascal: 'WorkOS',
|
|
193
244
|
spec: { ...emptySpec, services: [mfaService], models: [] },
|
|
194
|
-
irVersion: 6,
|
|
195
245
|
overlayLookup: {
|
|
196
246
|
methodByOperation: new Map([
|
|
197
247
|
[
|
|
198
248
|
'POST /auth/factors/enroll',
|
|
199
|
-
{
|
|
249
|
+
{
|
|
250
|
+
className: 'Mfa',
|
|
251
|
+
methodName: 'enrollFactor',
|
|
252
|
+
params: [],
|
|
253
|
+
returnType: 'void',
|
|
254
|
+
},
|
|
200
255
|
],
|
|
201
256
|
]),
|
|
202
257
|
httpKeyByMethod: new Map(),
|
|
@@ -254,7 +309,1511 @@ describe('generateResources', () => {
|
|
|
254
309
|
expect(content).toContain(' *');
|
|
255
310
|
expect(content).toContain(' * You may optionally inform Radar that an attempt was successful.');
|
|
256
311
|
expect(content).toContain(' * @param id - The unique identifier of the attempt.');
|
|
312
|
+
expect(content).toContain(' * @returns {RadarAttempt}');
|
|
257
313
|
expect(content).toContain(' * @deprecated');
|
|
258
314
|
expect(content).toContain(' */');
|
|
259
315
|
});
|
|
316
|
+
|
|
317
|
+
it('renders @returns for response model', () => {
|
|
318
|
+
const services: Service[] = [
|
|
319
|
+
{
|
|
320
|
+
name: 'Organizations',
|
|
321
|
+
operations: [
|
|
322
|
+
{
|
|
323
|
+
name: 'getOrganization',
|
|
324
|
+
httpMethod: 'get',
|
|
325
|
+
path: '/organizations/{id}',
|
|
326
|
+
pathParams: [
|
|
327
|
+
{
|
|
328
|
+
name: 'id',
|
|
329
|
+
type: { kind: 'primitive', type: 'string' },
|
|
330
|
+
required: true,
|
|
331
|
+
},
|
|
332
|
+
],
|
|
333
|
+
queryParams: [],
|
|
334
|
+
headerParams: [],
|
|
335
|
+
response: { kind: 'model', name: 'Organization' },
|
|
336
|
+
errors: [],
|
|
337
|
+
injectIdempotencyKey: false,
|
|
338
|
+
},
|
|
339
|
+
],
|
|
340
|
+
},
|
|
341
|
+
];
|
|
342
|
+
|
|
343
|
+
const files = generateResources(services, ctx);
|
|
344
|
+
const content = files[0].content;
|
|
345
|
+
expect(content).toContain('@returns {Organization}');
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it('renders query param docs for non-paginated operations', () => {
|
|
349
|
+
const services: Service[] = [
|
|
350
|
+
{
|
|
351
|
+
name: 'Organizations',
|
|
352
|
+
operations: [
|
|
353
|
+
{
|
|
354
|
+
name: 'getOrganization',
|
|
355
|
+
httpMethod: 'get',
|
|
356
|
+
path: '/organizations/{id}',
|
|
357
|
+
pathParams: [
|
|
358
|
+
{
|
|
359
|
+
name: 'id',
|
|
360
|
+
type: { kind: 'primitive', type: 'string' },
|
|
361
|
+
required: true,
|
|
362
|
+
},
|
|
363
|
+
],
|
|
364
|
+
queryParams: [
|
|
365
|
+
{
|
|
366
|
+
name: 'include_fields',
|
|
367
|
+
type: { kind: 'primitive', type: 'string' },
|
|
368
|
+
required: false,
|
|
369
|
+
description: 'Comma-separated list of fields to include.',
|
|
370
|
+
},
|
|
371
|
+
],
|
|
372
|
+
headerParams: [],
|
|
373
|
+
response: { kind: 'model', name: 'Organization' },
|
|
374
|
+
errors: [],
|
|
375
|
+
injectIdempotencyKey: false,
|
|
376
|
+
},
|
|
377
|
+
],
|
|
378
|
+
},
|
|
379
|
+
];
|
|
380
|
+
|
|
381
|
+
const files = generateResources(services, ctx);
|
|
382
|
+
const content = files[0].content;
|
|
383
|
+
expect(content).toContain('@param options.includeFields - Comma-separated list of fields to include.');
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it('renders header and cookie param docs', () => {
|
|
387
|
+
const services: Service[] = [
|
|
388
|
+
{
|
|
389
|
+
name: 'Sessions',
|
|
390
|
+
operations: [
|
|
391
|
+
{
|
|
392
|
+
name: 'getSession',
|
|
393
|
+
httpMethod: 'get',
|
|
394
|
+
path: '/sessions/{id}',
|
|
395
|
+
pathParams: [
|
|
396
|
+
{
|
|
397
|
+
name: 'id',
|
|
398
|
+
type: { kind: 'primitive', type: 'string' },
|
|
399
|
+
required: true,
|
|
400
|
+
},
|
|
401
|
+
],
|
|
402
|
+
queryParams: [],
|
|
403
|
+
headerParams: [
|
|
404
|
+
{
|
|
405
|
+
name: 'X-Request-Id',
|
|
406
|
+
type: { kind: 'primitive', type: 'string' },
|
|
407
|
+
required: false,
|
|
408
|
+
description: 'Unique request identifier.',
|
|
409
|
+
},
|
|
410
|
+
],
|
|
411
|
+
cookieParams: [
|
|
412
|
+
{
|
|
413
|
+
name: 'session_token',
|
|
414
|
+
type: { kind: 'primitive', type: 'string' },
|
|
415
|
+
required: true,
|
|
416
|
+
description: 'The session cookie.',
|
|
417
|
+
},
|
|
418
|
+
],
|
|
419
|
+
response: { kind: 'model', name: 'Session' },
|
|
420
|
+
errors: [],
|
|
421
|
+
injectIdempotencyKey: false,
|
|
422
|
+
},
|
|
423
|
+
],
|
|
424
|
+
},
|
|
425
|
+
];
|
|
426
|
+
|
|
427
|
+
const files = generateResources(services, ctx);
|
|
428
|
+
const content = files[0].content;
|
|
429
|
+
// Header and cookie params are intentionally NOT documented in JSDoc —
|
|
430
|
+
// they are not exposed in the method signature (handled internally by the SDK).
|
|
431
|
+
expect(content).not.toContain('@param xRequestId');
|
|
432
|
+
expect(content).not.toContain('@param sessionToken');
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
it('renders single @returns without status-code duplicates', () => {
|
|
436
|
+
const services: Service[] = [
|
|
437
|
+
{
|
|
438
|
+
name: 'Organizations',
|
|
439
|
+
operations: [
|
|
440
|
+
{
|
|
441
|
+
name: 'createOrganization',
|
|
442
|
+
httpMethod: 'post',
|
|
443
|
+
path: '/organizations',
|
|
444
|
+
pathParams: [],
|
|
445
|
+
queryParams: [],
|
|
446
|
+
headerParams: [],
|
|
447
|
+
requestBody: { kind: 'model', name: 'CreateOrganizationInput' },
|
|
448
|
+
response: { kind: 'model', name: 'Organization' },
|
|
449
|
+
successResponses: [
|
|
450
|
+
{
|
|
451
|
+
statusCode: 200,
|
|
452
|
+
type: { kind: 'model', name: 'Organization' },
|
|
453
|
+
},
|
|
454
|
+
{
|
|
455
|
+
statusCode: 201,
|
|
456
|
+
type: { kind: 'model', name: 'Organization' },
|
|
457
|
+
},
|
|
458
|
+
],
|
|
459
|
+
errors: [],
|
|
460
|
+
injectIdempotencyKey: false,
|
|
461
|
+
},
|
|
462
|
+
],
|
|
463
|
+
},
|
|
464
|
+
];
|
|
465
|
+
|
|
466
|
+
const files = generateResources(services, ctx);
|
|
467
|
+
const content = files[0].content;
|
|
468
|
+
// Only emit a single @returns for the primary response model (no status-code variants)
|
|
469
|
+
expect(content).toContain('@returns {Organization}');
|
|
470
|
+
expect(content).not.toContain('@returns {Organization} 200');
|
|
471
|
+
expect(content).not.toContain('@returns {Organization} 201');
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
it('generates DELETE-with-body method using deleteWithBody', () => {
|
|
475
|
+
const services: Service[] = [
|
|
476
|
+
{
|
|
477
|
+
name: 'Radar',
|
|
478
|
+
operations: [
|
|
479
|
+
{
|
|
480
|
+
name: 'deleteRadarListEntry',
|
|
481
|
+
httpMethod: 'delete',
|
|
482
|
+
path: '/radar/lists/{listId}/entries',
|
|
483
|
+
pathParams: [
|
|
484
|
+
{
|
|
485
|
+
name: 'listId',
|
|
486
|
+
type: { kind: 'primitive', type: 'string' },
|
|
487
|
+
required: true,
|
|
488
|
+
},
|
|
489
|
+
],
|
|
490
|
+
queryParams: [],
|
|
491
|
+
headerParams: [],
|
|
492
|
+
requestBody: { kind: 'model', name: 'DeleteRadarListEntryInput' },
|
|
493
|
+
response: { kind: 'primitive', type: 'unknown' },
|
|
494
|
+
errors: [],
|
|
495
|
+
injectIdempotencyKey: false,
|
|
496
|
+
},
|
|
497
|
+
],
|
|
498
|
+
},
|
|
499
|
+
];
|
|
500
|
+
|
|
501
|
+
const files = generateResources(services, ctx);
|
|
502
|
+
const content = files[0].content;
|
|
503
|
+
expect(content).toContain(
|
|
504
|
+
'async deleteRadarListEntry(listId: string, payload: DeleteRadarListEntryInput): Promise<void>',
|
|
505
|
+
);
|
|
506
|
+
expect(content).toContain('await this.workos.deleteWithBody(');
|
|
507
|
+
expect(content).toContain('serializeDeleteRadarListEntryInput(payload)');
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
it('renders deprecated path params', () => {
|
|
511
|
+
const services: Service[] = [
|
|
512
|
+
{
|
|
513
|
+
name: 'Organizations',
|
|
514
|
+
operations: [
|
|
515
|
+
{
|
|
516
|
+
name: 'getOrganization',
|
|
517
|
+
httpMethod: 'get',
|
|
518
|
+
path: '/organizations/{slug}',
|
|
519
|
+
pathParams: [
|
|
520
|
+
{
|
|
521
|
+
name: 'slug',
|
|
522
|
+
type: { kind: 'primitive', type: 'string' },
|
|
523
|
+
required: true,
|
|
524
|
+
description: 'The organization slug.',
|
|
525
|
+
deprecated: true,
|
|
526
|
+
},
|
|
527
|
+
],
|
|
528
|
+
queryParams: [],
|
|
529
|
+
headerParams: [],
|
|
530
|
+
response: { kind: 'model', name: 'Organization' },
|
|
531
|
+
errors: [],
|
|
532
|
+
injectIdempotencyKey: false,
|
|
533
|
+
},
|
|
534
|
+
],
|
|
535
|
+
},
|
|
536
|
+
];
|
|
537
|
+
|
|
538
|
+
const files = generateResources(services, ctx);
|
|
539
|
+
const content = files[0].content;
|
|
540
|
+
expect(content).toContain('@param slug - (deprecated) The organization slug.');
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
it('generates typed options interface for non-paginated GET with query params', () => {
|
|
544
|
+
const services: Service[] = [
|
|
545
|
+
{
|
|
546
|
+
name: 'Organizations',
|
|
547
|
+
operations: [
|
|
548
|
+
{
|
|
549
|
+
name: 'getOrganization',
|
|
550
|
+
httpMethod: 'get',
|
|
551
|
+
path: '/organizations/{id}',
|
|
552
|
+
pathParams: [
|
|
553
|
+
{
|
|
554
|
+
name: 'id',
|
|
555
|
+
type: { kind: 'primitive', type: 'string' },
|
|
556
|
+
required: true,
|
|
557
|
+
},
|
|
558
|
+
],
|
|
559
|
+
queryParams: [
|
|
560
|
+
{
|
|
561
|
+
name: 'include_fields',
|
|
562
|
+
type: { kind: 'primitive', type: 'string' },
|
|
563
|
+
required: false,
|
|
564
|
+
description: 'Comma-separated list of fields to include.',
|
|
565
|
+
},
|
|
566
|
+
],
|
|
567
|
+
headerParams: [],
|
|
568
|
+
response: { kind: 'model', name: 'Organization' },
|
|
569
|
+
errors: [],
|
|
570
|
+
injectIdempotencyKey: false,
|
|
571
|
+
},
|
|
572
|
+
],
|
|
573
|
+
},
|
|
574
|
+
];
|
|
575
|
+
|
|
576
|
+
const files = generateResources(services, ctx);
|
|
577
|
+
const content = files[0].content;
|
|
578
|
+
|
|
579
|
+
// Should generate a typed options interface
|
|
580
|
+
expect(content).toContain('export interface GetOrganizationOptions {');
|
|
581
|
+
expect(content).toContain('includeFields?: string;');
|
|
582
|
+
|
|
583
|
+
// Should use the typed options in the method signature
|
|
584
|
+
expect(content).toContain(
|
|
585
|
+
'async getOrganization(id: string, options?: GetOrganizationOptions): Promise<Organization>',
|
|
586
|
+
);
|
|
587
|
+
|
|
588
|
+
// Should NOT use Record<string, unknown>
|
|
589
|
+
expect(content).not.toContain('Record<string, unknown>');
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
it('generates typed options interface for void GET with query params', () => {
|
|
593
|
+
const services: Service[] = [
|
|
594
|
+
{
|
|
595
|
+
name: 'Auth',
|
|
596
|
+
operations: [
|
|
597
|
+
{
|
|
598
|
+
name: 'authorize',
|
|
599
|
+
httpMethod: 'get',
|
|
600
|
+
path: '/user_management/authorize',
|
|
601
|
+
pathParams: [],
|
|
602
|
+
queryParams: [
|
|
603
|
+
{
|
|
604
|
+
name: 'client_id',
|
|
605
|
+
type: { kind: 'primitive', type: 'string' },
|
|
606
|
+
required: true,
|
|
607
|
+
},
|
|
608
|
+
{
|
|
609
|
+
name: 'redirect_uri',
|
|
610
|
+
type: { kind: 'primitive', type: 'string' },
|
|
611
|
+
required: true,
|
|
612
|
+
},
|
|
613
|
+
{
|
|
614
|
+
name: 'response_type',
|
|
615
|
+
type: { kind: 'primitive', type: 'string' },
|
|
616
|
+
required: true,
|
|
617
|
+
},
|
|
618
|
+
],
|
|
619
|
+
headerParams: [],
|
|
620
|
+
response: { kind: 'primitive', type: 'unknown' },
|
|
621
|
+
errors: [],
|
|
622
|
+
injectIdempotencyKey: false,
|
|
623
|
+
},
|
|
624
|
+
],
|
|
625
|
+
},
|
|
626
|
+
];
|
|
627
|
+
|
|
628
|
+
const files = generateResources(services, ctx);
|
|
629
|
+
const content = files[0].content;
|
|
630
|
+
|
|
631
|
+
// Should generate a typed options interface
|
|
632
|
+
expect(content).toContain('export interface AuthorizeOptions {');
|
|
633
|
+
expect(content).toContain('clientId: string;');
|
|
634
|
+
expect(content).toContain('redirectUri: string;');
|
|
635
|
+
expect(content).toContain('responseType: string;');
|
|
636
|
+
|
|
637
|
+
// Should use the typed options in the method signature
|
|
638
|
+
expect(content).toContain('async authorize(options?: AuthorizeOptions): Promise<void>');
|
|
639
|
+
|
|
640
|
+
// Should pass options as query params
|
|
641
|
+
expect(content).toContain('query: options');
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
it('falls back to pass-through for non-discriminated union when models not in spec', () => {
|
|
645
|
+
const services: Service[] = [
|
|
646
|
+
{
|
|
647
|
+
name: 'Auth',
|
|
648
|
+
operations: [
|
|
649
|
+
{
|
|
650
|
+
name: 'authenticate',
|
|
651
|
+
httpMethod: 'post',
|
|
652
|
+
path: '/user_management/authenticate',
|
|
653
|
+
pathParams: [],
|
|
654
|
+
queryParams: [],
|
|
655
|
+
headerParams: [],
|
|
656
|
+
requestBody: {
|
|
657
|
+
kind: 'union',
|
|
658
|
+
variants: [
|
|
659
|
+
{ kind: 'model', name: 'AuthByPassword' },
|
|
660
|
+
{ kind: 'model', name: 'AuthByCode' },
|
|
661
|
+
{ kind: 'model', name: 'AuthByMagicAuth' },
|
|
662
|
+
],
|
|
663
|
+
},
|
|
664
|
+
response: { kind: 'model', name: 'AuthenticateResponse' },
|
|
665
|
+
errors: [],
|
|
666
|
+
injectIdempotencyKey: false,
|
|
667
|
+
},
|
|
668
|
+
],
|
|
669
|
+
},
|
|
670
|
+
];
|
|
671
|
+
|
|
672
|
+
const files = generateResources(services, ctx);
|
|
673
|
+
const content = files[0].content;
|
|
674
|
+
|
|
675
|
+
// Should use the union type for the payload parameter
|
|
676
|
+
expect(content).toContain('payload: AuthByPassword | AuthByCode | AuthByMagicAuth');
|
|
677
|
+
|
|
678
|
+
// Should NOT use Record<string, unknown>
|
|
679
|
+
expect(content).not.toContain('Record<string, unknown>');
|
|
680
|
+
|
|
681
|
+
// Models not in spec → falls back to pass-through
|
|
682
|
+
expect(content).toContain("'/user_management/authenticate',");
|
|
683
|
+
expect(content).toContain('payload,');
|
|
684
|
+
|
|
685
|
+
// Should import all union variant types
|
|
686
|
+
expect(content).toContain('AuthByPassword');
|
|
687
|
+
expect(content).toContain('AuthByCode');
|
|
688
|
+
expect(content).toContain('AuthByMagicAuth');
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
it('generates field-guard serializer dispatch for non-discriminated union with models', () => {
|
|
692
|
+
const services: Service[] = [
|
|
693
|
+
{
|
|
694
|
+
name: 'Applications',
|
|
695
|
+
operations: [
|
|
696
|
+
{
|
|
697
|
+
name: 'createApplication',
|
|
698
|
+
httpMethod: 'post',
|
|
699
|
+
path: '/connect/applications',
|
|
700
|
+
pathParams: [],
|
|
701
|
+
queryParams: [],
|
|
702
|
+
headerParams: [],
|
|
703
|
+
requestBody: {
|
|
704
|
+
kind: 'union',
|
|
705
|
+
variants: [
|
|
706
|
+
{ kind: 'model', name: 'CreateOAuthApplication' },
|
|
707
|
+
{ kind: 'model', name: 'CreateM2MApplication' },
|
|
708
|
+
],
|
|
709
|
+
},
|
|
710
|
+
response: { kind: 'model', name: 'ConnectApplication' },
|
|
711
|
+
errors: [],
|
|
712
|
+
injectIdempotencyKey: false,
|
|
713
|
+
},
|
|
714
|
+
],
|
|
715
|
+
},
|
|
716
|
+
];
|
|
717
|
+
|
|
718
|
+
const testCtx: EmitterContext = {
|
|
719
|
+
namespace: 'workos',
|
|
720
|
+
namespacePascal: 'WorkOS',
|
|
721
|
+
spec: {
|
|
722
|
+
...emptySpec,
|
|
723
|
+
services,
|
|
724
|
+
models: [
|
|
725
|
+
{
|
|
726
|
+
name: 'CreateOAuthApplication',
|
|
727
|
+
fields: [
|
|
728
|
+
{
|
|
729
|
+
name: 'name',
|
|
730
|
+
type: { kind: 'primitive', type: 'string' },
|
|
731
|
+
required: true,
|
|
732
|
+
},
|
|
733
|
+
{
|
|
734
|
+
name: 'redirect_uris',
|
|
735
|
+
type: {
|
|
736
|
+
kind: 'array',
|
|
737
|
+
items: { kind: 'primitive', type: 'string' },
|
|
738
|
+
},
|
|
739
|
+
required: true,
|
|
740
|
+
},
|
|
741
|
+
{
|
|
742
|
+
name: 'uses_pkce',
|
|
743
|
+
type: { kind: 'primitive', type: 'boolean' },
|
|
744
|
+
required: false,
|
|
745
|
+
},
|
|
746
|
+
],
|
|
747
|
+
},
|
|
748
|
+
{
|
|
749
|
+
name: 'CreateM2MApplication',
|
|
750
|
+
fields: [
|
|
751
|
+
{
|
|
752
|
+
name: 'name',
|
|
753
|
+
type: { kind: 'primitive', type: 'string' },
|
|
754
|
+
required: true,
|
|
755
|
+
},
|
|
756
|
+
{
|
|
757
|
+
name: 'scopes',
|
|
758
|
+
type: {
|
|
759
|
+
kind: 'array',
|
|
760
|
+
items: { kind: 'primitive', type: 'string' },
|
|
761
|
+
},
|
|
762
|
+
required: true,
|
|
763
|
+
},
|
|
764
|
+
],
|
|
765
|
+
},
|
|
766
|
+
{
|
|
767
|
+
name: 'ConnectApplication',
|
|
768
|
+
fields: [
|
|
769
|
+
{
|
|
770
|
+
name: 'id',
|
|
771
|
+
type: { kind: 'primitive', type: 'string' },
|
|
772
|
+
required: true,
|
|
773
|
+
},
|
|
774
|
+
],
|
|
775
|
+
},
|
|
776
|
+
],
|
|
777
|
+
},
|
|
778
|
+
};
|
|
779
|
+
|
|
780
|
+
const files = generateResources(services, testCtx);
|
|
781
|
+
const content = files[0].content;
|
|
782
|
+
|
|
783
|
+
// Should use the union type for the payload parameter
|
|
784
|
+
expect(content).toContain('payload: CreateOAuthApplication | CreateM2MApplication');
|
|
785
|
+
|
|
786
|
+
// Should dispatch via unique required field guards
|
|
787
|
+
expect(content).toContain("'redirectUris' in payload");
|
|
788
|
+
expect(content).toContain('serializeCreateOAuthApplication(payload as any)');
|
|
789
|
+
expect(content).toContain('serializeCreateM2MApplication(payload as any)');
|
|
790
|
+
|
|
791
|
+
// Should import serializers for all union variants
|
|
792
|
+
expect(content).toContain('serializeCreateOAuthApplication');
|
|
793
|
+
expect(content).toContain('serializeCreateM2MApplication');
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
it('generates discriminated union serializer dispatch for request body', () => {
|
|
797
|
+
const services: Service[] = [
|
|
798
|
+
{
|
|
799
|
+
name: 'Auth',
|
|
800
|
+
operations: [
|
|
801
|
+
{
|
|
802
|
+
name: 'authenticate',
|
|
803
|
+
httpMethod: 'post',
|
|
804
|
+
path: '/user_management/authenticate',
|
|
805
|
+
pathParams: [],
|
|
806
|
+
queryParams: [],
|
|
807
|
+
headerParams: [],
|
|
808
|
+
requestBody: {
|
|
809
|
+
kind: 'union',
|
|
810
|
+
variants: [
|
|
811
|
+
{ kind: 'model', name: 'AuthByPassword' },
|
|
812
|
+
{ kind: 'model', name: 'AuthByCode' },
|
|
813
|
+
{ kind: 'model', name: 'AuthByMagicAuth' },
|
|
814
|
+
],
|
|
815
|
+
discriminator: {
|
|
816
|
+
property: 'grant_type',
|
|
817
|
+
mapping: {
|
|
818
|
+
password: 'AuthByPassword',
|
|
819
|
+
authorization_code: 'AuthByCode',
|
|
820
|
+
'urn:workos:oauth:grant-type:magic-auth:code': 'AuthByMagicAuth',
|
|
821
|
+
},
|
|
822
|
+
},
|
|
823
|
+
},
|
|
824
|
+
response: { kind: 'model', name: 'AuthenticateResponse' },
|
|
825
|
+
errors: [],
|
|
826
|
+
injectIdempotencyKey: false,
|
|
827
|
+
},
|
|
828
|
+
],
|
|
829
|
+
},
|
|
830
|
+
];
|
|
831
|
+
|
|
832
|
+
const files = generateResources(services, ctx);
|
|
833
|
+
const content = files[0].content;
|
|
834
|
+
|
|
835
|
+
// Should use the union type for the payload parameter
|
|
836
|
+
expect(content).toContain('payload: AuthByPassword | AuthByCode | AuthByMagicAuth');
|
|
837
|
+
|
|
838
|
+
// Should dispatch to the correct serializer based on the discriminator
|
|
839
|
+
expect(content).toContain('switch ((payload as any).grantType)');
|
|
840
|
+
expect(content).toContain("case 'password': return serializeAuthByPassword(payload as any)");
|
|
841
|
+
expect(content).toContain("case 'authorization_code': return serializeAuthByCode(payload as any)");
|
|
842
|
+
expect(content).toContain(
|
|
843
|
+
"case 'urn:workos:oauth:grant-type:magic-auth:code': return serializeAuthByMagicAuth(payload as any)",
|
|
844
|
+
);
|
|
845
|
+
|
|
846
|
+
// Should import serializers for all union variants
|
|
847
|
+
expect(content).toContain('serializeAuthByPassword');
|
|
848
|
+
expect(content).toContain('serializeAuthByCode');
|
|
849
|
+
expect(content).toContain('serializeAuthByMagicAuth');
|
|
850
|
+
|
|
851
|
+
// Should NOT pass payload directly without serialization
|
|
852
|
+
expect(content).not.toMatch(/,\n\s+payload,\n/);
|
|
853
|
+
});
|
|
854
|
+
|
|
855
|
+
it('generates discriminated union serializer dispatch for void method', () => {
|
|
856
|
+
const services: Service[] = [
|
|
857
|
+
{
|
|
858
|
+
name: 'Auth',
|
|
859
|
+
operations: [
|
|
860
|
+
{
|
|
861
|
+
name: 'sendToken',
|
|
862
|
+
httpMethod: 'post',
|
|
863
|
+
path: '/auth/token',
|
|
864
|
+
pathParams: [],
|
|
865
|
+
queryParams: [],
|
|
866
|
+
headerParams: [],
|
|
867
|
+
requestBody: {
|
|
868
|
+
kind: 'union',
|
|
869
|
+
variants: [
|
|
870
|
+
{ kind: 'model', name: 'TokenByCode' },
|
|
871
|
+
{ kind: 'model', name: 'TokenByRefresh' },
|
|
872
|
+
],
|
|
873
|
+
discriminator: {
|
|
874
|
+
property: 'grant_type',
|
|
875
|
+
mapping: {
|
|
876
|
+
authorization_code: 'TokenByCode',
|
|
877
|
+
refresh_token: 'TokenByRefresh',
|
|
878
|
+
},
|
|
879
|
+
},
|
|
880
|
+
},
|
|
881
|
+
response: { kind: 'primitive', type: 'unknown' },
|
|
882
|
+
errors: [],
|
|
883
|
+
injectIdempotencyKey: false,
|
|
884
|
+
},
|
|
885
|
+
],
|
|
886
|
+
},
|
|
887
|
+
];
|
|
888
|
+
|
|
889
|
+
const files = generateResources(services, ctx);
|
|
890
|
+
const content = files[0].content;
|
|
891
|
+
|
|
892
|
+
// Should dispatch to the correct serializer
|
|
893
|
+
expect(content).toContain('switch ((payload as any).grantType)');
|
|
894
|
+
expect(content).toContain("case 'authorization_code': return serializeTokenByCode(payload as any)");
|
|
895
|
+
expect(content).toContain("case 'refresh_token': return serializeTokenByRefresh(payload as any)");
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
it('uses createPaginatedList helper in paginated methods', () => {
|
|
899
|
+
const services: Service[] = [
|
|
900
|
+
{
|
|
901
|
+
name: 'Connections',
|
|
902
|
+
operations: [
|
|
903
|
+
{
|
|
904
|
+
name: 'listConnections',
|
|
905
|
+
httpMethod: 'get',
|
|
906
|
+
path: '/connections',
|
|
907
|
+
pathParams: [],
|
|
908
|
+
queryParams: [],
|
|
909
|
+
headerParams: [],
|
|
910
|
+
response: { kind: 'model', name: 'Connection' },
|
|
911
|
+
errors: [],
|
|
912
|
+
pagination: {
|
|
913
|
+
strategy: 'cursor',
|
|
914
|
+
param: 'after',
|
|
915
|
+
dataPath: 'data',
|
|
916
|
+
itemType: { kind: 'model', name: 'Connection' },
|
|
917
|
+
},
|
|
918
|
+
injectIdempotencyKey: false,
|
|
919
|
+
},
|
|
920
|
+
],
|
|
921
|
+
},
|
|
922
|
+
];
|
|
923
|
+
|
|
924
|
+
const files = generateResources(services, ctx);
|
|
925
|
+
const content = files[0].content;
|
|
926
|
+
|
|
927
|
+
// Should use createPaginatedList helper for concise paginated methods
|
|
928
|
+
expect(content).toContain('createPaginatedList<ConnectionResponse, Connection,');
|
|
929
|
+
expect(content).toContain('this.workos,');
|
|
930
|
+
expect(content).toContain('deserializeConnection');
|
|
931
|
+
});
|
|
932
|
+
|
|
933
|
+
it('prefixes ListOptions with service name when method is "list"', () => {
|
|
934
|
+
const services: Service[] = [
|
|
935
|
+
{
|
|
936
|
+
name: 'Payments',
|
|
937
|
+
operations: [
|
|
938
|
+
{
|
|
939
|
+
name: 'list',
|
|
940
|
+
httpMethod: 'get',
|
|
941
|
+
path: '/payments',
|
|
942
|
+
pathParams: [],
|
|
943
|
+
queryParams: [
|
|
944
|
+
{
|
|
945
|
+
name: 'connection_type',
|
|
946
|
+
type: { kind: 'primitive', type: 'string' },
|
|
947
|
+
required: false,
|
|
948
|
+
},
|
|
949
|
+
],
|
|
950
|
+
headerParams: [],
|
|
951
|
+
response: { kind: 'model', name: 'Connection' },
|
|
952
|
+
errors: [],
|
|
953
|
+
pagination: {
|
|
954
|
+
strategy: 'cursor',
|
|
955
|
+
param: 'after',
|
|
956
|
+
dataPath: 'data',
|
|
957
|
+
itemType: { kind: 'model', name: 'Connection' },
|
|
958
|
+
},
|
|
959
|
+
injectIdempotencyKey: false,
|
|
960
|
+
},
|
|
961
|
+
],
|
|
962
|
+
},
|
|
963
|
+
];
|
|
964
|
+
|
|
965
|
+
// Use overlay to resolve method name to "list"
|
|
966
|
+
const overlayCtx: EmitterContext = {
|
|
967
|
+
namespace: 'workos',
|
|
968
|
+
namespacePascal: 'WorkOS',
|
|
969
|
+
spec: { ...emptySpec, services, models: [] },
|
|
970
|
+
overlayLookup: {
|
|
971
|
+
methodByOperation: new Map([
|
|
972
|
+
[
|
|
973
|
+
'GET /payments',
|
|
974
|
+
{
|
|
975
|
+
className: 'Payments',
|
|
976
|
+
methodName: 'list',
|
|
977
|
+
params: [],
|
|
978
|
+
returnType: 'void',
|
|
979
|
+
},
|
|
980
|
+
],
|
|
981
|
+
]),
|
|
982
|
+
httpKeyByMethod: new Map(),
|
|
983
|
+
interfaceByName: new Map(),
|
|
984
|
+
typeAliasByName: new Map(),
|
|
985
|
+
requiredExports: new Map(),
|
|
986
|
+
modelNameByIR: new Map(),
|
|
987
|
+
fileBySymbol: new Map(),
|
|
988
|
+
},
|
|
989
|
+
};
|
|
990
|
+
|
|
991
|
+
const files = generateResources(services, overlayCtx);
|
|
992
|
+
const content = files[0].content;
|
|
993
|
+
|
|
994
|
+
// Should use service-prefixed options name instead of generic "ListOptions"
|
|
995
|
+
expect(content).toContain('export interface PaymentsListOptions extends PaginationOptions {');
|
|
996
|
+
expect(content).toContain('Promise<AutoPaginatable<Connection, PaymentsListOptions>>');
|
|
997
|
+
// Should NOT use the generic "ListOptions"
|
|
998
|
+
expect(content).not.toContain('export interface ListOptions ');
|
|
999
|
+
});
|
|
1000
|
+
|
|
1001
|
+
it('does not prefix ListOptions when method is not "list"', () => {
|
|
1002
|
+
const services: Service[] = [
|
|
1003
|
+
{
|
|
1004
|
+
name: 'Organizations',
|
|
1005
|
+
operations: [
|
|
1006
|
+
{
|
|
1007
|
+
name: 'listOrganizations',
|
|
1008
|
+
httpMethod: 'get',
|
|
1009
|
+
path: '/organizations',
|
|
1010
|
+
pathParams: [],
|
|
1011
|
+
queryParams: [
|
|
1012
|
+
{
|
|
1013
|
+
name: 'domains',
|
|
1014
|
+
type: {
|
|
1015
|
+
kind: 'array',
|
|
1016
|
+
items: { kind: 'primitive', type: 'string' },
|
|
1017
|
+
},
|
|
1018
|
+
required: false,
|
|
1019
|
+
},
|
|
1020
|
+
],
|
|
1021
|
+
headerParams: [],
|
|
1022
|
+
response: { kind: 'model', name: 'Organization' },
|
|
1023
|
+
errors: [],
|
|
1024
|
+
pagination: {
|
|
1025
|
+
strategy: 'cursor',
|
|
1026
|
+
param: 'after',
|
|
1027
|
+
dataPath: 'data',
|
|
1028
|
+
itemType: { kind: 'model', name: 'Organization' },
|
|
1029
|
+
},
|
|
1030
|
+
injectIdempotencyKey: false,
|
|
1031
|
+
},
|
|
1032
|
+
],
|
|
1033
|
+
},
|
|
1034
|
+
];
|
|
1035
|
+
|
|
1036
|
+
const files = generateResources(services, ctx);
|
|
1037
|
+
const content = files[0].content;
|
|
1038
|
+
|
|
1039
|
+
// Method is "listOrganizations", not "list", so options name should be normal
|
|
1040
|
+
expect(content).toContain('export interface ListOrganizationsOptions extends PaginationOptions {');
|
|
1041
|
+
});
|
|
1042
|
+
|
|
1043
|
+
it('removes skipIfExists when fully-covered service has methods absent from baseline', () => {
|
|
1044
|
+
const services: Service[] = [
|
|
1045
|
+
{
|
|
1046
|
+
name: 'SSOService',
|
|
1047
|
+
operations: [
|
|
1048
|
+
{
|
|
1049
|
+
name: 'getAuthorizationUrl',
|
|
1050
|
+
httpMethod: 'get',
|
|
1051
|
+
path: '/sso/authorize',
|
|
1052
|
+
pathParams: [],
|
|
1053
|
+
queryParams: [],
|
|
1054
|
+
headerParams: [],
|
|
1055
|
+
response: { kind: 'model', name: 'AuthorizationUrl' },
|
|
1056
|
+
errors: [],
|
|
1057
|
+
injectIdempotencyKey: false,
|
|
1058
|
+
},
|
|
1059
|
+
{
|
|
1060
|
+
name: 'logout',
|
|
1061
|
+
httpMethod: 'get',
|
|
1062
|
+
path: '/sso/logout',
|
|
1063
|
+
pathParams: [],
|
|
1064
|
+
queryParams: [],
|
|
1065
|
+
headerParams: [],
|
|
1066
|
+
response: { kind: 'model', name: 'LogoutResult' },
|
|
1067
|
+
errors: [],
|
|
1068
|
+
injectIdempotencyKey: false,
|
|
1069
|
+
},
|
|
1070
|
+
],
|
|
1071
|
+
},
|
|
1072
|
+
];
|
|
1073
|
+
|
|
1074
|
+
// Overlay maps both operations to SSO class
|
|
1075
|
+
// Baseline SSO class exists but only has getAuthorizationUrl (logout is missing)
|
|
1076
|
+
const overlayCtx: EmitterContext = {
|
|
1077
|
+
namespace: 'workos',
|
|
1078
|
+
namespacePascal: 'WorkOS',
|
|
1079
|
+
spec: { ...emptySpec, services, models: [] },
|
|
1080
|
+
overlayLookup: {
|
|
1081
|
+
methodByOperation: new Map([
|
|
1082
|
+
[
|
|
1083
|
+
'GET /sso/authorize',
|
|
1084
|
+
{
|
|
1085
|
+
className: 'SSO',
|
|
1086
|
+
methodName: 'getAuthorizationUrl',
|
|
1087
|
+
params: [],
|
|
1088
|
+
returnType: 'void',
|
|
1089
|
+
},
|
|
1090
|
+
],
|
|
1091
|
+
[
|
|
1092
|
+
'GET /sso/logout',
|
|
1093
|
+
{
|
|
1094
|
+
className: 'SSO',
|
|
1095
|
+
methodName: 'logout',
|
|
1096
|
+
params: [],
|
|
1097
|
+
returnType: 'void',
|
|
1098
|
+
},
|
|
1099
|
+
],
|
|
1100
|
+
]),
|
|
1101
|
+
httpKeyByMethod: new Map(),
|
|
1102
|
+
interfaceByName: new Map(),
|
|
1103
|
+
typeAliasByName: new Map(),
|
|
1104
|
+
requiredExports: new Map(),
|
|
1105
|
+
modelNameByIR: new Map(),
|
|
1106
|
+
fileBySymbol: new Map(),
|
|
1107
|
+
},
|
|
1108
|
+
apiSurface: {
|
|
1109
|
+
language: 'node',
|
|
1110
|
+
extractedFrom: 'test',
|
|
1111
|
+
extractedAt: '2024-01-01',
|
|
1112
|
+
classes: {
|
|
1113
|
+
SSO: {
|
|
1114
|
+
name: 'SSO',
|
|
1115
|
+
methods: {
|
|
1116
|
+
getAuthorizationUrl: [
|
|
1117
|
+
{
|
|
1118
|
+
name: 'getAuthorizationUrl',
|
|
1119
|
+
params: [],
|
|
1120
|
+
returnType: 'void',
|
|
1121
|
+
async: true,
|
|
1122
|
+
},
|
|
1123
|
+
],
|
|
1124
|
+
// logout method is intentionally ABSENT
|
|
1125
|
+
},
|
|
1126
|
+
properties: {},
|
|
1127
|
+
constructorParams: [{ name: 'workos', type: 'WorkOS', optional: false }],
|
|
1128
|
+
},
|
|
1129
|
+
},
|
|
1130
|
+
interfaces: {},
|
|
1131
|
+
typeAliases: {},
|
|
1132
|
+
enums: {},
|
|
1133
|
+
exports: {},
|
|
1134
|
+
},
|
|
1135
|
+
};
|
|
1136
|
+
|
|
1137
|
+
const files = generateResources(services, overlayCtx);
|
|
1138
|
+
expect(files.length).toBe(1);
|
|
1139
|
+
|
|
1140
|
+
// skipIfExists should be removed because 'logout' is absent from baseline
|
|
1141
|
+
expect(files[0].skipIfExists).toBeUndefined();
|
|
1142
|
+
});
|
|
1143
|
+
|
|
1144
|
+
it('keeps skipIfExists when fully-covered service has all methods in baseline', () => {
|
|
1145
|
+
const services: Service[] = [
|
|
1146
|
+
{
|
|
1147
|
+
name: 'SSOService',
|
|
1148
|
+
operations: [
|
|
1149
|
+
{
|
|
1150
|
+
name: 'getAuthorizationUrl',
|
|
1151
|
+
httpMethod: 'get',
|
|
1152
|
+
path: '/sso/authorize',
|
|
1153
|
+
pathParams: [],
|
|
1154
|
+
queryParams: [],
|
|
1155
|
+
headerParams: [],
|
|
1156
|
+
response: { kind: 'model', name: 'AuthorizationUrl' },
|
|
1157
|
+
errors: [],
|
|
1158
|
+
injectIdempotencyKey: false,
|
|
1159
|
+
},
|
|
1160
|
+
],
|
|
1161
|
+
},
|
|
1162
|
+
];
|
|
1163
|
+
|
|
1164
|
+
const overlayCtx: EmitterContext = {
|
|
1165
|
+
namespace: 'workos',
|
|
1166
|
+
namespacePascal: 'WorkOS',
|
|
1167
|
+
spec: { ...emptySpec, services, models: [] },
|
|
1168
|
+
overlayLookup: {
|
|
1169
|
+
methodByOperation: new Map([
|
|
1170
|
+
[
|
|
1171
|
+
'GET /sso/authorize',
|
|
1172
|
+
{
|
|
1173
|
+
className: 'SSO',
|
|
1174
|
+
methodName: 'getAuthorizationUrl',
|
|
1175
|
+
params: [],
|
|
1176
|
+
returnType: 'void',
|
|
1177
|
+
},
|
|
1178
|
+
],
|
|
1179
|
+
]),
|
|
1180
|
+
httpKeyByMethod: new Map(),
|
|
1181
|
+
interfaceByName: new Map(),
|
|
1182
|
+
typeAliasByName: new Map(),
|
|
1183
|
+
requiredExports: new Map(),
|
|
1184
|
+
modelNameByIR: new Map(),
|
|
1185
|
+
fileBySymbol: new Map(),
|
|
1186
|
+
},
|
|
1187
|
+
apiSurface: {
|
|
1188
|
+
language: 'node',
|
|
1189
|
+
extractedFrom: 'test',
|
|
1190
|
+
extractedAt: '2024-01-01',
|
|
1191
|
+
classes: {
|
|
1192
|
+
SSO: {
|
|
1193
|
+
name: 'SSO',
|
|
1194
|
+
methods: {
|
|
1195
|
+
getAuthorizationUrl: [
|
|
1196
|
+
{
|
|
1197
|
+
name: 'getAuthorizationUrl',
|
|
1198
|
+
params: [],
|
|
1199
|
+
returnType: 'void',
|
|
1200
|
+
async: true,
|
|
1201
|
+
},
|
|
1202
|
+
],
|
|
1203
|
+
},
|
|
1204
|
+
properties: {},
|
|
1205
|
+
constructorParams: [{ name: 'workos', type: 'WorkOS', optional: false }],
|
|
1206
|
+
},
|
|
1207
|
+
},
|
|
1208
|
+
interfaces: {},
|
|
1209
|
+
typeAliases: {},
|
|
1210
|
+
enums: {},
|
|
1211
|
+
exports: {},
|
|
1212
|
+
},
|
|
1213
|
+
};
|
|
1214
|
+
|
|
1215
|
+
const files = generateResources(services, overlayCtx);
|
|
1216
|
+
expect(files.length).toBe(1);
|
|
1217
|
+
|
|
1218
|
+
// skipIfExists should stay true because all methods exist in baseline
|
|
1219
|
+
expect(files[0].skipIfExists).toBe(true);
|
|
1220
|
+
});
|
|
1221
|
+
});
|
|
1222
|
+
|
|
1223
|
+
describe('resolveResourceClassName', () => {
|
|
1224
|
+
const webhooksService: Service = {
|
|
1225
|
+
name: 'WebhookEvents',
|
|
1226
|
+
operations: [
|
|
1227
|
+
{
|
|
1228
|
+
name: 'listWebhookEvents',
|
|
1229
|
+
httpMethod: 'get',
|
|
1230
|
+
path: '/webhook_events',
|
|
1231
|
+
pathParams: [],
|
|
1232
|
+
queryParams: [],
|
|
1233
|
+
headerParams: [],
|
|
1234
|
+
response: { kind: 'model', name: 'WebhookEvent' },
|
|
1235
|
+
errors: [],
|
|
1236
|
+
injectIdempotencyKey: false,
|
|
1237
|
+
},
|
|
1238
|
+
],
|
|
1239
|
+
};
|
|
1240
|
+
|
|
1241
|
+
it('generates separate class when baseline has incompatible constructor', () => {
|
|
1242
|
+
const overlayCtx: EmitterContext = {
|
|
1243
|
+
namespace: 'workos',
|
|
1244
|
+
namespacePascal: 'WorkOS',
|
|
1245
|
+
spec: { ...emptySpec, services: [webhooksService] },
|
|
1246
|
+
overlayLookup: {
|
|
1247
|
+
methodByOperation: new Map([
|
|
1248
|
+
[
|
|
1249
|
+
'GET /webhook_events',
|
|
1250
|
+
{
|
|
1251
|
+
className: 'Webhooks',
|
|
1252
|
+
methodName: 'listWebhookEvents',
|
|
1253
|
+
params: [],
|
|
1254
|
+
returnType: 'void',
|
|
1255
|
+
},
|
|
1256
|
+
],
|
|
1257
|
+
]),
|
|
1258
|
+
httpKeyByMethod: new Map(),
|
|
1259
|
+
interfaceByName: new Map(),
|
|
1260
|
+
typeAliasByName: new Map(),
|
|
1261
|
+
requiredExports: new Map(),
|
|
1262
|
+
modelNameByIR: new Map(),
|
|
1263
|
+
fileBySymbol: new Map(),
|
|
1264
|
+
},
|
|
1265
|
+
apiSurface: {
|
|
1266
|
+
language: 'node',
|
|
1267
|
+
extractedFrom: 'test',
|
|
1268
|
+
extractedAt: '2024-01-01',
|
|
1269
|
+
classes: {
|
|
1270
|
+
Webhooks: {
|
|
1271
|
+
name: 'Webhooks',
|
|
1272
|
+
methods: {},
|
|
1273
|
+
properties: {},
|
|
1274
|
+
constructorParams: [
|
|
1275
|
+
{
|
|
1276
|
+
name: 'cryptoProvider',
|
|
1277
|
+
type: 'CryptoProvider',
|
|
1278
|
+
optional: false,
|
|
1279
|
+
},
|
|
1280
|
+
],
|
|
1281
|
+
},
|
|
1282
|
+
},
|
|
1283
|
+
interfaces: {},
|
|
1284
|
+
typeAliases: {},
|
|
1285
|
+
enums: {},
|
|
1286
|
+
exports: {},
|
|
1287
|
+
},
|
|
1288
|
+
};
|
|
1289
|
+
|
|
1290
|
+
const result = resolveResourceClassName(webhooksService, overlayCtx);
|
|
1291
|
+
// Falls back to IR name since overlay name has incompatible constructor
|
|
1292
|
+
expect(result).toBe('WebhookEvents');
|
|
1293
|
+
});
|
|
1294
|
+
|
|
1295
|
+
it('uses overlay name when baseline has compatible constructor', () => {
|
|
1296
|
+
const overlayCtx: EmitterContext = {
|
|
1297
|
+
namespace: 'workos',
|
|
1298
|
+
namespacePascal: 'WorkOS',
|
|
1299
|
+
spec: { ...emptySpec, services: [webhooksService] },
|
|
1300
|
+
overlayLookup: {
|
|
1301
|
+
methodByOperation: new Map([
|
|
1302
|
+
[
|
|
1303
|
+
'GET /webhook_events',
|
|
1304
|
+
{
|
|
1305
|
+
className: 'Webhooks',
|
|
1306
|
+
methodName: 'listWebhookEvents',
|
|
1307
|
+
params: [],
|
|
1308
|
+
returnType: 'void',
|
|
1309
|
+
},
|
|
1310
|
+
],
|
|
1311
|
+
]),
|
|
1312
|
+
httpKeyByMethod: new Map(),
|
|
1313
|
+
interfaceByName: new Map(),
|
|
1314
|
+
typeAliasByName: new Map(),
|
|
1315
|
+
requiredExports: new Map(),
|
|
1316
|
+
modelNameByIR: new Map(),
|
|
1317
|
+
fileBySymbol: new Map(),
|
|
1318
|
+
},
|
|
1319
|
+
apiSurface: {
|
|
1320
|
+
language: 'node',
|
|
1321
|
+
extractedFrom: 'test',
|
|
1322
|
+
extractedAt: '2024-01-01',
|
|
1323
|
+
classes: {
|
|
1324
|
+
Webhooks: {
|
|
1325
|
+
name: 'Webhooks',
|
|
1326
|
+
methods: {},
|
|
1327
|
+
properties: {},
|
|
1328
|
+
constructorParams: [{ name: 'workos', type: 'WorkOS', optional: false }],
|
|
1329
|
+
},
|
|
1330
|
+
},
|
|
1331
|
+
interfaces: {},
|
|
1332
|
+
typeAliases: {},
|
|
1333
|
+
enums: {},
|
|
1334
|
+
exports: {},
|
|
1335
|
+
},
|
|
1336
|
+
};
|
|
1337
|
+
|
|
1338
|
+
const result = resolveResourceClassName(webhooksService, overlayCtx);
|
|
1339
|
+
expect(result).toBe('Webhooks');
|
|
1340
|
+
});
|
|
1341
|
+
|
|
1342
|
+
it('appends Endpoints suffix when IR name collides with overlay name', () => {
|
|
1343
|
+
const collisionService: Service = {
|
|
1344
|
+
name: 'Webhooks',
|
|
1345
|
+
operations: [
|
|
1346
|
+
{
|
|
1347
|
+
name: 'listWebhooks',
|
|
1348
|
+
httpMethod: 'get',
|
|
1349
|
+
path: '/webhooks',
|
|
1350
|
+
pathParams: [],
|
|
1351
|
+
queryParams: [],
|
|
1352
|
+
headerParams: [],
|
|
1353
|
+
response: { kind: 'model', name: 'Webhook' },
|
|
1354
|
+
errors: [],
|
|
1355
|
+
injectIdempotencyKey: false,
|
|
1356
|
+
},
|
|
1357
|
+
],
|
|
1358
|
+
};
|
|
1359
|
+
|
|
1360
|
+
const overlayCtx: EmitterContext = {
|
|
1361
|
+
namespace: 'workos',
|
|
1362
|
+
namespacePascal: 'WorkOS',
|
|
1363
|
+
spec: { ...emptySpec, services: [collisionService] },
|
|
1364
|
+
overlayLookup: {
|
|
1365
|
+
methodByOperation: new Map([
|
|
1366
|
+
[
|
|
1367
|
+
'GET /webhooks',
|
|
1368
|
+
{
|
|
1369
|
+
className: 'Webhooks',
|
|
1370
|
+
methodName: 'listWebhooks',
|
|
1371
|
+
params: [],
|
|
1372
|
+
returnType: 'void',
|
|
1373
|
+
},
|
|
1374
|
+
],
|
|
1375
|
+
]),
|
|
1376
|
+
httpKeyByMethod: new Map(),
|
|
1377
|
+
interfaceByName: new Map(),
|
|
1378
|
+
typeAliasByName: new Map(),
|
|
1379
|
+
requiredExports: new Map(),
|
|
1380
|
+
modelNameByIR: new Map(),
|
|
1381
|
+
fileBySymbol: new Map(),
|
|
1382
|
+
},
|
|
1383
|
+
apiSurface: {
|
|
1384
|
+
language: 'node',
|
|
1385
|
+
extractedFrom: 'test',
|
|
1386
|
+
extractedAt: '2024-01-01',
|
|
1387
|
+
classes: {
|
|
1388
|
+
Webhooks: {
|
|
1389
|
+
name: 'Webhooks',
|
|
1390
|
+
methods: {},
|
|
1391
|
+
properties: {},
|
|
1392
|
+
constructorParams: [
|
|
1393
|
+
{
|
|
1394
|
+
name: 'cryptoProvider',
|
|
1395
|
+
type: 'CryptoProvider',
|
|
1396
|
+
optional: false,
|
|
1397
|
+
},
|
|
1398
|
+
],
|
|
1399
|
+
},
|
|
1400
|
+
},
|
|
1401
|
+
interfaces: {},
|
|
1402
|
+
typeAliases: {},
|
|
1403
|
+
enums: {},
|
|
1404
|
+
exports: {},
|
|
1405
|
+
},
|
|
1406
|
+
};
|
|
1407
|
+
|
|
1408
|
+
const result = resolveResourceClassName(collisionService, overlayCtx);
|
|
1409
|
+
// IR name "Webhooks" collides with overlay name "Webhooks", so append Endpoints
|
|
1410
|
+
expect(result).toBe('WebhooksEndpoints');
|
|
1411
|
+
});
|
|
1412
|
+
});
|
|
1413
|
+
|
|
1414
|
+
describe('hasCompatibleConstructor', () => {
|
|
1415
|
+
it('returns true when no baseline exists', () => {
|
|
1416
|
+
expect(hasCompatibleConstructor('NewService', ctx)).toBe(true);
|
|
1417
|
+
});
|
|
1418
|
+
|
|
1419
|
+
it('returns true when baseline has workos: WorkOS param', () => {
|
|
1420
|
+
const ctxWithSurface: EmitterContext = {
|
|
1421
|
+
...ctx,
|
|
1422
|
+
apiSurface: {
|
|
1423
|
+
language: 'node',
|
|
1424
|
+
extractedFrom: 'test',
|
|
1425
|
+
extractedAt: '2024-01-01',
|
|
1426
|
+
classes: {
|
|
1427
|
+
Organizations: {
|
|
1428
|
+
name: 'Organizations',
|
|
1429
|
+
methods: {},
|
|
1430
|
+
properties: {},
|
|
1431
|
+
constructorParams: [{ name: 'workos', type: 'WorkOS', optional: false }],
|
|
1432
|
+
},
|
|
1433
|
+
},
|
|
1434
|
+
interfaces: {},
|
|
1435
|
+
typeAliases: {},
|
|
1436
|
+
enums: {},
|
|
1437
|
+
exports: {},
|
|
1438
|
+
},
|
|
1439
|
+
};
|
|
1440
|
+
|
|
1441
|
+
expect(hasCompatibleConstructor('Organizations', ctxWithSurface)).toBe(true);
|
|
1442
|
+
});
|
|
1443
|
+
|
|
1444
|
+
it('returns false when baseline has incompatible constructor', () => {
|
|
1445
|
+
const ctxWithSurface: EmitterContext = {
|
|
1446
|
+
...ctx,
|
|
1447
|
+
apiSurface: {
|
|
1448
|
+
language: 'node',
|
|
1449
|
+
extractedFrom: 'test',
|
|
1450
|
+
extractedAt: '2024-01-01',
|
|
1451
|
+
classes: {
|
|
1452
|
+
Webhooks: {
|
|
1453
|
+
name: 'Webhooks',
|
|
1454
|
+
methods: {},
|
|
1455
|
+
properties: {},
|
|
1456
|
+
constructorParams: [
|
|
1457
|
+
{
|
|
1458
|
+
name: 'cryptoProvider',
|
|
1459
|
+
type: 'CryptoProvider',
|
|
1460
|
+
optional: false,
|
|
1461
|
+
},
|
|
1462
|
+
],
|
|
1463
|
+
},
|
|
1464
|
+
},
|
|
1465
|
+
interfaces: {},
|
|
1466
|
+
typeAliases: {},
|
|
1467
|
+
enums: {},
|
|
1468
|
+
exports: {},
|
|
1469
|
+
},
|
|
1470
|
+
};
|
|
1471
|
+
|
|
1472
|
+
expect(hasCompatibleConstructor('Webhooks', ctxWithSurface)).toBe(false);
|
|
1473
|
+
});
|
|
1474
|
+
|
|
1475
|
+
it('returns true when baseline has no constructor params', () => {
|
|
1476
|
+
const ctxWithSurface: EmitterContext = {
|
|
1477
|
+
...ctx,
|
|
1478
|
+
apiSurface: {
|
|
1479
|
+
language: 'node',
|
|
1480
|
+
extractedFrom: 'test',
|
|
1481
|
+
extractedAt: '2024-01-01',
|
|
1482
|
+
classes: {
|
|
1483
|
+
EmptyService: {
|
|
1484
|
+
name: 'EmptyService',
|
|
1485
|
+
methods: {},
|
|
1486
|
+
properties: {},
|
|
1487
|
+
constructorParams: [],
|
|
1488
|
+
},
|
|
1489
|
+
},
|
|
1490
|
+
interfaces: {},
|
|
1491
|
+
typeAliases: {},
|
|
1492
|
+
enums: {},
|
|
1493
|
+
exports: {},
|
|
1494
|
+
},
|
|
1495
|
+
};
|
|
1496
|
+
|
|
1497
|
+
expect(hasCompatibleConstructor('EmptyService', ctxWithSurface)).toBe(true);
|
|
1498
|
+
});
|
|
1499
|
+
});
|
|
1500
|
+
|
|
1501
|
+
describe('partial service coverage', () => {
|
|
1502
|
+
it('generates methods for uncovered operations in partially covered services', () => {
|
|
1503
|
+
const services: Service[] = [
|
|
1504
|
+
{
|
|
1505
|
+
name: 'AuditLogs',
|
|
1506
|
+
operations: [
|
|
1507
|
+
{
|
|
1508
|
+
name: 'createEvent',
|
|
1509
|
+
httpMethod: 'post',
|
|
1510
|
+
path: '/audit_logs/events',
|
|
1511
|
+
pathParams: [],
|
|
1512
|
+
queryParams: [],
|
|
1513
|
+
headerParams: [],
|
|
1514
|
+
response: { kind: 'model', name: 'AuditLogEvent' },
|
|
1515
|
+
errors: [],
|
|
1516
|
+
injectIdempotencyKey: false,
|
|
1517
|
+
},
|
|
1518
|
+
{
|
|
1519
|
+
name: 'getRetention',
|
|
1520
|
+
httpMethod: 'get',
|
|
1521
|
+
path: '/audit_logs/retention',
|
|
1522
|
+
pathParams: [],
|
|
1523
|
+
queryParams: [],
|
|
1524
|
+
headerParams: [],
|
|
1525
|
+
response: { kind: 'model', name: 'AuditLogRetention' },
|
|
1526
|
+
errors: [],
|
|
1527
|
+
injectIdempotencyKey: false,
|
|
1528
|
+
},
|
|
1529
|
+
],
|
|
1530
|
+
},
|
|
1531
|
+
];
|
|
1532
|
+
|
|
1533
|
+
// createEvent is covered by existing AuditLogs class, getRetention is NOT
|
|
1534
|
+
const ctxPartial: EmitterContext = {
|
|
1535
|
+
...ctx,
|
|
1536
|
+
spec: { ...emptySpec, services, models: [] },
|
|
1537
|
+
overlayLookup: {
|
|
1538
|
+
methodByOperation: new Map([
|
|
1539
|
+
[
|
|
1540
|
+
'POST /audit_logs/events',
|
|
1541
|
+
{
|
|
1542
|
+
className: 'AuditLogs',
|
|
1543
|
+
methodName: 'createEvent',
|
|
1544
|
+
params: [],
|
|
1545
|
+
returnType: 'AuditLogEvent',
|
|
1546
|
+
},
|
|
1547
|
+
],
|
|
1548
|
+
]),
|
|
1549
|
+
httpKeyByMethod: new Map(),
|
|
1550
|
+
interfaceByName: new Map(),
|
|
1551
|
+
typeAliasByName: new Map(),
|
|
1552
|
+
requiredExports: new Map(),
|
|
1553
|
+
modelNameByIR: new Map(),
|
|
1554
|
+
fileBySymbol: new Map(),
|
|
1555
|
+
},
|
|
1556
|
+
apiSurface: {
|
|
1557
|
+
language: 'node',
|
|
1558
|
+
extractedFrom: 'test',
|
|
1559
|
+
extractedAt: '2024-01-01',
|
|
1560
|
+
classes: {
|
|
1561
|
+
AuditLogs: {
|
|
1562
|
+
name: 'AuditLogs',
|
|
1563
|
+
methods: {
|
|
1564
|
+
createEvent: [
|
|
1565
|
+
{
|
|
1566
|
+
name: 'createEvent',
|
|
1567
|
+
params: [],
|
|
1568
|
+
returnType: 'AuditLogEvent',
|
|
1569
|
+
async: true,
|
|
1570
|
+
},
|
|
1571
|
+
],
|
|
1572
|
+
},
|
|
1573
|
+
properties: {},
|
|
1574
|
+
constructorParams: [{ name: 'workos', type: 'WorkOS', optional: false }],
|
|
1575
|
+
},
|
|
1576
|
+
},
|
|
1577
|
+
interfaces: {},
|
|
1578
|
+
typeAliases: {},
|
|
1579
|
+
enums: {},
|
|
1580
|
+
exports: {},
|
|
1581
|
+
},
|
|
1582
|
+
};
|
|
1583
|
+
|
|
1584
|
+
const files = generateResources(services, ctxPartial);
|
|
1585
|
+
expect(files.length).toBe(1);
|
|
1586
|
+
const content = files[0].content;
|
|
1587
|
+
|
|
1588
|
+
// Should generate method for uncovered operation
|
|
1589
|
+
expect(content).toContain('async getRetention');
|
|
1590
|
+
// Should also generate covered operation so the merger can apply JSDoc
|
|
1591
|
+
expect(content).toContain('async createEvent');
|
|
1592
|
+
});
|
|
1593
|
+
|
|
1594
|
+
it('generates resource class for fully covered services to provide JSDoc', () => {
|
|
1595
|
+
const services: Service[] = [
|
|
1596
|
+
{
|
|
1597
|
+
name: 'Permissions',
|
|
1598
|
+
operations: [
|
|
1599
|
+
{
|
|
1600
|
+
name: 'listPermissions',
|
|
1601
|
+
description: 'List all permissions.',
|
|
1602
|
+
httpMethod: 'get',
|
|
1603
|
+
path: '/authorization/permissions',
|
|
1604
|
+
pathParams: [],
|
|
1605
|
+
queryParams: [],
|
|
1606
|
+
headerParams: [],
|
|
1607
|
+
response: { kind: 'model', name: 'PermissionList' },
|
|
1608
|
+
errors: [{ statusCode: 404 }],
|
|
1609
|
+
injectIdempotencyKey: false,
|
|
1610
|
+
},
|
|
1611
|
+
],
|
|
1612
|
+
},
|
|
1613
|
+
];
|
|
1614
|
+
|
|
1615
|
+
const ctxCovered: EmitterContext = {
|
|
1616
|
+
...ctx,
|
|
1617
|
+
spec: { ...emptySpec, services, models: [] },
|
|
1618
|
+
overlayLookup: {
|
|
1619
|
+
methodByOperation: new Map([
|
|
1620
|
+
[
|
|
1621
|
+
'GET /authorization/permissions',
|
|
1622
|
+
{
|
|
1623
|
+
className: 'Permissions',
|
|
1624
|
+
methodName: 'listPermissions',
|
|
1625
|
+
params: [],
|
|
1626
|
+
returnType: 'void',
|
|
1627
|
+
},
|
|
1628
|
+
],
|
|
1629
|
+
]),
|
|
1630
|
+
httpKeyByMethod: new Map(),
|
|
1631
|
+
interfaceByName: new Map(),
|
|
1632
|
+
typeAliasByName: new Map(),
|
|
1633
|
+
requiredExports: new Map(),
|
|
1634
|
+
modelNameByIR: new Map(),
|
|
1635
|
+
fileBySymbol: new Map(),
|
|
1636
|
+
},
|
|
1637
|
+
apiSurface: {
|
|
1638
|
+
language: 'node',
|
|
1639
|
+
extractedFrom: 'test',
|
|
1640
|
+
extractedAt: '2024-01-01',
|
|
1641
|
+
classes: {
|
|
1642
|
+
Permissions: {
|
|
1643
|
+
name: 'Permissions',
|
|
1644
|
+
methods: {
|
|
1645
|
+
listPermissions: [
|
|
1646
|
+
{
|
|
1647
|
+
name: 'listPermissions',
|
|
1648
|
+
params: [],
|
|
1649
|
+
returnType: 'void',
|
|
1650
|
+
async: true,
|
|
1651
|
+
},
|
|
1652
|
+
],
|
|
1653
|
+
},
|
|
1654
|
+
properties: {},
|
|
1655
|
+
constructorParams: [{ name: 'workos', type: 'WorkOS', optional: false }],
|
|
1656
|
+
},
|
|
1657
|
+
},
|
|
1658
|
+
interfaces: {},
|
|
1659
|
+
typeAliases: {},
|
|
1660
|
+
enums: {},
|
|
1661
|
+
exports: {},
|
|
1662
|
+
},
|
|
1663
|
+
};
|
|
1664
|
+
|
|
1665
|
+
const files = generateResources(services, ctxCovered);
|
|
1666
|
+
expect(files.length).toBe(1);
|
|
1667
|
+
const content = files[0].content;
|
|
1668
|
+
// Should contain JSDoc with description from the spec
|
|
1669
|
+
expect(content).toContain('List all permissions.');
|
|
1670
|
+
// skipIfExists should remain true for covered services
|
|
1671
|
+
expect(files[0].skipIfExists).toBe(true);
|
|
1672
|
+
});
|
|
1673
|
+
|
|
1674
|
+
it('reconciles method names against api-surface using word-set matching', () => {
|
|
1675
|
+
const services: Service[] = [
|
|
1676
|
+
{
|
|
1677
|
+
name: 'Authorization',
|
|
1678
|
+
operations: [
|
|
1679
|
+
{
|
|
1680
|
+
name: 'listRolesOrganizations',
|
|
1681
|
+
httpMethod: 'get',
|
|
1682
|
+
path: '/authorization/organizations/{organizationId}/roles',
|
|
1683
|
+
pathParams: [
|
|
1684
|
+
{
|
|
1685
|
+
name: 'organizationId',
|
|
1686
|
+
type: { kind: 'primitive', type: 'string' },
|
|
1687
|
+
required: true,
|
|
1688
|
+
},
|
|
1689
|
+
],
|
|
1690
|
+
queryParams: [],
|
|
1691
|
+
headerParams: [],
|
|
1692
|
+
response: { kind: 'model', name: 'RoleList' },
|
|
1693
|
+
errors: [],
|
|
1694
|
+
pagination: {
|
|
1695
|
+
strategy: 'cursor' as const,
|
|
1696
|
+
param: 'after',
|
|
1697
|
+
itemType: { kind: 'model' as const, name: 'RoleList' },
|
|
1698
|
+
},
|
|
1699
|
+
injectIdempotencyKey: false,
|
|
1700
|
+
},
|
|
1701
|
+
],
|
|
1702
|
+
},
|
|
1703
|
+
];
|
|
1704
|
+
|
|
1705
|
+
const ctxRecon: EmitterContext = {
|
|
1706
|
+
...ctx,
|
|
1707
|
+
spec: {
|
|
1708
|
+
...emptySpec,
|
|
1709
|
+
services,
|
|
1710
|
+
models: [{ name: 'RoleList', fields: [] }],
|
|
1711
|
+
},
|
|
1712
|
+
overlayLookup: {
|
|
1713
|
+
methodByOperation: new Map(), // no overlay mapping
|
|
1714
|
+
httpKeyByMethod: new Map(),
|
|
1715
|
+
interfaceByName: new Map(),
|
|
1716
|
+
typeAliasByName: new Map(),
|
|
1717
|
+
requiredExports: new Map(),
|
|
1718
|
+
modelNameByIR: new Map(),
|
|
1719
|
+
fileBySymbol: new Map(),
|
|
1720
|
+
},
|
|
1721
|
+
apiSurface: {
|
|
1722
|
+
language: 'node',
|
|
1723
|
+
extractedFrom: 'test',
|
|
1724
|
+
extractedAt: '2024-01-01',
|
|
1725
|
+
classes: {
|
|
1726
|
+
Authorization: {
|
|
1727
|
+
name: 'Authorization',
|
|
1728
|
+
methods: {
|
|
1729
|
+
listOrganizationRoles: [
|
|
1730
|
+
{
|
|
1731
|
+
name: 'listOrganizationRoles',
|
|
1732
|
+
params: [],
|
|
1733
|
+
returnType: 'void',
|
|
1734
|
+
async: true,
|
|
1735
|
+
},
|
|
1736
|
+
],
|
|
1737
|
+
},
|
|
1738
|
+
properties: {},
|
|
1739
|
+
constructorParams: [{ name: 'workos', type: 'WorkOS', optional: false }],
|
|
1740
|
+
},
|
|
1741
|
+
},
|
|
1742
|
+
interfaces: {},
|
|
1743
|
+
typeAliases: {},
|
|
1744
|
+
enums: {},
|
|
1745
|
+
exports: {},
|
|
1746
|
+
},
|
|
1747
|
+
};
|
|
1748
|
+
|
|
1749
|
+
const files = generateResources(services, ctxRecon);
|
|
1750
|
+
expect(files.length).toBe(1);
|
|
1751
|
+
const content = files[0].content;
|
|
1752
|
+
// Should use reconciled name from api-surface, not spec-derived name
|
|
1753
|
+
expect(content).toContain('async listOrganizationRoles');
|
|
1754
|
+
expect(content).not.toContain('async listRolesOrganizations');
|
|
1755
|
+
});
|
|
1756
|
+
|
|
1757
|
+
it('deduplicates method names for operations on different paths', () => {
|
|
1758
|
+
const services: Service[] = [
|
|
1759
|
+
{
|
|
1760
|
+
name: 'Organizations',
|
|
1761
|
+
operations: [
|
|
1762
|
+
{
|
|
1763
|
+
name: 'create',
|
|
1764
|
+
httpMethod: 'post',
|
|
1765
|
+
path: '/organization_domains',
|
|
1766
|
+
pathParams: [],
|
|
1767
|
+
queryParams: [],
|
|
1768
|
+
headerParams: [],
|
|
1769
|
+
requestBody: { kind: 'model', name: 'CreateOrgDomain' },
|
|
1770
|
+
response: { kind: 'model', name: 'OrgDomain' },
|
|
1771
|
+
errors: [],
|
|
1772
|
+
injectIdempotencyKey: false,
|
|
1773
|
+
},
|
|
1774
|
+
{
|
|
1775
|
+
name: 'create',
|
|
1776
|
+
httpMethod: 'post',
|
|
1777
|
+
path: '/organizations',
|
|
1778
|
+
pathParams: [],
|
|
1779
|
+
queryParams: [],
|
|
1780
|
+
headerParams: [],
|
|
1781
|
+
requestBody: { kind: 'model', name: 'CreateOrg' },
|
|
1782
|
+
response: { kind: 'model', name: 'Organization' },
|
|
1783
|
+
errors: [],
|
|
1784
|
+
injectIdempotencyKey: false,
|
|
1785
|
+
},
|
|
1786
|
+
],
|
|
1787
|
+
},
|
|
1788
|
+
];
|
|
1789
|
+
|
|
1790
|
+
const ctxDedup: EmitterContext = {
|
|
1791
|
+
...ctx,
|
|
1792
|
+
spec: {
|
|
1793
|
+
...emptySpec,
|
|
1794
|
+
services,
|
|
1795
|
+
models: [
|
|
1796
|
+
{ name: 'CreateOrgDomain', fields: [] },
|
|
1797
|
+
{ name: 'OrgDomain', fields: [] },
|
|
1798
|
+
{ name: 'CreateOrg', fields: [] },
|
|
1799
|
+
{ name: 'Organization', fields: [] },
|
|
1800
|
+
],
|
|
1801
|
+
},
|
|
1802
|
+
};
|
|
1803
|
+
|
|
1804
|
+
const files = generateResources(services, ctxDedup);
|
|
1805
|
+
expect(files.length).toBe(1);
|
|
1806
|
+
const content = files[0].content;
|
|
1807
|
+
// The best-scoring plan keeps the name; the other gets disambiguated.
|
|
1808
|
+
// "create" matches "organizations" path better (the word "create" doesn't
|
|
1809
|
+
// appear in either path, but scoring is equal — first wins).
|
|
1810
|
+
// The other gets a path suffix.
|
|
1811
|
+
const createMatches = content.match(/async create\b/g);
|
|
1812
|
+
// At most one un-suffixed "create"
|
|
1813
|
+
expect(createMatches?.length ?? 0).toBeLessThanOrEqual(1);
|
|
1814
|
+
// The two methods should have different names
|
|
1815
|
+
const methodNames = [...content.matchAll(/async (\w+)\(/g)].map((m) => m[1]);
|
|
1816
|
+
const createMethods = methodNames.filter((n) => n.toLowerCase().startsWith('create'));
|
|
1817
|
+
expect(new Set(createMethods).size).toBe(createMethods.length); // all unique
|
|
1818
|
+
});
|
|
260
1819
|
});
|