0
Fork 0
mirror of https://github.com/withastro/astro.git synced 2024-12-30 22:03:56 -05:00

feat(actions): add support for accept: 'search' for GET requests

This commit is contained in:
Wes Souza 2024-11-26 21:05:24 -05:00
parent 74ee2e45ec
commit e76bc82909
6 changed files with 88 additions and 12 deletions

View file

@ -76,7 +76,7 @@ export const server = {
}),
},
sum: defineAction({
accept: 'form',
accept: 'search',
input: z.object({
a: z.number(),
b: z.number(),

View file

@ -27,7 +27,7 @@ import {
export * from './shared.js';
export type ActionAccept = 'form' | 'json';
export type ActionAccept = 'form' | 'json' | 'search';
export type ActionHandler<TInputSchema, TOutput> = TInputSchema extends z.ZodType
? (input: z.infer<TInputSchema>, context: ActionAPIContext) => MaybePromise<TOutput>
@ -78,6 +78,8 @@ export function defineAction<
const serverHandler =
accept === 'form'
? getFormServerHandler(handler, inputSchema)
: accept === 'search'
? getSearchServerHandler(handler, inputSchema)
: getJsonServerHandler(handler, inputSchema);
async function safeServerHandler(this: ActionAPIContext, unparsedInput: unknown) {
@ -148,9 +150,36 @@ function getJsonServerHandler<TOutput, TInputSchema extends z.ZodType>(
};
}
function getSearchServerHandler<TOutput, TInputSchema extends z.ZodType>(
handler: ActionHandler<TInputSchema, TOutput>,
inputSchema?: TInputSchema,
) {
return async (unparsedInput: unknown, context: ActionAPIContext): Promise<Awaited<TOutput>> => {
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<T extends z.AnyZodObject>(
formData: FormData,
formData: FormData | URLSearchParams,
schema: T,
): Record<string, unknown> {
const obj: Record<string, unknown> =
@ -187,7 +216,7 @@ export function formDataToObject<T extends z.AnyZodObject>(
function handleFormDataGetAll(
key: string,
formData: FormData,
formData: FormData | URLSearchParams,
validator: z.ZodArray<z.ZodUnknown>,
) {
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;

View file

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

View file

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

View file

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

View file

@ -18,6 +18,7 @@ describe('defineAction accept', () => {
expectTypeOf(jsonAction).parameter(0).toBeAny();
expectTypeOf(jsonAction).parameter(0).not.toEqualTypeOf<FormData>();
});
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<FormData>();
});
it('accepts type `URLSearchParams` when input is omitted with accept form', async () => {
const action = defineAction({
accept: 'search',
handler: () => {},
});
expectTypeOf(action).parameter(0).toEqualTypeOf<URLSearchParams>();
});
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<FormData>();
});
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<URLSearchParams>();
});
});