Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Move Contracts integration from Farfetched #93

Draft
wants to merge 2 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/early-turkeys-cheer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@withease/zod': major
---

Initial release of Zod integration
2 changes: 2 additions & 0 deletions apps/website/docs/.vitepress/config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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/' },
Expand Down Expand Up @@ -140,6 +141,7 @@ export default defineConfig({
},
{ text: 'APIs', link: '/contracts/api' },
]),
...createSidebar('zod', [{ text: 'Get Started', link: '/zod/' }]),
'/magazine/': [
{
text: 'Architecture',
Expand Down
13 changes: 9 additions & 4 deletions apps/website/docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
---

Expand Down
2 changes: 1 addition & 1 deletion apps/website/docs/protocols/contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
41 changes: 41 additions & 0 deletions apps/website/docs/zod/index.md
Original file line number Diff line number Diff line change
@@ -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
* >
*/
```
1 change: 1 addition & 0 deletions packages/zod/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# @withease/zod
3 changes: 3 additions & 0 deletions packages/zod/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# @withease/zod

Read documentation [here](https://withease.effector.dev/zod/).
47 changes: 47 additions & 0 deletions packages/zod/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
61 changes: 61 additions & 0 deletions packages/zod/src/contract.test-d.ts
Original file line number Diff line number Diff line change
@@ -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<string>();
expectTypeOf(smth).not.toEqualTypeOf<number>();
}
});

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<string>;
},
'literal',
42
]
>();

expectTypeOf(smth).not.toEqualTypeOf<number>();

expectTypeOf(smth).not.toEqualTypeOf<
[
{
x: string;
y: false;
k: Set<string>;
},
'literal',
42
]
>();
}
});
});
121 changes: 121 additions & 0 deletions packages/zod/src/contract.test.ts
Original file line number Diff line number Diff line change
@@ -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",
]
`);
});
});
14 changes: 14 additions & 0 deletions packages/zod/src/contract_protocol.ts
Original file line number Diff line number Diff line change
@@ -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<Raw, Data extends Raw> = {
/**
* 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[];
};
29 changes: 29 additions & 0 deletions packages/zod/src/index.ts
Original file line number Diff line number Diff line change
@@ -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<D>(data: ZodType<D>): Contract<unknown, D> {
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;
});
},
};
}
11 changes: 11 additions & 0 deletions packages/zod/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"declaration": true,
"types": ["node"],
"outDir": "dist",
"rootDir": "src",
"baseUrl": "src"
},
"include": ["src/**/*.ts"]
}
Loading
Loading