Skip to content

Commit 4dcf742

Browse files
authoredJun 5, 2024··
feat: support passing schema via async function (#154)
The code in this PR adds the support for passing a schema to the action via an async function inside the `schema` method. This is necessary, for instance, when you're using a i18n solution that requires to await the translations and pass them to schemas, as discussed in #111.
1 parent cd567a2 commit 4dcf742

File tree

13 files changed

+163
-26
lines changed

13 files changed

+163
-26
lines changed
 
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
"use server";
2+
3+
import { action } from "@/lib/safe-action";
4+
import {
5+
flattenValidationErrors,
6+
returnValidationErrors,
7+
} from "next-safe-action";
8+
import { z } from "zod";
9+
10+
async function getSchema() {
11+
return z.object({
12+
username: z.string().min(3).max(10),
13+
password: z.string().min(8).max(100),
14+
});
15+
}
16+
17+
export const loginUser = action
18+
.metadata({ actionName: "loginUser" })
19+
.schema(getSchema, {
20+
// Here we use the `flattenValidationErrors` function to customize the returned validation errors
21+
// object to the client.
22+
handleValidationErrorsShape: (ve) =>
23+
flattenValidationErrors(ve).fieldErrors,
24+
})
25+
.action(async ({ parsedInput: { username, password } }) => {
26+
if (username === "johndoe") {
27+
returnValidationErrors(getSchema, {
28+
username: {
29+
_errors: ["user_suspended"],
30+
},
31+
});
32+
}
33+
34+
if (username === "user" && password === "password") {
35+
return {
36+
success: true,
37+
};
38+
}
39+
40+
returnValidationErrors(getSchema, {
41+
username: {
42+
_errors: ["incorrect_credentials"],
43+
},
44+
});
45+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
"use client";
2+
3+
import { StyledButton } from "@/app/_components/styled-button";
4+
import { StyledHeading } from "@/app/_components/styled-heading";
5+
import { StyledInput } from "@/app/_components/styled-input";
6+
import { useState } from "react";
7+
import { ResultBox } from "../../_components/result-box";
8+
import { loginUser } from "./login-action";
9+
10+
export default function DirectExamplePage() {
11+
const [result, setResult] = useState<any>(undefined);
12+
13+
return (
14+
<main className="w-96 max-w-full px-4">
15+
<StyledHeading>
16+
Action using direct call
17+
<br />
18+
(async schema)
19+
</StyledHeading>
20+
<form
21+
className="flex flex-col mt-8 space-y-4"
22+
onSubmit={async (e) => {
23+
e.preventDefault();
24+
const formData = new FormData(e.currentTarget);
25+
const input = Object.fromEntries(formData) as {
26+
username: string;
27+
password: string;
28+
};
29+
const res = await loginUser(input); // this is the typesafe action directly called
30+
setResult(res);
31+
}}>
32+
<StyledInput
33+
type="text"
34+
name="username"
35+
id="username"
36+
placeholder="Username"
37+
/>
38+
<StyledInput
39+
type="password"
40+
name="password"
41+
id="password"
42+
placeholder="Password"
43+
/>
44+
<StyledButton type="submit">Log in</StyledButton>
45+
</form>
46+
<ResultBox result={result} />
47+
</main>
48+
);
49+
}

‎apps/playground/src/app/page.tsx

+3
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ export default function Home() {
66
<h1 className="text-4xl font-semibold">Playground</h1>
77
<div className="mt-4 flex flex-col space-y-2">
88
<ExampleLink href="/direct">Direct call</ExampleLink>
9+
<ExampleLink href="/async-schema">
10+
Direct call (async schema)
11+
</ExampleLink>
912
<ExampleLink href="/with-context">With Context</ExampleLink>
1013
<ExampleLink href="/nested-schema">Nested schema</ExampleLink>
1114
<ExampleLink href="/hook">

‎packages/next-safe-action/.eslintrc.js

+1
Original file line numberDiff line numberDiff line change
@@ -20,5 +20,6 @@ module.exports = defineConfig({
2020
"@typescript-eslint/no-explicit-any": "off",
2121
"@typescript-eslint/ban-types": "off",
2222
"react-hooks/exhaustive-deps": "warn",
23+
"@typescript-eslint/require-await": "off",
2324
},
2425
});

‎packages/next-safe-action/src/action-builder.ts

+6-5
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,13 @@ export function actionBuilder<
2727
MetadataSchema extends Schema | undefined = undefined,
2828
MD = MetadataSchema extends Schema ? Infer<Schema> : undefined,
2929
Ctx = undefined,
30-
S extends Schema | undefined = undefined,
30+
SF extends (() => Promise<Schema>) | undefined = undefined, // schema function
31+
S extends Schema | undefined = SF extends Function ? Awaited<ReturnType<SF>> : undefined,
3132
const BAS extends readonly Schema[] = [],
3233
CVE = undefined,
3334
CBAVE = undefined,
3435
>(args: {
35-
schema?: S;
36+
schemaFn?: SF;
3637
bindArgsSchemas?: BAS;
3738
handleValidationErrorsShape: HandleValidationErrorsShapeFn<S, CVE>;
3839
handleBindArgsValidationErrorsShape: HandleBindArgsValidationErrorsShapeFn<BAS, CBAVE>;
@@ -118,19 +119,19 @@ export function actionBuilder<
118119
} else {
119120
// Validate the client inputs in parallel.
120121
const parsedInputs = await Promise.all(
121-
clientInputs.map((input, i) => {
122+
clientInputs.map(async (input, i) => {
122123
// Last client input in the array, main argument (no bind arg).
123124
if (i === clientInputs.length - 1) {
124125
// If schema is undefined, set parsed data to undefined.
125-
if (typeof args.schema === "undefined") {
126+
if (typeof args.schemaFn === "undefined") {
126127
return {
127128
success: true,
128129
data: undefined,
129130
} as const;
130131
}
131132

132133
// Otherwise, parse input with the schema.
133-
return valFn(args.schema, input);
134+
return valFn(await args.schemaFn(), input);
134135
}
135136

136137
// Otherwise, we're processing bind args client inputs.

‎packages/next-safe-action/src/index.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ export const createSafeActionClient = <
5757
handleServerErrorLog,
5858
handleReturnedServerError,
5959
validationStrategy: "zod",
60-
schema: undefined,
60+
schemaFn: undefined,
6161
bindArgsSchemas: [],
6262
ctxType: undefined,
6363
metadataSchema: createOpts?.defineMetadataSchema?.(),

‎packages/next-safe-action/src/safe-action-client.ts

+18-15
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,12 @@ import type {
1313

1414
export class SafeActionClient<
1515
ServerError,
16-
ODVES extends DVES | undefined,
16+
ODVES extends DVES | undefined, // override default validation errors shape
1717
MetadataSchema extends Schema | undefined = undefined,
1818
MD = MetadataSchema extends Schema ? Infer<Schema> : undefined,
1919
Ctx = undefined,
20-
S extends Schema | undefined = undefined,
20+
SF extends (() => Promise<Schema>) | undefined = undefined, // schema function
21+
S extends Schema | undefined = SF extends Function ? Awaited<ReturnType<SF>> : undefined,
2122
const BAS extends readonly Schema[] = [],
2223
CVE = undefined,
2324
const CBAVE = undefined,
@@ -31,7 +32,7 @@ export class SafeActionClient<
3132
readonly #ctxType = undefined as Ctx;
3233
readonly #metadataSchema: MetadataSchema;
3334
readonly #metadata: MD;
34-
readonly #schema: S;
35+
readonly #schemaFn: SF;
3536
readonly #bindArgsSchemas: BAS;
3637
readonly #handleValidationErrorsShape: HandleValidationErrorsShapeFn<S, CVE>;
3738
readonly #handleBindArgsValidationErrorsShape: HandleBindArgsValidationErrorsShapeFn<BAS, CBAVE>;
@@ -43,7 +44,7 @@ export class SafeActionClient<
4344
validationStrategy: "typeschema" | "zod";
4445
metadataSchema: MetadataSchema;
4546
metadata: MD;
46-
schema: S;
47+
schemaFn: SF;
4748
bindArgsSchemas: BAS;
4849
handleValidationErrorsShape: HandleValidationErrorsShapeFn<S, CVE>;
4950
handleBindArgsValidationErrorsShape: HandleBindArgsValidationErrorsShapeFn<BAS, CBAVE>;
@@ -61,7 +62,7 @@ export class SafeActionClient<
6162
this.#validationStrategy = opts.validationStrategy;
6263
this.#metadataSchema = opts.metadataSchema;
6364
this.#metadata = opts.metadata;
64-
this.#schema = (opts.schema ?? undefined) as S;
65+
this.#schemaFn = (opts.schemaFn ?? undefined) as SF;
6566
this.#bindArgsSchemas = opts.bindArgsSchemas ?? [];
6667
this.#handleValidationErrorsShape = opts.handleValidationErrorsShape;
6768
this.#handleBindArgsValidationErrorsShape = opts.handleBindArgsValidationErrorsShape;
@@ -82,7 +83,7 @@ export class SafeActionClient<
8283
validationStrategy: this.#validationStrategy,
8384
metadataSchema: this.#metadataSchema,
8485
metadata: this.#metadata,
85-
schema: this.#schema,
86+
schemaFn: this.#schemaFn,
8687
bindArgsSchemas: this.#bindArgsSchemas,
8788
handleValidationErrorsShape: this.#handleValidationErrorsShape,
8889
handleBindArgsValidationErrorsShape: this.#handleBindArgsValidationErrorsShape,
@@ -105,7 +106,7 @@ export class SafeActionClient<
105106
validationStrategy: this.#validationStrategy,
106107
metadataSchema: this.#metadataSchema,
107108
metadata: data,
108-
schema: this.#schema,
109+
schemaFn: this.#schemaFn,
109110
bindArgsSchemas: this.#bindArgsSchemas,
110111
handleValidationErrorsShape: this.#handleValidationErrorsShape,
111112
handleBindArgsValidationErrorsShape: this.#handleBindArgsValidationErrorsShape,
@@ -122,12 +123,13 @@ export class SafeActionClient<
122123
* {@link https://next-safe-action.dev/docs/safe-action-client/instance-methods#schema See docs for more information}
123124
*/
124125
schema<
125-
OS extends Schema,
126-
OCVE = ODVES extends "flattened" ? FlattenedValidationErrors<ValidationErrors<OS>> : ValidationErrors<OS>,
126+
OS extends Schema | (() => Promise<Schema>),
127+
AS extends Schema = OS extends () => Promise<Schema> ? Awaited<ReturnType<OS>> : OS, // actual schema
128+
OCVE = ODVES extends "flattened" ? FlattenedValidationErrors<ValidationErrors<AS>> : ValidationErrors<AS>,
127129
>(
128130
schema: OS,
129131
utils?: {
130-
handleValidationErrorsShape?: HandleValidationErrorsShapeFn<OS, OCVE>;
132+
handleValidationErrorsShape?: HandleValidationErrorsShapeFn<AS, OCVE>;
131133
}
132134
) {
133135
return new SafeActionClient({
@@ -137,10 +139,11 @@ export class SafeActionClient<
137139
validationStrategy: this.#validationStrategy,
138140
metadataSchema: this.#metadataSchema,
139141
metadata: this.#metadata,
140-
schema,
142+
// @ts-expect-error
143+
schemaFn: (schema[Symbol.toStringTag] === "AsyncFunction" ? schema : async () => schema) as SF,
141144
bindArgsSchemas: this.#bindArgsSchemas,
142145
handleValidationErrorsShape: (utils?.handleValidationErrorsShape ??
143-
this.#handleValidationErrorsShape) as HandleValidationErrorsShapeFn<OS, OCVE>,
146+
this.#handleValidationErrorsShape) as HandleValidationErrorsShapeFn<AS, OCVE>,
144147
handleBindArgsValidationErrorsShape: this.#handleBindArgsValidationErrorsShape,
145148
ctxType: undefined as Ctx,
146149
defaultValidationErrorsShape: this.#defaultValidationErrorsShape,
@@ -170,7 +173,7 @@ export class SafeActionClient<
170173
validationStrategy: this.#validationStrategy,
171174
metadataSchema: this.#metadataSchema,
172175
metadata: this.#metadata,
173-
schema: this.#schema,
176+
schemaFn: this.#schemaFn,
174177
bindArgsSchemas,
175178
handleValidationErrorsShape: this.#handleValidationErrorsShape,
176179
handleBindArgsValidationErrorsShape: (utils?.handleBindArgsValidationErrorsShape ??
@@ -195,7 +198,7 @@ export class SafeActionClient<
195198
ctxType: this.#ctxType,
196199
metadataSchema: this.#metadataSchema,
197200
metadata: this.#metadata,
198-
schema: this.#schema,
201+
schemaFn: this.#schemaFn,
199202
bindArgsSchemas: this.#bindArgsSchemas,
200203
handleValidationErrorsShape: this.#handleValidationErrorsShape,
201204
handleBindArgsValidationErrorsShape: this.#handleBindArgsValidationErrorsShape,
@@ -218,7 +221,7 @@ export class SafeActionClient<
218221
ctxType: this.#ctxType,
219222
metadataSchema: this.#metadataSchema,
220223
metadata: this.#metadata,
221-
schema: this.#schema,
224+
schemaFn: this.#schemaFn,
222225
bindArgsSchemas: this.#bindArgsSchemas,
223226
handleValidationErrorsShape: this.#handleValidationErrorsShape,
224227
handleBindArgsValidationErrorsShape: this.#handleBindArgsValidationErrorsShape,

‎packages/next-safe-action/src/typeschema.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ export const createSafeActionClient = <
5757
handleServerErrorLog,
5858
handleReturnedServerError,
5959
validationStrategy: "typeschema",
60-
schema: undefined,
60+
schemaFn: undefined,
6161
bindArgsSchemas: [],
6262
ctxType: undefined,
6363
metadataSchema: createOpts?.defineMetadataSchema?.(),

‎packages/next-safe-action/src/validation-errors.ts

+5-2
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,11 @@ export class ActionServerValidationError<S extends Schema> extends Error {
7070
*
7171
* {@link https://next-safe-action.dev/docs/recipes/additional-validation-errors#returnvalidationerrors See docs for more information}
7272
*/
73-
export function returnValidationErrors<S extends Schema>(schema: S, validationErrors: ValidationErrors<S>): never {
74-
throw new ActionServerValidationError<S>(validationErrors);
73+
export function returnValidationErrors<
74+
S extends Schema | (() => Promise<Schema>),
75+
AS extends Schema = S extends () => Promise<Schema> ? Awaited<ReturnType<S>> : S, // actual schema
76+
>(schema: S, validationErrors: ValidationErrors<AS>): never {
77+
throw new ActionServerValidationError<AS>(validationErrors);
7578
}
7679

7780
/**

‎website/docs/migrations/v6-to-v7.md

+4
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,10 @@ This is customizable by using the `handleValidationErrorsShape`/`handleBindArgsV
143143

144144
Sometimes it's not necessary to define an action with input. In this case, you can omit the [`schema`](/docs/safe-action-client/instance-methods#schema) method and use directly the [`action`/`stateAction`](/docs/safe-action-client/instance-methods#action--stateaction) method.
145145

146+
### [Support passing schema via async function](https://github.com/TheEdoRan/next-safe-action/issues/155)
147+
148+
When working with i18n solutions, often you'll find implementations that require awaiting a `getTranslations` function in order to get the translations, that then get passed to the schema. Starting from version 7, next-safe-action allows you to pass an async function to the [`schema`](/docs/safe-action-client/instance-methods#schema) method, that returns a promise of type `Schema`. More information about this feature can be found in [this discussion](https://github.com/TheEdoRan/next-safe-action/discussions/111) on GitHub and in the [i18n](/docs/recipes/i18n) recipe.
149+
146150
### [Support stateful actions using React `useActionState` hook](https://github.com/TheEdoRan/next-safe-action/issues/91)
147151

148152
React added a hook called `useActionState` that replaces the previous `useFormState` hook and improves it. next-safe-action v7 uses it under the hood in the exported [`useStateAction`](/docs/execution/hooks/usestateaction) hook, that keeps track of the state of the action execution.

‎website/docs/recipes/additional-validation-errors.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ When registering a new user, we also need to check if the email is already store
3838

3939
```typescript
4040
import { returnValidationErrors } from "next-safe-action";
41-
import { action } from "@/lib/safe-action";
41+
import { actionClient } from "@/lib/safe-action";
4242

4343
// Here we're using the same schema declared above.
4444
const signupAction = actionClient

‎website/docs/recipes/i18n.md

+28
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
sidebar_position: 5
3+
description: Learn how to use next-safe-action with a i18n solution.
4+
---
5+
6+
# i18n
7+
8+
If you're using a i18n solution, there's a high probability that you'll need to await the translations and then pass them to schemas.\
9+
next-safe-action allows you to do that by passing an async function to the [`schema`](/docs/safe-action-client/instance-methods#schema) method that returns a promise with the schema.\
10+
The setup is pretty simple:
11+
12+
```typescript
13+
"use server";
14+
15+
import { actionClient } from "@/lib/safe-action";
16+
import { z } from "zod";
17+
import { getTranslations } from "my-i18n-lib";
18+
19+
async function getSchema() {
20+
// This is an example of a i18n setup.
21+
const t = await getTranslations();
22+
return mySchema(t); // this is the schema that will be used to validate and parse the input
23+
}
24+
25+
export const myAction = actionClient.schema(getSchema).action(async ({ parsedInput }) => {
26+
// Do something useful here...
27+
});
28+
```

‎website/docs/safe-action-client/instance-methods.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ metadata(data: Metadata) => new SafeActionClient()
3131
schema(schema: S, utils?: { handleValidationErrorsShape?: HandleValidationErrorsShapeFn } }) => new SafeActionClient()
3232
```
3333

34-
`schema` accepts an **optional** input schema of type `Schema` (from TypeSchema) 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.
34+
`schema` accepts an input schema of type `Schema` (from TypeSchema) 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.
3535

3636
## `bindArgsSchemas`
3737

0 commit comments

Comments
 (0)
Please sign in to comment.