@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
package/test/node/enums.test.ts
CHANGED
|
@@ -15,7 +15,6 @@ const ctx: EmitterContext = {
|
|
|
15
15
|
namespace: 'workos',
|
|
16
16
|
namespacePascal: 'WorkOS',
|
|
17
17
|
spec: emptySpec,
|
|
18
|
-
irVersion: 6,
|
|
19
18
|
};
|
|
20
19
|
|
|
21
20
|
describe('generateEnums', () => {
|
|
@@ -31,7 +30,13 @@ describe('generateEnums', () => {
|
|
|
31
30
|
name: 'getOrganization',
|
|
32
31
|
httpMethod: 'get',
|
|
33
32
|
path: '/organizations/{id}',
|
|
34
|
-
pathParams: [
|
|
33
|
+
pathParams: [
|
|
34
|
+
{
|
|
35
|
+
name: 'id',
|
|
36
|
+
type: { kind: 'primitive', type: 'string' },
|
|
37
|
+
required: true,
|
|
38
|
+
},
|
|
39
|
+
],
|
|
35
40
|
queryParams: [],
|
|
36
41
|
headerParams: [],
|
|
37
42
|
response: {
|
|
@@ -77,7 +82,13 @@ describe('generateEnums', () => {
|
|
|
77
82
|
name: 'getOrganization',
|
|
78
83
|
httpMethod: 'get',
|
|
79
84
|
path: '/organizations/{id}',
|
|
80
|
-
pathParams: [
|
|
85
|
+
pathParams: [
|
|
86
|
+
{
|
|
87
|
+
name: 'id',
|
|
88
|
+
type: { kind: 'primitive', type: 'string' },
|
|
89
|
+
required: true,
|
|
90
|
+
},
|
|
91
|
+
],
|
|
81
92
|
queryParams: [],
|
|
82
93
|
headerParams: [],
|
|
83
94
|
response: { kind: 'enum', name: 'OrgStatus' },
|
|
@@ -104,13 +115,71 @@ describe('generateEnums', () => {
|
|
|
104
115
|
expect(files[0].path).toBe('src/organizations/interfaces/org-status.interface.ts');
|
|
105
116
|
});
|
|
106
117
|
|
|
118
|
+
it('derives PascalCase member names when merging new enum values into baseline', () => {
|
|
119
|
+
const enums: Enum[] = [
|
|
120
|
+
{
|
|
121
|
+
name: 'OrganizationDomainState',
|
|
122
|
+
values: [
|
|
123
|
+
{ name: 'FAILED', value: 'failed' },
|
|
124
|
+
{ name: 'PENDING', value: 'pending' },
|
|
125
|
+
{ name: 'VERIFIED', value: 'verified' },
|
|
126
|
+
{ name: 'LEGACY_VERIFIED', value: 'legacy_verified' },
|
|
127
|
+
{ name: 'UNVERIFIED', value: 'unverified' },
|
|
128
|
+
],
|
|
129
|
+
},
|
|
130
|
+
];
|
|
131
|
+
|
|
132
|
+
const testCtx: EmitterContext = {
|
|
133
|
+
...ctx,
|
|
134
|
+
apiSurface: {
|
|
135
|
+
language: 'node',
|
|
136
|
+
extractedFrom: 'test',
|
|
137
|
+
extractedAt: '2024-01-01',
|
|
138
|
+
classes: {},
|
|
139
|
+
interfaces: {},
|
|
140
|
+
typeAliases: {},
|
|
141
|
+
enums: {
|
|
142
|
+
OrganizationDomainState: {
|
|
143
|
+
name: 'OrganizationDomainState',
|
|
144
|
+
members: {
|
|
145
|
+
Failed: 'failed',
|
|
146
|
+
Pending: 'pending',
|
|
147
|
+
Verified: 'verified',
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
exports: {},
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const files = generateEnums(enums, testCtx);
|
|
156
|
+
const content = files[0].content;
|
|
157
|
+
|
|
158
|
+
// Existing members should be preserved as-is
|
|
159
|
+
expect(content).toContain("Failed = 'failed',");
|
|
160
|
+
expect(content).toContain("Pending = 'pending',");
|
|
161
|
+
expect(content).toContain("Verified = 'verified',");
|
|
162
|
+
|
|
163
|
+
// New members should be PascalCase, not lowercased
|
|
164
|
+
expect(content).toContain("LegacyVerified = 'legacy_verified',");
|
|
165
|
+
expect(content).toContain("Unverified = 'unverified',");
|
|
166
|
+
|
|
167
|
+
// Should NOT produce lowercased member names
|
|
168
|
+
expect(content).not.toContain('legacyverified');
|
|
169
|
+
});
|
|
170
|
+
|
|
107
171
|
it('renders @deprecated on enum values', () => {
|
|
108
172
|
const enums: Enum[] = [
|
|
109
173
|
{
|
|
110
174
|
name: 'Status',
|
|
111
175
|
values: [
|
|
112
176
|
{ name: 'ACTIVE', value: 'active' },
|
|
113
|
-
{
|
|
177
|
+
{
|
|
178
|
+
name: 'LEGACY',
|
|
179
|
+
value: 'legacy',
|
|
180
|
+
description: 'No longer supported.',
|
|
181
|
+
deprecated: true,
|
|
182
|
+
},
|
|
114
183
|
{ name: 'OLD', value: 'old', deprecated: true },
|
|
115
184
|
],
|
|
116
185
|
},
|
package/test/node/errors.test.ts
CHANGED
|
@@ -1,26 +1,9 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
2
|
import { generateErrors } from '../../src/node/errors.js';
|
|
3
|
-
import type { EmitterContext, ApiSpec } from '@workos/oagen';
|
|
4
|
-
|
|
5
|
-
const emptySpec: ApiSpec = {
|
|
6
|
-
name: 'Test',
|
|
7
|
-
version: '1.0.0',
|
|
8
|
-
baseUrl: '',
|
|
9
|
-
services: [],
|
|
10
|
-
models: [],
|
|
11
|
-
enums: [],
|
|
12
|
-
};
|
|
13
|
-
|
|
14
|
-
const ctx: EmitterContext = {
|
|
15
|
-
namespace: 'workos',
|
|
16
|
-
namespacePascal: 'WorkOS',
|
|
17
|
-
spec: emptySpec,
|
|
18
|
-
irVersion: 6,
|
|
19
|
-
};
|
|
20
3
|
|
|
21
4
|
describe('generateErrors', () => {
|
|
22
5
|
it('generates all exception classes', () => {
|
|
23
|
-
const files = generateErrors(
|
|
6
|
+
const files = generateErrors();
|
|
24
7
|
|
|
25
8
|
const names = files.map((f) => f.path);
|
|
26
9
|
expect(names).toContain('src/common/exceptions/bad-request.exception.ts');
|
|
@@ -35,7 +18,7 @@ describe('generateErrors', () => {
|
|
|
35
18
|
});
|
|
36
19
|
|
|
37
20
|
it('generates NotFoundException with correct status', () => {
|
|
38
|
-
const files = generateErrors(
|
|
21
|
+
const files = generateErrors();
|
|
39
22
|
const notFoundFile = files.find((f) => f.path.includes('not-found.exception.ts'))!;
|
|
40
23
|
|
|
41
24
|
expect(notFoundFile.content).toContain('export class NotFoundException extends Error');
|
|
@@ -44,7 +27,7 @@ describe('generateErrors', () => {
|
|
|
44
27
|
});
|
|
45
28
|
|
|
46
29
|
it('generates RateLimitExceededException with retryAfter', () => {
|
|
47
|
-
const files = generateErrors(
|
|
30
|
+
const files = generateErrors();
|
|
48
31
|
const rateLimitFile = files.find((f) => f.path.includes('rate-limit-exceeded.exception.ts'))!;
|
|
49
32
|
|
|
50
33
|
expect(rateLimitFile.content).toContain('export class RateLimitExceededException extends Error');
|
|
@@ -53,7 +36,7 @@ describe('generateErrors', () => {
|
|
|
53
36
|
});
|
|
54
37
|
|
|
55
38
|
it('generates exception barrel with all exports', () => {
|
|
56
|
-
const files = generateErrors(
|
|
39
|
+
const files = generateErrors();
|
|
57
40
|
const barrel = files.find((f) => f.path === 'src/common/exceptions/index.ts')!;
|
|
58
41
|
|
|
59
42
|
expect(barrel.content).toContain('export { BadRequestException }');
|
package/test/node/models.test.ts
CHANGED
|
@@ -15,7 +15,6 @@ const ctx: EmitterContext = {
|
|
|
15
15
|
namespace: 'workos',
|
|
16
16
|
namespacePascal: 'WorkOS',
|
|
17
17
|
spec: emptySpec,
|
|
18
|
-
irVersion: 6,
|
|
19
18
|
};
|
|
20
19
|
|
|
21
20
|
describe('generateModels', () => {
|
|
@@ -31,7 +30,13 @@ describe('generateModels', () => {
|
|
|
31
30
|
name: 'getOrganization',
|
|
32
31
|
httpMethod: 'get',
|
|
33
32
|
path: '/organizations/{id}',
|
|
34
|
-
pathParams: [
|
|
33
|
+
pathParams: [
|
|
34
|
+
{
|
|
35
|
+
name: 'id',
|
|
36
|
+
type: { kind: 'primitive', type: 'string' },
|
|
37
|
+
required: true,
|
|
38
|
+
},
|
|
39
|
+
],
|
|
35
40
|
queryParams: [],
|
|
36
41
|
headerParams: [],
|
|
37
42
|
response: { kind: 'model', name: 'Organization' },
|
|
@@ -85,7 +90,7 @@ describe('generateModels', () => {
|
|
|
85
90
|
expect(files[0].content).toContain('export interface Organization {');
|
|
86
91
|
expect(files[0].content).toContain(' id: string;');
|
|
87
92
|
expect(files[0].content).toContain(' name: string;');
|
|
88
|
-
expect(files[0].content).toContain(' createdAt:
|
|
93
|
+
expect(files[0].content).toContain(' createdAt: Date;');
|
|
89
94
|
expect(files[0].content).toContain(' externalId?: string | null;');
|
|
90
95
|
|
|
91
96
|
// Response interface has snake_case fields
|
|
@@ -102,7 +107,13 @@ describe('generateModels', () => {
|
|
|
102
107
|
name: 'getOrganization',
|
|
103
108
|
httpMethod: 'get',
|
|
104
109
|
path: '/organizations/{id}',
|
|
105
|
-
pathParams: [
|
|
110
|
+
pathParams: [
|
|
111
|
+
{
|
|
112
|
+
name: 'id',
|
|
113
|
+
type: { kind: 'primitive', type: 'string' },
|
|
114
|
+
required: true,
|
|
115
|
+
},
|
|
116
|
+
],
|
|
106
117
|
queryParams: [],
|
|
107
118
|
headerParams: [],
|
|
108
119
|
response: { kind: 'model', name: 'Organization' },
|
|
@@ -248,7 +259,13 @@ describe('generateModels', () => {
|
|
|
248
259
|
name: 'getOrganization',
|
|
249
260
|
httpMethod: 'get',
|
|
250
261
|
path: '/organizations/{id}',
|
|
251
|
-
pathParams: [
|
|
262
|
+
pathParams: [
|
|
263
|
+
{
|
|
264
|
+
name: 'id',
|
|
265
|
+
type: { kind: 'primitive', type: 'string' },
|
|
266
|
+
required: true,
|
|
267
|
+
},
|
|
268
|
+
],
|
|
252
269
|
queryParams: [],
|
|
253
270
|
headerParams: [],
|
|
254
271
|
response: { kind: 'model', name: 'Organization' },
|
|
@@ -298,4 +315,481 @@ describe('generateModels', () => {
|
|
|
298
315
|
// Field with only deprecated gets single-line JSDoc
|
|
299
316
|
expect(content).toContain(' /** @deprecated */');
|
|
300
317
|
});
|
|
318
|
+
|
|
319
|
+
it('renders field-level JSDoc from OpenAPI descriptions', () => {
|
|
320
|
+
const service: Service = {
|
|
321
|
+
name: 'Organizations',
|
|
322
|
+
operations: [
|
|
323
|
+
{
|
|
324
|
+
name: 'getOrganization',
|
|
325
|
+
httpMethod: 'get',
|
|
326
|
+
path: '/organizations/{id}',
|
|
327
|
+
pathParams: [
|
|
328
|
+
{
|
|
329
|
+
name: 'id',
|
|
330
|
+
type: { kind: 'primitive', type: 'string' },
|
|
331
|
+
required: true,
|
|
332
|
+
},
|
|
333
|
+
],
|
|
334
|
+
queryParams: [],
|
|
335
|
+
headerParams: [],
|
|
336
|
+
response: { kind: 'model', name: 'Organization' },
|
|
337
|
+
errors: [],
|
|
338
|
+
injectIdempotencyKey: false,
|
|
339
|
+
},
|
|
340
|
+
],
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
const models: Model[] = [
|
|
344
|
+
{
|
|
345
|
+
name: 'Organization',
|
|
346
|
+
description: 'An organization in the WorkOS system.',
|
|
347
|
+
fields: [
|
|
348
|
+
{
|
|
349
|
+
name: 'id',
|
|
350
|
+
type: { kind: 'primitive', type: 'string' },
|
|
351
|
+
required: true,
|
|
352
|
+
description: 'Unique identifier for the organization.',
|
|
353
|
+
},
|
|
354
|
+
{
|
|
355
|
+
name: 'name',
|
|
356
|
+
type: { kind: 'primitive', type: 'string' },
|
|
357
|
+
required: true,
|
|
358
|
+
description: 'The display name of the organization.',
|
|
359
|
+
},
|
|
360
|
+
{
|
|
361
|
+
name: 'created_at',
|
|
362
|
+
type: { kind: 'primitive', type: 'string', format: 'date-time' },
|
|
363
|
+
required: true,
|
|
364
|
+
// No description — should not get JSDoc
|
|
365
|
+
},
|
|
366
|
+
{
|
|
367
|
+
name: 'allow_profiles_outside_organization',
|
|
368
|
+
type: { kind: 'primitive', type: 'boolean' },
|
|
369
|
+
required: false,
|
|
370
|
+
description:
|
|
371
|
+
'Whether connections within the organization allow profiles\nthat do not have a domain that is verified by the organization.',
|
|
372
|
+
},
|
|
373
|
+
],
|
|
374
|
+
},
|
|
375
|
+
];
|
|
376
|
+
|
|
377
|
+
const ctxWithServices: EmitterContext = {
|
|
378
|
+
...ctx,
|
|
379
|
+
spec: { ...emptySpec, services: [service], models },
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
const files = generateModels(models, ctxWithServices);
|
|
383
|
+
const content = files[0].content;
|
|
384
|
+
|
|
385
|
+
// Model-level JSDoc is emitted
|
|
386
|
+
expect(content).toContain('/** An organization in the WorkOS system. */');
|
|
387
|
+
|
|
388
|
+
// Fields with description get per-field JSDoc
|
|
389
|
+
expect(content).toContain('/** Unique identifier for the organization. */');
|
|
390
|
+
expect(content).toContain('/** The display name of the organization. */');
|
|
391
|
+
|
|
392
|
+
// Multiline description renders correctly
|
|
393
|
+
expect(content).toContain(
|
|
394
|
+
' /**\n * Whether connections within the organization allow profiles\n * that do not have a domain that is verified by the organization.\n */',
|
|
395
|
+
);
|
|
396
|
+
|
|
397
|
+
// Field without description does NOT get JSDoc
|
|
398
|
+
const lines = content.split('\n');
|
|
399
|
+
const createdAtIdx = lines.findIndex((l) => l.includes('createdAt'));
|
|
400
|
+
expect(createdAtIdx).toBeGreaterThan(0);
|
|
401
|
+
// The line before createdAt should not be a JSDoc closing tag
|
|
402
|
+
expect(lines[createdAtIdx - 1].trim()).not.toBe('*/');
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it('renders readOnly/writeOnly/default annotations', () => {
|
|
406
|
+
const service: Service = {
|
|
407
|
+
name: 'Organizations',
|
|
408
|
+
operations: [
|
|
409
|
+
{
|
|
410
|
+
name: 'getOrganization',
|
|
411
|
+
httpMethod: 'get',
|
|
412
|
+
path: '/organizations/{id}',
|
|
413
|
+
pathParams: [
|
|
414
|
+
{
|
|
415
|
+
name: 'id',
|
|
416
|
+
type: { kind: 'primitive', type: 'string' },
|
|
417
|
+
required: true,
|
|
418
|
+
},
|
|
419
|
+
],
|
|
420
|
+
queryParams: [],
|
|
421
|
+
headerParams: [],
|
|
422
|
+
response: { kind: 'model', name: 'Organization' },
|
|
423
|
+
errors: [],
|
|
424
|
+
injectIdempotencyKey: false,
|
|
425
|
+
},
|
|
426
|
+
],
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
const models: Model[] = [
|
|
430
|
+
{
|
|
431
|
+
name: 'Organization',
|
|
432
|
+
fields: [
|
|
433
|
+
{
|
|
434
|
+
name: 'id',
|
|
435
|
+
type: { kind: 'primitive', type: 'string' },
|
|
436
|
+
required: true,
|
|
437
|
+
readOnly: true,
|
|
438
|
+
},
|
|
439
|
+
{
|
|
440
|
+
name: 'secret_key',
|
|
441
|
+
type: { kind: 'primitive', type: 'string' },
|
|
442
|
+
required: true,
|
|
443
|
+
writeOnly: true,
|
|
444
|
+
},
|
|
445
|
+
{
|
|
446
|
+
name: 'status',
|
|
447
|
+
type: { kind: 'primitive', type: 'string' },
|
|
448
|
+
required: false,
|
|
449
|
+
default: 'active',
|
|
450
|
+
},
|
|
451
|
+
],
|
|
452
|
+
},
|
|
453
|
+
];
|
|
454
|
+
|
|
455
|
+
const ctxWithServices: EmitterContext = {
|
|
456
|
+
...ctx,
|
|
457
|
+
spec: { ...emptySpec, services: [service], models },
|
|
458
|
+
};
|
|
459
|
+
|
|
460
|
+
const files = generateModels(models, ctxWithServices);
|
|
461
|
+
const content = files[0].content;
|
|
462
|
+
|
|
463
|
+
// readOnly field gets @readonly JSDoc and readonly TS modifier
|
|
464
|
+
expect(content).toContain('/** @readonly */');
|
|
465
|
+
expect(content).toContain(' readonly id: string;');
|
|
466
|
+
|
|
467
|
+
// writeOnly field gets @writeonly JSDoc
|
|
468
|
+
expect(content).toContain('/** @writeonly */');
|
|
469
|
+
|
|
470
|
+
// default field gets @default JSDoc
|
|
471
|
+
expect(content).toContain('@default "active"');
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
it('skips per-domain ListMetadata models (Fix #4)', () => {
|
|
475
|
+
const service: Service = {
|
|
476
|
+
name: 'Connections',
|
|
477
|
+
operations: [
|
|
478
|
+
{
|
|
479
|
+
name: 'listConnections',
|
|
480
|
+
httpMethod: 'get',
|
|
481
|
+
path: '/connections',
|
|
482
|
+
pathParams: [],
|
|
483
|
+
queryParams: [],
|
|
484
|
+
headerParams: [],
|
|
485
|
+
response: { kind: 'model', name: 'ConnectionList' },
|
|
486
|
+
errors: [],
|
|
487
|
+
injectIdempotencyKey: false,
|
|
488
|
+
pagination: {
|
|
489
|
+
strategy: 'cursor',
|
|
490
|
+
param: 'after',
|
|
491
|
+
itemType: { kind: 'model', name: 'Connection' },
|
|
492
|
+
},
|
|
493
|
+
},
|
|
494
|
+
],
|
|
495
|
+
};
|
|
496
|
+
|
|
497
|
+
const models: Model[] = [
|
|
498
|
+
{
|
|
499
|
+
name: 'ConnectionListListMetadata',
|
|
500
|
+
fields: [
|
|
501
|
+
{
|
|
502
|
+
name: 'before',
|
|
503
|
+
type: {
|
|
504
|
+
kind: 'nullable',
|
|
505
|
+
inner: { kind: 'primitive', type: 'string' },
|
|
506
|
+
},
|
|
507
|
+
required: false,
|
|
508
|
+
},
|
|
509
|
+
{
|
|
510
|
+
name: 'after',
|
|
511
|
+
type: {
|
|
512
|
+
kind: 'nullable',
|
|
513
|
+
inner: { kind: 'primitive', type: 'string' },
|
|
514
|
+
},
|
|
515
|
+
required: false,
|
|
516
|
+
},
|
|
517
|
+
],
|
|
518
|
+
},
|
|
519
|
+
{
|
|
520
|
+
name: 'Connection',
|
|
521
|
+
fields: [
|
|
522
|
+
{
|
|
523
|
+
name: 'id',
|
|
524
|
+
type: { kind: 'primitive', type: 'string' },
|
|
525
|
+
required: true,
|
|
526
|
+
},
|
|
527
|
+
],
|
|
528
|
+
},
|
|
529
|
+
];
|
|
530
|
+
|
|
531
|
+
const ctxWithServices: EmitterContext = {
|
|
532
|
+
...ctx,
|
|
533
|
+
spec: { ...emptySpec, services: [service], models },
|
|
534
|
+
};
|
|
535
|
+
|
|
536
|
+
const files = generateModels(models, ctxWithServices);
|
|
537
|
+
|
|
538
|
+
// The ListMetadata model should be skipped entirely
|
|
539
|
+
const listMetadataFile = files.find((f) => f.path.includes('list-metadata'));
|
|
540
|
+
expect(listMetadataFile).toBeUndefined();
|
|
541
|
+
|
|
542
|
+
// The Connection model should still be generated
|
|
543
|
+
const connectionFile = files.find((f) => f.path.includes('connection.interface.ts'));
|
|
544
|
+
expect(connectionFile).toBeDefined();
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
it('skips per-domain list wrapper models (Fix #6)', () => {
|
|
548
|
+
const service: Service = {
|
|
549
|
+
name: 'Connections',
|
|
550
|
+
operations: [
|
|
551
|
+
{
|
|
552
|
+
name: 'listConnections',
|
|
553
|
+
httpMethod: 'get',
|
|
554
|
+
path: '/connections',
|
|
555
|
+
pathParams: [],
|
|
556
|
+
queryParams: [],
|
|
557
|
+
headerParams: [],
|
|
558
|
+
response: { kind: 'model', name: 'ConnectionList' },
|
|
559
|
+
errors: [],
|
|
560
|
+
injectIdempotencyKey: false,
|
|
561
|
+
pagination: {
|
|
562
|
+
strategy: 'cursor',
|
|
563
|
+
param: 'after',
|
|
564
|
+
itemType: { kind: 'model', name: 'Connection' },
|
|
565
|
+
},
|
|
566
|
+
},
|
|
567
|
+
],
|
|
568
|
+
};
|
|
569
|
+
|
|
570
|
+
const models: Model[] = [
|
|
571
|
+
{
|
|
572
|
+
name: 'ConnectionList',
|
|
573
|
+
fields: [
|
|
574
|
+
{
|
|
575
|
+
name: 'object',
|
|
576
|
+
type: { kind: 'literal', value: 'list' },
|
|
577
|
+
required: true,
|
|
578
|
+
},
|
|
579
|
+
{
|
|
580
|
+
name: 'data',
|
|
581
|
+
type: {
|
|
582
|
+
kind: 'array',
|
|
583
|
+
items: { kind: 'model', name: 'Connection' },
|
|
584
|
+
},
|
|
585
|
+
required: true,
|
|
586
|
+
},
|
|
587
|
+
{
|
|
588
|
+
name: 'list_metadata',
|
|
589
|
+
type: { kind: 'model', name: 'ConnectionListListMetadata' },
|
|
590
|
+
required: true,
|
|
591
|
+
},
|
|
592
|
+
],
|
|
593
|
+
},
|
|
594
|
+
{
|
|
595
|
+
name: 'ConnectionListListMetadata',
|
|
596
|
+
fields: [
|
|
597
|
+
{
|
|
598
|
+
name: 'before',
|
|
599
|
+
type: {
|
|
600
|
+
kind: 'nullable',
|
|
601
|
+
inner: { kind: 'primitive', type: 'string' },
|
|
602
|
+
},
|
|
603
|
+
required: false,
|
|
604
|
+
},
|
|
605
|
+
{
|
|
606
|
+
name: 'after',
|
|
607
|
+
type: {
|
|
608
|
+
kind: 'nullable',
|
|
609
|
+
inner: { kind: 'primitive', type: 'string' },
|
|
610
|
+
},
|
|
611
|
+
required: false,
|
|
612
|
+
},
|
|
613
|
+
],
|
|
614
|
+
},
|
|
615
|
+
{
|
|
616
|
+
name: 'Connection',
|
|
617
|
+
fields: [
|
|
618
|
+
{
|
|
619
|
+
name: 'id',
|
|
620
|
+
type: { kind: 'primitive', type: 'string' },
|
|
621
|
+
required: true,
|
|
622
|
+
},
|
|
623
|
+
],
|
|
624
|
+
},
|
|
625
|
+
];
|
|
626
|
+
|
|
627
|
+
const ctxWithServices: EmitterContext = {
|
|
628
|
+
...ctx,
|
|
629
|
+
spec: { ...emptySpec, services: [service], models },
|
|
630
|
+
};
|
|
631
|
+
|
|
632
|
+
const files = generateModels(models, ctxWithServices);
|
|
633
|
+
|
|
634
|
+
// The list wrapper model should be skipped
|
|
635
|
+
const listFile = files.find((f) => f.path.includes('connection-list.interface.ts'));
|
|
636
|
+
expect(listFile).toBeUndefined();
|
|
637
|
+
|
|
638
|
+
// The ListMetadata model should also be skipped
|
|
639
|
+
const listMetadataFile = files.find((f) => f.path.includes('list-metadata'));
|
|
640
|
+
expect(listMetadataFile).toBeUndefined();
|
|
641
|
+
|
|
642
|
+
// The Connection model should still be generated
|
|
643
|
+
const connectionFile = files.find((f) => f.path.includes('connection.interface.ts'));
|
|
644
|
+
expect(connectionFile).toBeDefined();
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
it('does not skip models that only partially match list-metadata shape', () => {
|
|
648
|
+
const service: Service = {
|
|
649
|
+
name: 'Organizations',
|
|
650
|
+
operations: [
|
|
651
|
+
{
|
|
652
|
+
name: 'getOrganization',
|
|
653
|
+
httpMethod: 'get',
|
|
654
|
+
path: '/organizations/{id}',
|
|
655
|
+
pathParams: [
|
|
656
|
+
{
|
|
657
|
+
name: 'id',
|
|
658
|
+
type: { kind: 'primitive', type: 'string' },
|
|
659
|
+
required: true,
|
|
660
|
+
},
|
|
661
|
+
],
|
|
662
|
+
queryParams: [],
|
|
663
|
+
headerParams: [],
|
|
664
|
+
response: { kind: 'model', name: 'Pagination' },
|
|
665
|
+
errors: [],
|
|
666
|
+
injectIdempotencyKey: false,
|
|
667
|
+
},
|
|
668
|
+
],
|
|
669
|
+
};
|
|
670
|
+
|
|
671
|
+
const models: Model[] = [
|
|
672
|
+
{
|
|
673
|
+
name: 'Pagination',
|
|
674
|
+
fields: [
|
|
675
|
+
{
|
|
676
|
+
name: 'before',
|
|
677
|
+
type: {
|
|
678
|
+
kind: 'nullable',
|
|
679
|
+
inner: { kind: 'primitive', type: 'string' },
|
|
680
|
+
},
|
|
681
|
+
required: false,
|
|
682
|
+
},
|
|
683
|
+
{
|
|
684
|
+
name: 'after',
|
|
685
|
+
type: {
|
|
686
|
+
kind: 'nullable',
|
|
687
|
+
inner: { kind: 'primitive', type: 'string' },
|
|
688
|
+
},
|
|
689
|
+
required: false,
|
|
690
|
+
},
|
|
691
|
+
{
|
|
692
|
+
name: 'total',
|
|
693
|
+
type: { kind: 'primitive', type: 'integer' },
|
|
694
|
+
required: true,
|
|
695
|
+
},
|
|
696
|
+
],
|
|
697
|
+
},
|
|
698
|
+
];
|
|
699
|
+
|
|
700
|
+
const ctxWithServices: EmitterContext = {
|
|
701
|
+
...ctx,
|
|
702
|
+
spec: { ...emptySpec, services: [service], models },
|
|
703
|
+
};
|
|
704
|
+
|
|
705
|
+
const files = generateModels(models, ctxWithServices);
|
|
706
|
+
// Model with 3 fields should NOT be skipped even if it has before/after
|
|
707
|
+
expect(files.length).toBe(1);
|
|
708
|
+
expect(files[0].path).toContain('pagination.interface.ts');
|
|
709
|
+
});
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
describe('model deduplication', () => {
|
|
713
|
+
it('emits type alias for structurally identical models', () => {
|
|
714
|
+
const service: Service = {
|
|
715
|
+
name: 'Roles',
|
|
716
|
+
operations: [
|
|
717
|
+
{
|
|
718
|
+
name: 'getRole',
|
|
719
|
+
httpMethod: 'get',
|
|
720
|
+
path: '/roles/{id}',
|
|
721
|
+
pathParams: [
|
|
722
|
+
{
|
|
723
|
+
name: 'id',
|
|
724
|
+
type: { kind: 'primitive', type: 'string' },
|
|
725
|
+
required: true,
|
|
726
|
+
},
|
|
727
|
+
],
|
|
728
|
+
queryParams: [],
|
|
729
|
+
headerParams: [],
|
|
730
|
+
response: { kind: 'model', name: 'EnvironmentRole' },
|
|
731
|
+
errors: [],
|
|
732
|
+
injectIdempotencyKey: false,
|
|
733
|
+
},
|
|
734
|
+
],
|
|
735
|
+
};
|
|
736
|
+
|
|
737
|
+
const models: Model[] = [
|
|
738
|
+
{
|
|
739
|
+
name: 'EnvironmentRole',
|
|
740
|
+
fields: [
|
|
741
|
+
{
|
|
742
|
+
name: 'id',
|
|
743
|
+
type: { kind: 'primitive', type: 'string' },
|
|
744
|
+
required: true,
|
|
745
|
+
},
|
|
746
|
+
{
|
|
747
|
+
name: 'name',
|
|
748
|
+
type: { kind: 'primitive', type: 'string' },
|
|
749
|
+
required: true,
|
|
750
|
+
},
|
|
751
|
+
{
|
|
752
|
+
name: 'type',
|
|
753
|
+
type: { kind: 'literal', value: 'environment_role' },
|
|
754
|
+
required: true,
|
|
755
|
+
},
|
|
756
|
+
],
|
|
757
|
+
},
|
|
758
|
+
{
|
|
759
|
+
name: 'OrganizationRole',
|
|
760
|
+
fields: [
|
|
761
|
+
{
|
|
762
|
+
name: 'id',
|
|
763
|
+
type: { kind: 'primitive', type: 'string' },
|
|
764
|
+
required: true,
|
|
765
|
+
},
|
|
766
|
+
{
|
|
767
|
+
name: 'name',
|
|
768
|
+
type: { kind: 'primitive', type: 'string' },
|
|
769
|
+
required: true,
|
|
770
|
+
},
|
|
771
|
+
{
|
|
772
|
+
name: 'type',
|
|
773
|
+
type: { kind: 'literal', value: 'environment_role' },
|
|
774
|
+
required: true,
|
|
775
|
+
},
|
|
776
|
+
],
|
|
777
|
+
},
|
|
778
|
+
];
|
|
779
|
+
|
|
780
|
+
const ctxWithServices: EmitterContext = {
|
|
781
|
+
...ctx,
|
|
782
|
+
spec: { ...emptySpec, services: [service], models },
|
|
783
|
+
};
|
|
784
|
+
|
|
785
|
+
const files = generateModels(models, ctxWithServices);
|
|
786
|
+
expect(files.length).toBe(2);
|
|
787
|
+
|
|
788
|
+
// First model: full interface
|
|
789
|
+
expect(files[0].content).toContain('export interface EnvironmentRole');
|
|
790
|
+
|
|
791
|
+
// Second model: type alias referencing canonical
|
|
792
|
+
expect(files[1].content).toContain('export type OrganizationRole = EnvironmentRole');
|
|
793
|
+
expect(files[1].content).toContain('export type OrganizationRoleResponse = EnvironmentRoleResponse');
|
|
794
|
+
});
|
|
301
795
|
});
|