-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Refactorto saner approach of schema injection
- Loading branch information
Showing
6 changed files
with
257 additions
and
77 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -3,4 +3,4 @@ | |
"trailingComma": "none", | ||
"singleQuote": true, | ||
"printWidth": 120 | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
); | ||
} | ||
``` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
}; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
}; |
Oops, something went wrong.