From b62c2fcd341703832d43d5c39925f11c0f929646 Mon Sep 17 00:00:00 2001 From: K Date: Fri, 20 Oct 2023 15:00:04 +0000 Subject: [PATCH] Refactorto saner approach of schema injection --- .prettierrc | 2 +- README.md | 226 +++++++++++++++++++++++++++++++++++++++------------ package.json | 11 ++- src/index.ts | 42 ++++++---- src/types.ts | 51 ++++++++++++ yarn.lock | 2 +- 6 files changed, 257 insertions(+), 77 deletions(-) create mode 100644 src/types.ts diff --git a/.prettierrc b/.prettierrc index 6961e6e..0706fd1 100644 --- a/.prettierrc +++ b/.prettierrc @@ -3,4 +3,4 @@ "trailingComma": "none", "singleQuote": true, "printWidth": 120 -} \ No newline at end of file +} diff --git a/README.md b/README.md index eb58990..7704fd7 100644 --- a/README.md +++ b/README.md @@ -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.(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 + +// 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; ``` -## 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; -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; -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; -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(''); + +export function App() { + const [state, setState] = React.useState(); + + 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 ( +
+ +
{JSON.stringify(state, null, 2)}
+
+ ); } ``` diff --git a/package.json b/package.json index ea5f481..1c3f3ea 100644 --- a/package.json +++ b/package.json @@ -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", @@ -46,12 +46,11 @@ "rimraf": "*", "ts-node": "*", "typescript": "*", - "warp-contracts": "*" + "warp-contracts": "*", + "zod": "*" }, "peerDependencies": { - "warp-contracts": "*" - }, - "dependencies": { - "zod": "^3.22.4" + "warp-contracts": "*", + "zod": "*" } } diff --git a/src/index.ts b/src/index.ts index e5c00dc..223f25a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 implements WarpPlugin { + private schemas: KeyedSchemas; -const validate = (schema: Zod.ZodType, 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(schema: Zod.ZodType, 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 { - process() {} + constructor(schemas: KeyedSchemas) { + 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 + }; + } } diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..66ee6aa --- /dev/null +++ b/src/types.ts @@ -0,0 +1,51 @@ +import { z } from 'zod'; + +export type KeyedSchemas = { [key in keyof Schemas]: Schemas[key] }; + +export type KeyedSchemaParsers = { + [key in keyof Schemas]: (input: unknown, errorMsg?: string) => Schemas[key] extends z.ZodType ? T : never; +}; + +export type ParsedSchemas = { + [key in keyof Schemas]: Schemas[key] extends z.ZodType ? T : never; +}; + +export type SmartWeaveExtensionZod = { + extensions: { + zod: { + parse: KeyedSchemaParsers; + }; + }; +}; + +export const base64Url = z.string().regex(/^[a-zA-Z0-9_-]$/); +export type Base64Url = z.infer; + +export const arweaveAddr = base64Url.length(43); +export type ArweaveAddr = z.infer; + +export const arweaveBlockheight = z.number().int().positive(); +export type arweaveBlockheight = z.infer; + +export const arweaveTag = z.object({ + name: z.string(), + value: z.string() +}); +export type ArweaveTag = z.infer; + +export const arweaveTxId = base64Url.length(43); +export type ArweaveTxId = z.infer; + +export const arweaveWinston = z + .string() + .regex(/^([0-9]){1-13}$/) + .max(13); +export type ArweaveWinston = z.infer; + +export const arweaveSchemas = { + arweaveAddr, + arweaveBlockheight, + arweaveTag, + arweaveTxId, + arweaveWinston +}; diff --git a/yarn.lock b/yarn.lock index 8172352..93ad648 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3730,7 +3730,7 @@ zip-stream@^4.1.0: compress-commons "^4.1.2" readable-stream "^3.6.0" -zod@^3.22.4: +zod@*: version "3.22.4" resolved "https://registry.yarnpkg.com/zod/-/zod-3.22.4.tgz#f31c3a9386f61b1f228af56faa9255e845cf3fff" integrity sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==