diff --git a/.changeset/red-bags-camp.md b/.changeset/red-bags-camp.md new file mode 100644 index 000000000..b25776f91 --- /dev/null +++ b/.changeset/red-bags-camp.md @@ -0,0 +1,5 @@ +--- +"@trigger.dev/sdk": patch +--- + +Better handle event parsing errors from Zod diff --git a/apps/docs/getting-started.mdx b/apps/docs/getting-started.mdx index ed480d893..83f9bc61c 100644 --- a/apps/docs/getting-started.mdx +++ b/apps/docs/getting-started.mdx @@ -201,13 +201,13 @@ Triggers are what cause your workflows to run. Easily subscribe to the APIs you're using - + Trigger your workflows on a repeating schedule - + More details on custom events - + On received email, HTTP endpoint and AWS Event Bridge @@ -222,11 +222,14 @@ Triggers are what cause your workflows to run. > We are making it easy to use lots of APIs by adding integrations. - + + Call any API from your workflow + + Add delays to your workflows. They're resilient so it doesn't matter if your server goes down. - + Send an event, to trigger a custom event workflow diff --git a/apps/docs/guides/zod.mdx b/apps/docs/guides/zod.mdx index 8803c2f59..646d36814 100644 --- a/apps/docs/guides/zod.mdx +++ b/apps/docs/guides/zod.mdx @@ -1,7 +1,306 @@ --- -title: "Zod" +title: "Zod Guide" sidebarTitle: "Zod" description: "TypeScript-first schema validation with static type inference" --- -## Coming soon +## Intro + +Zod is a fantastic utility package by [@colinhacks](https://twitter.com/colinhacks) that allows for defining runtime schema validation and type-safety. + +We use it [extensively](https://github.com/search?q=repo%3Atriggerdotdev%2Ftrigger.dev+%22zod%22%3B&type=code) internally at Trigger.dev to ensure we never trigger your workflows with invalid data (and more). + +But there are a few places where we ask you to provide us with a Zod schema, for example when defining a [Custom Event Trigger](/triggers/custom-events): + +```ts +import { Trigger, customEvent } from "@trigger.dev/sdk"; +import { slack } from "@trigger.dev/integrations"; +import { z } from "zod"; + +//this workflow will run when a "user.created" event is sent +new Trigger({ + id: "zod", + name: "Zod custom event", + //this is the custom event subscription + on: customEvent({ + name: "user.created", + schema: z.object({ + name: z.string(), + email: z.string(), + paidPlan: z.boolean(), + }), + }), + //this function is run when the custom event is received + run: async (event, ctx) => { + console.log(event); // Has name, email, paidPlan + }, +}).listen(); +``` + +So it will help to know a little about Zod and how to use it. We definitely recommend the well written [Zod README](https://github.com/colinhacks/zod#readme) but we've included a short primer below. + + + Wherever we require you to pass in a Zod schema, you can always start with + `z.any()` which accepts `any` type and then add more strict validations later. + + +## Basic Usage + +There are three main steps to using Zod: + +1. Define a schema + +```ts +import { z } from "zod"; + +const mySchema = z.object({ + name: z.string(), + email: z.string(), + paidPlan: z.boolean(), +}); +``` + +2. Infer TypeScript types from the schema + +```ts +type MySchema = z.infer; +``` + +3. Validate data against the schema + +```ts +const data: unknown = { + name: "Eric", + email: "eric@trigger.dev", + paidPlan: true, +}; + +const result = mySchema.parse(data); +``` + +When using Zod with Trigger.dev, you'll only really need to do the first step, and by passing it to us (through the `schema` property of the `customEvent` function), we'll do the second and third steps for you. + +## Defining Schemas + +### Primitives + +Zod schemas are a way to define the shape of an object. They can be as simple as a single type, or as complex as a nested object. + +```ts +// Primitives +z.string(); +z.number(); +z.boolean(); +z.date(); +z.undefined(); +z.null(); +``` + +Any schema can be marked as optional, which means the schema can be `undefined` or `null`: + +```ts +z.string().optional(); +``` + +Schemas can also be marked optional by providing a default value: + +```ts +const optionalString = z.string().default("default value"); + +const value = optionalString.parse(undefined); // value === "default value" +``` + +If you need to allow a value to be `null`, you can use `nullable()`: + +```ts +const nullableString = z.string().nullable(); + +const value = nullableString.parse(null); +``` + +You can also use Zod to coerce primites into other types. For example, you can coerce a string into a number: + +```ts +const numberString = z.coerce.number(); + +const value = numberString.parse("123"); // value === 123 +``` + +The following primitives are supported: + +```ts +z.coerce.string(); +z.coerce.number(); +z.coerce.boolean(); +z.coerce.bigint(); +z.coerce.date(); +``` + +Coercing dates are especially useful when you are receiving a `string` from an API and want to convert it to a JavaScript `Date` object: + +```ts +const date = z.coerce.date(); + +const value = date.parse("2021-01-01T00:00:00.000Z"); // value === Date object +``` + +### Objects + +Object schemas are the most common type of schema. They allow you to define the shape of an object, and the types of each property. + +```ts +const mySchema = z.object({ + name: z.string(), + email: z.string(), + paidPlan: z.boolean(), +}); +``` + +All properties are required by default, although you can make them all optional using `partial()`: + +```ts +const mySchema = z + .object({ + name: z.string(), + email: z.string(), + paidPlan: z.boolean(), + }) + .partial(); +``` + +You can also make individual properties optional: + +```ts +const mySchema = z.object({ + name: z.string(), + email: z.string(), + paidPlan: z.boolean().optional(), +}); +``` + +By default an object schema will strip out any extra properties that are not defined in the schema. You can disable this behavior using `passthrough()`: + +```ts +const mySchema = z.object({ + name: z.string(), +}); + +mySchema.parse({ name: "Eric", email: "eric@trigger.dev" }); // { name: "Eric" } +mySchema.passthrough().parse({ name: "Eric", email: "eric@trigger.dev" }); // { name: "Eric", email: "eric@trigger.dev" } +``` + +Or you can use `strict()` to make the schema throw an error if there are any extra properties: + +```ts +const mySchema = z + .object({ + name: z.string(), + }) + .strict(); + +mySchema.parse({ name: "Eric", email: "eric@trigger.dev" }); // throws error +``` + +Zod includes a few useful object schema utilities to help with reusing schemas, `extends()` and `merge()`: + +```ts +const baseSchema = z.object({ + name: z.string(), +}); + +const extendedSchema = baseSchema.extend({ + email: z.string(), +}); + +const mergedSchema = baseSchema.merge( + z.object({ + email: z.string(), + }) +); +``` + +You can also use `pick()` and `omit()` to create a new schema that only includes or excludes certain properties: + +```ts +const mySchema = z.object({ + name: z.string(), + email: z.string(), + paidPlan: z.boolean(), +}); + +const pickedSchema = mySchema.pick({ name: true, email: true }); +const omittedSchema = mySchema.omit({ paidPlan: true }); +``` + +### Arrays + +You can specify the schema of an array using `z.array()`: + +```ts +z.array(z.string()); // string[] +z.array(z.number()); // number[] +z.array(z.object({ name: z.string() })); // Array<{ name: string }> +``` + +You can also specify the type of array that has a fixed number of elements using `z.tuple()`: + +```ts +z.tuple([z.string(), z.number(), z.boolean()]); // [string, number, boolean] +``` + +### Unions + +You can specify a union of schemas using `z.union()`: + +```ts +z.union([z.string(), z.number(), z.boolean()]); // string | number | boolean +``` + +Discriminating unions are also supported, and especially useful when paired with type narrowing: + +```ts +const mySchema = z.discriminatingUnion("type", [ + z.object({ + type: z.literal("a"), + data: z.string(), + }), + z.object({ + type: z.literal("b"), + data: z.number(), + }), +]); + +const value = mySchema.parse({ type: "a", data: "hello" }); + +if (type.a) { + // value is { type: "a", data: string } +} else if (type.b) { + // value is { type: "b", data: number } +} +``` + +### Records + +You can specify a record of schemas using `z.record()`, useful for when you have a map of values but don't care about the keys: + +```ts +z.record(z.string()); // Record +z.record(z.number()); // Record +``` + +### JSON type + +If you want to accept any valid JSON value, you can use the following schema: + +```ts +const literalSchema = z.union([z.string(), z.number(), z.boolean(), z.null()]); +type Literal = z.infer; +type Json = Literal | { [key: string]: Json } | Json[]; +const jsonSchema: z.ZodType = z.lazy(() => + z.union([literalSchema, z.array(jsonSchema), z.record(jsonSchema)]) +); + +jsonSchema.parse(data); +``` + +Hat tip to [ggoodman](https://github.com/ggoodman) for this one. diff --git a/apps/docs/triggers/custom-events.mdx b/apps/docs/triggers/custom-events.mdx index 30cd1c25b..96b793c98 100644 --- a/apps/docs/triggers/custom-events.mdx +++ b/apps/docs/triggers/custom-events.mdx @@ -4,27 +4,28 @@ sidebarTitle: "Custom events" description: "Custom event triggers allow you to run workflows from your own code (or your other workflows)" --- -[Send an event](/functions/send-event) and any workflows that subscribe to that Custom event will get triggered. +[Send an event](/functions/send-event) and any workflows that subscribe to that custom event will get triggered. ## Name and Schemas ### Name -Custom event triggers have a `name`. They will only get triggered when a custom even with that name are sent. +Custom event triggers have a `name`. They will only get triggered when a custom event with that name are sent. ### Schema -Custom event triggers have a schema. This is used to validate the data that is sent with the event. If the data does not match the schema, the workflow will not run. +Custom event triggers take a [Zod](https://github.com/colinhacks/zod) schema. This is used to validate the data that is sent with the event. If the data does not match the schema, the workflow will not run. -It also means that inside your run function you will have type safety for the data you receive. We use [Zod](https://github.com/colinhacks/zod#installation) for our schemas – it's a fantastic library that allows you to define schemas in a very simple way. +It also means that inside your run function the event param will be typed correctly. We use [Zod](https://github.com/colinhacks/zod#installation) for our schemas – it's a fantastic library that allows you to define schemas in a very simple way. -You can always start out by using `z.any()` as your schema, and then later on you can add more strict validation. +You can always start out by using `z.any()` as your schema, and then later on you can add more strict validation. See our [Zod guide](/guides/zod) for more information. ## Example ```ts import { Trigger, customEvent } from "@trigger.dev/sdk"; import { slack } from "@trigger.dev/integrations"; +import { z } from "zod"; //this workflow will run when a "user.created" event is sent new Trigger({ diff --git a/examples/smoke-test/src/index.ts b/examples/smoke-test/src/index.ts index 98707bff2..6d5db22cf 100644 --- a/examples/smoke-test/src/index.ts +++ b/examples/smoke-test/src/index.ts @@ -11,7 +11,7 @@ const trigger = new Trigger({ name: "My workflow", apiKey: "trigger_dev_zC25mKNn6c0q", endpoint: "ws://localhost:8889/ws", - logLevel: "log", + logLevel: "debug", triggerTTL: 60 * 60 * 24, on: customEvent({ name: "user.created", schema: userCreatedEvent }), run: async (event, ctx) => { diff --git a/packages/trigger-sdk/package.json b/packages/trigger-sdk/package.json index c04c0a3e7..3939ca006 100644 --- a/packages/trigger-sdk/package.json +++ b/packages/trigger-sdk/package.json @@ -42,6 +42,7 @@ "ulid": "^2.3.0", "uuid": "^9.0.0", "ws": "^8.11.0", - "zod": "^3.20.2" + "zod": "^3.20.2", + "zod-error": "^1.1.0" } } diff --git a/packages/trigger-sdk/src/client.ts b/packages/trigger-sdk/src/client.ts index 255c87ec8..b126dce12 100644 --- a/packages/trigger-sdk/src/client.ts +++ b/packages/trigger-sdk/src/client.ts @@ -14,6 +14,13 @@ import { triggerRunLocalStorage } from "./localStorage"; import { ContextLogger } from "./logger"; import { Trigger, TriggerOptions } from "./trigger"; import { TriggerContext, TriggerFetch } from "./types"; +import { generateErrorMessage, ErrorMessageOptions } from "zod-error"; + +const zodErrorMessageOptions: ErrorMessageOptions = { + delimiter: { + error: " 🔥 ", + }, +}; export class TriggerClient { #trigger: Trigger; @@ -292,6 +299,26 @@ export class TriggerClient { TRIGGER_WORKFLOW: async (data) => { this.#logger.debug("Handling TRIGGER_WORKFLOW", data); + const parsedEventData = this.#options.on.schema.safeParse( + data.trigger.input + ); + + if (!parsedEventData.success) { + await serverRPC.send("SEND_WORKFLOW_ERROR", { + runId: data.id, + timestamp: String(highPrecisionTimestamp()), + error: { + name: "Event validation error", + message: generateErrorMessage( + parsedEventData.error.issues, + zodErrorMessageOptions + ), + }, + }); + + return true; + } + const fetchFunction: TriggerFetch = async (key, url, options) => { const result = new Promise((resolve, reject) => { this.#fetchCallbacks.set(messageKey(data.id, key), { @@ -399,7 +426,7 @@ export class TriggerClient { fetch: fetchFunction, }; - const eventData = this.#options.on.schema.parse(data.trigger.input); + const eventData = parsedEventData.data; this.#logger.debug("Parsed event data", eventData); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 31775fa76..d848e31db 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -770,6 +770,7 @@ importers: uuid: ^9.0.0 ws: ^8.11.0 zod: ^3.20.2 + zod-error: ^1.1.0 dependencies: debug: 4.3.4 evt: 2.4.13 @@ -778,6 +779,7 @@ importers: uuid: 9.0.0 ws: 8.12.0 zod: 3.20.2 + zod-error: 1.1.0 devDependencies: '@trigger.dev/common-schemas': link:../common-schemas '@trigger.dev/tsconfig': link:../../config-packages/tsconfig @@ -16581,6 +16583,12 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + /zod-error/1.1.0: + resolution: {integrity: sha512-zZ0I9/eFQsaDqglBPOM5Y/kJzbTkoD6hNZGON/kcMe2WIfLse2DIwsZpLMfKwBmcciYaEg+DubtLMnAbZ4/rfA==} + dependencies: + zod: 3.20.2 + dev: false + /zod/3.20.2: resolution: {integrity: sha512-1MzNQdAvO+54H+EaK5YpyEy0T+Ejo/7YLHS93G3RnYWh5gaotGHwGeN/ZO687qEDU2y4CdStQYXVHIgrUl5UVQ==}