mirror of
https://github.com/withastro/astro.git
synced 2025-04-07 23:41:43 -05:00
Actions: Allow effect chaining on form input validators (#11809)
* feat: support effects on form validators * feat: support object passthrough on form input * feat: support infinitely nested effects with simplified types * feat(test): ensure arbitrary schemas work with form data * chore: changeset * fix: support zod pipe()
This commit is contained in:
parent
260c4be050
commit
62e97a20f7
6 changed files with 178 additions and 21 deletions
5
.changeset/pink-kids-taste.md
Normal file
5
.changeset/pink-kids-taste.md
Normal file
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
'astro': patch
|
||||
---
|
||||
|
||||
Fixes usage of `.transform()`, `.refine()`, `.passthrough()`, and other effects on Action form inputs.
|
|
@ -1,4 +1,5 @@
|
|||
import type { OutgoingHttpHeaders } from 'node:http';
|
||||
import type { z } from 'zod';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import type {
|
||||
MarkdownHeading,
|
||||
|
@ -14,7 +15,6 @@ import type * as vite from 'vite';
|
|||
import type {
|
||||
ActionAccept,
|
||||
ActionClient,
|
||||
ActionInputSchema,
|
||||
ActionReturnType,
|
||||
} from '../actions/runtime/virtual/server.js';
|
||||
import type { RemotePattern } from '../assets/utils/remotePattern.js';
|
||||
|
@ -3010,7 +3010,7 @@ interface AstroSharedContext<
|
|||
*/
|
||||
getActionResult: <
|
||||
TAccept extends ActionAccept,
|
||||
TInputSchema extends ActionInputSchema<TAccept>,
|
||||
TInputSchema extends z.ZodType,
|
||||
TAction extends ActionClient<unknown, TAccept, TInputSchema>,
|
||||
>(
|
||||
action: TAction,
|
||||
|
@ -3020,7 +3020,7 @@ interface AstroSharedContext<
|
|||
*/
|
||||
callAction: <
|
||||
TAccept extends ActionAccept,
|
||||
TInputSchema extends ActionInputSchema<TAccept>,
|
||||
TInputSchema extends z.ZodType,
|
||||
TOutput,
|
||||
TAction extends
|
||||
| ActionClient<TOutput, TAccept, TInputSchema>
|
||||
|
|
|
@ -9,9 +9,6 @@ export * from './shared.js';
|
|||
export { z } from 'zod';
|
||||
|
||||
export type ActionAccept = 'form' | 'json';
|
||||
export type ActionInputSchema<T extends ActionAccept | undefined> = T extends 'form'
|
||||
? z.AnyZodObject | z.ZodType<FormData>
|
||||
: z.ZodType;
|
||||
|
||||
export type ActionHandler<TInputSchema, TOutput> = TInputSchema extends z.ZodType
|
||||
? (input: z.infer<TInputSchema>, context: ActionAPIContext) => MaybePromise<TOutput>
|
||||
|
@ -22,7 +19,7 @@ export type ActionReturnType<T extends ActionHandler<any, any>> = Awaited<Return
|
|||
export type ActionClient<
|
||||
TOutput,
|
||||
TAccept extends ActionAccept | undefined,
|
||||
TInputSchema extends ActionInputSchema<TAccept> | undefined,
|
||||
TInputSchema extends z.ZodType | undefined,
|
||||
> = TInputSchema extends z.ZodType
|
||||
? ((
|
||||
input: TAccept extends 'form' ? FormData : z.input<TInputSchema>,
|
||||
|
@ -46,7 +43,7 @@ export type ActionClient<
|
|||
export function defineAction<
|
||||
TOutput,
|
||||
TAccept extends ActionAccept | undefined = undefined,
|
||||
TInputSchema extends ActionInputSchema<ActionAccept> | undefined = TAccept extends 'form'
|
||||
TInputSchema extends z.ZodType | undefined = TAccept extends 'form'
|
||||
? // If `input` is omitted, default to `FormData` for forms and `any` for JSON.
|
||||
z.ZodType<FormData>
|
||||
: undefined,
|
||||
|
@ -83,7 +80,7 @@ export function defineAction<
|
|||
return safeServerHandler as ActionClient<TOutput, TAccept, TInputSchema> & string;
|
||||
}
|
||||
|
||||
function getFormServerHandler<TOutput, TInputSchema extends ActionInputSchema<'form'>>(
|
||||
function getFormServerHandler<TOutput, TInputSchema extends z.ZodType>(
|
||||
handler: ActionHandler<TInputSchema, TOutput>,
|
||||
inputSchema?: TInputSchema,
|
||||
) {
|
||||
|
@ -95,9 +92,14 @@ function getFormServerHandler<TOutput, TInputSchema extends ActionInputSchema<'f
|
|||
});
|
||||
}
|
||||
|
||||
if (!(inputSchema instanceof z.ZodObject)) return await handler(unparsedInput, context);
|
||||
if (!inputSchema) return await handler(unparsedInput, context);
|
||||
|
||||
const parsed = await inputSchema.safeParseAsync(formDataToObject(unparsedInput, inputSchema));
|
||||
const baseSchema = unwrapSchemaEffects(inputSchema);
|
||||
const parsed = await inputSchema.safeParseAsync(
|
||||
baseSchema instanceof z.ZodObject
|
||||
? formDataToObject(unparsedInput, baseSchema)
|
||||
: unparsedInput,
|
||||
);
|
||||
if (!parsed.success) {
|
||||
throw new ActionInputError(parsed.error.issues);
|
||||
}
|
||||
|
@ -105,7 +107,7 @@ function getFormServerHandler<TOutput, TInputSchema extends ActionInputSchema<'f
|
|||
};
|
||||
}
|
||||
|
||||
function getJsonServerHandler<TOutput, TInputSchema extends ActionInputSchema<'json'>>(
|
||||
function getJsonServerHandler<TOutput, TInputSchema extends z.ZodType>(
|
||||
handler: ActionHandler<TInputSchema, TOutput>,
|
||||
inputSchema?: TInputSchema,
|
||||
) {
|
||||
|
@ -131,7 +133,8 @@ export function formDataToObject<T extends z.AnyZodObject>(
|
|||
formData: FormData,
|
||||
schema: T,
|
||||
): Record<string, unknown> {
|
||||
const obj: Record<string, unknown> = {};
|
||||
const obj: Record<string, unknown> =
|
||||
schema._def.unknownKeys === 'passthrough' ? Object.fromEntries(formData.entries()) : {};
|
||||
for (const [key, baseValidator] of Object.entries(schema.shape)) {
|
||||
let validator = baseValidator;
|
||||
|
||||
|
@ -189,3 +192,15 @@ function handleFormDataGet(
|
|||
}
|
||||
return validator instanceof z.ZodNumber ? Number(value) : value;
|
||||
}
|
||||
|
||||
function unwrapSchemaEffects(schema: z.ZodType) {
|
||||
while (schema instanceof z.ZodEffects || schema instanceof z.ZodPipeline) {
|
||||
if (schema instanceof z.ZodEffects) {
|
||||
schema = schema._def.schema;
|
||||
}
|
||||
if (schema instanceof z.ZodPipeline) {
|
||||
schema = schema._def.in;
|
||||
}
|
||||
}
|
||||
return schema;
|
||||
}
|
||||
|
|
|
@ -240,6 +240,71 @@ describe('Astro Actions', () => {
|
|||
assert.ok($('#user'));
|
||||
});
|
||||
|
||||
it('Supports effects on form input validators', async () => {
|
||||
const formData = new FormData();
|
||||
formData.set('password', 'benisawesome');
|
||||
formData.set('confirmPassword', 'benisveryawesome');
|
||||
|
||||
const req = new Request('http://example.com/_actions/validatePassword', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const res = await app.render(req);
|
||||
|
||||
assert.equal(res.ok, false);
|
||||
assert.equal(res.status, 400);
|
||||
assert.equal(res.headers.get('Content-Type'), 'application/json');
|
||||
|
||||
const data = await res.json();
|
||||
assert.equal(data.type, 'AstroActionInputError');
|
||||
assert.equal(data.issues?.[0]?.message, 'Passwords do not match');
|
||||
});
|
||||
|
||||
it('Supports complex chained effects on form input validators', async () => {
|
||||
const formData = new FormData();
|
||||
formData.set('currentPassword', 'benisboring');
|
||||
formData.set('newPassword', 'benisawesome');
|
||||
formData.set('confirmNewPassword', 'benisawesome');
|
||||
|
||||
const req = new Request('http://example.com/_actions/validatePasswordComplex', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const res = await app.render(req);
|
||||
|
||||
assert.equal(res.ok, true);
|
||||
assert.equal(res.headers.get('Content-Type'), 'application/json+devalue');
|
||||
|
||||
const data = devalue.parse(await res.text());
|
||||
assert.equal(Object.keys(data).length, 2, 'More keys than expected');
|
||||
assert.deepEqual(data, {
|
||||
currentPassword: 'benisboring',
|
||||
newPassword: 'benisawesome',
|
||||
});
|
||||
});
|
||||
|
||||
it('Supports input form data transforms', async () => {
|
||||
const formData = new FormData();
|
||||
formData.set('name', 'ben');
|
||||
formData.set('age', '42');
|
||||
|
||||
const req = new Request('http://example.com/_actions/transformFormInput', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const res = await app.render(req);
|
||||
|
||||
assert.equal(res.ok, true);
|
||||
assert.equal(res.headers.get('Content-Type'), 'application/json+devalue');
|
||||
|
||||
const data = devalue.parse(await res.text());
|
||||
assert.equal(data?.name, 'ben');
|
||||
assert.equal(data?.age, '42');
|
||||
});
|
||||
|
||||
describe('legacy', () => {
|
||||
it('Response middleware fallback', async () => {
|
||||
const formData = new FormData();
|
||||
|
|
|
@ -1,5 +1,10 @@
|
|||
import { defineAction, ActionError, z } from 'astro:actions';
|
||||
|
||||
const passwordSchema = z
|
||||
.string()
|
||||
.min(8, 'Password should be at least 8 chars length')
|
||||
.max(128, 'Password length exceeded. Max 128 chars.');
|
||||
|
||||
export const server = {
|
||||
subscribe: defineAction({
|
||||
input: z.object({ channel: z.string() }),
|
||||
|
@ -44,7 +49,56 @@ export const server = {
|
|||
accept: 'form',
|
||||
handler: async (_, { locals }) => {
|
||||
return locals.user;
|
||||
}
|
||||
},
|
||||
}),
|
||||
validatePassword: defineAction({
|
||||
accept: 'form',
|
||||
input: z
|
||||
.object({ password: z.string(), confirmPassword: z.string() })
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
message: 'Passwords do not match',
|
||||
}),
|
||||
handler: async ({ password }) => {
|
||||
return password;
|
||||
},
|
||||
}),
|
||||
validatePasswordComplex: defineAction({
|
||||
accept: 'form',
|
||||
input: z
|
||||
.object({
|
||||
currentPassword: passwordSchema,
|
||||
newPassword: passwordSchema,
|
||||
confirmNewPassword: passwordSchema,
|
||||
})
|
||||
.required()
|
||||
.refine(
|
||||
({ newPassword, confirmNewPassword }) => newPassword === confirmNewPassword,
|
||||
'The new password confirmation does not match',
|
||||
)
|
||||
.refine(
|
||||
({ currentPassword, newPassword }) => currentPassword !== newPassword,
|
||||
'The old password and the new password must not match',
|
||||
)
|
||||
.transform((input) => ({
|
||||
currentPassword: input.currentPassword,
|
||||
newPassword: input.newPassword,
|
||||
}))
|
||||
.pipe(
|
||||
z.object({
|
||||
currentPassword: passwordSchema,
|
||||
newPassword: passwordSchema,
|
||||
}),
|
||||
),
|
||||
handler: async (data) => {
|
||||
return data;
|
||||
},
|
||||
}),
|
||||
transformFormInput: defineAction({
|
||||
accept: 'form',
|
||||
input: z.instanceof(FormData).transform((formData) => Object.fromEntries(formData.entries())),
|
||||
handler: async (data) => {
|
||||
return data;
|
||||
},
|
||||
}),
|
||||
getUserOrThrow: defineAction({
|
||||
accept: 'form',
|
||||
|
@ -57,22 +111,22 @@ export const server = {
|
|||
});
|
||||
}
|
||||
return locals.user;
|
||||
}
|
||||
},
|
||||
}),
|
||||
fireAndForget: defineAction({
|
||||
handler: async () => {
|
||||
return;
|
||||
}
|
||||
},
|
||||
}),
|
||||
zero: defineAction({
|
||||
handler: async () => {
|
||||
return 0;
|
||||
}
|
||||
},
|
||||
}),
|
||||
false: defineAction({
|
||||
handler: async () => {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
}),
|
||||
complexValues: defineAction({
|
||||
handler: async () => {
|
||||
|
@ -80,7 +134,7 @@ export const server = {
|
|||
date: new Date(),
|
||||
set: new Set(),
|
||||
url: new URL('https://example.com'),
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
|
|
@ -192,4 +192,22 @@ describe('formDataToObject', () => {
|
|||
assert.equal(res.files instanceof Array, true);
|
||||
assert.deepEqual(res.files, [file1, file2]);
|
||||
});
|
||||
|
||||
it('should allow object passthrough when chaining .passthrough() on root object', () => {
|
||||
const formData = new FormData();
|
||||
formData.set('expected', '42');
|
||||
formData.set('unexpected', '42');
|
||||
|
||||
const input = z
|
||||
.object({
|
||||
expected: z.number(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const res = formDataToObject(formData, input);
|
||||
assert.deepEqual(res, {
|
||||
expected: 42,
|
||||
unexpected: '42',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
Loading…
Add table
Reference in a new issue