From 62e97a20f72bacb017c633ddcb776abc89167660 Mon Sep 17 00:00:00 2001 From: Ben Holmes Date: Thu, 22 Aug 2024 05:51:24 -0400 Subject: [PATCH] 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() --- .changeset/pink-kids-taste.md | 5 ++ packages/astro/src/@types/astro.ts | 6 +- .../src/actions/runtime/virtual/server.ts | 35 +++++++--- packages/astro/test/actions.test.js | 65 +++++++++++++++++ .../fixtures/actions/src/actions/index.ts | 70 ++++++++++++++++--- .../units/actions/form-data-to-object.test.js | 18 +++++ 6 files changed, 178 insertions(+), 21 deletions(-) create mode 100644 .changeset/pink-kids-taste.md diff --git a/.changeset/pink-kids-taste.md b/.changeset/pink-kids-taste.md new file mode 100644 index 000000000000..1b1b61125ff7 --- /dev/null +++ b/.changeset/pink-kids-taste.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Fixes usage of `.transform()`, `.refine()`, `.passthrough()`, and other effects on Action form inputs. diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts index 30d5a50d872e..ab4dcc11a054 100644 --- a/packages/astro/src/@types/astro.ts +++ b/packages/astro/src/@types/astro.ts @@ -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, + TInputSchema extends z.ZodType, TAction extends ActionClient, >( action: TAction, @@ -3020,7 +3020,7 @@ interface AstroSharedContext< */ callAction: < TAccept extends ActionAccept, - TInputSchema extends ActionInputSchema, + TInputSchema extends z.ZodType, TOutput, TAction extends | ActionClient diff --git a/packages/astro/src/actions/runtime/virtual/server.ts b/packages/astro/src/actions/runtime/virtual/server.ts index 9bc387d6b860..fcb0dc6030dd 100644 --- a/packages/astro/src/actions/runtime/virtual/server.ts +++ b/packages/astro/src/actions/runtime/virtual/server.ts @@ -9,9 +9,6 @@ export * from './shared.js'; export { z } from 'zod'; export type ActionAccept = 'form' | 'json'; -export type ActionInputSchema = T extends 'form' - ? z.AnyZodObject | z.ZodType - : z.ZodType; export type ActionHandler = TInputSchema extends z.ZodType ? (input: z.infer, context: ActionAPIContext) => MaybePromise @@ -22,7 +19,7 @@ export type ActionReturnType> = Awaited | undefined, + TInputSchema extends z.ZodType | undefined, > = TInputSchema extends z.ZodType ? (( input: TAccept extends 'form' ? FormData : z.input, @@ -46,7 +43,7 @@ export type ActionClient< export function defineAction< TOutput, TAccept extends ActionAccept | undefined = undefined, - TInputSchema extends ActionInputSchema | 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 : undefined, @@ -83,7 +80,7 @@ export function defineAction< return safeServerHandler as ActionClient & string; } -function getFormServerHandler>( +function getFormServerHandler( handler: ActionHandler, inputSchema?: TInputSchema, ) { @@ -95,9 +92,14 @@ function getFormServerHandler>( +function getJsonServerHandler( handler: ActionHandler, inputSchema?: TInputSchema, ) { @@ -131,7 +133,8 @@ export function formDataToObject( formData: FormData, schema: T, ): Record { - const obj: Record = {}; + const obj: Record = + 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; +} diff --git a/packages/astro/test/actions.test.js b/packages/astro/test/actions.test.js index 0b2cc6a8151a..806bfad4b237 100644 --- a/packages/astro/test/actions.test.js +++ b/packages/astro/test/actions.test.js @@ -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(); diff --git a/packages/astro/test/fixtures/actions/src/actions/index.ts b/packages/astro/test/fixtures/actions/src/actions/index.ts index bc61ade3ab63..881656994f22 100644 --- a/packages/astro/test/fixtures/actions/src/actions/index.ts +++ b/packages/astro/test/fixtures/actions/src/actions/index.ts @@ -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'), - } - } - }) + }; + }, + }), }; diff --git a/packages/astro/test/units/actions/form-data-to-object.test.js b/packages/astro/test/units/actions/form-data-to-object.test.js index 136909305689..e9f52a13fef2 100644 --- a/packages/astro/test/units/actions/form-data-to-object.test.js +++ b/packages/astro/test/units/actions/form-data-to-object.test.js @@ -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', + }); + }); });