From 81cd392f3ec8b0ce2e400bffce70c23c3557485a Mon Sep 17 00:00:00 2001 From: Edoardo Ranghieri Date: Thu, 29 Aug 2024 15:16:02 +0200 Subject: [PATCH] feat: support output data validation (#250) This PR adds the `outputSchema` method to allow for optional validation of the action's return value. re #245 --- README.md | 2 +- package.json | 2 +- packages/next-safe-action/README.md | 2 +- .../src/__tests__/happy-path.test.ts | 24 +++-- .../src/__tests__/validation-errors.test.ts | 62 +++++++++++- .../next-safe-action/src/action-builder.ts | 94 ++++++++++-------- packages/next-safe-action/src/index.ts | 7 +- .../src/safe-action-client.ts | 99 +++++++++++++------ packages/next-safe-action/src/utils.ts | 11 --- .../next-safe-action/src/validation-errors.ts | 33 ++++++- .../src/validation-errors.types.ts | 7 +- pnpm-lock.yaml | 58 +++++------ .../safe-action-client/instance-methods.md | 10 +- website/docs/types/index.md | 2 +- website/src/components/landing/features.tsx | 4 +- website/src/components/landing/hero.tsx | 3 +- 16 files changed, 281 insertions(+), 139 deletions(-) diff --git a/README.md b/README.md index 142e286f..88e768c5 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ https://github.com/TheEdoRan/next-safe-action/assets/1337629/664eb3ee-92f3-4d4a- - ✅ End-to-end type safety - ✅ Form Actions support - ✅ Powerful middleware system -- ✅ Input validation using multiple validation libraries +- ✅ Input/output validation using multiple validation libraries - ✅ Advanced server error handling - ✅ Optimistic updates diff --git a/package.json b/package.json index 3224deed..5a3affa4 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "cz-conventional-changelog": "^3.3.0", "husky": "^9.0.11", "is-ci": "^3.0.1", - "turbo": "^2.0.14" + "turbo": "^2.1.0" }, "packageManager": "pnpm@9.9.0+sha512.60c18acd138bff695d339be6ad13f7e936eea6745660d4cc4a776d5247c540d0edee1a563695c183a66eb917ef88f2b4feb1fc25f32a7adcadc7aaf3438e99c1" } diff --git a/packages/next-safe-action/README.md b/packages/next-safe-action/README.md index 142e286f..88e768c5 100644 --- a/packages/next-safe-action/README.md +++ b/packages/next-safe-action/README.md @@ -17,7 +17,7 @@ https://github.com/TheEdoRan/next-safe-action/assets/1337629/664eb3ee-92f3-4d4a- - ✅ End-to-end type safety - ✅ Form Actions support - ✅ Powerful middleware system -- ✅ Input validation using multiple validation libraries +- ✅ Input/output validation using multiple validation libraries - ✅ Advanced server error handling - ✅ Optimistic updates diff --git a/packages/next-safe-action/src/__tests__/happy-path.test.ts b/packages/next-safe-action/src/__tests__/happy-path.test.ts index c3f368bd..d6fcd34d 100644 --- a/packages/next-safe-action/src/__tests__/happy-path.test.ts +++ b/packages/next-safe-action/src/__tests__/happy-path.test.ts @@ -38,14 +38,17 @@ test("action with no input schema and return data gives back an object with corr assert.deepStrictEqual(actualResult, expectedResult); }); -test("action with input schema and return data gives back an object with correct `data`", async () => { +test("action with input, output schema and return data gives back an object with correct `data`", async () => { const userId = "ed6f5b84-6bca-4d01-9a51-c3d0c49a7996"; - const action = ac.schema(z.object({ userId: z.string().uuid() })).action(async ({ parsedInput }) => { - return { - userId: parsedInput.userId, - }; - }); + const action = ac + .schema(z.object({ userId: z.string().uuid() })) + .outputSchema(z.object({ userId: z.string() })) + .action(async ({ parsedInput }) => { + return { + userId: parsedInput.userId, + }; + }); const actualResult = await action({ userId }); @@ -80,13 +83,14 @@ test("action with input schema passed via async function and return data gives b assert.deepStrictEqual(actualResult, expectedResult); }); -test("action with input schema extended via async function and return data gives back an object with correct `data`", async () => { +test("action with input schema extended via async function, ouput schema and return data gives back an object with correct `data`", async () => { const userId = "ed6f5b84-6bca-4d01-9a51-c3d0c49a7996"; const password = "password"; const action = ac .schema(z.object({ password: z.string() })) .schema(async (prevSchema) => prevSchema.extend({ userId: z.string().uuid() })) + .outputSchema(z.object({ userId: z.string(), password: z.string() })) .action(async ({ parsedInput }) => { return { userId: parsedInput.userId, @@ -106,12 +110,13 @@ test("action with input schema extended via async function and return data gives assert.deepStrictEqual(actualResult, expectedResult); }); -test("action with no input schema, bind args input schemas and return data gives back an object with correct `data`", async () => { +test("action with no input schema, with bind args input schemas, output schema and return data gives back an object with correct `data`", async () => { const username = "johndoe"; const age = 30; const action = ac .bindArgsSchemas<[username: z.ZodString, age: z.ZodNumber]>([z.string(), z.number()]) + .outputSchema(z.object({ username: z.string(), age: z.number() })) .action(async ({ bindArgsParsedInputs: [username, age] }) => { return { username, @@ -131,7 +136,7 @@ test("action with no input schema, bind args input schemas and return data gives assert.deepStrictEqual(actualResult, expectedResult); }); -test("action with input schema, bind args input schemas and return data gives back an object with correct `data`", async () => { +test("action with input schema, bind args input schemas, output schema and return data gives back an object with correct `data`", async () => { const userId = "ed6f5b84-6bca-4d01-9a51-c3d0c49a7996"; const username = "johndoe"; const age = 30; @@ -139,6 +144,7 @@ test("action with input schema, bind args input schemas and return data gives ba const action = ac .schema(z.object({ userId: z.string().uuid() })) .bindArgsSchemas<[username: z.ZodString, age: z.ZodNumber]>([z.string(), z.number()]) + .outputSchema(z.object({ userId: z.string(), username: z.string(), age: z.number() })) .action(async ({ parsedInput, bindArgsParsedInputs: [username, age] }) => { return { userId: parsedInput.userId, diff --git a/packages/next-safe-action/src/__tests__/validation-errors.test.ts b/packages/next-safe-action/src/__tests__/validation-errors.test.ts index 96b26d20..d80797c6 100644 --- a/packages/next-safe-action/src/__tests__/validation-errors.test.ts +++ b/packages/next-safe-action/src/__tests__/validation-errors.test.ts @@ -3,8 +3,16 @@ import assert from "node:assert"; import { test } from "node:test"; import { z } from "zod"; -import { createSafeActionClient, flattenValidationErrors, formatValidationErrors, returnValidationErrors } from ".."; +import type { ValidationErrors } from ".."; +import { + createSafeActionClient, + DEFAULT_SERVER_ERROR_MESSAGE, + flattenValidationErrors, + formatValidationErrors, + returnValidationErrors, +} from ".."; import { zodAdapter } from "../adapters/zod"; +import { ActionOutputDataValidationError } from "../validation-errors"; // Default client tests. @@ -144,6 +152,58 @@ test("action with invalid input gives back an object with correct `validationErr assert.deepStrictEqual(actualResult, expectedResult); }); +test("action with invalid output data returns the default `serverError`", async () => { + const action = dac.outputSchema(z.object({ result: z.string().min(3) })).action(async () => { + return { + result: "ok", + }; + }); + + const actualResult = await action(); + + const expectedResult = { + serverError: DEFAULT_SERVER_ERROR_MESSAGE, + }; + + assert.deepStrictEqual(actualResult, expectedResult); +}); + +test("action with invalid output data throws an error of the correct type", async () => { + const tac = createSafeActionClient({ + validationAdapter: zodAdapter(), + handleReturnedServerError: (e) => { + throw e; + }, + }); + + const outputSchema = z.object({ result: z.string().min(3) }); + + const action = tac.outputSchema(outputSchema).action(async () => { + return { + result: "ok", + }; + }); + + const expectedResult = { + serverError: "String must contain at least 3 character(s)", + }; + + const actualResult = { + serverError: "", + }; + + try { + await action(); + } catch (e) { + if (e instanceof ActionOutputDataValidationError) { + actualResult.serverError = + (e.validationErrors as ValidationErrors).result?._errors?.[0] ?? ""; + } + } + + assert.deepStrictEqual(actualResult, expectedResult); +}); + // Formatted shape tests (same as default). const foac = createSafeActionClient({ diff --git a/packages/next-safe-action/src/action-builder.ts b/packages/next-safe-action/src/action-builder.ts index 5cea50ca..ac965be7 100644 --- a/packages/next-safe-action/src/action-builder.ts +++ b/packages/next-safe-action/src/action-builder.ts @@ -14,9 +14,15 @@ import type { ServerCodeFn, StateServerCodeFn, } from "./index.types"; -import { ActionMetadataError, DEFAULT_SERVER_ERROR_MESSAGE, isError } from "./utils"; +import { DEFAULT_SERVER_ERROR_MESSAGE, isError } from "./utils"; import type { MaybePromise } from "./utils.types"; -import { ActionServerValidationError, ActionValidationError, buildValidationErrors } from "./validation-errors"; +import { + ActionMetadataValidationError, + ActionOutputDataValidationError, + ActionServerValidationError, + ActionValidationError, + buildValidationErrors, +} from "./validation-errors"; import type { BindArgsValidationErrors, HandleBindArgsValidationErrorsShapeFn, @@ -27,18 +33,20 @@ import type { export function actionBuilder< ServerError, MetadataSchema extends Schema | undefined = undefined, - MD = MetadataSchema extends Schema ? Infer : undefined, + MD = MetadataSchema extends Schema ? Infer : undefined, // metadata type (inferred from metadata schema) Ctx extends object = {}, - SF extends (() => Promise) | undefined = undefined, // schema function - S extends Schema | undefined = SF extends Function ? Awaited> : undefined, + ISF extends (() => Promise) | undefined = undefined, // input schema function + IS extends Schema | undefined = ISF extends Function ? Awaited> : undefined, // input schema + OS extends Schema | undefined = undefined, // output schema const BAS extends readonly Schema[] = [], CVE = undefined, CBAVE = undefined, >(args: { - schemaFn?: SF; + inputSchemaFn?: ISF; bindArgsSchemas?: BAS; + outputSchema?: OS; validationAdapter: ValidationAdapter; - handleValidationErrorsShape: HandleValidationErrorsShapeFn; + handleValidationErrorsShape: HandleValidationErrorsShapeFn; handleBindArgsValidationErrorsShape: HandleBindArgsValidationErrorsShapeFn; metadataSchema: MetadataSchema; metadata: MD; @@ -53,29 +61,29 @@ export function actionBuilder< const bindArgsSchemas = (args.bindArgsSchemas ?? []) as BAS; function buildAction({ withState }: { withState: false }): { - action: ( - serverCodeFn: ServerCodeFn, - utils?: SafeActionUtils - ) => SafeActionFn; + action: : any>( + serverCodeFn: ServerCodeFn, + utils?: SafeActionUtils + ) => SafeActionFn; }; function buildAction({ withState }: { withState: true }): { - action: ( - serverCodeFn: StateServerCodeFn, - utils?: SafeActionUtils - ) => SafeStateActionFn; + action: : any>( + serverCodeFn: StateServerCodeFn, + utils?: SafeActionUtils + ) => SafeStateActionFn; }; function buildAction({ withState }: { withState: boolean }) { return { - action: ( + action: : any>( serverCodeFn: - | ServerCodeFn - | StateServerCodeFn, - utils?: SafeActionUtils + | ServerCodeFn + | StateServerCodeFn, + utils?: SafeActionUtils ) => { return async (...clientInputs: unknown[]) => { let currentCtx: object = {}; const middlewareResult: MiddlewareResult = { success: false }; - type PrevResult = SafeActionResult | undefined; + type PrevResult = SafeActionResult | undefined; let prevResult: PrevResult | undefined = undefined; const parsedInputDatas: any[] = []; let frameworkError: Error | null = null; @@ -107,10 +115,10 @@ export function actionBuilder< if (idx === 0) { if (args.metadataSchema) { // Validate metadata input. - if (!(await args.validationAdapter.validate(args.metadataSchema, args.metadata)).success) { - throw new ActionMetadataError( - "Invalid metadata input. Please be sure to pass metadata via `metadata` method before defining the action." - ); + const parsedMd = await args.validationAdapter.validate(args.metadataSchema, args.metadata); + + if (!parsedMd.success) { + throw new ActionMetadataValidationError(buildValidationErrors(parsedMd.issues)); } } } @@ -124,7 +132,6 @@ export function actionBuilder< metadata: args.metadata, next: async (nextOpts) => { currentCtx = deepmerge(currentCtx, nextOpts?.ctx ?? {}); - // currentCtx = { ...cloneDeep(currentCtx), ...(nextOpts?.ctx ?? {}) }; await executeMiddlewareStack(idx + 1); return middlewareResult; }, @@ -137,7 +144,7 @@ export function actionBuilder< // Last client input in the array, main argument (no bind arg). if (i === clientInputs.length - 1) { // If schema is undefined, set parsed data to undefined. - if (typeof args.schemaFn === "undefined") { + if (typeof args.inputSchemaFn === "undefined") { return { success: true, data: undefined, @@ -145,7 +152,7 @@ export function actionBuilder< } // Otherwise, parse input with the schema. - return args.validationAdapter.validate(await args.schemaFn(), input); + return args.validationAdapter.validate(await args.inputSchemaFn(), input); } // Otherwise, we're processing bind args client inputs. @@ -172,7 +179,7 @@ export function actionBuilder< hasBindValidationErrors = true; } else { // Otherwise, we're processing the non-bind argument (the last one) in the array. - const validationErrors = buildValidationErrors(parsedInput.issues); + const validationErrors = buildValidationErrors(parsedInput.issues); middlewareResult.validationErrors = await Promise.resolve( args.handleValidationErrorsShape(validationErrors) @@ -193,11 +200,11 @@ export function actionBuilder< } // @ts-expect-error - const scfArgs: Parameters> = []; + const scfArgs: Parameters> = []; // Server code function always has this object as the first argument. scfArgs[0] = { - parsedInput: parsedInputDatas.at(-1) as S extends Schema ? Infer : undefined, + parsedInput: parsedInputDatas.at(-1) as IS extends Schema ? Infer : undefined, bindArgsParsedInputs: parsedInputDatas.slice(0, -1) as InferArray, ctx: currentCtx as Ctx, metadata: args.metadata, @@ -211,6 +218,15 @@ export function actionBuilder< const data = await serverCodeFn(...scfArgs); + // If a `outputSchema` is passed, validate the action return value. + if (typeof args.outputSchema !== "undefined") { + const parsedData = await args.validationAdapter.validate(args.outputSchema, data); + + if (!parsedData.success) { + throw new ActionOutputDataValidationError(buildValidationErrors(parsedData.issues)); + } + } + middlewareResult.success = true; middlewareResult.data = data; middlewareResult.parsedInput = parsedInputDatas.at(-1); @@ -227,7 +243,7 @@ export function actionBuilder< // If error is `ActionServerValidationError`, return `validationErrors` as if schema validation would fail. if (e instanceof ActionServerValidationError) { - const ve = e.validationErrors as ValidationErrors; + const ve = e.validationErrors as ValidationErrors; middlewareResult.validationErrors = await Promise.resolve(args.handleValidationErrorsShape(ve)); } else { // If error is not an instance of Error, wrap it in an Error object with @@ -269,9 +285,9 @@ export function actionBuilder< data: undefined, metadata: args.metadata, ctx: currentCtx as Ctx, - clientInput: clientInputs.at(-1) as S extends Schema ? InferIn : undefined, + clientInput: clientInputs.at(-1) as IS extends Schema ? InferIn : undefined, bindArgsClientInputs: (bindArgsSchemas.length ? clientInputs.slice(0, -1) : []) as InferInArray, - parsedInput: parsedInputDatas.at(-1) as S extends Schema ? Infer : undefined, + parsedInput: parsedInputDatas.at(-1) as IS extends Schema ? Infer : undefined, bindArgsParsedInputs: parsedInputDatas.slice(0, -1) as InferArray, hasRedirected: isRedirectError(frameworkError), hasNotFound: isNotFoundError(frameworkError), @@ -282,7 +298,7 @@ export function actionBuilder< utils?.onSettled?.({ metadata: args.metadata, ctx: currentCtx as Ctx, - clientInput: clientInputs.at(-1) as S extends Schema ? InferIn : undefined, + clientInput: clientInputs.at(-1) as IS extends Schema ? InferIn : undefined, bindArgsClientInputs: (bindArgsSchemas.length ? clientInputs.slice(0, -1) : []) as InferInArray, result: {}, hasRedirected: isRedirectError(frameworkError), @@ -295,7 +311,7 @@ export function actionBuilder< throw frameworkError; } - const actionResult: SafeActionResult = {}; + const actionResult: SafeActionResult = {}; if (typeof middlewareResult.validationErrors !== "undefined") { // Throw validation errors if either `throwValidationErrors` property at the action or instance level is `true`. @@ -333,9 +349,9 @@ export function actionBuilder< metadata: args.metadata, ctx: currentCtx as Ctx, data: actionResult.data as Data, - clientInput: clientInputs.at(-1) as S extends Schema ? InferIn : undefined, + clientInput: clientInputs.at(-1) as IS extends Schema ? InferIn : undefined, bindArgsClientInputs: (bindArgsSchemas.length ? clientInputs.slice(0, -1) : []) as InferInArray, - parsedInput: parsedInputDatas.at(-1) as S extends Schema ? Infer : undefined, + parsedInput: parsedInputDatas.at(-1) as IS extends Schema ? Infer : undefined, bindArgsParsedInputs: parsedInputDatas.slice(0, -1) as InferArray, hasRedirected: false, hasNotFound: false, @@ -346,7 +362,7 @@ export function actionBuilder< utils?.onError?.({ metadata: args.metadata, ctx: currentCtx as Ctx, - clientInput: clientInputs.at(-1) as S extends Schema ? InferIn : undefined, + clientInput: clientInputs.at(-1) as IS extends Schema ? InferIn : undefined, bindArgsClientInputs: (bindArgsSchemas.length ? clientInputs.slice(0, -1) : []) as InferInArray, error: actionResult, }) @@ -358,7 +374,7 @@ export function actionBuilder< utils?.onSettled?.({ metadata: args.metadata, ctx: currentCtx as Ctx, - clientInput: clientInputs.at(-1) as S extends Schema ? InferIn : undefined, + clientInput: clientInputs.at(-1) as IS extends Schema ? InferIn : undefined, bindArgsClientInputs: (bindArgsSchemas.length ? clientInputs.slice(0, -1) : []) as InferInArray, result: actionResult, hasRedirected: false, diff --git a/packages/next-safe-action/src/index.ts b/packages/next-safe-action/src/index.ts index b40e2417..8207fa05 100644 --- a/packages/next-safe-action/src/index.ts +++ b/packages/next-safe-action/src/index.ts @@ -11,8 +11,10 @@ import { } from "./validation-errors"; export { createMiddleware } from "./middleware"; -export { ActionMetadataError, DEFAULT_SERVER_ERROR_MESSAGE } from "./utils"; +export { DEFAULT_SERVER_ERROR_MESSAGE } from "./utils"; export { + ActionMetadataValidationError, + ActionOutputDataValidationError, ActionValidationError, flattenBindArgsValidationErrors, flattenValidationErrors, @@ -59,8 +61,9 @@ export const createSafeActionClient = < middlewareFns: [async ({ next }) => next({ ctx: {} })], handleServerErrorLog, handleReturnedServerError, - schemaFn: undefined, + inputSchemaFn: undefined, bindArgsSchemas: [], + outputSchema: undefined, validationAdapter: createOpts?.validationAdapter ?? zodAdapter(), // use zod adapter by default ctxType: {}, metadataSchema: (createOpts?.defineMetadataSchema?.() ?? undefined) as MetadataSchema, diff --git a/packages/next-safe-action/src/safe-action-client.ts b/packages/next-safe-action/src/safe-action-client.ts index 3d7e9f83..7a8b0e49 100644 --- a/packages/next-safe-action/src/safe-action-client.ts +++ b/packages/next-safe-action/src/safe-action-client.ts @@ -22,10 +22,11 @@ export class SafeActionClient< ServerError, ODVES extends DVES | undefined, // override default validation errors shape MetadataSchema extends Schema | undefined = undefined, - MD = MetadataSchema extends Schema ? Infer : undefined, + MD = MetadataSchema extends Schema ? Infer : undefined, // metadata type (inferred from metadata schema) Ctx extends object = {}, - SF extends (() => Promise) | undefined = undefined, // schema function - S extends Schema | undefined = SF extends Function ? Awaited> : undefined, + ISF extends (() => Promise) | undefined = undefined, // input schema function + IS extends Schema | undefined = ISF extends Function ? Awaited> : undefined, // input schema + OS extends Schema | undefined = undefined, // output schema const BAS extends readonly Schema[] = [], CVE = undefined, const CBAVE = undefined, @@ -39,11 +40,12 @@ export class SafeActionClient< readonly #middlewareFns: MiddlewareFn[]; readonly #metadataSchema: MetadataSchema; readonly #metadata: MD; - readonly #schemaFn: SF; + readonly #inputSchemaFn: ISF; + readonly #outputSchema: OS; readonly #ctxType: Ctx; readonly #bindArgsSchemas: BAS; readonly #validationAdapter: ValidationAdapter; - readonly #handleValidationErrorsShape: HandleValidationErrorsShapeFn; + readonly #handleValidationErrorsShape: HandleValidationErrorsShapeFn; readonly #handleBindArgsValidationErrorsShape: HandleBindArgsValidationErrorsShapeFn; readonly #defaultValidationErrorsShape: ODVES; readonly #throwValidationErrors: boolean; @@ -53,10 +55,11 @@ export class SafeActionClient< middlewareFns: MiddlewareFn[]; metadataSchema: MetadataSchema; metadata: MD; - schemaFn: SF; + inputSchemaFn: ISF; + outputSchema: OS; bindArgsSchemas: BAS; validationAdapter: ValidationAdapter; - handleValidationErrorsShape: HandleValidationErrorsShapeFn; + handleValidationErrorsShape: HandleValidationErrorsShapeFn; handleBindArgsValidationErrorsShape: HandleBindArgsValidationErrorsShapeFn; ctxType: Ctx; } & Required< @@ -71,7 +74,8 @@ export class SafeActionClient< this.#handleReturnedServerError = opts.handleReturnedServerError; this.#metadataSchema = opts.metadataSchema; this.#metadata = opts.metadata; - this.#schemaFn = (opts.schemaFn ?? undefined) as SF; + this.#inputSchemaFn = (opts.inputSchemaFn ?? undefined) as ISF; + this.#outputSchema = opts.outputSchema; this.#bindArgsSchemas = opts.bindArgsSchemas ?? []; this.#validationAdapter = opts.validationAdapter; this.#ctxType = opts.ctxType as unknown as Ctx; @@ -94,7 +98,8 @@ export class SafeActionClient< handleServerErrorLog: this.#handleServerErrorLog, metadataSchema: this.#metadataSchema, metadata: this.#metadata, - schemaFn: this.#schemaFn, + inputSchemaFn: this.#inputSchemaFn, + outputSchema: this.#outputSchema, bindArgsSchemas: this.#bindArgsSchemas, validationAdapter: this.#validationAdapter, handleValidationErrorsShape: this.#handleValidationErrorsShape, @@ -118,8 +123,9 @@ export class SafeActionClient< handleServerErrorLog: this.#handleServerErrorLog, metadataSchema: this.#metadataSchema, metadata: data, - schemaFn: this.#schemaFn, + inputSchemaFn: this.#inputSchemaFn, bindArgsSchemas: this.#bindArgsSchemas, + outputSchema: this.#outputSchema, validationAdapter: this.#validationAdapter, handleValidationErrorsShape: this.#handleValidationErrorsShape, handleBindArgsValidationErrorsShape: this.#handleBindArgsValidationErrorsShape, @@ -131,19 +137,19 @@ export class SafeActionClient< /** * Define the input validation schema for the action. - * @param schema Input validation schema + * @param inputSchema Input validation schema * @param utils Optional utils object * - * {@link https://next-safe-action.dev/docs/safe-action-client/instance-methods#schema See docs for more information} + * {@link https://next-safe-action.dev/docs/safe-action-client/instance-methods#inputschema See docs for more information} */ schema< - OS extends Schema | ((prevSchema: S) => Promise), - AS extends Schema = OS extends (prevSchema: S) => Promise ? Awaited> : OS, // actual schema - OCVE = ODVES extends "flattened" ? FlattenedValidationErrors> : ValidationErrors, + OIS extends Schema | ((prevSchema: IS) => Promise), // override input schema + AIS extends Schema = OIS extends (prevSchema: IS) => Promise ? Awaited> : OIS, // actual input schema + OCVE = ODVES extends "flattened" ? FlattenedValidationErrors> : ValidationErrors, >( - schema: OS, + inputSchema: OIS, utils?: { - handleValidationErrorsShape?: HandleValidationErrorsShapeFn; + handleValidationErrorsShape?: HandleValidationErrorsShapeFn; } ) { return new SafeActionClient({ @@ -153,17 +159,18 @@ export class SafeActionClient< metadataSchema: this.#metadataSchema, metadata: this.#metadata, // @ts-expect-error - schemaFn: (schema[Symbol.toStringTag] === "AsyncFunction" + inputSchemaFn: (inputSchema[Symbol.toStringTag] === "AsyncFunction" ? async () => { - const prevSchema = await this.#schemaFn?.(); + const prevSchema = await this.#inputSchemaFn?.(); // @ts-expect-error - return schema(prevSchema as S) as AS; + return inputSchema(prevSchema as IS) as AIS; } - : async () => schema) as SF, + : async () => inputSchema) as ISF, bindArgsSchemas: this.#bindArgsSchemas, + outputSchema: this.#outputSchema, validationAdapter: this.#validationAdapter, handleValidationErrorsShape: (utils?.handleValidationErrorsShape ?? - this.#handleValidationErrorsShape) as HandleValidationErrorsShapeFn, + this.#handleValidationErrorsShape) as HandleValidationErrorsShapeFn, handleBindArgsValidationErrorsShape: this.#handleBindArgsValidationErrorsShape, ctxType: {} as Ctx, defaultValidationErrorsShape: this.#defaultValidationErrorsShape, @@ -176,7 +183,7 @@ export class SafeActionClient< * @param bindArgsSchemas Bind args input validation schemas * @param utils Optional utils object * - * {@link https://next-safe-action.dev/docs/safe-action-client/instance-methods#schema See docs for more information} + * {@link https://next-safe-action.dev/docs/safe-action-client/instance-methods#bindargsschemas See docs for more information} */ bindArgsSchemas< const OBAS extends readonly Schema[], @@ -193,8 +200,9 @@ export class SafeActionClient< handleServerErrorLog: this.#handleServerErrorLog, metadataSchema: this.#metadataSchema, metadata: this.#metadata, - schemaFn: this.#schemaFn, + inputSchemaFn: this.#inputSchemaFn, bindArgsSchemas, + outputSchema: this.#outputSchema, validationAdapter: this.#validationAdapter, handleValidationErrorsShape: this.#handleValidationErrorsShape, handleBindArgsValidationErrorsShape: (utils?.handleBindArgsValidationErrorsShape ?? @@ -205,6 +213,31 @@ export class SafeActionClient< }); } + /** + * Define the output data validation schema for the action. + * @param schema Output data validation schema + * + * {@link https://next-safe-action.dev/docs/safe-action-client/instance-methods#outputschema See docs for more information} + */ + outputSchema(dataSchema: OOS) { + return new SafeActionClient({ + middlewareFns: this.#middlewareFns, + handleReturnedServerError: this.#handleReturnedServerError, + handleServerErrorLog: this.#handleServerErrorLog, + metadataSchema: this.#metadataSchema, + metadata: this.#metadata, + inputSchemaFn: this.#inputSchemaFn, + bindArgsSchemas: this.#bindArgsSchemas, + outputSchema: dataSchema, + validationAdapter: this.#validationAdapter, + handleValidationErrorsShape: this.#handleValidationErrorsShape, + handleBindArgsValidationErrorsShape: this.#handleBindArgsValidationErrorsShape, + ctxType: {} as Ctx, + defaultValidationErrorsShape: this.#defaultValidationErrorsShape, + throwValidationErrors: this.#throwValidationErrors, + }); + } + /** * Define the action. * @param serverCodeFn Code that will be executed on the **server side** @@ -212,9 +245,9 @@ export class SafeActionClient< * * {@link https://next-safe-action.dev/docs/safe-action-client/instance-methods#action--stateaction See docs for more information} */ - action( - serverCodeFn: ServerCodeFn, - utils?: SafeActionUtils + action : any>( + serverCodeFn: ServerCodeFn, + utils?: SafeActionUtils ) { return actionBuilder({ handleReturnedServerError: this.#handleReturnedServerError, @@ -223,8 +256,9 @@ export class SafeActionClient< ctxType: this.#ctxType, metadataSchema: this.#metadataSchema, metadata: this.#metadata, - schemaFn: this.#schemaFn, + inputSchemaFn: this.#inputSchemaFn, bindArgsSchemas: this.#bindArgsSchemas, + outputSchema: this.#outputSchema, validationAdapter: this.#validationAdapter, handleValidationErrorsShape: this.#handleValidationErrorsShape, handleBindArgsValidationErrorsShape: this.#handleBindArgsValidationErrorsShape, @@ -240,9 +274,9 @@ export class SafeActionClient< * * {@link https://next-safe-action.dev/docs/safe-action-client/instance-methods#action--stateaction See docs for more information} */ - stateAction( - serverCodeFn: StateServerCodeFn, - utils?: SafeActionUtils + stateAction : any>( + serverCodeFn: StateServerCodeFn, + utils?: SafeActionUtils ) { return actionBuilder({ handleReturnedServerError: this.#handleReturnedServerError, @@ -251,8 +285,9 @@ export class SafeActionClient< ctxType: this.#ctxType, metadataSchema: this.#metadataSchema, metadata: this.#metadata, - schemaFn: this.#schemaFn, + inputSchemaFn: this.#inputSchemaFn, bindArgsSchemas: this.#bindArgsSchemas, + outputSchema: this.#outputSchema, validationAdapter: this.#validationAdapter, handleValidationErrorsShape: this.#handleValidationErrorsShape, handleBindArgsValidationErrorsShape: this.#handleBindArgsValidationErrorsShape, diff --git a/packages/next-safe-action/src/utils.ts b/packages/next-safe-action/src/utils.ts index 78def49c..8c693134 100644 --- a/packages/next-safe-action/src/utils.ts +++ b/packages/next-safe-action/src/utils.ts @@ -1,14 +1,3 @@ export const DEFAULT_SERVER_ERROR_MESSAGE = "Something went wrong while executing the operation."; export const isError = (error: unknown): error is Error => error instanceof Error; - -/** - * This error is thrown when an action's metadata input is invalid, i.e. when there's a mismatch between the - * type of the metadata schema returned from `defineMetadataSchema` and the actual input. - */ -export class ActionMetadataError extends Error { - constructor(message: string) { - super(message); - this.name = "ActionMetadataError"; - } -} diff --git a/packages/next-safe-action/src/validation-errors.ts b/packages/next-safe-action/src/validation-errors.ts index 130e3fd5..9dd614e3 100644 --- a/packages/next-safe-action/src/validation-errors.ts +++ b/packages/next-safe-action/src/validation-errors.ts @@ -1,11 +1,10 @@ /* eslint-disable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment */ -import type { Schema } from "./adapters/types"; +import type { Schema, ValidationIssue } from "./adapters/types"; import type { FlattenedBindArgsValidationErrors, FlattenedValidationErrors, ValidationErrors, - ValidationIssue, } from "./validation-errors.types"; // This function is used internally to build the validation errors object from a list of validation issues. @@ -145,3 +144,33 @@ export function flattenBindArgsValidationErrors flattenValidationErrors(ve)) as FlattenedBindArgsValidationErrors; } + +/** + * This error is thrown when an action metadata is invalid, i.e. when there's a mismatch between the + * type of the metadata schema returned from `defineMetadataSchema` and the actual data passed. + */ +export class ActionMetadataValidationError extends Error { + public validationErrors: ValidationErrors; + + constructor(validationErrors: ValidationErrors) { + super("Invalid metadata input. Please be sure to pass metadata via `metadata` method before defining the action."); + this.name = "ActionMetadataError"; + this.validationErrors = validationErrors; + } +} + +/** + * This error is thrown when an action's data (output) is invalid, i.e. when there's a mismatch between the + * type of the data schema passed to `dataSchema` method and the actual return of the action. + */ +export class ActionOutputDataValidationError extends Error { + public validationErrors: ValidationErrors; + + constructor(validationErrors: ValidationErrors) { + super( + "Invalid action data (output). Please be sure to return data following the shape of the schema passed to `dataSchema` method." + ); + this.name = "ActionOutputDataError"; + this.validationErrors = validationErrors; + } +} diff --git a/packages/next-safe-action/src/validation-errors.types.ts b/packages/next-safe-action/src/validation-errors.types.ts index 9288800f..7f44f2db 100644 --- a/packages/next-safe-action/src/validation-errors.types.ts +++ b/packages/next-safe-action/src/validation-errors.types.ts @@ -1,11 +1,6 @@ import type { Infer, Schema } from "./adapters/types"; import type { Prettify } from "./utils.types"; -export type ValidationIssue = { - message: string; - path?: Array; -}; - // Object with an optional list of validation errors. type VEList = Prettify<{ _errors?: string[] }>; @@ -15,7 +10,7 @@ type SchemaErrors = { } & {}; /** - * Type of the returned object when input validation fails. + * Type of the returned object when validation fails. */ export type ValidationErrors = S extends Schema ? Infer extends object diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 234d0046..9928703d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -30,8 +30,8 @@ importers: specifier: ^3.0.1 version: 3.0.1 turbo: - specifier: ^2.0.14 - version: 2.0.14 + specifier: ^2.1.0 + version: 2.1.0 apps/playground: dependencies: @@ -3718,38 +3718,38 @@ packages: engines: {node: '>=18.0.0'} hasBin: true - turbo-darwin-64@2.0.14: - resolution: {integrity: sha512-kwfDmjNwlNfvtrvT29+ZBg5n1Wvxl891bFHchMJyzMoR0HOE9N1NSNdSZb9wG3e7sYNIu4uDkNk+VBEqJW0HzQ==} + turbo-darwin-64@2.1.0: + resolution: {integrity: sha512-gHwpDk2gyB7qZ57gUUwDIS/IkglqEjjVtPZCTxmCRg28Tiwjui0azsLVKrnHP9UZHllozwbi28x8HXLXLEFF1w==} cpu: [x64] os: [darwin] - turbo-darwin-arm64@2.0.14: - resolution: {integrity: sha512-m3LXYEshCx3wc4ZClM6gb01KYpFmtjQ9IBF3A7ofjb6ahux3xlYZJZ3uFCLAGHuvGLuJ3htfiPbwlDPTdknqqw==} + turbo-darwin-arm64@2.1.0: + resolution: {integrity: sha512-GLaqGetNC6eS4eqXgsheLOHic/OcnGCGDi5boVf+TFZTXYH6YE15L4ugZha4xHXCr1KouCLILHh+f8EHEmWylg==} cpu: [arm64] os: [darwin] - turbo-linux-64@2.0.14: - resolution: {integrity: sha512-7vBzCPdoTtR92SNn2JMgj1FlMmyonGmpMaQdgAB1OVYtuQ6NVGoh7/lODfaILqXjpvmFSVbpBIDrKOT6EvcprQ==} + turbo-linux-64@2.1.0: + resolution: {integrity: sha512-VzBOsj7JyGoZtiNZZ6brjnY7UehRnClluw7pwznuLPzClkqOOPMd2jOcgkWxnP/xW4NBmOoFANXXrtvKBD4f2w==} cpu: [x64] os: [linux] - turbo-linux-arm64@2.0.14: - resolution: {integrity: sha512-jwH+c0bfjpBf26K/tdEFatmnYyXwGROjbr6bZmNcL8R+IkGAc/cglL+OToqJnQZTgZvH7uDGbeSyUo7IsHyjuA==} + turbo-linux-arm64@2.1.0: + resolution: {integrity: sha512-St7svJnOO5g4F6R7Z32e10I/0M3e6qpNjEYybXwPNul9NSfnUXeky4WoKaALwqNhyJ7nYemoFpZ1d+i8hFQTHg==} cpu: [arm64] os: [linux] - turbo-windows-64@2.0.14: - resolution: {integrity: sha512-w9/XwkHSzvLjmioo6cl3S1yRfI6swxsV1j1eJwtl66JM4/pn0H2rBa855R0n7hZnmI6H5ywLt/nLt6Ae8RTDmw==} + turbo-windows-64@2.1.0: + resolution: {integrity: sha512-iSobNud2MrJ1SZ1upVPlErT8xexsr0MQtKapdfq6z0M0rBnrDGEq5bUCSScWyGu+O4+glB4br9xkTAkGFqaxqQ==} cpu: [x64] os: [win32] - turbo-windows-arm64@2.0.14: - resolution: {integrity: sha512-XaQlyYk+Rf4xS5XWCo8XCMIpssgGGy8blzLfolN6YBp4baElIWMlkLZHDbGyiFmCbNf9I9gJI64XGRG+LVyyjA==} + turbo-windows-arm64@2.1.0: + resolution: {integrity: sha512-d61jN4rjE5PnUfF66GKrKoj8S8Ql4FGXzFFzZz4kjsHpZZzCTtqlzPZBmd1byzGYhDPTorTqG3G1USohbdyohA==} cpu: [arm64] os: [win32] - turbo@2.0.14: - resolution: {integrity: sha512-00JjdCMD/cpsjP0Izkjcm8Oaor5yUCfDwODtaLb+WyblyadkaDEisGhy3Dbd5az9n+5iLSPiUgf+WjPbns6MRg==} + turbo@2.1.0: + resolution: {integrity: sha512-A969/LO/sPHKlapIarY2VVzqQ5JnnW2/1kksZlnMEpsRD6gwOELvVL+ozfMiO7av9RILt3UeN02L17efr6HUCA==} hasBin: true type-check@0.4.0: @@ -7658,32 +7658,32 @@ snapshots: optionalDependencies: fsevents: 2.3.3 - turbo-darwin-64@2.0.14: + turbo-darwin-64@2.1.0: optional: true - turbo-darwin-arm64@2.0.14: + turbo-darwin-arm64@2.1.0: optional: true - turbo-linux-64@2.0.14: + turbo-linux-64@2.1.0: optional: true - turbo-linux-arm64@2.0.14: + turbo-linux-arm64@2.1.0: optional: true - turbo-windows-64@2.0.14: + turbo-windows-64@2.1.0: optional: true - turbo-windows-arm64@2.0.14: + turbo-windows-arm64@2.1.0: optional: true - turbo@2.0.14: + turbo@2.1.0: optionalDependencies: - turbo-darwin-64: 2.0.14 - turbo-darwin-arm64: 2.0.14 - turbo-linux-64: 2.0.14 - turbo-linux-arm64: 2.0.14 - turbo-windows-64: 2.0.14 - turbo-windows-arm64: 2.0.14 + turbo-darwin-64: 2.1.0 + turbo-darwin-arm64: 2.1.0 + turbo-linux-64: 2.1.0 + turbo-linux-arm64: 2.1.0 + turbo-windows-64: 2.1.0 + turbo-windows-arm64: 2.1.0 type-check@0.4.0: dependencies: diff --git a/website/docs/safe-action-client/instance-methods.md b/website/docs/safe-action-client/instance-methods.md index 9d6640b6..6695a802 100644 --- a/website/docs/safe-action-client/instance-methods.md +++ b/website/docs/safe-action-client/instance-methods.md @@ -28,7 +28,7 @@ metadata(data: Metadata) => new SafeActionClient() ## `schema` ```typescript -schema(schema: S, utils?: { handleValidationErrorsShape?: HandleValidationErrorsShapeFn } }) => new SafeActionClient() +schema(inputSchema: S, utils?: { handleValidationErrorsShape?: HandleValidationErrorsShapeFn } }) => new SafeActionClient() ``` `schema` accepts an input schema of type `Schema` or a function that returns a promise of type `Schema` and an optional `utils` object that accepts a [`handleValidationErrorsShape`](/docs/recipes/customize-validation-errors-format) function. The schema is used to define the arguments that the safe action will receive, the optional [`handleValidationErrorsShape`](/docs/recipes/customize-validation-errors-format) function is used to return a custom format for validation errors. If you don't pass an input schema, `parsedInput` and validation errors will be typed `undefined`, and `clientInput` will be typed `void`. It returns a new instance of the safe action client. @@ -41,6 +41,14 @@ bindArgsSchemas(bindArgsSchemas: BAS, bindArgsUtils?: { handleBindArgsValidation `bindArgsSchemas` accepts an array of bind input schemas of type `Schema[]` and an optional `bindArgsUtils` object that accepts a `handleBindArgsValidationErrorsShape` function. The schema is used to define the bind arguments that the safe action will receive, the optional `handleBindArgsValidationErrorsShape` function is used to [return a custom format for bind arguments validation errors](/docs/recipes/customize-validation-errors-format). It returns a new instance of the safe action client. +## `outputSchema` + +```typescript +outputSchema(outputSchema: S) => new SafeActionClient() +``` + +`outputSchema` accepts a schema of type `Schema`. That schema is used to define what the safe action will return. If you don't pass an output schema when you're defining an action, the return type will be inferred instead. If validation fails, an `ActionOutputDataValidationError` is internally thrown. You can catch it inside [`handleReturnedServerError`](/docs/safe-action-client/initialization-options#handlereturnedservererror)/[`handleServerErrorLog`](/docs/safe-action-client/initialization-options#handleservererrorlog) and access the `validationErrors` property to get the validation errors. It returns a new instance of the safe action client. + ## `action` / `stateAction` ```typescript diff --git a/website/docs/types/index.md b/website/docs/types/index.md index b54748a6..618c15f5 100644 --- a/website/docs/types/index.md +++ b/website/docs/types/index.md @@ -246,7 +246,7 @@ export type SafeActionUtils< ### `ValidationErrors` -Type of the returned object when input validation fails. +Type of the returned object when validation fails. ```typescript export type ValidationErrors = S extends Schema diff --git a/website/src/components/landing/features.tsx b/website/src/components/landing/features.tsx index 47be628e..7aa44db3 100644 --- a/website/src/components/landing/features.tsx +++ b/website/src/components/landing/features.tsx @@ -23,8 +23,8 @@ const features: { title: string; description: string }[] = [ "Manage authorization, log and halt execution, and much more with a composable middleware system.", }, { - title: "Input validation using multiple validation libraries", - description: `Input passed from the client to the server is validated using Zod, Valibot or Yup.`, + title: "Input/output validation using multiple validation libraries", + description: `Input and output are validated using your favorite library.`, }, { title: "Advanced server error handling", diff --git a/website/src/components/landing/hero.tsx b/website/src/components/landing/hero.tsx index 76838f56..e1a93c19 100644 --- a/website/src/components/landing/hero.tsx +++ b/website/src/components/landing/hero.tsx @@ -18,7 +18,8 @@ export function Hero() {

next-safe-action handles your Next.js app mutations type - safety, input validation, server errors and even more! + safety, input/output validation, server errors and even + more!