diff --git a/packages/astro/e2e/fixtures/actions-blog/src/actions/index.ts b/packages/astro/e2e/fixtures/actions-blog/src/actions/index.ts index 01b479b2b9..e4730367aa 100644 --- a/packages/astro/e2e/fixtures/actions-blog/src/actions/index.ts +++ b/packages/astro/e2e/fixtures/actions-blog/src/actions/index.ts @@ -76,7 +76,7 @@ export const server = { }), }, sum: defineAction({ - accept: 'form', + accept: 'search', input: z.object({ a: z.number(), b: z.number(), diff --git a/packages/astro/src/actions/runtime/virtual/server.ts b/packages/astro/src/actions/runtime/virtual/server.ts index 47decf18e5..38aa2b7257 100644 --- a/packages/astro/src/actions/runtime/virtual/server.ts +++ b/packages/astro/src/actions/runtime/virtual/server.ts @@ -27,7 +27,7 @@ import { export * from './shared.js'; -export type ActionAccept = 'form' | 'json'; +export type ActionAccept = 'form' | 'json' | 'search'; export type ActionHandler = TInputSchema extends z.ZodType ? (input: z.infer, context: ActionAPIContext) => MaybePromise @@ -78,7 +78,9 @@ export function defineAction< const serverHandler = accept === 'form' ? getFormServerHandler(handler, inputSchema) - : getJsonServerHandler(handler, inputSchema); + : accept === 'search' + ? getSearchServerHandler(handler, inputSchema) + : getJsonServerHandler(handler, inputSchema); async function safeServerHandler(this: ActionAPIContext, unparsedInput: unknown) { // The ActionAPIContext should always contain the `params` property @@ -148,9 +150,36 @@ function getJsonServerHandler( }; } +function getSearchServerHandler( + handler: ActionHandler, + inputSchema?: TInputSchema, +) { + return async (unparsedInput: unknown, context: ActionAPIContext): Promise> => { + if (!(unparsedInput instanceof URLSearchParams)) { + throw new ActionError({ + code: 'UNSUPPORTED_MEDIA_TYPE', + message: 'This action only accepts URLSearchParams.', + }); + } + + if (!inputSchema) return await handler(unparsedInput, context); + + const baseSchema = unwrapBaseObjectSchema(inputSchema, unparsedInput); + const parsed = await inputSchema.safeParseAsync( + baseSchema instanceof z.ZodObject + ? formDataToObject(unparsedInput, baseSchema) + : unparsedInput, + ); + if (!parsed.success) { + throw new ActionInputError(parsed.error.issues); + } + return await handler(parsed.data, context); + }; +} + /** Transform form data to an object based on a Zod schema. */ export function formDataToObject( - formData: FormData, + formData: FormData | URLSearchParams, schema: T, ): Record { const obj: Record = @@ -187,7 +216,7 @@ export function formDataToObject( function handleFormDataGetAll( key: string, - formData: FormData, + formData: FormData | URLSearchParams, validator: z.ZodArray, ) { const entries = Array.from(formData.getAll(key)); @@ -202,7 +231,7 @@ function handleFormDataGetAll( function handleFormDataGet( key: string, - formData: FormData, + formData: FormData | URLSearchParams, validator: unknown, baseValidator: unknown, ) { @@ -213,7 +242,7 @@ function handleFormDataGet( return validator instanceof z.ZodNumber ? Number(value) : value; } -function unwrapBaseObjectSchema(schema: z.ZodType, unparsedInput: FormData) { +function unwrapBaseObjectSchema(schema: z.ZodType, unparsedInput: FormData | URLSearchParams) { while (schema instanceof z.ZodEffects || schema instanceof z.ZodPipeline) { if (schema instanceof z.ZodEffects) { schema = schema._def.schema; diff --git a/packages/astro/templates/actions.mjs b/packages/astro/templates/actions.mjs index 82a287448a..2cf7e41abb 100644 --- a/packages/astro/templates/actions.mjs +++ b/packages/astro/templates/actions.mjs @@ -67,8 +67,15 @@ async function handleAction(param, path, context) { // When running client-side, make a fetch request to the action path. const headers = new Headers(); headers.set('Accept', 'application/json'); - let body = param; - if (!(body instanceof FormData)) { + let method = 'POST' + let search = '' + let body + if (param instanceof URLSearchParams) { + method = 'GET' + search = `?${param.toString()}` + } else if (param instanceof FormData) { + body = param + } else { try { body = JSON.stringify(param); } catch (e) { @@ -83,8 +90,8 @@ async function handleAction(param, path, context) { headers.set('Content-Length', '0'); } } - const rawResult = await fetch(`${import.meta.env.BASE_URL.replace(/\/$/, '')}/_actions/${path}`, { - method: 'POST', + const rawResult = await fetch(`${import.meta.env.BASE_URL.replace(/\/$/, '')}/_actions/${path}${search}`, { + method, body, headers, }); diff --git a/packages/astro/test/actions.test.js b/packages/astro/test/actions.test.js index d8da4e72e3..d99f60d9fa 100644 --- a/packages/astro/test/actions.test.js +++ b/packages/astro/test/actions.test.js @@ -254,8 +254,22 @@ describe('Astro Actions', () => { assert.equal($('#user').text(), 'Houston'); }); + it('Supports URLSearchParams', async () => { + const req = new Request('http://example.com/_actions/getUserProperty?property=name', { + method: 'GET', + }); + + 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.deepEqual(data, 'Houston'); + }); + it('Respects custom errors - POST', async () => { - const req = new Request('http://example.com/user-or-throw?_action=getUserOrThrow', { + const req = new Request('http://example.com/user-or-throw?_astroAction=getUserOrThrow', { method: 'POST', body: new FormData(), headers: { diff --git a/packages/astro/test/fixtures/actions/src/actions/index.ts b/packages/astro/test/fixtures/actions/src/actions/index.ts index d3cd1b1bba..ffb9381912 100644 --- a/packages/astro/test/fixtures/actions/src/actions/index.ts +++ b/packages/astro/test/fixtures/actions/src/actions/index.ts @@ -75,6 +75,14 @@ export const server = { return locals.user; }, }), + getUserProperty: defineAction({ + accept: 'search', + input: z + .object({ property: z.string() }), + handler: async (input, { locals }) => { + return locals.user[input.property]; + }, + }), validatePassword: defineAction({ accept: 'form', input: z diff --git a/packages/astro/test/types/define-action-accept.ts b/packages/astro/test/types/define-action-accept.ts index c9a9ca315a..7f81511827 100644 --- a/packages/astro/test/types/define-action-accept.ts +++ b/packages/astro/test/types/define-action-accept.ts @@ -18,6 +18,7 @@ describe('defineAction accept', () => { expectTypeOf(jsonAction).parameter(0).toBeAny(); expectTypeOf(jsonAction).parameter(0).not.toEqualTypeOf(); }); + it('accepts type `FormData` when input is omitted with accept form', async () => { const action = defineAction({ accept: 'form', @@ -26,6 +27,14 @@ describe('defineAction accept', () => { expectTypeOf(action).parameter(0).toEqualTypeOf(); }); + it('accepts type `URLSearchParams` when input is omitted with accept form', async () => { + const action = defineAction({ + accept: 'search', + handler: () => {}, + }); + expectTypeOf(action).parameter(0).toEqualTypeOf(); + }); + it('accept type safe values for input with accept json', async () => { const action = defineAction({ input: z.object({ name: z.string() }), @@ -42,4 +51,13 @@ describe('defineAction accept', () => { }); expectTypeOf(action).parameter(0).toEqualTypeOf(); }); + + it('accepts type `URLSearchParams` for all inputs with accept form', async () => { + const action = defineAction({ + accept: 'form', + input: z.object({ name: z.string() }), + handler: () => {}, + }); + expectTypeOf(action).parameter(0).toEqualTypeOf(); + }); });