From 8eb2922a76b2ba3d71a8fe5d24d06ad5487a294b Mon Sep 17 00:00:00 2001 From: Igor Kamyshev Date: Tue, 30 Jul 2024 16:02:53 +0700 Subject: [PATCH] Add contract helper --- .../contracts/cookbook/custom_matchers.md | 18 ++--- packages/contracts/package.json | 2 +- packages/contracts/src/contracts.test.ts | 79 +++++++++++++++++++ packages/contracts/src/index.ts | 63 +++++++++++++++ 4 files changed, 151 insertions(+), 11 deletions(-) diff --git a/apps/website/docs/contracts/cookbook/custom_matchers.md b/apps/website/docs/contracts/cookbook/custom_matchers.md index 93d81a9..2f59dce 100644 --- a/apps/website/docs/contracts/cookbook/custom_matchers.md +++ b/apps/website/docs/contracts/cookbook/custom_matchers.md @@ -5,24 +5,22 @@ Since `@withease/contracts` is built on top of [_Contract_](/protocols/contract) Let us write a custom matcher that checks if an age of a user is within a certain range: ```ts -import { type Contract } from '@withease/contracts'; +import { type Contract, contract, nul } from '@withease/contracts'; -function age(min, max): Contract { - return { - isData: (data) => data >= min && data <= max, - getErrorMessages: (data) => - `Expected a number between ${min} and ${max}, but got ${data}`, - }; -} +const age = contract(num, ({ min, max }: { min: number; max: number }) => ({ + isData: (data) => data >= min && data <= max, + getErrorMessages: (data) => + `Expected a number between ${min} and ${max}, but got ${data}`, +})); ``` Now you can use this matcher in your schema: ```ts -import { obj, str, and, num } from '@withease/contracts'; +import { obj, str } from '@withease/contracts'; const User = obj({ name: str, - age: and(num, age(18, 100)), + age: age(18, 100), }); ``` diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 1b788f8..254333e 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -32,7 +32,7 @@ "size-limit": [ { "path": "./dist/contracts.js", - "limit": "797 B" + "limit": "885 B" } ] } diff --git a/packages/contracts/src/contracts.test.ts b/packages/contracts/src/contracts.test.ts index 3ea1899..4560cc6 100644 --- a/packages/contracts/src/contracts.test.ts +++ b/packages/contracts/src/contracts.test.ts @@ -10,6 +10,7 @@ import { arr, and, tuple, + contract, Contract, } from './index'; @@ -547,3 +548,81 @@ describe('tuple', () => { `); }); }); + +describe('contract, no base', () => { + const onlySome = contract((x: number) => ({ + isData: (t): t is number => t === x, + getErrorMessages: (t) => ['expected ' + x + ', got ' + t], + })); + + const onlyOne = onlySome(1); + const onlyTwo = onlySome(2); + + it('valid', () => { + expect(onlyOne.isData(1)).toBeTruthy(); + expect(onlyOne.getErrorMessages(1)).toEqual([]); + + expect(onlyTwo.isData(2)).toBeTruthy(); + expect(onlyTwo.getErrorMessages(2)).toEqual([]); + }); + + it('invalid', () => { + expect(onlyOne.isData(2)).toBeFalsy(); + expect(onlyOne.getErrorMessages(2)).toMatchInlineSnapshot(` + [ + "expected 1, got 2", + ] + `); + + expect(onlyTwo.isData(1)).toBeFalsy(); + expect(onlyTwo.getErrorMessages(1)).toMatchInlineSnapshot(` + [ + "expected 2, got 1", + ] + `); + }); +}); + +describe('contract, with base', () => { + const age = contract(num, ({ min, max }: { min: number; max: number }) => ({ + isData: (t): t is number => t >= min && t <= max, + getErrorMessages: (t) => [ + 'expected ' + min + ' <= x <= ' + max + ', got ' + t, + ], + })); + + it('valid', () => { + const cntrct = age({ min: 18, max: 100 }); + + expect(cntrct.isData(18)).toBeTruthy(); + expect(cntrct.getErrorMessages(18)).toEqual([]); + + expect(cntrct.isData(100)).toBeTruthy(); + expect(cntrct.getErrorMessages(100)).toEqual([]); + }); + + it('invalid', () => { + const cntrct = age({ min: 18, max: 100 }); + + expect(cntrct.isData('KEK')).toBeFalsy(); + expect(cntrct.getErrorMessages('KEK')).toMatchInlineSnapshot(` + [ + "expected number, got string", + ] + `); + + expect(cntrct.isData(17)).toBeFalsy(); + expect(cntrct.getErrorMessages(17)).toMatchInlineSnapshot(` + [ + "expected 18 <= x <= 100, got 17", + ] + `); + + expect(cntrct.isData(101)).toBeFalsy(); + expect(cntrct.getErrorMessages(101)).toMatchInlineSnapshot(` + [ + "expected 18 <= x <= 100, got 101", + ] + `); + }); +}); diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 2bd5e47..7487f72 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -352,6 +352,69 @@ export function tuple(...contracts: Array>): any { }; } +/** + * Creates a _Contract_ based on a passed function. + * + * @overload "contract(fn)" + * + * @example + * + * const age = contract((min, max) => ({ + * isData: (data) => typeof data === 'number' && data >= min && data <= max, + * getErrorMessages: (data) => + * `Expected a number between ${min} and ${max}, but got ${data}`, + * })); + */ +export function contract( + fn: (config: P) => Contract +): (config: P) => Contract; + +/** + * Creates a _Contract_ based on a passed function and a base _Contract_. + * Base _Contract_ is checked first, then the _Contract_ created by the function. + * + * @overload "contract(base, fn)" + * + * @example + * + * const age = contract(num, (min, max) => ({ + * isData: (data) => data >= min && data <= max, + * getErrorMessages: (data) => + * `Expected a number between ${min} and ${max}, but got ${data}`, + * })); + */ +export function contract( + base: Contract, + fn: (config: P) => Contract +): (config: P) => Contract; + +export function contract( + base: Contract | ((config: P) => Contract), + fn?: (config: P) => Contract +): (config: P) => Contract { + const creator: any = fn ? fn : (base as (config: P) => Contract); + const realBase = fn ? (base as Contract) : null; + return (params: P) => { + const check = (data: unknown): data is O => { + if (realBase) { + return realBase.isData(data) && creator(params).isData(data); + } + return creator(params).isData(data); + }; + + return { + isData: check, + getErrorMessages: createGetErrorMessages(check, (data) => { + if (realBase && !realBase.isData(data)) { + return realBase.getErrorMessages(data); + } + + return creator(params).getErrorMessages(data); + }), + }; + }; +} + // -- utils function createSimpleContract(