0
Fork 0
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:
Ben Holmes 2024-08-22 05:51:24 -04:00 committed by GitHub
parent 260c4be050
commit 62e97a20f7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 178 additions and 21 deletions

View file

@ -0,0 +1,5 @@
---
'astro': patch
---
Fixes usage of `.transform()`, `.refine()`, `.passthrough()`, and other effects on Action form inputs.

View file

@ -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>

View file

@ -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;
}

View file

@ -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();

View file

@ -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'),
}
}
})
};
},
}),
};

View file

@ -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',
});
});
});