From 200f8238c695e4ef0bc5892ec525a78eb621df0e Mon Sep 17 00:00:00 2001 From: Igor Kamyshev Date: Tue, 3 Sep 2024 18:30:30 +0700 Subject: [PATCH 1/2] Zod --- apps/website/docs/.vitepress/config.mjs | 2 + apps/website/docs/index.md | 13 ++- apps/website/docs/protocols/contract.md | 2 +- apps/website/docs/zod/index.md | 41 ++++++++ packages/zod/CHANGELOG.md | 1 + packages/zod/README.md | 3 + packages/zod/package.json | 47 +++++++++ packages/zod/src/contract.test-d.ts | 61 ++++++++++++ packages/zod/src/contract.test.ts | 121 ++++++++++++++++++++++++ packages/zod/src/contract_protocol.ts | 14 +++ packages/zod/src/index.ts | 29 ++++++ packages/zod/tsconfig.json | 11 +++ packages/zod/vite.config.js | 19 ++++ pnpm-lock.yaml | 11 +++ tsconfig.base.json | 3 +- 15 files changed, 372 insertions(+), 6 deletions(-) create mode 100644 apps/website/docs/zod/index.md create mode 100644 packages/zod/CHANGELOG.md create mode 100644 packages/zod/README.md create mode 100644 packages/zod/package.json create mode 100644 packages/zod/src/contract.test-d.ts create mode 100644 packages/zod/src/contract.test.ts create mode 100644 packages/zod/src/contract_protocol.ts create mode 100644 packages/zod/src/index.ts create mode 100644 packages/zod/tsconfig.json create mode 100644 packages/zod/vite.config.js diff --git a/apps/website/docs/.vitepress/config.mjs b/apps/website/docs/.vitepress/config.mjs index b847f05a..1582b3fd 100644 --- a/apps/website/docs/.vitepress/config.mjs +++ b/apps/website/docs/.vitepress/config.mjs @@ -49,6 +49,7 @@ export default defineConfig({ { text: 'web-api', link: '/web-api/' }, { text: 'factories', link: '/factories/' }, { text: 'contracts', link: '/contracts/' }, + { text: 'zod', link: '/zod/' }, ], }, { text: 'Magazine', link: '/magazine/' }, @@ -140,6 +141,7 @@ export default defineConfig({ }, { text: 'APIs', link: '/contracts/api' }, ]), + ...createSidebar('zod', [{ text: 'Get Started', link: '/zod/' }]), '/magazine/': [ { text: 'Architecture', diff --git a/apps/website/docs/index.md b/apps/website/docs/index.md index b72c28b7..43866efc 100644 --- a/apps/website/docs/index.md +++ b/apps/website/docs/index.md @@ -34,15 +34,20 @@ features: details: Web API bindings — network status, tab visibility, and more link: /web-api/ linkText: Get Started + - icon: 👩‍🏭 + title: factories + details: Set of helpers to create factories in your application + link: /factories/ + linkText: Get Started - icon: 📄 title: contracts details: Extremely small library to validate data from external sources link: /contracts/ linkText: Get Started - - icon: 👩‍🏭 - title: factories - details: Set of helpers to create factories in your application - link: /factories/ + - icon: ♏️ + title: zod + details: Compatibility layer for Zod and Contract-protocol + link: /zod/ linkText: Get Started --- diff --git a/apps/website/docs/protocols/contract.md b/apps/website/docs/protocols/contract.md index 3a96610f..4118243f 100644 --- a/apps/website/docs/protocols/contract.md +++ b/apps/website/docs/protocols/contract.md @@ -13,7 +13,7 @@ A rule to statically validate received data. Any object following the strict API - [`@withease/contracts`](/contracts/) - [`@farfetched/runtypes`](https://farfetched.pages.dev/api/contracts/runtypes.html) -- [`@farfetched/zod`](https://farfetched.pages.dev/api/contracts/zod.html) +- [`@withease/zod`](/zod/) - [`@farfetched/io-ts`](https://farfetched.pages.dev/api/contracts/io-ts.html) - [`@farfetched/superstruct`](https://farfetched.pages.dev/api/contracts/superstruct.html) - [`@farfetched/typed-contracts`](https://farfetched.pages.dev/api/contracts/typed-contracts.html) diff --git a/apps/website/docs/zod/index.md b/apps/website/docs/zod/index.md new file mode 100644 index 00000000..c06da0d0 --- /dev/null +++ b/apps/website/docs/zod/index.md @@ -0,0 +1,41 @@ +# zod + +Compatibility layer for [Zod](https://zod.dev/) and [_Contract_](/protocols/contract). You need to install it and its peer dependencies before usage: + +::: code-group + +```sh [pnpm] +pnpm install zod @withease/zod +``` + +```sh [yarn] +yarn add zod @withease/zod +``` + +```sh [npm] +npm install zod @withease/zod +``` + +::: + +## `zodContract` + +Creates a [_Contract_](/protocols/contract) based on given `ZodType`. + +```ts +import { z } from 'zod'; +import { zodContract } from '@farfetched/zod'; + +const Asteroid = z.object({ + type: z.literal('asteroid'), + mass: z.number(), +}); + +const asteroidContract = zodContract(Asteroid); + +/* typeof asteroidContract === Contract< + * unknown, 👈 it accepts something unknown + * { type: 'asteriod', mass: number }, 👈 and validates if it is an asteroid + * > + */ +``` diff --git a/packages/zod/CHANGELOG.md b/packages/zod/CHANGELOG.md new file mode 100644 index 00000000..57e9a3b7 --- /dev/null +++ b/packages/zod/CHANGELOG.md @@ -0,0 +1 @@ +# @withease/zod diff --git a/packages/zod/README.md b/packages/zod/README.md new file mode 100644 index 00000000..b8b36636 --- /dev/null +++ b/packages/zod/README.md @@ -0,0 +1,3 @@ +# @withease/zod + +Read documentation [here](https://withease.effector.dev/zod/). diff --git a/packages/zod/package.json b/packages/zod/package.json new file mode 100644 index 00000000..f96fe9ed --- /dev/null +++ b/packages/zod/package.json @@ -0,0 +1,47 @@ +{ + "name": "@withease/zod", + "version": "0.0.1", + "license": "MIT", + "scripts": { + "test:run": "vitest run --typecheck", + "test:watch": "vitest --typecheck", + "build": "vite build", + "size": "size-limit", + "publint": "node ../../tools/publint.mjs", + "typelint": "attw --pack" + }, + "devDependencies": { + "zod": "^3.19" + }, + "peerDependencies": { + "zod": "^3.19" + }, + "type": "module", + "publishConfig": { + "access": "public" + }, + "files": [ + "dist" + ], + "main": "./dist/zod.cjs", + "module": "./dist/zod.js", + "types": "./dist/zod.d.ts", + "exports": { + ".": { + "import": { + "types": "./dist/zod.d.ts", + "default": "./dist/zod.js" + }, + "require": { + "types": "./dist/zod.d.cts", + "default": "./dist/zod.cjs" + } + } + }, + "size-limit": [ + { + "path": "./dist/zod.js", + "limit": "231 B" + } + ] +} diff --git a/packages/zod/src/contract.test-d.ts b/packages/zod/src/contract.test-d.ts new file mode 100644 index 00000000..a1eb08b8 --- /dev/null +++ b/packages/zod/src/contract.test-d.ts @@ -0,0 +1,61 @@ +import { describe, test, expectTypeOf } from 'vitest'; +import { z as zod } from 'zod'; + +import { zodContract } from './index'; + +describe('zodContract', () => { + test('string', () => { + const stringContract = zodContract(zod.string()); + + const smth: unknown = null; + + if (stringContract.isData(smth)) { + expectTypeOf(smth).toEqualTypeOf(); + expectTypeOf(smth).not.toEqualTypeOf(); + } + }); + + test('complex object', () => { + const complexContract = zodContract( + zod.tuple([ + zod.object({ + x: zod.number(), + y: zod.literal(false), + k: zod.set(zod.string()), + }), + zod.literal('literal'), + zod.literal(42), + ]) + ); + + const smth: unknown = null; + + if (complexContract.isData(smth)) { + expectTypeOf(smth).toEqualTypeOf< + [ + { + x: number; + y: false; + k: Set; + }, + 'literal', + 42 + ] + >(); + + expectTypeOf(smth).not.toEqualTypeOf(); + + expectTypeOf(smth).not.toEqualTypeOf< + [ + { + x: string; + y: false; + k: Set; + }, + 'literal', + 42 + ] + >(); + } + }); +}); diff --git a/packages/zod/src/contract.test.ts b/packages/zod/src/contract.test.ts new file mode 100644 index 00000000..b9a08b56 --- /dev/null +++ b/packages/zod/src/contract.test.ts @@ -0,0 +1,121 @@ +import { z as zod } from 'zod'; +import { describe, test, expect } from 'vitest'; + +import { zodContract } from './index'; + +describe('zod/zodContract short', () => { + test('interprets invalid response as error', () => { + const contract = zodContract(zod.string()); + + expect(contract.getErrorMessages(2)).toMatchInlineSnapshot(` + [ + "Expected string, received number", + ] + `); + }); + + test('passes valid data', () => { + const contract = zodContract(zod.string()); + + expect(contract.getErrorMessages('foo')).toEqual([]); + }); + + test('isData passes for valid data', () => { + const contract = zodContract( + zod.object({ + x: zod.number(), + y: zod.string(), + }) + ); + + expect( + contract.isData({ + x: 42, + y: 'answer', + }) + ).toEqual(true); + }); + + test('isData does not pass for invalid data', () => { + const contract = zodContract( + zod.object({ + x: zod.number(), + y: zod.string(), + }) + ); + + expect( + contract.isData({ + 42: 'x', + answer: 'y', + }) + ).toEqual(false); + }); + + test('interprets complex invalid response as error', () => { + const contract = zodContract( + zod.tuple([ + zod.object({ + x: zod.number(), + y: zod.literal(true), + k: zod + .set(zod.string()) + .nonempty('Invalid set, expected set of strings'), + }), + zod.literal('Uhm?'), + zod.literal(42), + ]) + ); + + expect( + contract.getErrorMessages([ + { + x: 456, + y: false, + k: new Set(), + }, + 'Answer is:', + '42', + ]) + ).toMatchInlineSnapshot(` + [ + "Invalid literal value, expected true, path: 0.y", + "Invalid set, expected set of strings, path: 0.k", + "Invalid literal value, expected "Uhm?", path: 1", + "Invalid literal value, expected 42, path: 2", + ] + `); + }); + + test('path from original zod error included in final message', () => { + const contract = zodContract( + zod.object({ + x: zod.number(), + y: zod.object({ + z: zod.string(), + k: zod.object({ + j: zod.boolean(), + }), + }), + }) + ); + + expect( + contract.getErrorMessages({ + x: '42', + y: { + z: 123, + k: { + j: new Map(), + }, + }, + }) + ).toMatchInlineSnapshot(` + [ + "Expected number, received string, path: x", + "Expected string, received number, path: y.z", + "Expected boolean, received map, path: y.k.j", + ] + `); + }); +}); diff --git a/packages/zod/src/contract_protocol.ts b/packages/zod/src/contract_protocol.ts new file mode 100644 index 00000000..55557ba1 --- /dev/null +++ b/packages/zod/src/contract_protocol.ts @@ -0,0 +1,14 @@ +/** + * A _Contract_ is a type that allows to check if a value is conform to a given structure. + */ +export type Contract = { + /** + * Checks if Raw is Data + */ + isData: (prepared: Raw) => prepared is Data; + /** + * - empty array is dedicated for valid response + * - array of string with validation errors for invalidDataError + */ + getErrorMessages: (prepared: Raw) => string[]; +}; diff --git a/packages/zod/src/index.ts b/packages/zod/src/index.ts new file mode 100644 index 00000000..319c5203 --- /dev/null +++ b/packages/zod/src/index.ts @@ -0,0 +1,29 @@ +import { type ZodType } from 'zod'; +import { type Contract } from './contract_protocol'; + +/** + * Transforms Zod contracts for `data` to internal Contract. + * Any response which does not conform to `data` will be treated as error. + * + * @param {ZodType} data Zod Contract for valid data + */ +export function zodContract(data: ZodType): Contract { + function isData(prepared: unknown): prepared is D { + return data.safeParse(prepared).success; + } + + return { + isData, + getErrorMessages(raw) { + const validation = data.safeParse(raw); + if (validation.success) { + return []; + } + + return validation.error.errors.map((e) => { + const path = e.path.join('.'); + return path !== '' ? `${e.message}, path: ${path}` : e.message; + }); + }, + }; +} diff --git a/packages/zod/tsconfig.json b/packages/zod/tsconfig.json new file mode 100644 index 00000000..65b1ff2c --- /dev/null +++ b/packages/zod/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "declaration": true, + "types": ["node"], + "outDir": "dist", + "rootDir": "src", + "baseUrl": "src" + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/zod/vite.config.js b/packages/zod/vite.config.js new file mode 100644 index 00000000..5821cfa5 --- /dev/null +++ b/packages/zod/vite.config.js @@ -0,0 +1,19 @@ +import tsconfigPaths from 'vite-tsconfig-paths'; +import dts from '../../tools/vite/types'; + +export default { + test: { + typecheck: { + ignoreSourceErrors: true, + }, + }, + plugins: [tsconfigPaths(), dts()], + build: { + lib: { + entry: 'src/index.ts', + name: '@withease/zod', + fileName: 'zod', + formats: ['es', 'cjs'], + }, + }, +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 38535486..6cb8e6d3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -114,6 +114,12 @@ importers: packages/web-api: specifiers: {} + packages/zod: + specifiers: + zod: ^3.19 + devDependencies: + zod: 3.23.8 + packages: /@algolia/autocomplete-core/1.9.3_andexh5lxsyy34kfg3pekcyz5e: @@ -976,6 +982,7 @@ packages: /@kurkle/color/0.3.2: resolution: {integrity: sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw==} + dev: false /@lezer/common/1.0.4: resolution: {integrity: sha512-lZHlk8p67x4aIDtJl6UQrXSOP6oi7dQR3W/geFVrENdA1JDaAJWldnVqVjPMJupbTKbzDfFcePfKttqVidS/dg==} @@ -4696,3 +4703,7 @@ packages: optionalDependencies: commander: 9.5.0 dev: true + + /zod/3.23.8: + resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==} + dev: true diff --git a/tsconfig.base.json b/tsconfig.base.json index aefd3c0d..7b507ca6 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -17,7 +17,8 @@ "@withease/i18next": ["packages/i18next/src/index.ts"], "@withease/redux": ["packages/redux/src/index.ts"], "@withease/web-api": ["packages/web-api/src/index.ts"], - "@withease/contracts": ["packages/contracts/src/index.ts"] + "@withease/contracts": ["packages/contracts/src/index.ts"], + "@withease/zod": ["packages/zod/src/index.ts"] } }, "exclude": ["node_modules"] From 550a2b0e7e620ff159b1e23c82d3dda6f712abde Mon Sep 17 00:00:00 2001 From: Igor Kamyshev Date: Tue, 3 Sep 2024 18:30:54 +0700 Subject: [PATCH 2/2] Initial release of Zod integration --- .changeset/early-turkeys-cheer.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/early-turkeys-cheer.md diff --git a/.changeset/early-turkeys-cheer.md b/.changeset/early-turkeys-cheer.md new file mode 100644 index 00000000..477532d2 --- /dev/null +++ b/.changeset/early-turkeys-cheer.md @@ -0,0 +1,5 @@ +--- +'@withease/zod': major +--- + +Initial release of Zod integration