@contractkit/plugin-typescript 0.17.5 → 0.19.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.
@@ -5,6 +5,11 @@ import {
5
5
  generateSdkAggregator,
6
6
  deriveClientClassName,
7
7
  deriveClientPropertyName,
8
+ deriveAreaClientClassName,
9
+ deriveAreaPropertyName,
10
+ deriveSubareaClientClassName,
11
+ deriveSubareaPropertyName,
12
+ getAreaSubarea,
8
13
  hasPublicOperations,
9
14
  } from '../src/codegen-sdk.js';
10
15
  import { collectPublicTypeNames } from '@contractkit/core';
@@ -762,11 +767,14 @@ describe('generateSdkOptions', () => {
762
767
  });
763
768
 
764
769
  describe('generateSdkAggregator', () => {
765
- it('generates Sdk class wrapping all clients', () => {
766
- const out = generateSdkAggregator([
767
- { className: 'UsersClient', propertyName: 'users', importPath: './src/users.client.js' },
768
- { className: 'CategoriesClient', propertyName: 'categories', importPath: './src/categories.client.js' },
769
- ]);
770
+ it('generates Sdk class wrapping all top-level clients', () => {
771
+ const out = generateSdkAggregator({
772
+ topLevelClients: [
773
+ { className: 'UsersClient', propertyName: 'users', importPath: './src/users.client.js' },
774
+ { className: 'CategoriesClient', propertyName: 'categories', importPath: './src/categories.client.js' },
775
+ ],
776
+ areas: [],
777
+ });
770
778
  expect(out).toContain("import type { SdkOptions } from './sdk-options.js'");
771
779
  expect(out).toContain("import { createSdkFetch } from './sdk-options.js'");
772
780
  expect(out).toContain("import { UsersClient } from './src/users.client.js'");
@@ -781,10 +789,11 @@ describe('generateSdkAggregator', () => {
781
789
  });
782
790
 
783
791
  it('uses custom sdkOptionsImportPath', () => {
784
- const out = generateSdkAggregator(
785
- [{ className: 'UsersClient', propertyName: 'users', importPath: './users.client.js' }],
786
- '../shared/sdk-options.js',
787
- );
792
+ const out = generateSdkAggregator({
793
+ topLevelClients: [{ className: 'UsersClient', propertyName: 'users', importPath: './users.client.js' }],
794
+ areas: [],
795
+ sdkOptionsImportPath: '../shared/sdk-options.js',
796
+ });
788
797
  expect(out).toContain("from '../shared/sdk-options.js'");
789
798
  });
790
799
  });
@@ -1401,17 +1410,17 @@ describe('generateSdk — type import paths', () => {
1401
1410
 
1402
1411
  describe('generateSdkAggregator — additional cases', () => {
1403
1412
  it('uses custom sdkClassName', () => {
1404
- const out = generateSdkAggregator(
1405
- [{ className: 'UsersClient', propertyName: 'users', importPath: './users.client.js' }],
1406
- './sdk-options.js',
1407
- 'ApiClient',
1408
- );
1413
+ const out = generateSdkAggregator({
1414
+ topLevelClients: [{ className: 'UsersClient', propertyName: 'users', importPath: './users.client.js' }],
1415
+ areas: [],
1416
+ sdkClassName: 'ApiClient',
1417
+ });
1409
1418
  expect(out).toContain('export class ApiClient {');
1410
1419
  expect(out).not.toContain('export class Sdk {');
1411
1420
  });
1412
1421
 
1413
- it('handles empty clients array', () => {
1414
- const out = generateSdkAggregator([]);
1422
+ it('handles empty input', () => {
1423
+ const out = generateSdkAggregator({ topLevelClients: [], areas: [] });
1415
1424
  expect(out).toContain('export class Sdk {');
1416
1425
  expect(out).toContain('constructor(options: SdkOptions)');
1417
1426
  expect(out).not.toContain('readonly ');
@@ -1498,3 +1507,166 @@ describe('generateSdk — json type in query / headers / params', () => {
1498
1507
  expect(out).toContain('export type JsonValue =');
1499
1508
  });
1500
1509
  });
1510
+
1511
+ // ─── Area / subarea name derivation ───────────────────────────────────────
1512
+
1513
+ describe('area + subarea name derivation', () => {
1514
+ it('builds area client class and property names', () => {
1515
+ expect(deriveAreaClientClassName('identity')).toBe('IdentityClient');
1516
+ expect(deriveAreaPropertyName('identity')).toBe('identity');
1517
+ expect(deriveAreaClientClassName('payment-flows')).toBe('PaymentFlowsClient');
1518
+ expect(deriveAreaPropertyName('payment-flows')).toBe('paymentFlows');
1519
+ });
1520
+
1521
+ it('builds subarea (concatenated) client class and property names', () => {
1522
+ expect(deriveSubareaClientClassName('identity', 'invitations')).toBe('IdentityInvitationsClient');
1523
+ expect(deriveSubareaPropertyName('invitations')).toBe('invitations');
1524
+ expect(deriveSubareaClientClassName('payments', 'gift-cards')).toBe('PaymentsGiftCardsClient');
1525
+ expect(deriveSubareaPropertyName('gift-cards')).toBe('giftCards');
1526
+ });
1527
+
1528
+ it('reads area and subarea from root.meta', () => {
1529
+ const root = opRoot([], 'x.ck', { area: 'identity', subarea: 'invitations' });
1530
+ expect(getAreaSubarea(root)).toEqual({ area: 'identity', subarea: 'invitations' });
1531
+ const noKeys = opRoot([], 'x.ck');
1532
+ expect(getAreaSubarea(noKeys)).toEqual({ area: undefined, subarea: undefined });
1533
+ });
1534
+ });
1535
+
1536
+ // ─── generateSdkAggregator — area + subarea wiring ────────────────────────
1537
+
1538
+ describe('generateSdkAggregator — area + subarea', () => {
1539
+ it('emits one <Area>Client per area with subarea property wiring', () => {
1540
+ const out = generateSdkAggregator({
1541
+ topLevelClients: [],
1542
+ areas: [
1543
+ {
1544
+ area: 'identity',
1545
+ inlineFiles: [],
1546
+ subareaClients: [
1547
+ {
1548
+ propertyName: 'invitations',
1549
+ client: { className: 'IdentityInvitationsClient', propertyName: 'invitations', importPath: './identity/invitations.client.js' },
1550
+ },
1551
+ {
1552
+ propertyName: 'users',
1553
+ client: { className: 'IdentityUsersClient', propertyName: 'users', importPath: './identity/users.client.js' },
1554
+ },
1555
+ ],
1556
+ },
1557
+ ],
1558
+ });
1559
+ expect(out).toContain("import { IdentityInvitationsClient } from './identity/invitations.client.js'");
1560
+ expect(out).toContain("import { IdentityUsersClient } from './identity/users.client.js'");
1561
+ expect(out).toContain('class IdentityClient {');
1562
+ expect(out).toContain('readonly invitations: IdentityInvitationsClient');
1563
+ expect(out).toContain('readonly users: IdentityUsersClient');
1564
+ expect(out).toContain('this.invitations = new IdentityInvitationsClient(fetch)');
1565
+ expect(out).toContain('this.users = new IdentityUsersClient(fetch)');
1566
+ // Sdk wires the area property
1567
+ expect(out).toContain('export class Sdk {');
1568
+ expect(out).toContain('readonly identity: IdentityClient');
1569
+ expect(out).toContain('this.identity = new IdentityClient(sdkFetch)');
1570
+ });
1571
+
1572
+ it('inlines methods from area-level (no-subarea) files into <Area>Client', () => {
1573
+ const root = opRoot(
1574
+ [opRoute('/me', [opOperation('get', { sdk: 'getCurrentUser', responses: [opResponse(200, 'User', 'application/json')] })])],
1575
+ 'identity.ck',
1576
+ { area: 'identity' },
1577
+ );
1578
+ const out = generateSdkAggregator({
1579
+ topLevelClients: [],
1580
+ areas: [
1581
+ {
1582
+ area: 'identity',
1583
+ inlineFiles: [{ root, codegenOptions: { outPath: '/out/sdk.ts', sdkOptionsPath: '/out/sdk-options.ts' } }],
1584
+ subareaClients: [],
1585
+ },
1586
+ ],
1587
+ });
1588
+ expect(out).toContain('class IdentityClient {');
1589
+ expect(out).toContain('async getCurrentUser(');
1590
+ // No subarea property declarations on this client
1591
+ expect(out).not.toMatch(/readonly \w+: \w+;[\s\S]*async getCurrentUser/);
1592
+ // The fetch arg is captured as `private fetch` since methods reference `this.fetch`
1593
+ expect(out).toContain('constructor(private fetch: SdkFetch)');
1594
+ });
1595
+
1596
+ it('mixes inline area methods with subarea property wiring on the same area client', () => {
1597
+ const inlineRoot = opRoot(
1598
+ [opRoute('/me', [opOperation('get', { sdk: 'getCurrentUser', responses: [opResponse(200, 'User', 'application/json')] })])],
1599
+ 'identity.ck',
1600
+ { area: 'identity' },
1601
+ );
1602
+ const out = generateSdkAggregator({
1603
+ topLevelClients: [],
1604
+ areas: [
1605
+ {
1606
+ area: 'identity',
1607
+ inlineFiles: [{ root: inlineRoot, codegenOptions: { outPath: '/out/sdk.ts', sdkOptionsPath: '/out/sdk-options.ts' } }],
1608
+ subareaClients: [
1609
+ {
1610
+ propertyName: 'invitations',
1611
+ client: { className: 'IdentityInvitationsClient', propertyName: 'invitations', importPath: './identity/invitations.client.js' },
1612
+ },
1613
+ ],
1614
+ },
1615
+ ],
1616
+ });
1617
+ expect(out).toContain('readonly invitations: IdentityInvitationsClient');
1618
+ expect(out).toContain('async getCurrentUser(');
1619
+ expect(out).toContain('this.invitations = new IdentityInvitationsClient(fetch)');
1620
+ });
1621
+
1622
+ it('throws on duplicate method names across area-level files in the same area', () => {
1623
+ const a = opRoot(
1624
+ [opRoute('/me', [opOperation('get', { sdk: 'getCurrentUser', responses: [opResponse(200, 'User', 'application/json')] })])],
1625
+ 'a.ck',
1626
+ { area: 'identity' },
1627
+ );
1628
+ const b = opRoot(
1629
+ [opRoute('/whoami', [opOperation('get', { sdk: 'getCurrentUser', responses: [opResponse(200, 'User', 'application/json')] })])],
1630
+ 'b.ck',
1631
+ { area: 'identity' },
1632
+ );
1633
+ expect(() =>
1634
+ generateSdkAggregator({
1635
+ topLevelClients: [],
1636
+ areas: [
1637
+ {
1638
+ area: 'identity',
1639
+ inlineFiles: [
1640
+ { root: a, codegenOptions: { outPath: '/out/sdk.ts', sdkOptionsPath: '/out/sdk-options.ts' } },
1641
+ { root: b, codegenOptions: { outPath: '/out/sdk.ts', sdkOptionsPath: '/out/sdk-options.ts' } },
1642
+ ],
1643
+ subareaClients: [],
1644
+ },
1645
+ ],
1646
+ }),
1647
+ ).toThrow(/duplicate method 'getCurrentUser' in area 'identity'/);
1648
+ });
1649
+
1650
+ it('mixes area clients with top-level (no-area) clients', () => {
1651
+ const out = generateSdkAggregator({
1652
+ topLevelClients: [{ className: 'WebhooksClient', propertyName: 'webhooks', importPath: './webhooks.client.js' }],
1653
+ areas: [
1654
+ {
1655
+ area: 'payments',
1656
+ inlineFiles: [],
1657
+ subareaClients: [
1658
+ {
1659
+ propertyName: 'transfers',
1660
+ client: { className: 'PaymentsTransfersClient', propertyName: 'transfers', importPath: './payments/transfers.client.js' },
1661
+ },
1662
+ ],
1663
+ },
1664
+ ],
1665
+ });
1666
+ // Sdk has both an area property AND a top-level property
1667
+ expect(out).toContain('readonly payments: PaymentsClient');
1668
+ expect(out).toContain('readonly webhooks: WebhooksClient');
1669
+ expect(out).toContain('this.payments = new PaymentsClient(sdkFetch)');
1670
+ expect(out).toContain('this.webhooks = new WebhooksClient(sdkFetch)');
1671
+ });
1672
+ });
@@ -10,6 +10,7 @@ function makeCtx(rootDir = '/project', options: Record<string, unknown> = {}): P
10
10
  return {
11
11
  rootDir,
12
12
  options,
13
+ cacheEnabled: true,
13
14
  emitFile: (outPath: string, content: string) => {
14
15
  emitted.set(outPath, content);
15
16
  },
@@ -63,7 +64,8 @@ describe('createTypescriptPlugin (server)', () => {
63
64
  ]),
64
65
  ctx,
65
66
  );
66
- expect(ctx.emitted.size).toBe(2);
67
+ const routeFiles = [...ctx.emitted.keys()].filter(p => p.endsWith('.router.ts'));
68
+ expect(routeFiles.length).toBe(2);
67
69
  });
68
70
 
69
71
  it('emits type files alongside routes when output.types is configured', async () => {
@@ -190,3 +192,101 @@ describe('createTypescriptPlugin (server)', () => {
190
192
  });
191
193
  });
192
194
  });
195
+
196
+ // ─── SDK pipeline: area / subarea grouping ─────────────────────────────────
197
+
198
+ describe('createTypescriptPlugin (sdk) — area / subarea grouping', () => {
199
+ function findEmitted(emitted: Map<string, string>, suffix: string): string | undefined {
200
+ for (const [path, content] of emitted) {
201
+ if (path.endsWith(suffix)) return content;
202
+ }
203
+ return undefined;
204
+ }
205
+
206
+ it('emits a leaf <Area><Subarea>Client and nests it under <Area>Client in sdk.ts', async () => {
207
+ const plugin = createTypescriptPlugin({ sdk: { output: { sdk: 'sdk.ts', clients: '{area}/{subarea}.client.ts' } } }, '/project');
208
+ const ctx = makeCtx('/project');
209
+ const root = opRoot(
210
+ [opRoute('/identity/invitations', [opOperation('post', { sdk: 'createInvitation', responses: [opResponse(201)] })])],
211
+ '/project/contracts/identity-invitations.ck',
212
+ { area: 'identity', subarea: 'invitations' },
213
+ );
214
+ await plugin.generateTargets!(inputs([root]), ctx);
215
+
216
+ // Leaf client emitted at the {area}/{subarea}.client.ts path
217
+ const leaf = findEmitted(ctx.emitted, 'identity/invitations.client.ts');
218
+ expect(leaf).toBeDefined();
219
+ expect(leaf!).toContain('export class IdentityInvitationsClient');
220
+ expect(leaf!).toContain('async createInvitation');
221
+
222
+ // Aggregator declares IdentityClient + Sdk wiring
223
+ const sdk = findEmitted(ctx.emitted, '/sdk.ts');
224
+ expect(sdk).toBeDefined();
225
+ expect(sdk!).toContain('class IdentityClient {');
226
+ expect(sdk!).toContain('readonly invitations: IdentityInvitationsClient');
227
+ expect(sdk!).toContain('this.invitations = new IdentityInvitationsClient(fetch)');
228
+ expect(sdk!).toContain('export class Sdk {');
229
+ expect(sdk!).toContain('readonly identity: IdentityClient');
230
+ expect(sdk!).toContain('this.identity = new IdentityClient(sdkFetch)');
231
+ });
232
+
233
+ it('does NOT emit a standalone client file for an area-level (no-subarea) input — methods inline into sdk.ts', async () => {
234
+ const plugin = createTypescriptPlugin({ sdk: { output: { sdk: 'sdk.ts', clients: '{area}/{filename}.client.ts' } } }, '/project');
235
+ const ctx = makeCtx('/project');
236
+ const root = opRoot(
237
+ [opRoute('/me', [opOperation('get', { sdk: 'getCurrentUser', responses: [opResponse(200)] })])],
238
+ '/project/contracts/identity-me.ck',
239
+ { area: 'identity' },
240
+ );
241
+ await plugin.generateTargets!(inputs([root]), ctx);
242
+
243
+ // No per-file leaf for the area-level input
244
+ for (const path of ctx.emitted.keys()) {
245
+ expect(path).not.toMatch(/identity[/-]me\.client\.ts$/);
246
+ }
247
+
248
+ // Methods land directly on IdentityClient inside sdk.ts
249
+ const sdk = findEmitted(ctx.emitted, '/sdk.ts');
250
+ expect(sdk).toBeDefined();
251
+ expect(sdk!).toContain('class IdentityClient {');
252
+ expect(sdk!).toContain('async getCurrentUser');
253
+ });
254
+
255
+ it('mixes area-level inline methods with subarea property wiring on the same area client', async () => {
256
+ const plugin = createTypescriptPlugin({ sdk: { output: { sdk: 'sdk.ts', clients: '{area}/{filename}.client.ts' } } }, '/project');
257
+ const ctx = makeCtx('/project');
258
+ const me = opRoot(
259
+ [opRoute('/me', [opOperation('get', { sdk: 'getCurrentUser', responses: [opResponse(200)] })])],
260
+ '/project/contracts/identity-me.ck',
261
+ { area: 'identity' },
262
+ );
263
+ const invitations = opRoot(
264
+ [opRoute('/identity/invitations', [opOperation('post', { sdk: 'createInvitation', responses: [opResponse(201)] })])],
265
+ '/project/contracts/identity-invitations.ck',
266
+ { area: 'identity', subarea: 'invitations' },
267
+ );
268
+ await plugin.generateTargets!(inputs([me, invitations]), ctx);
269
+
270
+ const sdk = findEmitted(ctx.emitted, '/sdk.ts')!;
271
+ expect(sdk).toContain('class IdentityClient {');
272
+ expect(sdk).toContain('readonly invitations: IdentityInvitationsClient');
273
+ expect(sdk).toContain('async getCurrentUser');
274
+ });
275
+
276
+ it('preserves legacy flat wiring for files with no area', async () => {
277
+ const plugin = createTypescriptPlugin({ sdk: { output: { sdk: 'sdk.ts', clients: '{filename}.client.ts' } } }, '/project');
278
+ const ctx = makeCtx('/project');
279
+ const root = opRoot(
280
+ [opRoute('/webhooks', [opOperation('post', { sdk: 'sendWebhook', responses: [opResponse(202)] })])],
281
+ '/project/contracts/webhooks.ck',
282
+ );
283
+ await plugin.generateTargets!(inputs([root]), ctx);
284
+
285
+ const sdk = findEmitted(ctx.emitted, '/sdk.ts')!;
286
+ // No <Area>Client wrapper; flat property on Sdk
287
+ expect(sdk).toContain('export class Sdk {');
288
+ expect(sdk).toContain('readonly webhooks: WebhooksClient');
289
+ expect(sdk).toContain('this.webhooks = new WebhooksClient(sdkFetch)');
290
+ expect(sdk).not.toMatch(/class \w+Client \{/m);
291
+ });
292
+ });