Skip to content

Commit

Permalink
@withease/contracts (#85)
Browse files Browse the repository at this point in the history
  • Loading branch information
igorkamyshev authored Jul 30, 2024
1 parent 52fcac8 commit 16968b8
Show file tree
Hide file tree
Showing 30 changed files with 1,799 additions and 10 deletions.
5 changes: 5 additions & 0 deletions .changeset/spicy-owls-bake.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@withease/contracts': major
---

Initial release
3 changes: 2 additions & 1 deletion .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@

/dist
/coverage
pnpm-lock.yaml
pnpm-lock.yaml
api.md
3 changes: 2 additions & 1 deletion apps/website/.gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
docs/.vitepress/cache
CHANGELOG.md
CHANGELOG.md
api.md
22 changes: 22 additions & 0 deletions apps/website/docs/.vitepress/config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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/' },
Expand Down Expand Up @@ -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',
Expand Down
2 changes: 2 additions & 0 deletions apps/website/docs/.vitepress/theme/LiveDemo.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 = {
Expand Down
32 changes: 32 additions & 0 deletions apps/website/docs/contracts/array_numbers.live.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<script setup>
import { arr, num } from '@withease/contracts';
const contract = arr(num);
</script>

<template>
<h1>
Out <em>Contract</em> is ensuring that passed data is an array of numbers
</h1>
<section>
<h2>Valid data example</h2>
<p>Let us pass [1, 2, 3] to the <em>Contract</em></p>
<p>isData() 👉 {{ contract.isData([1, 2, 3]) }}</p>
<p>
getErrorMessages() 👉
{{ JSON.stringify(contract.getErrorMessages([1, 2, 3])) }}
</p>
</section>

<section>
<h2>Invalid data example</h2>
<p>
Let us pass [1, 'WHOA', 3] to the <em>Contract</em>. instead of number.
</p>
<p>isData() 👉 {{ contract.isData([1, 'WHOA', 3]) }}</p>
<p>
getErrorMessages() 👉
{{ JSON.stringify(contract.getErrorMessages([1, 'WHOA', 3])) }}
</p>
</section>
</template>
29 changes: 29 additions & 0 deletions apps/website/docs/contracts/cookbook/custom_matchers.md
Original file line number Diff line number Diff line change
@@ -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),
});
```
24 changes: 24 additions & 0 deletions apps/website/docs/contracts/cookbook/merge_objects.md
Original file line number Diff line number Diff line change
@@ -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<typeof PriceWithDiscount>;
// 👆 { currency: string, value: number, discount: number }
```
29 changes: 29 additions & 0 deletions apps/website/docs/contracts/cookbook/optional_fields.md
Original file line number Diff line number Diff line change
@@ -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.
:::
177 changes: 177 additions & 0 deletions apps/website/docs/contracts/index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
<script setup>
import pkg from '../../../../packages/contracts/package.json';
import demoFile from './array_numbers.live.vue?raw';
import { data as sizes } from './sizes.data';
import SizeChart from './size_chart.vue';
import bytes from 'bytes'

const maxSize = pkg['size-limit'].at(0).limit;

const allSizes = [
{ name: '@withease/contracts', size: bytes(maxSize) },
...(sizes ?? [])
];
</script>

# 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.

<LiveDemo :demoFile="demoFile" />

## 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<typeof UserContract>;
```

## 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
<section v-if="sizes">
It is extremely small and we mean it 👇
<br />
<br />
<SizeChart :sizes="allSizes" />
::: tip
Data fetched directly from https://esm.run/ and updates on every commit.
:::
</section>
<section v-else>
It is significantly smaller than other libraries for creating _Contracts_.
</section>
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.
:::
Loading

0 comments on commit 16968b8

Please sign in to comment.