diff --git a/.changeset/spicy-owls-bake.md b/.changeset/spicy-owls-bake.md new file mode 100644 index 00000000..66db1bbb --- /dev/null +++ b/.changeset/spicy-owls-bake.md @@ -0,0 +1,5 @@ +--- +'@withease/contracts': major +--- + +Initial release diff --git a/.prettierignore b/.prettierignore index d8f99622..768fc9b4 100644 --- a/.prettierignore +++ b/.prettierignore @@ -2,4 +2,5 @@ /dist /coverage -pnpm-lock.yaml \ No newline at end of file +pnpm-lock.yaml +api.md \ No newline at end of file diff --git a/apps/website/.gitignore b/apps/website/.gitignore index 81d4b609..57854de4 100644 --- a/apps/website/.gitignore +++ b/apps/website/.gitignore @@ -1,2 +1,3 @@ docs/.vitepress/cache -CHANGELOG.md \ No newline at end of file +CHANGELOG.md +api.md \ No newline at end of file diff --git a/apps/website/docs/.vitepress/config.mjs b/apps/website/docs/.vitepress/config.mjs index be997375..a9bff707 100644 --- a/apps/website/docs/.vitepress/config.mjs +++ b/apps/website/docs/.vitepress/config.mjs @@ -48,6 +48,7 @@ export default defineConfig({ { text: 'redux', link: '/redux/' }, { text: 'web-api', link: '/web-api/' }, { text: 'factories', link: '/factories/' }, + { text: 'contracts', link: '/contracts/' }, ], }, { text: 'Magazine', link: '/magazine/' }, @@ -114,6 +115,27 @@ export default defineConfig({ { text: 'Motivation', link: '/factories/motivation' }, { text: 'Important Caveats', link: '/factories/important_caveats' }, ]), + ...createSidebar('contracts', [ + { text: 'Get Started', link: '/contracts/' }, + { + text: 'Cookbook', + items: [ + { + text: 'Optional Fields', + link: '/contracts/cookbook/optional_fields', + }, + { + text: 'Custom Matchers', + link: '/contracts/cookbook/custom_matchers', + }, + { + text: 'Merge Objects', + link: '/contracts/cookbook/merge_objects', + }, + ], + }, + { text: 'APIs', link: '/contracts/api' }, + ]), '/magazine/': [ { text: 'Architecture', diff --git a/apps/website/docs/.vitepress/theme/LiveDemo.vue b/apps/website/docs/.vitepress/theme/LiveDemo.vue index 84863f3b..6d1adf18 100644 --- a/apps/website/docs/.vitepress/theme/LiveDemo.vue +++ b/apps/website/docs/.vitepress/theme/LiveDemo.vue @@ -3,6 +3,7 @@ import { Sandpack } from 'sandpack-vue3'; import repositoryPackageJson from '../../../../../package.json'; import webApiRaw from '../../../../../packages/web-api/dist/web-api.js?raw'; +import contractsRaw from '../../../../../packages/contracts/dist/contracts.js?raw'; const repositoryVersions = { ...repositoryPackageJson.dependencies, @@ -14,6 +15,7 @@ const props = defineProps(['demoFile']); const files = { '/src/App.vue': props.demoFile, ...localPackage({ name: 'web-api', content: webApiRaw }), + ...localPackage({ name: 'contracts', content: contractsRaw }), }; const customSetup = { diff --git a/apps/website/docs/contracts/array_numbers.live.vue b/apps/website/docs/contracts/array_numbers.live.vue new file mode 100644 index 00000000..a5094e16 --- /dev/null +++ b/apps/website/docs/contracts/array_numbers.live.vue @@ -0,0 +1,32 @@ + + + diff --git a/apps/website/docs/contracts/cookbook/custom_matchers.md b/apps/website/docs/contracts/cookbook/custom_matchers.md new file mode 100644 index 00000000..3b505b38 --- /dev/null +++ b/apps/website/docs/contracts/cookbook/custom_matchers.md @@ -0,0 +1,29 @@ +# Custom Matchers + +Since `@withease/contracts` is built on top of [_Contract_](/protocols/contract), you can embed your own matcher into the schema naturally. + +Let us write a custom matcher that checks if an age of a user is within a certain range: + +```ts +import { type Contract, and, num } from '@withease/contracts'; + +function age({ min, max }: { min: number; max: number }) { + return and(num, { + 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 } from '@withease/contracts'; + +const User = obj({ + name: str, + age: age(18, 100), +}); +``` diff --git a/apps/website/docs/contracts/cookbook/merge_objects.md b/apps/website/docs/contracts/cookbook/merge_objects.md new file mode 100644 index 00000000..3faa3461 --- /dev/null +++ b/apps/website/docs/contracts/cookbook/merge_objects.md @@ -0,0 +1,24 @@ +# Merge Objects + +Merge two [_Contracts_](/protocols/contract) representing objects into a single [_Contract_](/protocols/contract) representing an object with fields from both input objects is a common operation in many applications. + +With `@withease/contracts` in can be done with simple `and` call: + +```ts +import { num, str, obj, and, type UnContract } from '@withease/contracts'; + +const Price = obj({ + currency: str, + value: num, +}); + +const PriceWithDiscount = and( + Price, + obj({ + discount: num, + }) +); + +type TPriceWithDiscount = UnContract; +// 👆 { currency: string, value: number, discount: number } +``` diff --git a/apps/website/docs/contracts/cookbook/optional_fields.md b/apps/website/docs/contracts/cookbook/optional_fields.md new file mode 100644 index 00000000..afafd65a --- /dev/null +++ b/apps/website/docs/contracts/cookbook/optional_fields.md @@ -0,0 +1,29 @@ +# Optional Fields + +By default, all fields mentioned in the schema of `obj` are required. However, you can make a field optional explicitly. + +In case you expect a field to have `null` as a value, you can add it to the field definition as follows: + +```ts +import { obj, str, num, or, val } from '@withease/contracts'; + +const UserWithOptionalAge = obj({ + name: str, + age: or(num, val(null)), +}); +``` + +If you expect a field to be missing, you can pass `undefined` as a value: + +```ts +import { obj, str, num, or, val } from '@withease/contracts'; + +const UserWithPossibleNoAge = obj({ + name: str, + age: or(num, val(undefined)), +}); +``` + +::: tip Q: But `undefined` as a field value is not the same as a missing field, right? +A: Correct. However, in **most cases**, you can treat `undefined` as a missing field and vice versa. In case you _really_ need to differentiate between the two, you can fallback to more powerful tools like Zod or Runtypes, `@withease/contracts` aims to cover only the most common use cases. +::: diff --git a/apps/website/docs/contracts/index.md b/apps/website/docs/contracts/index.md new file mode 100644 index 00000000..f1024eb9 --- /dev/null +++ b/apps/website/docs/contracts/index.md @@ -0,0 +1,177 @@ + + +# contracts + +Extremely small library (less than **{{maxSize}}** controlled by CI) for creating [_Contracts_](/protocols/contract) that allows you to introduce data validation on edges of the application with no performance compromises. + +## Installation + +First, you need to install package: + +::: code-group + +```sh [pnpm] +pnpm install @withease/contracts +``` + +```sh [yarn] +yarn add @withease/contracts +``` + +```sh [npm] +npm install @withease/contracts +``` + +::: + +## Creating a _Contract_ + +`@withease/contracts` exports bunch of utilities that can be used to create a _Contract_, read the full API reference [here](/contracts/api). Any of the utilities returns a _Contract_ object, that accepts something `unknown` and checks if it is something concrete defined by the used utility. + + + +## Extracting types from a _Contract_ + +`@withease/contracts` provides a special type `UnContract` that can be used to extract a type from a _Contract_. + +```ts +import { type UnContract, obj, str, num } from '@withease/contracts'; + +const UserContract = obj({ + id: num, + name: str, + email: str, +}); + +// type User = { id: number, name: string, email: string } +type User = UnContract; +``` + +## Usage of a _Contract_ + +`@withease/contracts` is designed to be compatible with Effector's ecosystem without additional interop, so most of the time you can pass created [_Contract_](/protocols/contract) to other Effector's libraries as is. + +### Farfetched + +[Farfetched](https://ff.effector.dev) is the advanced data fetching tool for web applications based of Effector. It suggests to ensure that data received from the server is conforms desired [_Contract_](/protocols/contract). + +```ts +import { createJsonQuery } from '@farfetched/core'; +import { obj, str, arr, val, or } from '@withease/contracts'; + +const characterQuery = createJsonQuery({ + params: declareParams<{ id: number }>(), + request: { + method: 'GET', + url: ({ id }) => `https://rickandmortyapi.com/api/character/${id}`, + }, + response: { + // after receiving data from the server + // check if it is conforms the Contract to ensure + // API does not return something unexpected + contract: obj({ + id: str, + name: str, + status: Status, + species: str, + type: str, + gender: Gender, + origin: obj({ name: str, url: str }), + location: obj({ name: str, url: str }), + image: or(val('Female'), val('Male'), val('Genderless')), + episode: arr(str), + }), + }, +}); +``` + +### effector-storage + +[`effector-storage`](https://github.com/yumauri/effector-storage) is a small module for Effector to sync stores with different storages (local storage, session storage, async storage, IndexedDB, cookies, server side storage, etc). + +Since data is stored in an external storage it is important to validate it before using it in the application. + +```ts +import { createStore } from 'effector'; +import { persist } from 'effector-storage'; +import { num } from '@withease/contracts'; + +const $counter = createStore(0); + +persist({ + store: $counter, + key: 'counter', + // after reading value from a storage check if a value is number + // to avoid pushing invalid data to the Store + contract: num, +}); +``` + +## Integration with other libraries + +Since `@withease/contracts` is compatible [_Contract_](/protocols/contract) protocol it can be used with any library that supports it. + +For instance, you can define a part of a [_Contract_](/protocols/contract) with [Zod](https://zod.dev/) and combine it with `@withease/contracts`: + +```ts +import { z } from 'zod'; +import { arr, obj } from '@withease/contracts'; +import { zodContract } from '@farfetched/zod'; + +const User = z.object({ + name: z.string(), +}); + +const MyContract = arr( + obj({ + // 👇 easily integrate Zod via compatibility layer + users: zodContract(User), + }) +); +``` + +The full list of libraries that support _Contract_ protocol can be found [here](/protocols/contract). + +## Differences from other libraries + +
+It is extremely small and we mean it 👇 + +
+
+ + + +::: tip +Data fetched directly from https://esm.run/ and updates on every commit. +::: + +
+
+It is significantly smaller than other libraries for creating _Contracts_. +
+ +Of course smaller size is comes with some trade-offs, but we believe that in most cases it is worth it. `@withease/contracts` covers most of the common cases but does not try to be a silver bullet for all possible cases. It does not aim to have the following features from other libraries: + +- Branded types ([like in Runtypes](https://github.com/runtypes/runtypes?tab=readme-ov-file#branded-types)) +- Advanced string-validators ([like IP-validation in Zod](https://zod.dev/?id=ip-addresses)) +- Promise schemas ([like in Zod](https://zod.dev/?id=promise)) +- Error i18n ([like in Valibot](https://valibot.dev/guides/internationalization/)) +- ...and many other features that are not needed in _most_ of the cases + +::: tip Q: What if I started a project with `@withease/contracts` and then realized that I need some of the features that are not covered by it? +A: No worries! You can easily integrate `@withease/contracts` with other libraries that have the features you need. Check out the [Integration with other libraries](#integration-with-other-libraries) section for more details. +::: diff --git a/apps/website/docs/contracts/size_chart.vue b/apps/website/docs/contracts/size_chart.vue new file mode 100644 index 00000000..9f1bb6b6 --- /dev/null +++ b/apps/website/docs/contracts/size_chart.vue @@ -0,0 +1,61 @@ + + + diff --git a/apps/website/docs/contracts/sizes.data.ts b/apps/website/docs/contracts/sizes.data.ts new file mode 100644 index 00000000..c72630ec --- /dev/null +++ b/apps/website/docs/contracts/sizes.data.ts @@ -0,0 +1,44 @@ +export default { + async load() { + try { + const [ + zodSize, + runtypesSize, + ioTsSize, + fpTsSize, + superstructSize, + typedContractsSize, + valibotSize, + ] = await Promise.all([ + definePackageSize('zod', 'lib/index.js'), + definePackageSize('runtypes', 'lib/index.js'), + definePackageSize('io-ts', 'lib/index.js'), + definePackageSize('fp-ts', 'lib/index.js'), + definePackageSize('superstruct', 'dist/index.mjs'), + definePackageSize('typed-contracts', 'lib/bundle.js'), + definePackageSize('valibot', './dist/index.js'), + ]); + + return [ + { name: 'Zod', size: zodSize }, + { name: 'runtypes', size: runtypesSize }, + { name: 'io-ts + fp-ts', size: ioTsSize + fpTsSize }, + { name: 'superstruct', size: superstructSize }, + { name: 'typed-contracts', size: typedContractsSize }, + { name: 'valibot', size: valibotSize }, + ]; + } catch (error) { + return null; + } + }, +}; + +async function definePackageSize(packageName, moduleName) { + const response = await fetch( + `https://esm.run/${packageName}@latest/${moduleName}` + ); + + const encodedSize = Number(response.headers.get('content-length') ?? 0); + + return encodedSize; +} diff --git a/apps/website/docs/index.md b/apps/website/docs/index.md index af688b40..670c7de5 100644 --- a/apps/website/docs/index.md +++ b/apps/website/docs/index.md @@ -32,6 +32,11 @@ features: details: Web API bindings — network status, tab visibility, and more link: /web-api/ 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 diff --git a/apps/website/docs/protocols/contract.md b/apps/website/docs/protocols/contract.md index 1ea14101..3a96610f 100644 --- a/apps/website/docs/protocols/contract.md +++ b/apps/website/docs/protocols/contract.md @@ -11,6 +11,7 @@ A rule to statically validate received data. Any object following the strict API ::: tip Packages that provide integration for creating _Contract_ +- [`@withease/contracts`](/contracts/) - [`@farfetched/runtypes`](https://farfetched.pages.dev/api/contracts/runtypes.html) - [`@farfetched/zod`](https://farfetched.pages.dev/api/contracts/zod.html) - [`@farfetched/io-ts`](https://farfetched.pages.dev/api/contracts/io-ts.html) diff --git a/apps/website/package.json b/apps/website/package.json index 8116824f..9ece05ff 100644 --- a/apps/website/package.json +++ b/apps/website/package.json @@ -2,7 +2,7 @@ "name": "website", "private": true, "scripts": { - "prepare": "node changelog/cli.mjs", + "prepare": "node scripts/changelog.mjs && node scripts/jsdoc.mjs", "dev": "pnpm prepare && vitepress dev docs", "build": "pnpm prepare && vitepress build docs" }, diff --git a/apps/website/changelog/cli.mjs b/apps/website/scripts/changelog.mjs similarity index 100% rename from apps/website/changelog/cli.mjs rename to apps/website/scripts/changelog.mjs diff --git a/apps/website/scripts/jsdoc.mjs b/apps/website/scripts/jsdoc.mjs new file mode 100644 index 00000000..f6fdfb4c --- /dev/null +++ b/apps/website/scripts/jsdoc.mjs @@ -0,0 +1,144 @@ +import glob from 'glob'; +import { readFile, writeFile } from 'node:fs/promises'; +import { promisify } from 'node:util'; +import { resolve } from 'node:path'; +import * as babelParser from '@babel/parser'; +import { parse as parseComment } from 'comment-parser'; +import { asyncWalk } from 'estree-walker'; +import prettier from 'prettier'; +import { groupBy } from 'lodash-es'; + +const files = await promisify(glob)('../../packages/*/src/**/*.ts', { + absolute: true, +}); + +const apis = new Map(); + +await Promise.all( + files.map(async (file) => { + const packageName = file.match(/packages\/([^/]+)\//)[1]; + + if (!apis.has(packageName)) { + apis.set(packageName, []); + } + + const packageApis = apis.get(packageName); + + const content = await readFile(file, 'utf-8'); + + asyncWalk( + babelParser.parse(content, { + sourceType: 'module', + plugins: ['typescript', 'jsx', 'estree', 'decorators-legacy'], + }), + { + async enter(node) { + if (node.type !== 'ExportNamedDeclaration') { + return; + } + + let kind = ''; + let name = ''; + switch (node.declaration?.type) { + case 'TSTypeAliasDeclaration': + name = node.declaration.id.name; + kind = 'type'; + break; + case 'FunctionDeclaration': + name = node.declaration.id.name; + kind = 'function'; + break; + case 'TSDeclareFunction': + name = node.declaration.id.name; + kind = 'function'; + break; + case 'VariableDeclaration': + name = node.declaration.declarations[0].id.name; + kind = 'variable'; + break; + } + + const comments = + node.leadingComments?.filter( + (comment) => comment.type === 'CommentBlock' + ) ?? []; + + if (!name || !kind || comments?.length === 0) { + return; + } + + const parsedDocs = comments + .filter((comment) => comment.value.startsWith('*')) + .flatMap((comment) => + // comment-parser requires /* */ around the comment + parseComment('/*' + comment.value + '*/') + ); + + for (const doc of parsedDocs) { + const privateTag = doc.tags.find((tag) => tag.tag === 'private'); + + if (privateTag) { + continue; + } + + const exampleTags = doc.tags.filter((tag) => tag.tag === 'example'); + + let examples = await Promise.all( + exampleTags.map((tag) => + prettier.format(tag.description, { parser: 'babel' }) + ) + ); + + const overloadTag = doc.tags.find((tag) => tag.tag === 'overload'); + + packageApis.push({ + kind, + name, + description: doc.description, + examples, + alias: overloadTag?.name, + }); + } + }, + } + ); + }) +); + +for (const [packageName, packageApis] of apis) { + if (packageApis.length === 0) { + continue; + } + + const groupedApis = groupBy(packageApis, (api) => api.name); + + const filePath = resolve('docs', packageName, 'api.md'); + + const content = ['# APIs', 'Full list of available APIs.']; + + for (const [name, overloads] of Object.entries(groupedApis)) { + const tsOnly = overloads.every((api) => api.kind === 'type'); + content.push( + `## \`${name}\` ${tsOnly ? '' : ''}` + ); + + if (overloads.length === 1) { + const [onlyOverload] = overloads; + content.push(onlyOverload.description); + content.push( + ...onlyOverload.examples.map((example) => '```ts\n' + example + '\n```') + ); + } else { + content.push('Is has multiple overloads 👇'); + for (const overload of overloads) { + content.push(`### \`${overload.alias ?? overload.name}\``); + content.push(overload.description); + content.push( + ...overload.examples.map((example) => '```ts\n' + example + '\n```') + ); + } + } + } + + await writeFile(filePath, content.join('\n\n')); +} diff --git a/package.json b/package.json index 61a81a08..b00b5d00 100644 --- a/package.json +++ b/package.json @@ -9,14 +9,20 @@ }, "devDependencies": { "@arethetypeswrong/cli": "^0.15.3", + "@babel/parser": "^7.25.0", "@changesets/cli": "^2.24.1", "@farfetched/core": "^0.8.13", + "@farfetched/runtypes": "^0.12.4", + "@farfetched/superstruct": "^0.12.4", "@playwright/test": "^1.32.2", "@reduxjs/toolkit": "^2.0.1", "@size-limit/file": "^7.0.8", "@types/node": "^20.12.7", + "chart.js": "^4.4.3", + "comment-parser": "^1.4.1", "effector": "23.0.0", "effector-vue": "23.0.0", + "estree-walker": "^3.0.3", "glob": "^8.0.3", "i18next": "23.0.0", "markdown": "^0.5.0", @@ -27,6 +33,7 @@ "redux-saga": "^1.2.3", "runtypes": "^6.7.0", "size-limit": "^7.0.8", + "superstruct": "^2.0.2", "typescript": "5.1.6", "vite": "4.4.9", "vite-plugin-dts": "^3.8.3", @@ -34,10 +41,14 @@ "vitepress": "1.2.3", "vitepress-plugin-rss": "^0.2.8", "vitest": "~1.5.0", - "vue": "3.4.27" + "vue": "3.4.27", + "vue-chartjs": "^5.3.1" }, "dependencies": { "@algolia/client-search": "^4.14.3", + "bytes": "^3.1.2", + "lodash-es": "^4.17.21", + "pretty-bytes": "^6.1.1", "sandpack-vue3": "^3.1.7" } } diff --git a/packages/contracts/CHANGELOG.md b/packages/contracts/CHANGELOG.md new file mode 100644 index 00000000..724991ef --- /dev/null +++ b/packages/contracts/CHANGELOG.md @@ -0,0 +1 @@ +# @withease/contracts diff --git a/packages/contracts/README.md b/packages/contracts/README.md new file mode 100644 index 00000000..8bc8be06 --- /dev/null +++ b/packages/contracts/README.md @@ -0,0 +1,3 @@ +# @withease/contracts + +Read documentation [here](https://withease.effector.dev/contracts/). diff --git a/packages/contracts/package.json b/packages/contracts/package.json new file mode 100644 index 00000000..9498c6be --- /dev/null +++ b/packages/contracts/package.json @@ -0,0 +1,38 @@ +{ + "name": "@withease/contracts", + "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" + }, + "type": "module", + "files": [ + "dist" + ], + "main": "./dist/contracts.cjs", + "module": "./dist/contracts.js", + "types": "./dist/contracts.d.ts", + "exports": { + ".": { + "import": { + "types": "./dist/contracts.d.ts", + "default": "./dist/contracts.js" + }, + "require": { + "types": "./dist/contracts.d.cts", + "default": "./dist/contracts.cjs" + } + } + }, + "size-limit": [ + { + "path": "./dist/contracts.js", + "limit": "774 B" + } + ] +} diff --git a/packages/contracts/src/contract.test-d.ts b/packages/contracts/src/contract.test-d.ts new file mode 100644 index 00000000..46c06d83 --- /dev/null +++ b/packages/contracts/src/contract.test-d.ts @@ -0,0 +1,22 @@ +import { describe, test, expectTypeOf } from 'vitest'; + +import { and, Contract, num, obj, str } from './index'; + +describe('and', () => { + test('inline contract', () => { + const contract = and(num, { + isData: (data): data is number => data > 0, + getErrorMessages: () => ['data must be greater than 0'], + }); + + expectTypeOf(contract).toEqualTypeOf>(); + }); + + test('as extends', () => { + const contract = and(obj({ name: str }), obj({ age: num })); + + expectTypeOf(contract).toEqualTypeOf< + Contract + >(); + }); +}); diff --git a/packages/contracts/src/contracts.test.ts b/packages/contracts/src/contracts.test.ts new file mode 100644 index 00000000..63e5bde4 --- /dev/null +++ b/packages/contracts/src/contracts.test.ts @@ -0,0 +1,573 @@ +import { describe, it, test, expect } from 'vitest'; + +import { + bool, + num, + str, + obj, + or, + val, + arr, + and, + tuple, + type Contract, +} from './index'; + +describe('bool', () => { + it('valid', () => { + expect(bool.isData(true)).toBeTruthy(); + expect(bool.getErrorMessages(true)).toEqual([]); + + expect(bool.isData(false)).toBeTruthy(); + expect(bool.getErrorMessages(false)).toEqual([]); + }); + + it('invalid', () => { + expect(bool.isData(null)).toBeFalsy(); + expect(bool.getErrorMessages(null)).toMatchInlineSnapshot(` + [ + "expected boolean, got null", + ] + `); + + expect(bool.isData(undefined)).toBeFalsy(); + expect(bool.getErrorMessages(undefined)).toMatchInlineSnapshot( + ` + [ + "expected boolean, got undefined", + ] + ` + ); + + expect(bool.isData(0)).toBeFalsy(); + expect(bool.getErrorMessages(0)).toMatchInlineSnapshot(` + [ + "expected boolean, got number", + ] + `); + + expect(bool.isData(1)).toBeFalsy(); + expect(bool.getErrorMessages(1)).toMatchInlineSnapshot(` + [ + "expected boolean, got number", + ] + `); + + expect(bool.isData('')).toBeFalsy(); + expect(bool.getErrorMessages('')).toMatchInlineSnapshot(` + [ + "expected boolean, got string", + ] + `); + + expect(bool.isData('a')).toBeFalsy(); + expect(bool.getErrorMessages('a')).toMatchInlineSnapshot(` + [ + "expected boolean, got string", + ] + `); + }); +}); + +describe('num', () => { + it('valid', () => { + expect(num.isData(0)).toBeTruthy(); + expect(num.getErrorMessages(0)).toEqual([]); + + expect(num.isData(1)).toBeTruthy(); + expect(num.getErrorMessages(1)).toEqual([]); + + expect(num.isData(-1)).toBeTruthy(); + expect(num.getErrorMessages(-1)).toEqual([]); + }); + + it('invalid', () => { + expect(num.isData(null)).toBeFalsy(); + expect(num.getErrorMessages(null)).toMatchInlineSnapshot(` + [ + "expected number, got null", + ] + `); + + expect(num.isData(undefined)).toBeFalsy(); + expect(num.getErrorMessages(undefined)).toMatchInlineSnapshot( + ` + [ + "expected number, got undefined", + ] + ` + ); + + expect(num.isData('')).toBeFalsy(); + expect(num.getErrorMessages('')).toMatchInlineSnapshot(` + [ + "expected number, got string", + ] + `); + + expect(num.isData('a')).toBeFalsy(); + expect(num.getErrorMessages('a')).toMatchInlineSnapshot(` + [ + "expected number, got string", + ] + `); + + expect(num.isData(true)).toBeFalsy(); + expect(num.getErrorMessages(true)).toMatchInlineSnapshot(` + [ + "expected number, got boolean", + ] + `); + + expect(num.isData(false)).toBeFalsy(); + expect(num.getErrorMessages(false)).toMatchInlineSnapshot(` + [ + "expected number, got boolean", + ] + `); + }); +}); + +describe('str', () => { + it('valid', () => { + expect(str.isData('')).toBeTruthy(); + expect(str.getErrorMessages('')).toEqual([]); + + expect(str.isData('a')).toBeTruthy(); + expect(str.getErrorMessages('a')).toEqual([]); + + expect(str.isData('abc')).toBeTruthy(); + expect(str.getErrorMessages('abc')).toEqual([]); + }); + + it('invalid', () => { + expect(str.isData(null)).toBeFalsy(); + expect(str.getErrorMessages(null)).toMatchInlineSnapshot(` + [ + "expected string, got null", + ] + `); + + expect(str.isData(undefined)).toBeFalsy(); + expect(str.getErrorMessages(undefined)).toMatchInlineSnapshot( + ` + [ + "expected string, got undefined", + ] + ` + ); + + expect(str.isData(0)).toBeFalsy(); + expect(str.getErrorMessages(0)).toMatchInlineSnapshot(` + [ + "expected string, got number", + ] + `); + + expect(str.isData(1)).toBeFalsy(); + expect(str.getErrorMessages(1)).toMatchInlineSnapshot(` + [ + "expected string, got number", + ] + `); + + expect(str.isData(true)).toBeFalsy(); + expect(str.getErrorMessages(true)).toMatchInlineSnapshot(` + [ + "expected string, got boolean", + ] + `); + + expect(str.isData(false)).toBeFalsy(); + expect(str.getErrorMessages(false)).toMatchInlineSnapshot(` + [ + "expected string, got boolean", + ] + `); + }); + + describe('val', () => { + const cntrctA = val('a'); + const cntrct1 = val(1); + const cntrctTrue = val(true); + + it('valid', () => { + expect(cntrctA.isData('a')).toBeTruthy(); + expect(cntrctA.getErrorMessages('a')).toEqual([]); + + expect(cntrct1.isData(1)).toBeTruthy(); + expect(cntrct1.getErrorMessages(1)).toEqual([]); + + expect(cntrctTrue.isData(true)).toBeTruthy(); + expect(cntrctTrue.getErrorMessages(true)).toEqual([]); + }); + + it('invalid', () => { + expect(cntrctA.isData('b')).toBeFalsy(); + expect(cntrct1.getErrorMessages('b')).toMatchInlineSnapshot(` + [ + "expected 1, got "b"", + ] + `); + + expect(cntrct1.isData(2)).toBeFalsy(); + expect(cntrct1.getErrorMessages(2)).toMatchInlineSnapshot(` + [ + "expected 1, got 2", + ] + `); + + expect(cntrctTrue.isData(false)).toBeFalsy(); + expect(cntrctTrue.getErrorMessages(false)).toMatchInlineSnapshot(` + [ + "expected true, got false", + ] + `); + }); + }); + + describe('nullable', () => { + /* nullable is or(c, val(null)) because it is more explicit */ + const nullableBool = or(bool, val(null)); + + it('valid', () => { + expect(nullableBool.isData(true)).toBeTruthy(); + expect(nullableBool.getErrorMessages(true)).toEqual([]); + + expect(nullableBool.isData(null)).toBeTruthy(); + expect(nullableBool.getErrorMessages(null)).toEqual([]); + }); + + it('invalid', () => { + expect(nullableBool.isData(undefined)).toBeFalsy(); + expect(nullableBool.getErrorMessages(undefined)).toMatchInlineSnapshot( + ` + [ + "expected boolean, got undefined", + "expected null, got undefined", + ] + ` + ); + }); + }); + + describe('and', () => { + const len = (l: number): Contract => { + return { + isData: (x: string): x is string => x.length >= l, + getErrorMessages: (x) => + x.length >= l ? [] : [`expected length >= ${l}, got ${x.length}`], + }; + }; + + const cntrct = and(str, len(10)); + + it('valid all', () => { + const str10 = '1234567890'; + + expect(cntrct.isData(str10)).toBeTruthy(); + expect(cntrct.getErrorMessages(str10)).toEqual([]); + }); + + it('invalid first', () => { + const number = 1234567890; + + expect(cntrct.isData(number)).toBeFalsy(); + expect(cntrct.getErrorMessages(number)).toMatchInlineSnapshot(` + [ + "expected string, got number", + ] + `); + }); + + it('invalid second', () => { + const str9 = '123456789'; + + expect(cntrct.isData(str9)).toBeFalsy(); + expect(cntrct.getErrorMessages(str9)).toMatchInlineSnapshot(` + [ + "expected length >= 10, got 9", + ] + `); + }); + }); + + describe('or', () => { + const cntrct = or(bool, val(0)); + it('valid', () => { + expect(cntrct.isData(0)).toBeTruthy(); + expect(cntrct.getErrorMessages(0)).toEqual([]); + + expect(cntrct.isData(true)).toBeTruthy(); + expect(cntrct.getErrorMessages(true)).toEqual([]); + + expect(cntrct.isData(false)).toBeTruthy(); + expect(cntrct.getErrorMessages(false)).toEqual([]); + }); + + it('invalid', () => { + expect(cntrct.isData(1)).toBeFalsy(); + expect(cntrct.getErrorMessages(1)).toMatchInlineSnapshot(` + [ + "expected boolean, got number", + "expected 0, got 1", + ] + `); + + expect(cntrct.isData('')).toBeFalsy(); + expect(cntrct.getErrorMessages('')).toMatchInlineSnapshot(` + [ + "expected boolean, got string", + "expected 0, got """, + ] + `); + }); + }); + + describe('obj, overload with fields', () => { + it('empty object', () => { + const cntrct = obj({}); + + expect(cntrct.isData({})).toBeTruthy(); + expect(cntrct.getErrorMessages({})).toEqual([]); + + /* extra keys are allowed */ + expect(cntrct.isData({ a: 1 })).toBeTruthy(); + expect(cntrct.getErrorMessages({ a: 1 })).toEqual([]); + }); + + it('object with bool', () => { + const cntrct = obj({ enabled: bool }); + + expect(cntrct.isData({ enabled: true })).toBeTruthy(); + expect(cntrct.getErrorMessages({ enabled: true })).toEqual([]); + + expect(cntrct.isData({ enabled: false })).toBeTruthy(); + expect(cntrct.getErrorMessages({ enabled: false })).toEqual([]); + + expect(cntrct.isData({})).toBeFalsy(); + expect(cntrct.getErrorMessages({})).toMatchInlineSnapshot(` + [ + "enabled: expected boolean, got undefined", + ] + `); + + expect(cntrct.isData({ enabled: 'true' })).toBeFalsy(); + expect(cntrct.getErrorMessages({ enabled: 'true' })) + .toMatchInlineSnapshot(` + [ + "enabled: expected boolean, got string", + ] + `); + + expect(cntrct.isData(1)).toBeFalsy(); + expect(cntrct.getErrorMessages(1)).toMatchInlineSnapshot(` + [ + "expected object, got number", + ] + `); + }); + + it('optional field edge case', () => { + expect(obj({ name: or(str, val(undefined)) }).isData({})).toBeTruthy(); + }); + }); + + describe('obj, overload with types', () => { + it('empty object', () => { + const cntrct = obj(str, num); + + expect(cntrct.isData({})).toBeTruthy(); + expect(cntrct.getErrorMessages({})).toEqual([]); + }); + + it('invalid field type', () => { + const cntrct = obj(str, str); + + expect(cntrct.isData({ a: 'a' })).toBeTruthy(); + expect(cntrct.getErrorMessages({ a: 'a' })).toEqual([]); + + expect(cntrct.isData({ a: 1, b: 'b' })).toBeFalsy(); + expect(cntrct.getErrorMessages({ a: 1 })).toMatchInlineSnapshot(` + [ + "a: expected string, got number", + ] + `); + }); + }); + + describe('arr', () => { + it('valid', () => { + const cntrctNum = arr(num); + + expect(cntrctNum.isData([])).toBeTruthy(); + expect(cntrctNum.getErrorMessages([])).toEqual([]); + + expect(cntrctNum.isData([1])).toBeTruthy(); + expect(cntrctNum.getErrorMessages([1])).toEqual([]); + + const cntrctBool = arr(bool); + + expect(cntrctBool.isData([])).toBeTruthy(); + expect(cntrctBool.getErrorMessages([])).toEqual([]); + + expect(cntrctBool.isData([true, false])).toBeTruthy(); + expect(cntrctBool.getErrorMessages([true, false])).toEqual([]); + }); + + it('invalid', () => { + const cntrctNum = arr(num); + + expect(cntrctNum.isData([true])).toBeFalsy(); + expect(cntrctNum.getErrorMessages([true])).toMatchInlineSnapshot(` + [ + "0: expected number, got boolean", + ] + `); + + expect(cntrctNum.isData([1, 'a'])).toBeFalsy(); + expect(cntrctNum.getErrorMessages([1, 'a'])).toMatchInlineSnapshot(` + [ + "1: expected number, got string", + ] + `); + + expect(cntrctNum.isData(true)).toBeFalsy(); + expect(cntrctNum.getErrorMessages(true)).toMatchInlineSnapshot(` + [ + "expected array, got boolean", + ] + `); + + const cntrctBool = arr(bool); + + expect(cntrctBool.isData([1])).toBeFalsy(); + expect(cntrctBool.getErrorMessages([1])).toMatchInlineSnapshot(` + [ + "0: expected boolean, got number", + ] + `); + + expect(cntrctBool.isData([true, 1])).toBeFalsy(); + expect(cntrctBool.getErrorMessages([true, 1])).toMatchInlineSnapshot( + ` + [ + "1: expected boolean, got number", + ] + ` + ); + }); + }); +}); + +describe('complex nested', () => { + test('format errors for nested objects', () => { + const cntrct = obj({ + user: obj({ name: str }), + }); + + expect(cntrct.isData({ user: { name: 'a' } })).toBeTruthy(); + expect(cntrct.getErrorMessages({ user: { name: 'a' } })).toEqual([]); + + expect(cntrct.isData({ user: { name: 1 } })).toBeFalsy(); + expect(cntrct.getErrorMessages({ user: { name: 1 } })) + .toMatchInlineSnapshot(` + [ + "user: name: expected string, got number", + ] + `); + }); + + test('supports objects in arrays', () => { + const cntrct = arr(obj({ name: str })); + + expect(cntrct.isData([])).toBeTruthy(); + expect(cntrct.getErrorMessages([])).toEqual([]); + + expect(cntrct.isData([{ name: 'a' }])).toBeTruthy(); + expect(cntrct.getErrorMessages([{ name: 'a' }])).toEqual([]); + + expect(cntrct.isData([{ name: 1 }])).toBeFalsy(); + expect(cntrct.getErrorMessages([{ name: 1 }])).toMatchInlineSnapshot(` + [ + "0: name: expected string, got number", + ] + `); + }); +}); + +describe('tuple', () => { + it('one element', () => { + const cntrct = tuple(str); + + expect(cntrct.isData(['a'])).toBeTruthy(); + expect(cntrct.getErrorMessages(['a'])).toEqual([]); + + expect(cntrct.isData([1])).toBeFalsy(); + expect(cntrct.getErrorMessages([1])).toMatchInlineSnapshot(` + [ + "0: expected string, got number", + ] + `); + }); + + it('two elements', () => { + const cntrct = tuple(str, num); + + expect(cntrct.isData(['a', 1])).toBeTruthy(); + expect(cntrct.getErrorMessages(['a', 1])).toEqual([]); + + expect(cntrct.isData(['a', 'b'])).toBeFalsy(); + expect(cntrct.getErrorMessages(['a', 'b'])).toMatchInlineSnapshot(` + [ + "1: expected number, got string", + ] + `); + + expect(cntrct.isData([1, 'b'])).toBeFalsy(); + expect(cntrct.getErrorMessages([1, 'b'])).toMatchInlineSnapshot(` + [ + "0: expected string, got number", + "1: expected number, got string", + ] + `); + }); + + it('three elements', () => { + const cntrct = tuple(str, num, bool); + + expect(cntrct.isData(['a', 1, true])).toBeTruthy(); + expect(cntrct.getErrorMessages(['a', 1, true])).toEqual([]); + + expect(cntrct.isData(['a', 1, 'b'])).toBeFalsy(); + expect(cntrct.getErrorMessages(['a', 1, 'b'])).toMatchInlineSnapshot(` + [ + "2: expected boolean, got string", + ] + `); + }); +}); + +describe('special cases', () => { + it('or with two big objects', () => { + const cntrct = or(obj({ name: str }), obj({ age: num })); + + expect(cntrct.getErrorMessages({ lol: 'kek' })).toMatchInlineSnapshot(` + [ + "name: expected string, got undefined", + "age: expected number, got undefined", + ] + `); + }); + + it('and as extends', () => { + const contract = and(obj({ name: str }), obj({ age: num })); + + expect(contract.isData({ name: 'a', age: 1 })).toBeTruthy(); + + expect(contract.isData({ name: 'a' })).toBeFalsy(); + expect(contract.isData({ age: 1 })).toBeFalsy(); + expect(contract.isData({ name: 'a', age: 'ERROR' })).toBeFalsy(); + expect(contract.isData({ name: 18888, age: 1 })).toBeFalsy(); + }); +}); diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts new file mode 100644 index 00000000..e4ec1495 --- /dev/null +++ b/packages/contracts/src/index.ts @@ -0,0 +1,405 @@ +/** + * A type that allows to extract the result type of a _Contract_. + * + * @example + * const User = obj({ + * name: str, + * age: num, + * }); + * + * type User = UnContract; // { name: string, age: number } + */ +export type UnContract = T extends Contract ? U : never; + +/** + * A _Contract_ is a type that allows to check if a value is conform to a given structure. + * + * @example + * function age(min, max): Contract { + * return { + * isData: (data) => typeof data === 'number' && data >= min && data <= max, + * getErrorMessages: (data) => + * `Expected a number between ${min} and ${max}, but got ${data}`, + * }; + * } + */ +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[]; +}; + +/** + * _Contract_ that checks if a value is a boolean. + * + * @example + * bool.isData(true) === true; + * bool.isData(42) === false; + */ +export const bool: Contract = createSimpleContract('boolean'); + +/** + * _Contract_ that checks if a value is a number. + * + * @example + * num.isData(42) === true; + * num.isData('42') === false; + */ +export const num: Contract = createSimpleContract('number'); + +/** + * _Contract_ that checks if a value is a string. + * + * @example + * str.isData('hello') === true; + * str.isData(42) === false; + */ +export const str: Contract = createSimpleContract('string'); + +/** + * Function that creates a _Contract_ that checks if a value is equal to a given value. + * + * @example + * const only42 = val(42); + * only42.isData(42) === true; + * only42.isData(43) === false; + */ +export function val( + value: T +): Contract { + const check = (x: unknown): x is T => { + return x === value; + }; + + return { + isData: check, + getErrorMessages: createGetErrorMessages(check, (actual) => [ + `expected ${JSON.stringify(value)}, got ${JSON.stringify(actual)}`, + ]), + }; +} + +/** + * Function that creates a _Contract_ that checks if a value is conform to one of the given _Contracts_. + * + * @example + * const stringOrNumber = or(str, num); + * stringOrNumber.isData('hello') === true; + * stringOrNumber.isData(42) === true; + * stringOrNumber.isData(true) === false; + */ +export function or>>( + ...contracts: T +): Contract> { + const check = (x: unknown): x is UnContract => + contracts.some((c) => c.isData(x)); + + return { + isData: check, + getErrorMessages: createGetErrorMessages(check, (x) => { + return contracts.flatMap((c) => c.getErrorMessages(x)); + }), + }; +} + +type Merge = { + [K in keyof T | keyof U]: K extends keyof U + ? U[K] + : K extends keyof T + ? T[K] + : never; +}; + +/** + * Function that merges two _Contracts_ of objects into one. + * + * @overload "and(objectA, objectB)" + * + * @example + * + * const User = obj({ + * name: str, + * }); + * + * const Admin = and(User, obj({ + * permitted: bool, + * })); + */ +export function and< + T extends Record, + K extends Record +>( + first: Contract, + second: Contract +): Contract>; + +/** + * Function that creates a _Contract_ that checks if a value is conform to all of the given _Contracts_. + * + * @overload "and(first, ...rest)" + * + * @example + * 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 User = obj({ + * name: str, + * age: and(num, age(18, 100)), + * }); + */ +export function and( + first: Contract, + ...rest: Array> +): Contract; + +export function and( + first: Contract, + ...rest: Array> +): Contract { + const all = [first, ...rest]; + return { + isData: (x): x is T => all.every((c) => c.isData(x as any)), + getErrorMessages: (x) => { + for (const c of all) { + if (!c.isData(x as any)) { + return c.getErrorMessages(x as any); + } + } + + return []; + }, + }; +} + +/** + * Function that creates a _Contract_ that checks if a value is object and every property is conform to the given _Contract_. + * + * @overload "obj(str, contract)" + * + * @example + * const Ids = obj(str, num); + * + * Ids.isData({ id1: 1, id2: 2 }) === true; + * Ids.isData({ id1: 1, id2: '2' }) === false; + */ +export function obj( + keys: typeof str, + values: Contract +): Contract>; + +/** + * Function that creates a _Contract_ that checks if a value is conform to an object with the given _Contracts_ as properties. + * + * @overload "obj(shape)" + * + * @example + * const User = obj({ + * name: str, + * age: num, + * }); + * + * User.isData({ name: 'Alice', age: 42 }) === true; + * User.isData({ name: 'Alice' }) === false; + */ +export function obj>>( + c: C +): Contract }>; + +export function obj(shape: any, fieldContract?: any): any { + const check = (x: unknown) => { + if (typeof x !== 'object' || x === null) return false; + + let valid = true; + if (shape === str) { + for (const val of Object.values(x)) { + if (fieldContract.isData(val) === false) { + valid = false; + break; + } + } + } else { + for (const [key, val] of Object.entries(shape)) { + // @ts-expect-error + if (!val.isData((x as any)[key])) { + valid = false; + break; + } + } + } + + return valid; + }; + + return { + isData: check, + getErrorMessages: createGetErrorMessages(check, (x) => { + if (typeof x !== 'object' || x === null) { + return [`expected object, got ${typeOf(x)}`]; + } + const errors = [] as string[]; + + if (shape === str) { + for (const [key, val] of Object.entries(x)) { + if (fieldContract.isData(val) === false) { + errors.push(`${key}: ${fieldContract.getErrorMessages(val)}`); + } + } + } else { + for (const [key, val] of Object.entries(shape)) { + // @ts-expect-error + const newErrors = val.getErrorMessages((x as any)[key]); + errors.push(...newErrors.map((msg: string) => `${key}: ${msg}`)); + } + } + + return errors; + }), + }; +} + +/** + * Function that creates a _Contract_ that checks if a value is conform to an array of the given _Contracts_. + * + * @example + * const arrayOfStrings = arr(str); + * arrayOfStrings.isData(['hello', 'world']) === true; + * arrayOfStrings.isData(['hello', 42]) === false; + */ +export function arr(c: Contract): Contract { + const check = (x: unknown): x is V[] => + Array.isArray(x) && x.every((v) => c.isData(v)); + + return { + isData: check, + getErrorMessages: createGetErrorMessages(check, (x) => { + if (!Array.isArray(x)) { + return [`expected array, got ${typeOf(x)}`]; + } + + return x.flatMap((v, idx) => + c.getErrorMessages(v).map((message) => `${idx}: ${message}`) + ); + }), + }; +} + +export function tuple(a: Contract): Contract; +export function tuple( + a: Contract, + b: Contract +): Contract; +export function tuple( + a: Contract, + b: Contract, + c: Contract +): Contract; +export function tuple( + a: Contract, + b: Contract, + c: Contract, + d: Contract +): Contract; +export function tuple( + a: Contract, + b: Contract, + c: Contract, + d: Contract, + e: Contract +): Contract; +export function tuple( + a: Contract, + b: Contract, + c: Contract, + d: Contract, + e: Contract, + f: Contract +): Contract; +export function tuple( + a: Contract, + b: Contract, + c: Contract, + d: Contract, + e: Contract, + f: Contract, + g: Contract +): Contract; +export function tuple( + a: Contract, + b: Contract, + c: Contract, + d: Contract, + e: Contract, + f: Contract, + g: Contract, + h: Contract +): Contract; +/** + * Function that creates a _Contract_ that checks if a value is conform to a tuple of the given _Contracts_. + * + * @example + * const userAges = tuple(str, num); + * + * userAges.isData(['Alice', 42]) === true; + * userAges.isData(['Alice', 'what']) === false; + */ +export function tuple(...contracts: Array>): any { + const check = (x: unknown): x is any[] => + Array.isArray(x) && + x.length === contracts.length && + contracts.every((c, i) => c.isData(x[i])); + + return { + isData: check, + getErrorMessages: createGetErrorMessages(check, (x) => { + if (!Array.isArray(x)) { + return [`expected tuple, got ${typeOf(x)}`]; + } + + return x.flatMap((v, idx) => + contracts[idx] + .getErrorMessages(v) + .map((message) => `${idx}: ${message}`) + ); + }), + }; +} + +// -- utils + +function createSimpleContract(exepctedType: string): Contract { + const check = (x: unknown): x is T => typeof x === exepctedType; + return { + isData: check, + getErrorMessages: createGetErrorMessages(check, (actual) => [ + `expected ${exepctedType}, got ${typeOf(actual)}`, + ]), + }; +} + +function createGetErrorMessages( + check: (v: unknown) => boolean, + fn: (v: unknown) => string[] +): (v: unknown) => string[] { + return (v) => { + if (check(v)) { + return []; + } + + return fn(v); + }; +} + +function typeOf(x: unknown): string { + return x === null ? 'null' : typeof x; +} diff --git a/packages/contracts/src/interop.test.ts b/packages/contracts/src/interop.test.ts new file mode 100644 index 00000000..1b67dfdc --- /dev/null +++ b/packages/contracts/src/interop.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect } from 'vitest'; + +import { Array, String } from 'runtypes'; +import { object, string } from 'superstruct'; +import { runtypeContract } from '@farfetched/runtypes'; +import { superstructContract } from '@farfetched/superstruct'; + +import { obj, arr } from './index'; + +describe('runtypes', () => { + it('supports Runtype inside', () => { + const cntrct = obj({ name: runtypeContract(Array(String)) }); + + expect(cntrct.isData({ name: ['foo'] })).toBe(true); + expect(cntrct.getErrorMessages({ name: ['foo'] })).toEqual([]); + + expect(cntrct.isData({ name: [1] })).toBe(false); + expect(cntrct.getErrorMessages({ name: [1] })).toMatchInlineSnapshot(` + [ + "name: 0: Expected string, but was number", + ] + `); + }); +}); + +describe('superstruct', () => { + it('supports Superstruct inside', () => { + const cntrct = arr(superstructContract(object({ name: string() }))); + + expect(cntrct.isData([{ name: 'foo' }])).toBe(true); + expect(cntrct.getErrorMessages([{ name: 'foo' }])).toEqual([]); + + expect(cntrct.isData([{ name: 1 }])).toBe(false); + expect(cntrct.getErrorMessages([{ name: 1 }])).toMatchInlineSnapshot(` + [ + "0: name: Expected a string, but received: 1", + ] + `); + }); +}); diff --git a/packages/contracts/tsconfig.json b/packages/contracts/tsconfig.json new file mode 100644 index 00000000..65b1ff2c --- /dev/null +++ b/packages/contracts/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/contracts/vite.config.js b/packages/contracts/vite.config.js new file mode 100644 index 00000000..b5117dbb --- /dev/null +++ b/packages/contracts/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/contracts', + fileName: 'contracts', + formats: ['es', 'cjs'], + }, + }, +}; diff --git a/packages/factories/src/invoke.ts b/packages/factories/src/invoke.ts index ac63ed5b..9b5f2302 100644 --- a/packages/factories/src/invoke.ts +++ b/packages/factories/src/invoke.ts @@ -9,6 +9,7 @@ let factoryCalledCount = 0; /** * Have to be called inside factory created by createFactory + * @private */ export function markFactoryAsCalled() { factoryCalledCount += 1; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b742f4db..b5321645 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,25 +6,35 @@ importers: specifiers: '@algolia/client-search': ^4.14.3 '@arethetypeswrong/cli': ^0.15.3 + '@babel/parser': ^7.25.0 '@changesets/cli': ^2.24.1 '@farfetched/core': ^0.8.13 + '@farfetched/runtypes': ^0.12.4 + '@farfetched/superstruct': ^0.12.4 '@playwright/test': ^1.32.2 '@reduxjs/toolkit': ^2.0.1 '@size-limit/file': ^7.0.8 '@types/node': ^20.12.7 + bytes: ^3.1.2 + chart.js: ^4.4.3 + comment-parser: ^1.4.1 effector: 23.0.0 effector-vue: 23.0.0 + estree-walker: ^3.0.3 glob: ^8.0.3 i18next: 23.0.0 + lodash-es: ^4.17.21 markdown: ^0.5.0 playwright: ^1.32.2 prettier: ^2.6.2 + pretty-bytes: ^6.1.1 publint: ^0.2.7 redux: ^5.0.0 redux-saga: ^1.2.3 runtypes: ^6.7.0 sandpack-vue3: ^3.1.7 size-limit: ^7.0.8 + superstruct: ^2.0.2 typescript: 5.1.6 vite: 4.4.9 vite-plugin-dts: ^3.8.3 @@ -33,19 +43,29 @@ importers: vitepress-plugin-rss: ^0.2.8 vitest: ~1.5.0 vue: 3.4.27 + vue-chartjs: ^5.3.1 dependencies: '@algolia/client-search': 4.14.3 + bytes: 3.1.2 + lodash-es: 4.17.21 + pretty-bytes: 6.1.1 sandpack-vue3: 3.1.7_vue@3.4.27 devDependencies: '@arethetypeswrong/cli': 0.15.3 + '@babel/parser': 7.25.0 '@changesets/cli': 2.24.1 '@farfetched/core': 0.8.13_effector@23.0.0 + '@farfetched/runtypes': 0.12.4_x3t3da54h2pudzi4xrass7othq + '@farfetched/superstruct': 0.12.4_geb4xrowurf3cfiqaitxl4xbju '@playwright/test': 1.32.2 '@reduxjs/toolkit': 2.0.1 '@size-limit/file': 7.0.8_size-limit@7.0.8 '@types/node': 20.12.7 + chart.js: 4.4.3 + comment-parser: 1.4.1 effector: 23.0.0 effector-vue: 23.0.0_effector@23.0.0+vue@3.4.27 + estree-walker: 3.0.3 glob: 8.1.0 i18next: 23.0.0 markdown: 0.5.0 @@ -56,6 +76,7 @@ importers: redux-saga: 1.2.3 runtypes: 6.7.0 size-limit: 7.0.8 + superstruct: 2.0.2 typescript: 5.1.6 vite: 4.4.9_@types+node@20.12.7 vite-plugin-dts: 3.8.3_em34en7sabsx6ywz24kghffsne @@ -64,6 +85,7 @@ importers: vitepress-plugin-rss: 0.2.8_vitepress@1.2.3 vitest: 1.5.0_@types+node@20.12.7 vue: 3.4.27_typescript@5.1.6 + vue-chartjs: 5.3.1_chart.js@4.4.3+vue@3.4.27 apps/web-api-demo: specifiers: @@ -307,8 +329,8 @@ packages: js-tokens: 4.0.0 dev: true - /@babel/parser/7.24.7: - resolution: {integrity: sha512-9uUYRm6OqQrCqQdG1iCBwBPZgN8ciDBro2nIOFaiRz1/BCxaI7CNvQbDHvsArAC7Tw9Hda/B3U+6ui9u4HWXPw==} + /@babel/parser/7.25.0: + resolution: {integrity: sha512-CzdIU9jdP0dg7HdyB+bHvDJGagUv+qtzZt5rYCWwW6tITNqV9odjp6Qu41gkG0ca5UfdDUWrKkiAnHHdGRnOrA==} engines: {node: '>=6.0.0'} hasBin: true dependencies: @@ -1288,6 +1310,26 @@ packages: effector: 23.0.0 dev: true + /@farfetched/runtypes/0.12.4_x3t3da54h2pudzi4xrass7othq: + resolution: {integrity: sha512-x7lCiDzX25aSY15TrFtUaUMwJUsClcWphEMoGuPGo0akclCFJw9x2tki/W4vXJomcg6HFjaMbv11w/F1XUMbUA==} + peerDependencies: + '@farfetched/core': 0.12.4 + runtypes: ^6.6.0 + dependencies: + '@farfetched/core': 0.8.13_effector@23.0.0 + runtypes: 6.7.0 + dev: true + + /@farfetched/superstruct/0.12.4_geb4xrowurf3cfiqaitxl4xbju: + resolution: {integrity: sha512-2yBhvSdYFCbMa8h7x7+V0eYgwKV+5QfGz54ixdj8030ng4yEzrI2P7UJgm9xTjn1PxSl2EalsyzShRJ/drDBzw==} + peerDependencies: + '@farfetched/core': 0.12.4 + superstruct: ^1.0.3 + dependencies: + '@farfetched/core': 0.8.13_effector@23.0.0 + superstruct: 2.0.2 + dev: true + /@jest/schemas/29.6.3: resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -1298,6 +1340,10 @@ packages: /@jridgewell/sourcemap-codec/1.4.15: resolution: {integrity: sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==} + /@kurkle/color/0.3.2: + resolution: {integrity: sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw==} + dev: true + /@lezer/common/1.0.4: resolution: {integrity: sha512-lZHlk8p67x4aIDtJl6UQrXSOP6oi7dQR3W/geFVrENdA1JDaAJWldnVqVjPMJupbTKbzDfFcePfKttqVidS/dg==} dev: false @@ -1948,7 +1994,7 @@ packages: /@vue/compiler-core/3.4.27: resolution: {integrity: sha512-E+RyqY24KnyDXsCuQrI+mlcdW3ALND6U7Gqa/+bVwbcpcR3BRRIckFoz7Qyd4TTlnugtwuI7YgjbvsLmxb+yvg==} dependencies: - '@babel/parser': 7.24.7 + '@babel/parser': 7.25.0 '@vue/shared': 3.4.27 entities: 4.5.0 estree-walker: 2.0.2 @@ -1963,7 +2009,7 @@ packages: /@vue/compiler-sfc/3.4.27: resolution: {integrity: sha512-nDwntUEADssW8e0rrmE0+OrONwmRlegDA1pD6QhVeXxjIytV03yDqTey9SBDiALsvAd5U4ZrEKbMyVXhX6mCGA==} dependencies: - '@babel/parser': 7.24.7 + '@babel/parser': 7.25.0 '@vue/compiler-core': 3.4.27 '@vue/compiler-dom': 3.4.27 '@vue/compiler-ssr': 3.4.27 @@ -2332,6 +2378,11 @@ packages: engines: {node: '>= 0.8'} dev: true + /bytes/3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + dev: false + /cac/6.7.14: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} @@ -2410,6 +2461,13 @@ packages: resolution: {integrity: sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==} dev: true + /chart.js/4.4.3: + resolution: {integrity: sha512-qK1gkGSRYcJzqrrzdR6a+I0vQ4/R+SoODXyAjscQ/4mzuNzySaMCd+hyVxitSY1+L2fjPD1Gbn+ibNqRmwQeLw==} + engines: {pnpm: '>=8'} + dependencies: + '@kurkle/color': 0.3.2 + dev: true + /check-error/1.0.3: resolution: {integrity: sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==} dependencies: @@ -2506,6 +2564,11 @@ packages: dev: true optional: true + /comment-parser/1.4.1: + resolution: {integrity: sha512-buhp5kePrmda3vhc5B9t7pUQXAb2Tnd0qgpkIhPhkHXxJpiPJ11H0ZEU0oBpJ2QztSbzG/ZxMj/CHsYJqRHmyg==} + engines: {node: '>= 12.0.0'} + dev: true + /computeds/0.0.1: resolution: {integrity: sha512-7CEBgcMjVmitjYo5q8JTJVra6X5mQ20uTThdK+0kR7UEaDrAWEQcRiBtWJzga4eRpP6afNwwLsX2SET2JhVB1Q==} dev: true @@ -2841,7 +2904,7 @@ packages: /estree-walker/3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} dependencies: - '@types/estree': 1.0.1 + '@types/estree': 1.0.5 dev: true /execa/8.0.1: @@ -3471,6 +3534,10 @@ packages: p-locate: 5.0.0 dev: true + /lodash-es/4.17.21: + resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==} + dev: false + /lodash.get/4.4.2: resolution: {integrity: sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==} dev: true @@ -4019,6 +4086,11 @@ packages: hasBin: true dev: true + /pretty-bytes/6.1.1: + resolution: {integrity: sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==} + engines: {node: ^14.13.1 || >=16.0.0} + dev: false + /pretty-format/29.7.0: resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -4585,6 +4657,11 @@ packages: resolution: {integrity: sha512-Ca5ib8HrFn+f+0n4N4ScTIA9iTOQ7MaGS1ylHcoVqW9J7w2w8PzN6g9gKmTYgGEBH8e120+RCmhpje6jC5uGWA==} dev: false + /superstruct/2.0.2: + resolution: {integrity: sha512-uV+TFRZdXsqXTL2pRvujROjdZQ4RAlBUS5BTh9IGm+jTqQntYThciG/qu57Gs69yjnVUSqdxF9YLmSnpupBW9A==} + engines: {node: '>=14.0.0'} + dev: true + /supports-color/5.5.0: resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} engines: {node: '>=4'} @@ -5095,6 +5172,16 @@ packages: - terser dev: true + /vue-chartjs/5.3.1_chart.js@4.4.3+vue@3.4.27: + resolution: {integrity: sha512-rZjqcHBxKiHrBl0CIvcOlVEBwRhpWAVf6rDU3vUfa7HuSRmGtCslc0Oc8m16oAVuk0erzc1FCtH1VCriHsrz+A==} + peerDependencies: + chart.js: ^4.1.1 + vue: ^3.0.0-0 || ^2.7.0 + dependencies: + chart.js: 4.4.3 + vue: 3.4.27_typescript@5.1.6 + dev: true + /vue-demi/0.14.8_vue@3.4.27: resolution: {integrity: sha512-Uuqnk9YE9SsWeReYqK2alDI5YzciATE0r2SkA6iMAtuXvNTMNACJLJEXNXaEy94ECuBe4Sk6RzRU80kjdbIo1Q==} engines: {node: '>=12'} diff --git a/tsconfig.base.json b/tsconfig.base.json index 1e70b46a..aefd3c0d 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -16,7 +16,8 @@ "@withease/factories": ["packages/factories/src/index.ts"], "@withease/i18next": ["packages/i18next/src/index.ts"], "@withease/redux": ["packages/redux/src/index.ts"], - "@withease/web-api": ["packages/web-api/src/index.ts"] + "@withease/web-api": ["packages/web-api/src/index.ts"], + "@withease/contracts": ["packages/contracts/src/index.ts"] } }, "exclude": ["node_modules"]