@contractkit/plugin-typescript 0.33.2 → 0.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build$colon$ci.log +4 -4
- package/.turbo/turbo-test$colon$ci.log +26 -25
- package/CHANGELOG.md +273 -0
- package/dist/codegen-contract.d.ts +22 -6
- package/dist/codegen-contract.d.ts.map +1 -1
- package/dist/codegen-mcp.d.ts.map +1 -1
- package/dist/codegen-operation.d.ts.map +1 -1
- package/dist/codegen-plain-types.d.ts.map +1 -1
- package/dist/codegen-revive.d.ts +38 -4
- package/dist/codegen-revive.d.ts.map +1 -1
- package/dist/codegen-sdk.d.ts +2 -0
- package/dist/codegen-sdk.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +413 -202
- package/dist/index.js.map +1 -1
- package/dist/path-utils.d.ts +9 -0
- package/dist/path-utils.d.ts.map +1 -1
- package/dist/ts-render.d.ts +14 -0
- package/dist/ts-render.d.ts.map +1 -1
- package/package.json +3 -2
- package/src/codegen-contract.ts +84 -68
- package/src/codegen-mcp.ts +12 -11
- package/src/codegen-operation.ts +32 -19
- package/src/codegen-plain-types.ts +15 -8
- package/src/codegen-revive.ts +140 -20
- package/src/codegen-sdk.ts +322 -63
- package/src/index.ts +46 -4
- package/src/path-utils.ts +10 -0
- package/src/ts-render.ts +26 -0
- package/tests/codegen-contract.test.ts +87 -32
- package/tests/codegen-mcp.test.ts +31 -0
- package/tests/codegen-operation.test.ts +68 -8
- package/tests/codegen-plain-types.test.ts +13 -5
- package/tests/codegen-sdk.test.ts +265 -10
- package/tests/codegen-server.test.ts +43 -1
- package/tests/helpers.ts +7 -2
- package/tests/pipeline.test.ts +51 -4
- package/tests/ts-render.test.ts +29 -0
|
@@ -38,13 +38,21 @@ describe('generatePlainTypes', () => {
|
|
|
38
38
|
expect(output).not.toContain('z.infer');
|
|
39
39
|
});
|
|
40
40
|
|
|
41
|
-
it('
|
|
41
|
+
it('imports luxon for date fields, which render as DateTime', () => {
|
|
42
42
|
const root = contractRoot([model('Event', [field('startDate', scalarType('date')), field('endDate', scalarType('datetime'))])]);
|
|
43
43
|
const output = generatePlainTypes(root);
|
|
44
|
+
// The router parses these into Luxon objects and the SDK's revivers rehydrate them,
|
|
45
|
+
// so a `string` here was a claim neither side honoured.
|
|
46
|
+
expect(output).toContain("import { DateTime } from 'luxon';");
|
|
47
|
+
expect(output).toContain('startDate: DateTime;');
|
|
48
|
+
expect(output).toContain('endDate: DateTime;');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('leaves interval as a string, since it transforms back to ISO on output', () => {
|
|
52
|
+
const root = contractRoot([model('Window', [field('span', scalarType('interval'))])]);
|
|
53
|
+
const output = generatePlainTypes(root);
|
|
54
|
+
expect(output).toContain('span: string;');
|
|
44
55
|
expect(output).not.toContain('luxon');
|
|
45
|
-
expect(output).not.toContain('DateTime');
|
|
46
|
-
expect(output).toContain('startDate: string;');
|
|
47
|
-
expect(output).toContain('endDate: string;');
|
|
48
56
|
});
|
|
49
57
|
});
|
|
50
58
|
|
|
@@ -573,7 +581,7 @@ describe('generatePlainTypes', () => {
|
|
|
573
581
|
it('includes source location in JSDoc', () => {
|
|
574
582
|
const root = contractRoot([model('User', [field('name', scalarType('string'))], { loc: { file: 'user.ck', line: 5 } })]);
|
|
575
583
|
const output = generatePlainTypes(root);
|
|
576
|
-
expect(output).toContain('
|
|
584
|
+
expect(output).toContain('[User](./user.ck#L5)');
|
|
577
585
|
});
|
|
578
586
|
});
|
|
579
587
|
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
opRoute,
|
|
24
24
|
opOperation,
|
|
25
25
|
opParam,
|
|
26
|
+
paramRef,
|
|
26
27
|
opRequest,
|
|
27
28
|
opMultiRequest,
|
|
28
29
|
opResponse,
|
|
@@ -134,6 +135,34 @@ describe('generateSdk', () => {
|
|
|
134
135
|
expect(out).toContain("method: 'GET'");
|
|
135
136
|
expect(out).toContain('return await parseJson<User>(result)');
|
|
136
137
|
});
|
|
138
|
+
|
|
139
|
+
it('reads path params off the params argument when the route declares a model', () => {
|
|
140
|
+
const root = opRoot([
|
|
141
|
+
opRoute(
|
|
142
|
+
'/pets/{petId}',
|
|
143
|
+
[opOperation('get', { sdk: 'getPet', responses: [opResponse(200, 'Pet', 'application/json')] })],
|
|
144
|
+
paramRef('PetRef'),
|
|
145
|
+
),
|
|
146
|
+
]);
|
|
147
|
+
const out = generateSdk(root);
|
|
148
|
+
// The signature has one argument named `params`, so a bare `petId` refers to nothing.
|
|
149
|
+
expect(out).toContain('async getPet(params: PetRef)');
|
|
150
|
+
expect(out).toContain('${encodeURIComponent(String(params.petId))}');
|
|
151
|
+
expect(out).not.toContain('encodeURIComponent(petId)');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('brackets a hyphenated path param, which is not a property accessor', () => {
|
|
155
|
+
const root = opRoot([
|
|
156
|
+
opRoute(
|
|
157
|
+
'/pets/{pet-id}',
|
|
158
|
+
[opOperation('get', { sdk: 'getPet', responses: [opResponse(200, 'Pet', 'application/json')] })],
|
|
159
|
+
paramRef('PetRef'),
|
|
160
|
+
),
|
|
161
|
+
]);
|
|
162
|
+
const out = generateSdk(root);
|
|
163
|
+
expect(out).toContain('${encodeURIComponent(String(params["pet-id"]))}');
|
|
164
|
+
expect(out).not.toContain('{pet-id}');
|
|
165
|
+
});
|
|
137
166
|
});
|
|
138
167
|
|
|
139
168
|
describe('POST with JSON body', () => {
|
|
@@ -369,7 +398,7 @@ describe('generateSdk', () => {
|
|
|
369
398
|
const out = generateSdk(root);
|
|
370
399
|
expect(out).toContain('Promise<{ data: Transfer; headers: { preferenceApplied?: string; etag: string } }>');
|
|
371
400
|
expect(out).toContain("preferenceApplied: result.headers.get('preference-applied') ?? undefined");
|
|
372
|
-
expect(out).toContain("etag: result.headers.get('etag')
|
|
401
|
+
expect(out).toContain("etag: result.headers.get('etag')!");
|
|
373
402
|
expect(out).toContain('return { data, headers:');
|
|
374
403
|
});
|
|
375
404
|
|
|
@@ -395,11 +424,74 @@ describe('generateSdk', () => {
|
|
|
395
424
|
]);
|
|
396
425
|
const out = generateSdk(root);
|
|
397
426
|
expect(out).toContain('Promise<{ headers: { xDeletedAt: string } }>');
|
|
398
|
-
expect(out).toContain("xDeletedAt: result.headers.get('x-deleted-at')
|
|
427
|
+
expect(out).toContain("xDeletedAt: result.headers.get('x-deleted-at')!");
|
|
399
428
|
expect(out).toContain('return { headers:');
|
|
400
429
|
expect(out).not.toContain('parseJson<void>');
|
|
401
430
|
});
|
|
402
431
|
|
|
432
|
+
it('coerces each header to the type the return shape declares', () => {
|
|
433
|
+
const root = opRoot([
|
|
434
|
+
opRoute('/things', [
|
|
435
|
+
opOperation('get', {
|
|
436
|
+
sdk: 'getThing',
|
|
437
|
+
responses: [
|
|
438
|
+
{
|
|
439
|
+
statusCode: 200,
|
|
440
|
+
hasBlock: true,
|
|
441
|
+
bodies: [{ contentType: 'application/json', bodyType: { kind: 'ref', name: 'Thing' } }],
|
|
442
|
+
headers: [
|
|
443
|
+
{ name: 'x-count', optional: false, type: scalarType('int') },
|
|
444
|
+
{ name: 'x-ratio', optional: true, type: scalarType('number') },
|
|
445
|
+
{ name: 'x-cached', optional: false, type: scalarType('boolean') },
|
|
446
|
+
{ name: 'x-fresh', optional: true, type: scalarType('boolean') },
|
|
447
|
+
{ name: 'x-seq', optional: false, type: scalarType('bigint') },
|
|
448
|
+
{ name: 'x-prev', optional: true, type: scalarType('bigint') },
|
|
449
|
+
{ name: 'x-expires', optional: true, type: scalarType('datetime') },
|
|
450
|
+
{ name: 'x-day', optional: false, type: scalarType('date') },
|
|
451
|
+
],
|
|
452
|
+
},
|
|
453
|
+
],
|
|
454
|
+
}),
|
|
455
|
+
]),
|
|
456
|
+
]);
|
|
457
|
+
const out = generateSdk(root);
|
|
458
|
+
// Header values arrive as strings; the shape is typed from the contract, so without
|
|
459
|
+
// coercion every one of these is a TS2322 in one direction or the other.
|
|
460
|
+
expect(out).toContain('xCount: Number(result.headers.get(\'x-count\'))');
|
|
461
|
+
expect(out).toContain("xRatio: result.headers.get('x-ratio') === null ? undefined : Number(result.headers.get('x-ratio'))");
|
|
462
|
+
expect(out).toContain("xCached: result.headers.get('x-cached') === 'true'");
|
|
463
|
+
expect(out).toContain("xFresh: result.headers.get('x-fresh') === null ? undefined : result.headers.get('x-fresh') === 'true'");
|
|
464
|
+
expect(out).toContain("xSeq: BigInt(result.headers.get('x-seq')!)");
|
|
465
|
+
// Asserted in both branches: TS does not carry the null narrowing across a second
|
|
466
|
+
// `get()` call, and `BigInt` takes no null.
|
|
467
|
+
expect(out).toContain("xPrev: result.headers.get('x-prev') === null ? undefined : BigInt(result.headers.get('x-prev')!)");
|
|
468
|
+
// Temporals are Luxon objects since the SDK started reviving them, so a raw string no
|
|
469
|
+
// longer satisfies the shape `renderOutputTsType` produces.
|
|
470
|
+
expect(out).toContain("xExpires: result.headers.get('x-expires') === null ? undefined : DateTime.fromISO(result.headers.get('x-expires')!)");
|
|
471
|
+
expect(out).toContain("xDay: DateTime.fromFormat(result.headers.get('x-day')!, 'yyyy-MM-dd')");
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
it('rejects a header type that cannot be read from a header', () => {
|
|
475
|
+
const root = opRoot([
|
|
476
|
+
opRoute('/things', [
|
|
477
|
+
opOperation('get', {
|
|
478
|
+
sdk: 'getThing',
|
|
479
|
+
responses: [
|
|
480
|
+
{
|
|
481
|
+
statusCode: 200,
|
|
482
|
+
hasBlock: true,
|
|
483
|
+
bodies: [{ contentType: 'application/json', bodyType: { kind: 'ref', name: 'Thing' } }],
|
|
484
|
+
headers: [{ name: 'x-money', optional: false, type: scalarType('decimal') }],
|
|
485
|
+
},
|
|
486
|
+
],
|
|
487
|
+
}),
|
|
488
|
+
]),
|
|
489
|
+
]);
|
|
490
|
+
// Emitting code that does not compile would be worse than refusing; the CLI turns this
|
|
491
|
+
// into a plugin-scoped error naming the operation.
|
|
492
|
+
expect(() => generateSdk(root)).toThrow(/x-money.*GET \/things.*'decimal' scalar/s);
|
|
493
|
+
});
|
|
494
|
+
|
|
403
495
|
it('preserves plain return type when no response headers are declared', () => {
|
|
404
496
|
const root = opRoot([
|
|
405
497
|
opRoute(
|
|
@@ -415,7 +507,7 @@ describe('generateSdk', () => {
|
|
|
415
507
|
});
|
|
416
508
|
|
|
417
509
|
describe('query params', () => {
|
|
418
|
-
it('
|
|
510
|
+
it('requires the query argument when its fields are not optional', () => {
|
|
419
511
|
const root = opRoot([
|
|
420
512
|
opRoute('/users', [
|
|
421
513
|
opOperation('get', {
|
|
@@ -426,10 +518,47 @@ describe('generateSdk', () => {
|
|
|
426
518
|
]),
|
|
427
519
|
]);
|
|
428
520
|
const out = generateSdk(root);
|
|
429
|
-
|
|
521
|
+
// The contract declares neither field with `?`, so the router demands both. Typing
|
|
522
|
+
// them optional let a caller omit a value the request would then be rejected for.
|
|
523
|
+
expect(out).toContain('query: { page: number; limit: number }');
|
|
430
524
|
expect(out).toContain('URLSearchParams');
|
|
431
525
|
});
|
|
432
526
|
|
|
527
|
+
it('makes the query argument optional when every field is', () => {
|
|
528
|
+
const root = opRoot([
|
|
529
|
+
opRoute('/users', [
|
|
530
|
+
opOperation('get', {
|
|
531
|
+
sdk: 'listUsers',
|
|
532
|
+
query: [
|
|
533
|
+
opParam('page', scalarType('int'), { optional: true }),
|
|
534
|
+
// A default is equally omittable by the caller, so it counts as optional.
|
|
535
|
+
opParam('limit', scalarType('int'), { default: 20 }),
|
|
536
|
+
],
|
|
537
|
+
responses: [opResponse(200, 'array(User)', 'application/json')],
|
|
538
|
+
}),
|
|
539
|
+
]),
|
|
540
|
+
]);
|
|
541
|
+
const out = generateSdk(root);
|
|
542
|
+
expect(out).toContain('query?: { page?: number; limit?: number }');
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
it('widens an optional query argument that precedes a required one', () => {
|
|
546
|
+
const root = opRoot([
|
|
547
|
+
opRoute('/users', [
|
|
548
|
+
opOperation('get', {
|
|
549
|
+
sdk: 'listUsers',
|
|
550
|
+
query: [opParam('page', scalarType('int'), { optional: true })],
|
|
551
|
+
headers: [opParam('x-tenant', scalarType('string'))],
|
|
552
|
+
responses: [opResponse(200, 'array(User)', 'application/json')],
|
|
553
|
+
}),
|
|
554
|
+
]),
|
|
555
|
+
]);
|
|
556
|
+
const out = generateSdk(root);
|
|
557
|
+
// `async m(query?: Q, customHeaders: H)` is TS1016. Widening the earlier argument is
|
|
558
|
+
// the only fix that keeps the positional order call sites depend on.
|
|
559
|
+
expect(out).toContain("async listUsers(query: { page?: number }, customHeaders: { 'x-tenant': string })");
|
|
560
|
+
});
|
|
561
|
+
|
|
433
562
|
it('appends qs directly to URL', () => {
|
|
434
563
|
const root = opRoot([
|
|
435
564
|
opRoute('/users', [
|
|
@@ -501,7 +630,7 @@ describe('generateSdk', () => {
|
|
|
501
630
|
]),
|
|
502
631
|
]);
|
|
503
632
|
const out = generateSdk(root);
|
|
504
|
-
expect(out).toContain("customHeaders
|
|
633
|
+
expect(out).toContain("customHeaders: { 'x-api-key': string }");
|
|
505
634
|
});
|
|
506
635
|
});
|
|
507
636
|
|
|
@@ -1201,6 +1330,25 @@ describe('generateSdk — route-level deprecated cascade', () => {
|
|
|
1201
1330
|
expect(deprecatedCount).toBe(2);
|
|
1202
1331
|
});
|
|
1203
1332
|
|
|
1333
|
+
it('keeps @deprecated in the same block as the description', () => {
|
|
1334
|
+
const root = opRoot([
|
|
1335
|
+
opRoute('/users', [
|
|
1336
|
+
opOperation('get', {
|
|
1337
|
+
description: 'list the users',
|
|
1338
|
+
responses: [opResponse(200, 'User', 'application/json')],
|
|
1339
|
+
modifiers: ['deprecated'],
|
|
1340
|
+
}),
|
|
1341
|
+
]),
|
|
1342
|
+
]);
|
|
1343
|
+
const out = generateSdk(root);
|
|
1344
|
+
// TypeScript honours only the JSDoc adjacent to the declaration, so a standalone
|
|
1345
|
+
// `/** @deprecated */` above a description block is dropped by editors entirely.
|
|
1346
|
+
expect(out).not.toContain('/** @deprecated */');
|
|
1347
|
+
const block = out.slice(out.indexOf(' /**'), out.indexOf('async getUsers'));
|
|
1348
|
+
expect(block).toContain('@description list the users');
|
|
1349
|
+
expect(block).toContain('@deprecated');
|
|
1350
|
+
});
|
|
1351
|
+
|
|
1204
1352
|
it('operation-level modifiers override route-level deprecated', () => {
|
|
1205
1353
|
const root = opRoot([
|
|
1206
1354
|
opRoute('/users', [opOperation('get', { modifiers: [], responses: [opResponse(200, 'User', 'application/json')] })], undefined, [
|
|
@@ -1260,9 +1408,15 @@ describe('renderTsType', () => {
|
|
|
1260
1408
|
expect(renderTsType(scalarType('uuid'))).toBe('string');
|
|
1261
1409
|
});
|
|
1262
1410
|
|
|
1263
|
-
it('maps date and datetime to
|
|
1264
|
-
expect(renderTsType(scalarType('date'))).toBe('
|
|
1265
|
-
expect(renderTsType(scalarType('datetime'))).toBe('
|
|
1411
|
+
it('maps date and datetime to DateTime', () => {
|
|
1412
|
+
expect(renderTsType(scalarType('date'))).toBe('DateTime');
|
|
1413
|
+
expect(renderTsType(scalarType('datetime'))).toBe('DateTime');
|
|
1414
|
+
});
|
|
1415
|
+
|
|
1416
|
+
it('maps duration to Duration but leaves interval a string', () => {
|
|
1417
|
+
expect(renderTsType(scalarType('duration'))).toBe('Duration');
|
|
1418
|
+
// `_ZodInterval` ends in a transform back to ISO, so a string is what arrives.
|
|
1419
|
+
expect(renderTsType(scalarType('interval'))).toBe('string');
|
|
1266
1420
|
});
|
|
1267
1421
|
|
|
1268
1422
|
it('maps bigint to bigint', () => {
|
|
@@ -1289,8 +1443,8 @@ describe('renderTsType', () => {
|
|
|
1289
1443
|
expect(renderTsType(scalarType('json'))).toBe('JsonValue');
|
|
1290
1444
|
});
|
|
1291
1445
|
|
|
1292
|
-
it('maps time to
|
|
1293
|
-
expect(renderTsType(scalarType('time'))).toBe('
|
|
1446
|
+
it('maps time to DateTime', () => {
|
|
1447
|
+
expect(renderTsType(scalarType('time'))).toBe('DateTime');
|
|
1294
1448
|
});
|
|
1295
1449
|
|
|
1296
1450
|
it('throws on an unmapped scalar name', () => {
|
|
@@ -1484,6 +1638,48 @@ describe('generateSdk — multipart/form-data', () => {
|
|
|
1484
1638
|
expect(out).not.toContain("'Content-Type': 'application/json'");
|
|
1485
1639
|
expect(out).not.toContain('JSON.stringify');
|
|
1486
1640
|
});
|
|
1641
|
+
|
|
1642
|
+
it('does not import a model used only as a multipart body', () => {
|
|
1643
|
+
const root = opRoot([
|
|
1644
|
+
opRoute('/uploads', [
|
|
1645
|
+
opOperation('post', {
|
|
1646
|
+
sdk: 'upload',
|
|
1647
|
+
request: opRequest('UploadForm', 'multipart/form-data'),
|
|
1648
|
+
responses: [opResponse(201, 'Upload', 'application/json')],
|
|
1649
|
+
}),
|
|
1650
|
+
]),
|
|
1651
|
+
]);
|
|
1652
|
+
const out = generateSdk(root, {
|
|
1653
|
+
outPath: '/sdk/clients/uploads.client.ts',
|
|
1654
|
+
modelOutPaths: new Map([
|
|
1655
|
+
['UploadForm', '/sdk/types/uploads.types.ts'],
|
|
1656
|
+
['Upload', '/sdk/types/uploads.types.ts'],
|
|
1657
|
+
]),
|
|
1658
|
+
});
|
|
1659
|
+
// The body is typed `FormData`, so UploadForm is never named in the output; importing it
|
|
1660
|
+
// leaves an unused local that fails `noUnusedLocals` in the generated package.
|
|
1661
|
+
expect(out).toContain('async upload(body: FormData)');
|
|
1662
|
+
expect(out).not.toContain('UploadForm');
|
|
1663
|
+
// Negative control: the response model is genuinely referenced and must still be imported.
|
|
1664
|
+
expect(out).toContain('Upload');
|
|
1665
|
+
});
|
|
1666
|
+
|
|
1667
|
+
it('still imports a model that is a multipart body and also a response body', () => {
|
|
1668
|
+
const root = opRoot([
|
|
1669
|
+
opRoute('/uploads', [
|
|
1670
|
+
opOperation('post', {
|
|
1671
|
+
sdk: 'upload',
|
|
1672
|
+
request: opRequest('UploadForm', 'multipart/form-data'),
|
|
1673
|
+
responses: [opResponse(201, 'UploadForm', 'application/json')],
|
|
1674
|
+
}),
|
|
1675
|
+
]),
|
|
1676
|
+
]);
|
|
1677
|
+
const out = generateSdk(root, {
|
|
1678
|
+
outPath: '/sdk/clients/uploads.client.ts',
|
|
1679
|
+
modelOutPaths: new Map([['UploadForm', '/sdk/types/uploads.types.ts']]),
|
|
1680
|
+
});
|
|
1681
|
+
expect(out).toContain("import type { UploadForm } from '../types/uploads.types.js';");
|
|
1682
|
+
});
|
|
1487
1683
|
});
|
|
1488
1684
|
|
|
1489
1685
|
// ─── generateMethod fetch assembly ────────────────────────────────────────
|
|
@@ -1973,3 +2169,62 @@ describe('generateSdkTsconfig', () => {
|
|
|
1973
2169
|
expect(raw.endsWith('}\n')).toBe(true);
|
|
1974
2170
|
});
|
|
1975
2171
|
});
|
|
2172
|
+
|
|
2173
|
+
// ─── bigint reviver gating ────────────────────────────────────────────────
|
|
2174
|
+
|
|
2175
|
+
describe('generateSdk — bigint reviver gating', () => {
|
|
2176
|
+
const withBody = (bodyType: string) =>
|
|
2177
|
+
opRoot([opRoute('/things', [opOperation('get', { sdk: 'getThing', responses: [opResponse(200, bodyType, 'application/json')] })])]);
|
|
2178
|
+
|
|
2179
|
+
const opts = { outPath: '/sdk/src/things.client.ts', sdkOptionsPath: '/sdk/sdk-options.ts' };
|
|
2180
|
+
|
|
2181
|
+
it('imports the plain parseJson when no response carries a bigint', () => {
|
|
2182
|
+
// `bigIntReviver` matches /^-?\d+n$/ against every string in the document, so a contract
|
|
2183
|
+
// with no bigint anywhere still had a legitimate "123n" silently turned into a BigInt.
|
|
2184
|
+
const out = generateSdk(withBody('Thing'), opts);
|
|
2185
|
+
expect(out).toContain("import { parseJson } from '../sdk-options.js';");
|
|
2186
|
+
expect(out).not.toContain('parseJsonWithBigInt');
|
|
2187
|
+
});
|
|
2188
|
+
|
|
2189
|
+
it('imports the bigint-aware variant under the same name when one does', () => {
|
|
2190
|
+
const root = opRoot([
|
|
2191
|
+
opRoute('/things', [
|
|
2192
|
+
opOperation('get', {
|
|
2193
|
+
sdk: 'getThing',
|
|
2194
|
+
responses: [opResponse(200, inlineObjectType([field('seq', scalarType('bigint'))]), 'application/json')],
|
|
2195
|
+
}),
|
|
2196
|
+
]),
|
|
2197
|
+
]);
|
|
2198
|
+
const out = generateSdk(root, opts);
|
|
2199
|
+
// Aliased, so the method bodies are identical either way and only the import differs.
|
|
2200
|
+
expect(out).toContain("import { parseJsonWithBigInt as parseJson } from '../sdk-options.js';");
|
|
2201
|
+
expect(out).toContain('await parseJson<');
|
|
2202
|
+
});
|
|
2203
|
+
|
|
2204
|
+
it('emits both variants in the shared runtime, since clients pick per contract', () => {
|
|
2205
|
+
const runtime = generateSdkOptions();
|
|
2206
|
+
expect(runtime).toContain('export async function parseJson<T>(res: Response): Promise<T> {');
|
|
2207
|
+
expect(runtime).toContain('export async function parseJsonWithBigInt<T>(res: Response): Promise<T> {');
|
|
2208
|
+
expect(runtime).toContain('return JSON.parse(await res.text()) as T;');
|
|
2209
|
+
expect(runtime).toContain('return JSON.parse(await res.text(), bigIntReviver) as T;');
|
|
2210
|
+
});
|
|
2211
|
+
});
|
|
2212
|
+
|
|
2213
|
+
// ─── Hyphenated path parameters ───────────────────────────────────────────
|
|
2214
|
+
|
|
2215
|
+
describe('generateSdk — path parameter names that are not identifiers', () => {
|
|
2216
|
+
it('binds a valid identifier and interpolates it', () => {
|
|
2217
|
+
const root = opRoot([
|
|
2218
|
+
opRoute(
|
|
2219
|
+
'/invoices/{invoice-id}',
|
|
2220
|
+
[opOperation('get', { sdk: 'getInvoice', responses: [opResponse(200, 'Invoice', 'application/json')] })],
|
|
2221
|
+
[opParam('invoice-id', scalarType('uuid'))],
|
|
2222
|
+
),
|
|
2223
|
+
]);
|
|
2224
|
+
const out = generateSdk(root);
|
|
2225
|
+
// `async getInvoice(invoice-id: string)` did not parse, and the URL kept the literal braces.
|
|
2226
|
+
expect(out).toContain('async getInvoice(invoiceId: string)');
|
|
2227
|
+
expect(out).toContain('${encodeURIComponent(invoiceId)}');
|
|
2228
|
+
expect(out).not.toContain('{invoice-id}');
|
|
2229
|
+
});
|
|
2230
|
+
});
|
|
@@ -5,8 +5,12 @@ import { opRoot, opRoute, opOperation, opParam, opRequest, opResponse, scalarTyp
|
|
|
5
5
|
|
|
6
6
|
// ─── Helpers ───────────────────────────────────────────────────────────────
|
|
7
7
|
|
|
8
|
-
function makeCtx(
|
|
8
|
+
function makeCtx(
|
|
9
|
+
rootDir = '/project',
|
|
10
|
+
options: Record<string, unknown> = {},
|
|
11
|
+
): PluginContext & { emitted: Map<string, string>; warnings: string[] } {
|
|
9
12
|
const emitted = new Map<string, string>();
|
|
13
|
+
const warnings: string[] = [];
|
|
10
14
|
return {
|
|
11
15
|
rootDir,
|
|
12
16
|
options,
|
|
@@ -15,7 +19,11 @@ function makeCtx(rootDir = '/project', options: Record<string, unknown> = {}): P
|
|
|
15
19
|
emitFile: (outPath: string, content: string) => {
|
|
16
20
|
emitted.set(outPath, content);
|
|
17
21
|
},
|
|
22
|
+
warn: (message: string) => {
|
|
23
|
+
warnings.push(message);
|
|
24
|
+
},
|
|
18
25
|
emitted,
|
|
26
|
+
warnings,
|
|
19
27
|
};
|
|
20
28
|
}
|
|
21
29
|
|
|
@@ -495,3 +503,37 @@ describe('createTypescriptPlugin (sdk) — scaffold', () => {
|
|
|
495
503
|
expect(pkg.dependencies).toBeUndefined();
|
|
496
504
|
});
|
|
497
505
|
});
|
|
506
|
+
|
|
507
|
+
// ─── Output path template variables ───────────────────────────────────────
|
|
508
|
+
|
|
509
|
+
describe('unresolved output path template variables', () => {
|
|
510
|
+
it('warns and names the key when a template variable has no value', async () => {
|
|
511
|
+
const plugin = createTypescriptPlugin({ server: { output: { routes: '{area}/{filename}.router.ts' } } }, '/project');
|
|
512
|
+
const ctx = makeCtx('/project');
|
|
513
|
+
// The .ck file declares no `options { keys { area } }`, so `{area}` has no value.
|
|
514
|
+
await plugin.generateTargets!(inputs(), ctx);
|
|
515
|
+
|
|
516
|
+
expect(ctx.warnings).toHaveLength(1);
|
|
517
|
+
expect(ctx.warnings[0]).toContain('{area}');
|
|
518
|
+
expect(ctx.warnings[0]).toContain('users.router.ts');
|
|
519
|
+
expect(ctx.warnings[0]).toContain('options { keys { area');
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
it('still emits the file, since visibility is the point and not refusal', async () => {
|
|
523
|
+
// Throwing would be worse than a literal directory: cli.ts catches and continues to the
|
|
524
|
+
// next plugin, so one bad template would cost this plugin its entire output.
|
|
525
|
+
const plugin = createTypescriptPlugin({ server: { output: { routes: '{area}/{filename}.router.ts' } } }, '/project');
|
|
526
|
+
const ctx = makeCtx('/project');
|
|
527
|
+
await plugin.generateTargets!(inputs(), ctx);
|
|
528
|
+
|
|
529
|
+
expect([...ctx.emitted.keys()].some(p => p.includes('{area}'))).toBe(true);
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
it('stays quiet when every variable resolves', async () => {
|
|
533
|
+
const plugin = createTypescriptPlugin({ server: { output: { routes: '{filename}.router.ts' } } }, '/project');
|
|
534
|
+
const ctx = makeCtx('/project');
|
|
535
|
+
await plugin.generateTargets!(inputs(), ctx);
|
|
536
|
+
|
|
537
|
+
expect(ctx.warnings).toEqual([]);
|
|
538
|
+
});
|
|
539
|
+
});
|
package/tests/helpers.ts
CHANGED
|
@@ -107,8 +107,13 @@ export function contractRoot(models: ModelNode[], file = 'test.ck'): ContractRoo
|
|
|
107
107
|
return { kind: 'contractRoot', meta: {}, models, file };
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
-
|
|
111
|
-
|
|
110
|
+
/**
|
|
111
|
+
* `optional` and `nullable` default to `false` rather than being omitted. Once codegen reads
|
|
112
|
+
* them, an omitted `undefined` is falsy and so silently means "required" — which would make a
|
|
113
|
+
* fixture that meant to say nothing accidentally assert something.
|
|
114
|
+
*/
|
|
115
|
+
export function opParam(name: string, type: ContractTypeNode, overrides?: Partial<OpParamNode>): OpParamNode {
|
|
116
|
+
return { name, type, optional: false, nullable: false, loc: loc(1, 'test.op'), ...overrides };
|
|
112
117
|
}
|
|
113
118
|
|
|
114
119
|
export function paramNodes(nodes: OpParamNode[]): ParamSource {
|
package/tests/pipeline.test.ts
CHANGED
|
@@ -5,6 +5,10 @@ import { generateSdk } from '../src/codegen-sdk.js';
|
|
|
5
5
|
import { generateMcpFile } from '../src/codegen-mcp.js';
|
|
6
6
|
import { SIMPLE_USER_CONTRACT, VISIBILITY_CONTRACT, INHERITANCE_CONTRACT, SIMPLE_USERS_OP, PARAMETERIZED_OP } from './helpers.js';
|
|
7
7
|
|
|
8
|
+
/** The narrowed numeric coercion `renderScalar` emits — see NUMERIC_PREPROCESS in codegen-contract. */
|
|
9
|
+
const NUM = `z.preprocess((v) => (typeof v === 'string' && v.trim() !== '' ? Number(v) : v), z.number())`;
|
|
10
|
+
const NUM_INT = `z.preprocess((v) => (typeof v === 'string' && v.trim() !== '' ? Number(v) : v), z.number().int())`;
|
|
11
|
+
|
|
8
12
|
function compileContractSource(source: string) {
|
|
9
13
|
const diag = new DiagnosticCollector();
|
|
10
14
|
const ck = parseCk(source, 'test.ck', diag);
|
|
@@ -29,7 +33,7 @@ describe('Contract pipeline (source -> parse -> codegen)', () => {
|
|
|
29
33
|
expect(output).toContain('id: z.uuid()');
|
|
30
34
|
expect(output).toContain('name: z.string()');
|
|
31
35
|
expect(output).toContain('email: z.email()');
|
|
32
|
-
expect(output).toContain(
|
|
36
|
+
expect(output).toContain(`age: ${NUM}.optional()`);
|
|
33
37
|
expect(output).toContain(`active: z.preprocess((v) => v === 'true' ? true : v === 'false' ? false : v, z.boolean()).default(true)`);
|
|
34
38
|
});
|
|
35
39
|
|
|
@@ -60,7 +64,8 @@ contract Payslip: {
|
|
|
60
64
|
it('compiles a contract with visibility to three-schema pattern', () => {
|
|
61
65
|
const { output, diag } = compileContractSource(VISIBILITY_CONTRACT);
|
|
62
66
|
expect(diag.hasErrors()).toBe(false);
|
|
63
|
-
|
|
67
|
+
// No writeonly model extends User, so no UserBase is emitted — nothing would read it.
|
|
68
|
+
expect(output).not.toContain('const UserBase');
|
|
64
69
|
expect(output).toContain('export const User = z.strictObject({');
|
|
65
70
|
expect(output).toContain('export const UserInput = z.strictObject({');
|
|
66
71
|
|
|
@@ -95,11 +100,11 @@ contract Kitchen: {
|
|
|
95
100
|
const { output, diag } = compileContractSource(source);
|
|
96
101
|
expect(diag.hasErrors()).toBe(false);
|
|
97
102
|
expect(output).toContain('z.array(z.string())');
|
|
98
|
-
expect(output).toContain(
|
|
103
|
+
expect(output).toContain(`z.tuple([${NUM}, ${NUM}])`);
|
|
99
104
|
expect(output).toContain('z.record(z.string(), z.unknown())');
|
|
100
105
|
expect(output).toContain('z.enum(["open", "closed"])');
|
|
101
106
|
expect(output).toContain('z.literal("kitchen")');
|
|
102
|
-
expect(output).toContain(
|
|
107
|
+
expect(output).toContain(`z.union([z.string(), ${NUM}])`);
|
|
103
108
|
expect(output).toContain('Address');
|
|
104
109
|
expect(output).toContain('z.lazy(() => Kitchen)');
|
|
105
110
|
});
|
|
@@ -462,3 +467,45 @@ operation /users: {
|
|
|
462
467
|
expect(warnings).toHaveLength(0);
|
|
463
468
|
});
|
|
464
469
|
});
|
|
470
|
+
|
|
471
|
+
// ─── Numeric coercion, as it actually behaves at runtime ─────────────────
|
|
472
|
+
|
|
473
|
+
describe('numeric scalar coercion', () => {
|
|
474
|
+
/** Build the emitted schema for one field and run real Zod against it. */
|
|
475
|
+
async function schemaFor(fieldDecl: string) {
|
|
476
|
+
const { output } = compileContractSource(`contract M: {\n ${fieldDecl}\n}\n`);
|
|
477
|
+
const body = output.split('export const M = ')[1]!.split(');')[0]! + ')';
|
|
478
|
+
const { z } = await import('zod');
|
|
479
|
+
return new Function('z', `return ${body}`)(z) as { parse: (v: unknown) => unknown };
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
it('still coerces a string-shaped number, which query strings and headers depend on', async () => {
|
|
483
|
+
const M = await schemaFor('n: number');
|
|
484
|
+
expect(M.parse({ n: '42' })).toEqual({ n: 42 });
|
|
485
|
+
expect(M.parse({ n: 42 })).toEqual({ n: 42 });
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
it('rejects the values Number() silently turned into a number', async () => {
|
|
489
|
+
// `z.coerce.number()` is `Number(v)`: [] and '' become 0, null becomes 0, true becomes 1.
|
|
490
|
+
// Each of these validated cleanly and handed the handler a value the client never sent.
|
|
491
|
+
const M = await schemaFor('n: number');
|
|
492
|
+
for (const bad of [[], {}, null, true, '']) {
|
|
493
|
+
expect(() => M.parse({ n: bad }), `expected ${JSON.stringify(bad)} to be rejected`).toThrow();
|
|
494
|
+
}
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
it('keeps min and max chaining onto the outer schema', async () => {
|
|
498
|
+
const M = await schemaFor('n: int(min=1, max=5)');
|
|
499
|
+
expect(M.parse({ n: '3' })).toEqual({ n: 3 });
|
|
500
|
+
expect(() => M.parse({ n: 9 })).toThrow();
|
|
501
|
+
expect(() => M.parse({ n: 2.5 })).toThrow();
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
it('leaves boolean alone, which was already safe', async () => {
|
|
505
|
+
// Its preprocess maps only the two literal strings and hands everything else to
|
|
506
|
+
// z.boolean(), which rejects it — the shape the numeric scalars now share.
|
|
507
|
+
const M = await schemaFor('b: boolean');
|
|
508
|
+
expect(M.parse({ b: 'true' })).toEqual({ b: true });
|
|
509
|
+
expect(() => M.parse({ b: 1 })).toThrow();
|
|
510
|
+
});
|
|
511
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { sourceLink } from '../src/ts-render.js';
|
|
3
|
+
|
|
4
|
+
describe('sourceLink', () => {
|
|
5
|
+
it('emits a plain relative path, not a file:// URL', () => {
|
|
6
|
+
// `file://./x.ck` opens an authority component, so `.` parses as the host and the link
|
|
7
|
+
// resolves to nothing. The whole point of the helper is to not do that.
|
|
8
|
+
const link = sourceLink('User', '/out/schemas/user.schema.ts', '/out/contracts/user.ck', 5);
|
|
9
|
+
expect(link).toBe('[User](../contracts/user.ck#L5)');
|
|
10
|
+
expect(link).not.toContain('file://');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('relativises the source path against the emitted file, not the process cwd', () => {
|
|
14
|
+
expect(sourceLink('User', '/a/b/c/out.ts', '/a/user.ck')).toBe('[User](../../user.ck)');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('prefixes a bare sibling path with ./ so it reads as relative', () => {
|
|
18
|
+
expect(sourceLink('User', '/out/user.schema.ts', '/out/user.ck', 3)).toBe('[User](./user.ck#L3)');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('omits the line anchor when no line is given', () => {
|
|
22
|
+
expect(sourceLink('billing.ck', '/out/sdk.ts', '/out/billing.ck')).toBe('[billing.ck](./billing.ck)');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('falls back to the source path when there is no output path', () => {
|
|
26
|
+
// Codegen runs without a destination in the prettier plugin and in several tests.
|
|
27
|
+
expect(sourceLink('User', undefined, 'contracts/user.ck', 9)).toBe('[User](./contracts/user.ck#L9)');
|
|
28
|
+
});
|
|
29
|
+
});
|