Skip to content

Commit

Permalink
Refactorto saner approach of schema injection
Browse files Browse the repository at this point in the history
  • Loading branch information
kay-is committed Oct 20, 2023
1 parent b819b82 commit b62c2fc
Show file tree
Hide file tree
Showing 6 changed files with 257 additions and 77 deletions.
2 changes: 1 addition & 1 deletion .prettierrc
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@
"trailingComma": "none",
"singleQuote": true,
"printWidth": 120
}
}
226 changes: 172 additions & 54 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,90 +1,208 @@
# SmartWeave Extension for Zod

A Warp SmartWeave extension for the Zod schema validation library.
A Warp plugin that extends the global `SmartWeave` object with a Zod parser.

## Features

- Create Zod schemas with a global `zod` object in contract files.
- Validate contract inputs with Zod schemas.
- Create TypeScript types from Zod schemas.
- Use Zod schemas to validate contract inputs
- Infer TypeScript types from your Zod schemas for auto-complete in your
contract code
- Parse inputs inside your contract via the global `SmartWeave` object
- Throws a `ContractError` if the input is invalid
- Doesn't include the Zod library into your contract code

## Install

$ npm i @kay-is/warp-contracts-plugin-zod

or

$ yarn add @kay-is/warp-contracts-plugin-zod

## Setup
## Schemas and Types Setup

Define your Zod schemas in a separate file. They will be available in your
contract via

SmartWeave.extensions.zod.parse.<SCHEMA>(input: unknown, errorMsg?: string)

```ts
import { ZodExtension } from '@kay-is/warp-contracts-plugin-zod';
import { WarpFactory } from 'warp-contracts';
// types.ts
import { z } from 'zod';
import { arweaveAddr, arweaveTxId, SmartweaveExtensionZod, ParsedSchemas } from '@kay-is/warp-contracts-plugin-zod/types';

// Define schemas with Zod
const user = z.object({
id: arweaveAddr,
name: z.string(),
age: z.number().optional()
});

const comment = z.object({
id: arweaveTxId,
user: arweaveAddr,
text: z.string()
});

const addUserInput = z.object({
function: z.literal('addUser'),
name: z.string(),
age: z.number().optional()
});

const addCommentInput = z.object({
function: z.literal('addComment'),
user: arweaveAddr,
text: z.string()
});

const input = z.discriminatedUnion('function', [addUserInput, addCommentInput]);

// Export the schemas so the extension can inject them into the contract at evaluation time.
export const schemas = {
user,
comment,
addUserInput,
addCommentInput
input,
};

// Get types of the schemas for the global SmartWeave object
export type Schemas = typeof schemas;
export type SmartWeaveExtensionZodWithSchemas = SmartWeaveExtensionZod<Schemas>

// Create basic contract types
export type State = {
users: User[];
comments: Comment[];
};

export type Action = {
caller: arweaveAddr;
input: unknown;
}

const warp = WarpFactory.forMainnet().use(new ZodExtension());
export type HandlerResult = { state: State } | { result: any };

export type ContractCoreTypes = {
state: State;
action: Action;
handlerResult: HandlerResult;
};

// Merge the parsed schema types with the contract types
export type ContractTypes = ContractCoreTypes & ParsedSchemas<Schemas>;
```

## Usage
## Usage Inside a Contract

In the contract file, you can import the `SmartWeaveExtensionZod` type to get
auto-complete for the extension methods.

Import the `types.ts` file with `import type` to avoid importing the schema code.

```ts
import type { Zod } from '@kay-is/warp-contracts-plugin-zod';
// contract.ts
import type { SmartWeaveGlobal } from 'smartweave/lib/smartweave-global';
import type { ContractTypes, Schemas, SmartWeaveExtensionZodWithSchemas } from './types';

// Merge the standard SmartWeave types with the extenion type that includes the
// schema types for auto-completion.
declare global {
const zod: Zod;
const SmartWeave: SmartWeaveGlobal & SmartWeaveExtensionZodWithSchemas;
}

// Zod schemas for validation at contract evaluation time
export function handle(state: ContractTypes['state'], action: ContractTypes['action']): ContractTypes['handlerResult'] {
const { zod } = SmartWeave.extension;

const contractStateSchema = zod.object({
stringValue: zod.string(),
numberValue: zod.number()
});
// action.caller has type string
// action.input has type unknown

const emptyInputSchema = zod.object({
function: zod.literal('empty'),
value: zod.never()
});
// Throws a ContractError if the action is invalid
const { caller, input } = zod.parse.action(action);

const functionAInputSchema = zod.object({
function: zod.literal('A'),
value: zod.number()
});
// input now has type { function: 'addUser', ... } | { function: 'addComment', ... }

const functionBInputSchema = zod.object({
function: zod.literal('B'),
value: zod.string()
});
if (input.function === 'addUser') {
const addUserInput = zod.parse.addUserInput(input);
// addUserInput now has type { function: 'addUser', name: string, age?: number }

const actionSchema = z.object({
caller: z.string(),
input: zod.discriminatedUnion('function', [emptyInputSchema, functionAInputSchema, functionBInputSchema])
});

// Infer static type for autocmpletion and type safety
type ContractState = Zod.infer<typeof contractStateSchema>;
function handler(state: ContractState, action: unknown): ContractState {
const { caller, input } = validate(actionSchema, action);
if (input.function === 'A') {
return A(state, valiate(functionAInputSchema, input), caller);
return addUser(state, addUserInput, caller);
}

if (input.function === 'B') {
return B(state, valiate(functionBInputSchema, input), caller);
if (input.function === 'addComment') {
const addCommentInput = zod.parse.addCommentInput(input);
// addCommentInput now has type { function: 'addComment', user: string, text: string }

return addComment(state, addCommentInput, caller);
}

// ...
}

// Infer static type for autocmpletion and type safety
type FunctionAInput = Zod.infer<typeof functionAInputSchema>;
function A(state: ContractState, input: FunctionAInput, caller: string): ContractState {
return {
state...,
numberValue: input.value
}
function addUser(
state: ContractState,
input: ContractTypes['addUserInput'],
caller: string
): ContractTypes['handlerResult'] {
// ...
}

// Infer static type for autocmpletion and type safety
type FunctionBInput = Zod.infer<typeof functionBInputSchema>;
function B(state: ContractState, input: FunctionBInput, caller: string): ContractState {
return {
state...,
stringValue: input.value
function addComment(
state: ContractState,
input: ContractTypes['addCommentInput'],
caller: string
): ContractTypes['handlerResult'] {
// ...
}
```

## Usage Inside a Frontend

Create the contract with the `ZodExtension` class to load the schemas into the
extension.

Import the your `types.ts` file into your frontend code to get the same
types and schemas as in your contract code.

```ts
// frontend.ts
import { WarpFactory } from 'warp-contracts';
import { ContractTypes, schemas } from './types';

const warpFactory = WarpFactory.forMainnet().use(new ZodExtension(schemas));
const contract = warpFactory.contract<ContractTypes['state']>('<CONTRACT_ID>');

export function App() {
const [state, setState] = React.useState<ContractTypes['state']>();

React.useEffect(async () => {
const result = await contract.readState();
// result result.cachedValue.state has type ContractTypes['state']
setState(result.cachedValue.state);
}, []);


const addUser = async () => {
// throws a Zod validation error if the input is invalid
// returns the parsed input with the right type if it's valid
const input: ContractTypes['addUserInput'] = schemas.addUserInput.parse({
function: 'addUser',
name: 'John Doe',
age: 42
});

await contract.connect(jwk).writeInteraction(input, { strict: true });

const result = await contract.readState();
setState(result.cachedValue.state);
}

return (
<div>
<button onClick={addUser} >Create User</button>
<pre>{JSON.stringify(state, null, 2)}</pre>
</div>
);
}
```
11 changes: 5 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@kay-is/warp-contracts-plugin-zod",
"version": "0.1.0-alpha8",
"version": "0.1.0-beta1",
"description": "Use Zod validation inside Warp Contracts.",
"types": "./lib/types/index.d.ts",
"main": "./lib/cjs/index.js",
Expand Down Expand Up @@ -46,12 +46,11 @@
"rimraf": "*",
"ts-node": "*",
"typescript": "*",
"warp-contracts": "*"
"warp-contracts": "*",
"zod": "*"
},
"peerDependencies": {
"warp-contracts": "*"
},
"dependencies": {
"zod": "^3.22.4"
"warp-contracts": "*",
"zod": "*"
}
}
42 changes: 27 additions & 15 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,37 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { ContractError, WarpPlugin, WarpPluginType } from 'warp-contracts';
import { z } from 'zod';
import { KeyedSchemas, arweaveSchemas } from './types';

// To infer static types from schemas
export type Zod = typeof z;
export class ZodExtension<Schemas> implements WarpPlugin<any, void> {
private schemas: KeyedSchemas<Schemas>;

const validate = <T>(schema: Zod.ZodType<T>, input: unknown) => {
const result = schema.safeParse(input);
if (result.success) return result.data;
// @ts-expect-error if-statement guards against this error
throw new ContractError(result.error);
};
private parse<T>(schema: Zod.ZodType<T>, input: unknown, errorMsg?: string): T {
const result = schema.safeParse(input);
if (result.success) return result.data;
// @ts-expect-error The if-statement guards against this
const errorMessage = errorMsg || result.error;
throw new ContractError(errorMessage);
}

export class ZodExtension implements WarpPlugin<any, void> {
process() {}
constructor(schemas: KeyedSchemas<Schemas>) {
this.schemas = schemas;
}

type(): WarpPluginType {
// To make Zod available outside of a contract
global.zod = z;
global.validate = validate;

return 'smartweave-extension-zod';
}

process(extensionNamespace) {
const parsers = {};

for (const schema of Object.keys(arweaveSchemas))
parsers[schema] = (input: unknown, errorMsg?: string) => this.parse(arweaveSchemas[schema], input, errorMsg);

for (const schema of Object.keys(this.schemas))
parsers[schema] = (input: unknown, errorMsg?: string) => this.parse(this.schemas[schema], input, errorMsg);

extensionNamespace.zod = {
parse: parsers
};
}
}
51 changes: 51 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { z } from 'zod';

export type KeyedSchemas<Schemas> = { [key in keyof Schemas]: Schemas[key] };

export type KeyedSchemaParsers<Schemas> = {
[key in keyof Schemas]: (input: unknown, errorMsg?: string) => Schemas[key] extends z.ZodType<infer T> ? T : never;
};

export type ParsedSchemas<Schemas> = {
[key in keyof Schemas]: Schemas[key] extends z.ZodType<infer T> ? T : never;
};

export type SmartWeaveExtensionZod<Schemas> = {
extensions: {
zod: {
parse: KeyedSchemaParsers<Schemas>;
};
};
};

export const base64Url = z.string().regex(/^[a-zA-Z0-9_-]$/);
export type Base64Url = z.infer<typeof base64Url>;

export const arweaveAddr = base64Url.length(43);
export type ArweaveAddr = z.infer<typeof arweaveAddr>;

export const arweaveBlockheight = z.number().int().positive();
export type arweaveBlockheight = z.infer<typeof arweaveBlockheight>;

export const arweaveTag = z.object({
name: z.string(),
value: z.string()
});
export type ArweaveTag = z.infer<typeof arweaveTag>;

export const arweaveTxId = base64Url.length(43);
export type ArweaveTxId = z.infer<typeof arweaveTxId>;

export const arweaveWinston = z
.string()
.regex(/^([0-9]){1-13}$/)
.max(13);
export type ArweaveWinston = z.infer<typeof arweaveWinston>;

export const arweaveSchemas = {
arweaveAddr,
arweaveBlockheight,
arweaveTag,
arweaveTxId,
arweaveWinston
};
Loading

0 comments on commit b62c2fc

Please sign in to comment.