@contractkit/plugin-typescript 0.31.2 → 0.33.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 +5 -5
- package/.turbo/turbo-test$colon$ci.log +23 -21
- package/CHANGELOG.md +166 -0
- package/README.md +8 -0
- package/dist/codegen-contract.d.ts +7 -0
- package/dist/codegen-contract.d.ts.map +1 -1
- package/dist/codegen-mcp.d.ts.map +1 -1
- package/dist/codegen-operation.d.ts +12 -0
- 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 +42 -0
- package/dist/codegen-revive.d.ts.map +1 -0
- package/dist/codegen-sdk.d.ts +18 -6
- package/dist/codegen-sdk.d.ts.map +1 -1
- package/dist/decimal-runtime.d.ts +47 -0
- package/dist/decimal-runtime.d.ts.map +1 -0
- package/dist/index.d.ts +11 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +610 -49
- package/dist/index.js.map +1 -1
- package/dist/ts-render.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/codegen-contract.ts +58 -3
- package/src/codegen-mcp.ts +5 -0
- package/src/codegen-operation.ts +132 -10
- package/src/codegen-plain-types.ts +42 -2
- package/src/codegen-revive.ts +304 -0
- package/src/codegen-sdk.ts +248 -37
- package/src/decimal-runtime.ts +50 -0
- package/src/index.ts +60 -3
- package/src/ts-render.ts +6 -0
- package/tests/codegen-contract.test.ts +124 -1
- package/tests/codegen-operation.test.ts +266 -2
- package/tests/codegen-sdk.test.ts +77 -6
- package/tests/codegen-server.test.ts +48 -0
- package/tests/helpers.ts +5 -0
- package/tests/pipeline.test.ts +35 -2
|
@@ -1294,7 +1294,7 @@ describe('renderTsType', () => {
|
|
|
1294
1294
|
});
|
|
1295
1295
|
|
|
1296
1296
|
it('throws on an unmapped scalar name', () => {
|
|
1297
|
-
expect(() => renderTsType({ kind: 'scalar', name: '
|
|
1297
|
+
expect(() => renderTsType({ kind: 'scalar', name: 'quaternion' } as any)).toThrow(/unmapped scalar 'quaternion'/);
|
|
1298
1298
|
});
|
|
1299
1299
|
});
|
|
1300
1300
|
|
|
@@ -1857,9 +1857,72 @@ describe('generateAreaClient — <Area>Client emission', () => {
|
|
|
1857
1857
|
});
|
|
1858
1858
|
});
|
|
1859
1859
|
|
|
1860
|
+
describe('decimal rehydration', () => {
|
|
1861
|
+
const withDecimal = (extra: Partial<Parameters<typeof generateSdk>[1]> = {}) => ({
|
|
1862
|
+
modelsWithDecimal: new Set(['Invoice']),
|
|
1863
|
+
modelOutPaths: new Map([['Invoice', '/out/types/models.ts']]),
|
|
1864
|
+
outPath: '/out/clients/inv.client.ts',
|
|
1865
|
+
sdkOptionsPath: '/out/sdk-options.ts',
|
|
1866
|
+
...extra,
|
|
1867
|
+
});
|
|
1868
|
+
|
|
1869
|
+
it('wraps a model-ref response body in its reviver', () => {
|
|
1870
|
+
const root = opRoot([opRoute('/invoices/{id}', [opOperation('get', { responses: [opResponse(200, 'Invoice')] })])], 'inv.op');
|
|
1871
|
+
const out = generateSdk(root, withDecimal());
|
|
1872
|
+
expect(out).toContain('return reviveInvoice(await parseJson<Invoice>(result));');
|
|
1873
|
+
// The reviver is a value, so it needs a second import beside the `import type`.
|
|
1874
|
+
expect(out).toContain(`import { reviveInvoice } from '../types/models.js';`);
|
|
1875
|
+
});
|
|
1876
|
+
|
|
1877
|
+
it('maps over an array body rather than emitting a wrapper', () => {
|
|
1878
|
+
const root = opRoot([opRoute('/invoices', [opOperation('get', { responses: [opResponse(200, 'array(Invoice)')] })])], 'inv.op');
|
|
1879
|
+
const out = generateSdk(root, withDecimal());
|
|
1880
|
+
// Safe because the reviver returns the object it mutated.
|
|
1881
|
+
expect(out).toContain('return (await parseJson<Invoice[]>(result)).map(reviveInvoice);');
|
|
1882
|
+
});
|
|
1883
|
+
|
|
1884
|
+
it('emits a local wrapper for a body with no reviveX of its own', () => {
|
|
1885
|
+
const bodyType = inlineObjectType([field('grand', scalarType('decimal', { scale: 2 }))]);
|
|
1886
|
+
const root = opRoot([opRoute('/totals', [opOperation('get', { sdk: 'getTotals', responses: [opResponse(200, bodyType)] })])], 'tot.op');
|
|
1887
|
+
const out = generateSdk(root, withDecimal({ modelsWithDecimal: new Set(['Invoice']) }));
|
|
1888
|
+
expect(out).toMatch(/function __reviveGetTotals200\(/);
|
|
1889
|
+
// The wrapper calls `__dec`, which is file-local to the types module, so the client file
|
|
1890
|
+
// needs its own copy plus the decimal.js import and global config.
|
|
1891
|
+
expect(out).toContain(`import { Decimal } from 'decimal.js';`);
|
|
1892
|
+
expect(out).toContain('Decimal.set({ toExpNeg: -9e15, toExpPos: 9e15 });');
|
|
1893
|
+
expect(out).toContain('const __dec = (v: unknown, path: string): Decimal =>');
|
|
1894
|
+
});
|
|
1895
|
+
|
|
1896
|
+
it('picks the Output reviver when the model has an Output variant', () => {
|
|
1897
|
+
const root = opRoot([opRoute('/invoices/{id}', [opOperation('get', { responses: [opResponse(200, 'Invoice')] })])], 'inv.op');
|
|
1898
|
+
const out = generateSdk(root, withDecimal({ modelsWithOutput: new Set(['Invoice']) }));
|
|
1899
|
+
expect(out).toContain('reviveInvoiceOutput(');
|
|
1900
|
+
});
|
|
1901
|
+
|
|
1902
|
+
it('leaves a decimal-free SDK byte-identical', () => {
|
|
1903
|
+
const root = opRoot([opRoute('/users', [opOperation('get', { responses: [opResponse(200, 'User')] })])], 'users.op');
|
|
1904
|
+
const base = generateSdk(root, { modelOutPaths: new Map([['User', '/out/types/models.ts']]), outPath: '/out/clients/u.client.ts' });
|
|
1905
|
+
const withEmptySet = generateSdk(root, {
|
|
1906
|
+
modelOutPaths: new Map([['User', '/out/types/models.ts']]),
|
|
1907
|
+
outPath: '/out/clients/u.client.ts',
|
|
1908
|
+
modelsWithDecimal: new Set(),
|
|
1909
|
+
});
|
|
1910
|
+
expect(withEmptySet).toBe(base);
|
|
1911
|
+
expect(base).not.toContain('revive');
|
|
1912
|
+
expect(base).not.toContain('decimal.js');
|
|
1913
|
+
});
|
|
1914
|
+
|
|
1915
|
+
it('does not revive a body whose model carries no decimal', () => {
|
|
1916
|
+
const root = opRoot([opRoute('/users', [opOperation('get', { responses: [opResponse(200, 'User')] })])], 'users.op');
|
|
1917
|
+
const out = generateSdk(root, withDecimal({ modelOutPaths: new Map([['User', '/out/types/models.ts']]) }));
|
|
1918
|
+
expect(out).toContain('return await parseJson<User>(result);');
|
|
1919
|
+
expect(out).not.toContain('revive');
|
|
1920
|
+
});
|
|
1921
|
+
});
|
|
1922
|
+
|
|
1860
1923
|
describe('generateSdkPackageJson', () => {
|
|
1861
1924
|
it('emits a valid package.json with the given name and standard fields', () => {
|
|
1862
|
-
const pkg = JSON.parse(generateSdkPackageJson({ name: 'my-sdk', deps: { zod: false, luxon: false } }));
|
|
1925
|
+
const pkg = JSON.parse(generateSdkPackageJson({ name: 'my-sdk', deps: { zod: false, luxon: false, decimal: false } }));
|
|
1863
1926
|
expect(pkg.name).toBe('my-sdk');
|
|
1864
1927
|
expect(pkg.type).toBe('module');
|
|
1865
1928
|
expect(pkg.exports['.'].types).toBe('./dist/index.d.ts');
|
|
@@ -1868,25 +1931,33 @@ describe('generateSdkPackageJson', () => {
|
|
|
1868
1931
|
});
|
|
1869
1932
|
|
|
1870
1933
|
it('omits the dependencies block entirely when neither zod nor luxon is used', () => {
|
|
1871
|
-
const pkg = JSON.parse(generateSdkPackageJson({ name: 'sdk', deps: { zod: false, luxon: false } }));
|
|
1934
|
+
const pkg = JSON.parse(generateSdkPackageJson({ name: 'sdk', deps: { zod: false, luxon: false, decimal: false } }));
|
|
1872
1935
|
expect(pkg.dependencies).toBeUndefined();
|
|
1873
1936
|
expect(pkg.devDependencies['@types/luxon']).toBeUndefined();
|
|
1874
1937
|
});
|
|
1875
1938
|
|
|
1876
1939
|
it('adds zod as a runtime dependency when zod output is enabled', () => {
|
|
1877
|
-
const pkg = JSON.parse(generateSdkPackageJson({ name: 'sdk', deps: { zod: true, luxon: false } }));
|
|
1940
|
+
const pkg = JSON.parse(generateSdkPackageJson({ name: 'sdk', deps: { zod: true, luxon: false, decimal: false } }));
|
|
1878
1941
|
expect(pkg.dependencies.zod).toBeDefined();
|
|
1879
1942
|
expect(pkg.dependencies.luxon).toBeUndefined();
|
|
1880
1943
|
});
|
|
1881
1944
|
|
|
1882
1945
|
it('adds luxon (runtime) and @types/luxon (dev) when a date/time scalar is used', () => {
|
|
1883
|
-
const pkg = JSON.parse(generateSdkPackageJson({ name: 'sdk', deps: { zod: false, luxon: true } }));
|
|
1946
|
+
const pkg = JSON.parse(generateSdkPackageJson({ name: 'sdk', deps: { zod: false, luxon: true, decimal: false } }));
|
|
1884
1947
|
expect(pkg.dependencies.luxon).toBeDefined();
|
|
1885
1948
|
expect(pkg.devDependencies['@types/luxon']).toBeDefined();
|
|
1886
1949
|
});
|
|
1887
1950
|
|
|
1951
|
+
it('adds decimal.js, with no @types half, when a decimal scalar is used', () => {
|
|
1952
|
+
const pkg = JSON.parse(generateSdkPackageJson({ name: 'sdk', deps: { zod: false, luxon: false, decimal: true } }));
|
|
1953
|
+
expect(pkg.dependencies['decimal.js']).toBeDefined();
|
|
1954
|
+
// decimal.js ships its own declarations, unlike luxon.
|
|
1955
|
+
expect(pkg.devDependencies['@types/decimal.js']).toBeUndefined();
|
|
1956
|
+
expect(pkg.dependencies.luxon).toBeUndefined();
|
|
1957
|
+
});
|
|
1958
|
+
|
|
1888
1959
|
it('ends with a trailing newline', () => {
|
|
1889
|
-
expect(generateSdkPackageJson({ name: 'sdk', deps: { zod: false, luxon: false } }).endsWith('}\n')).toBe(true);
|
|
1960
|
+
expect(generateSdkPackageJson({ name: 'sdk', deps: { zod: false, luxon: false, decimal: false } }).endsWith('}\n')).toBe(true);
|
|
1890
1961
|
});
|
|
1891
1962
|
});
|
|
1892
1963
|
|
|
@@ -216,6 +216,54 @@ describe('createTypescriptPlugin (server)', () => {
|
|
|
216
216
|
});
|
|
217
217
|
});
|
|
218
218
|
|
|
219
|
+
describe('validateResponses', () => {
|
|
220
|
+
const userRoot = () =>
|
|
221
|
+
opRoot(
|
|
222
|
+
[opRoute('/users', [opOperation('get', { responses: [opResponse(200, 'User', 'application/json')] })])],
|
|
223
|
+
'/project/contracts/users.ck',
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
it('throws when validateResponses is set without zod', async () => {
|
|
227
|
+
const plugin = createTypescriptPlugin({ server: { validateResponses: true } }, '/project');
|
|
228
|
+
await expect(plugin.generateTargets!(inputs(), makeCtx('/project'))).rejects.toThrow(/validateResponses requires server\.zod/);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('accepts validateResponses alongside zod', async () => {
|
|
232
|
+
const plugin = createTypescriptPlugin({ server: { zod: true, validateResponses: true } }, '/project');
|
|
233
|
+
await expect(plugin.generateTargets!(inputs(), makeCtx('/project'))).resolves.toBeUndefined();
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('threads validateResponses into the generated router', async () => {
|
|
237
|
+
const plugin = createTypescriptPlugin({ server: { zod: true, validateResponses: true } }, '/project');
|
|
238
|
+
const ctx = makeCtx('/project');
|
|
239
|
+
await plugin.generateTargets!(inputs([userRoot()]), ctx);
|
|
240
|
+
const router = [...ctx.emitted.entries()].find(([p]) => p.endsWith('.router.ts'))![1];
|
|
241
|
+
expect(router).toContain('ctx.body = await parseAndValidate(result, User, 500);');
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('leaves routers unvalidated without the flag', async () => {
|
|
245
|
+
const plugin = createTypescriptPlugin({ server: { zod: true } }, '/project');
|
|
246
|
+
const ctx = makeCtx('/project');
|
|
247
|
+
await plugin.generateTargets!(inputs([userRoot()]), ctx);
|
|
248
|
+
const router = [...ctx.emitted.entries()].find(([p]) => p.endsWith('.router.ts'))![1];
|
|
249
|
+
expect(router).toContain('ctx.body = result;');
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
// The set is derived from the contract roots, not handed in via `inputs`, so a
|
|
253
|
+
// `format(input=...)` model reaches the router codegen and suppresses its validation.
|
|
254
|
+
it('derives modelsWithTransform from the contract roots', async () => {
|
|
255
|
+
const plugin = createTypescriptPlugin({ server: { zod: true, validateResponses: true } }, '/project');
|
|
256
|
+
const ctx = makeCtx('/project');
|
|
257
|
+
const contracts = [
|
|
258
|
+
contractRoot([model('User', [field('id', scalarType('uuid'))], { inputCase: 'snake' })], '/project/contracts/users.ck'),
|
|
259
|
+
];
|
|
260
|
+
await plugin.generateTargets!({ ...inputs([userRoot()]), contractRoots: contracts } as never, ctx);
|
|
261
|
+
const router = [...ctx.emitted.entries()].find(([p]) => p.endsWith('.router.ts'))![1];
|
|
262
|
+
expect(router).toContain('ctx.body = result;');
|
|
263
|
+
expect(router).not.toContain('parseAndValidate(result');
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
|
|
219
267
|
describe('default plugin export', () => {
|
|
220
268
|
it('plugin has name "typescript"', async () => {
|
|
221
269
|
const { default: plugin } = await import('../src/index.js');
|
package/tests/helpers.ts
CHANGED
|
@@ -11,6 +11,7 @@ import type {
|
|
|
11
11
|
LiteralTypeNode,
|
|
12
12
|
UnionTypeNode,
|
|
13
13
|
DiscriminatedUnionTypeNode,
|
|
14
|
+
IntersectionTypeNode,
|
|
14
15
|
ModelRefTypeNode,
|
|
15
16
|
InlineObjectTypeNode,
|
|
16
17
|
LazyTypeNode,
|
|
@@ -64,6 +65,10 @@ export function discriminatedUnionType(discriminator: string, ...members: Contra
|
|
|
64
65
|
return { kind: 'discriminatedUnion', discriminator, members };
|
|
65
66
|
}
|
|
66
67
|
|
|
68
|
+
export function intersectionType(...members: ContractTypeNode[]): IntersectionTypeNode {
|
|
69
|
+
return { kind: 'intersection', members };
|
|
70
|
+
}
|
|
71
|
+
|
|
67
72
|
export function refType(name: string): ModelRefTypeNode {
|
|
68
73
|
return { kind: 'ref', name };
|
|
69
74
|
}
|
package/tests/pipeline.test.ts
CHANGED
|
@@ -13,11 +13,11 @@ function compileContractSource(source: string) {
|
|
|
13
13
|
return { root: contract, output, diag };
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
function compileOpSource(source: string, file = 'users.ck') {
|
|
16
|
+
function compileOpSource(source: string, file = 'users.ck', options?: Parameters<typeof generateOp>[1]) {
|
|
17
17
|
const diag = new DiagnosticCollector();
|
|
18
18
|
const ck = parseCk(source, file, diag);
|
|
19
19
|
const { op } = decomposeCk(ck);
|
|
20
|
-
const output = generateOp(op);
|
|
20
|
+
const output = generateOp(op, options);
|
|
21
21
|
return { root: op, output, diag };
|
|
22
22
|
}
|
|
23
23
|
|
|
@@ -33,6 +33,30 @@ describe('Contract pipeline (source -> parse -> codegen)', () => {
|
|
|
33
33
|
expect(output).toContain(`active: z.preprocess((v) => v === 'true' ? true : v === 'false' ? false : v, z.boolean()).default(true)`);
|
|
34
34
|
});
|
|
35
35
|
|
|
36
|
+
it('compiles a decimal contract end to end, keeping bounds as exact strings', () => {
|
|
37
|
+
// Exercises the real parse path rather than a hand-built AST: `buildScalarWithModifiers`
|
|
38
|
+
// has to keep `0.01` as a string instead of routing it through `Number()`.
|
|
39
|
+
const { output, root, diag } = compileContractSource(`
|
|
40
|
+
contract Payslip: {
|
|
41
|
+
gross: decimal(min=0.01, max=999999.99, scale=2)
|
|
42
|
+
rate: decimal
|
|
43
|
+
}
|
|
44
|
+
`);
|
|
45
|
+
expect(diag.hasErrors()).toBe(false);
|
|
46
|
+
|
|
47
|
+
const gross = root.models[0]!.fields[0]!.type as { min: unknown; max: unknown; scale: unknown };
|
|
48
|
+
expect(gross.min).toBe('0.01');
|
|
49
|
+
expect(gross.max).toBe('999999.99');
|
|
50
|
+
expect(gross.scale).toBe(2);
|
|
51
|
+
|
|
52
|
+
expect(output).toContain("import { Decimal } from 'decimal.js';");
|
|
53
|
+
expect(output).toContain('Decimal.set({ toExpNeg: -9e15, toExpPos: 9e15 });');
|
|
54
|
+
expect(output).toContain(
|
|
55
|
+
`gross: _ZodDecimal.refine((v) => v.decimalPlaces() <= 2 && v.gte('0.01') && v.lte('999999.99'), { message: 'Must be at most 2 decimal places, at least 0.01, at most 999999.99' })`,
|
|
56
|
+
);
|
|
57
|
+
expect(output).toContain('rate: _ZodDecimal');
|
|
58
|
+
});
|
|
59
|
+
|
|
36
60
|
it('compiles a contract with visibility to three-schema pattern', () => {
|
|
37
61
|
const { output, diag } = compileContractSource(VISIBILITY_CONTRACT);
|
|
38
62
|
expect(diag.hasErrors()).toBe(false);
|
|
@@ -103,6 +127,15 @@ describe('OP pipeline (source -> parse -> codegen)', () => {
|
|
|
103
127
|
expect(output).toContain('ctx.status = 201');
|
|
104
128
|
});
|
|
105
129
|
|
|
130
|
+
it('validates response bodies end to end when validateResponses is on', () => {
|
|
131
|
+
const { output, diag } = compileOpSource(SIMPLE_USERS_OP, 'users.ck', { validateResponses: true });
|
|
132
|
+
expect(diag.hasErrors()).toBe(false);
|
|
133
|
+
// GET returns array(User), POST returns User — both re-parsed against their own schema.
|
|
134
|
+
expect(output).toContain('ctx.body = await parseAndValidate(result, z.array(User), 500);');
|
|
135
|
+
expect(output).toContain('ctx.body = await parseAndValidate(result, User, 500);');
|
|
136
|
+
expect(output).toContain("import { parseAndValidate } from '@maroonedsoftware/zod';");
|
|
137
|
+
});
|
|
138
|
+
|
|
106
139
|
it('compiles an operation with params, request, and response', () => {
|
|
107
140
|
const { output, diag } = compileOpSource(PARAMETERIZED_OP);
|
|
108
141
|
expect(diag.hasErrors()).toBe(false);
|