@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.
@@ -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: 'decimal' } as any)).toThrow(/unmapped scalar 'decimal'/);
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
  }
@@ -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);