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({ sum: defineAction({
accept: 'form', accept: 'search',
input: z.object({ input: z.object({
a: z.number(), a: z.number(),
b: z.number(), b: z.number(),

View file

@ -27,7 +27,7 @@ import {
export * from './shared.js'; export * from './shared.js';
export type ActionAccept = 'form' | 'json'; export type ActionAccept = 'form' | 'json' | 'search';
export type ActionHandler<TInputSchema, TOutput> = TInputSchema extends z.ZodType export type ActionHandler<TInputSchema, TOutput> = TInputSchema extends z.ZodType
? (input: z.infer<TInputSchema>, context: ActionAPIContext) => MaybePromise<TOutput> ? (input: z.infer<TInputSchema>, context: ActionAPIContext) => MaybePromise<TOutput>
@ -78,7 +78,9 @@ export function defineAction<
const serverHandler = const serverHandler =
accept === 'form' accept === 'form'
? getFormServerHandler(handler, inputSchema) ? getFormServerHandler(handler, inputSchema)
: getJsonServerHandler(handler, inputSchema); : accept === 'search'
? getSearchServerHandler(handler, inputSchema)
: getJsonServerHandler(handler, inputSchema);
async function safeServerHandler(this: ActionAPIContext, unparsedInput: unknown) { async function safeServerHandler(this: ActionAPIContext, unparsedInput: unknown) {
// The ActionAPIContext should always contain the `params` property // The ActionAPIContext should always contain the `params` property
@ -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. */ /** Transform form data to an object based on a Zod schema. */
export function formDataToObject<T extends z.AnyZodObject>( export function formDataToObject<T extends z.AnyZodObject>(
formData: FormData, formData: FormData | URLSearchParams,
schema: T, schema: T,
): Record<string, unknown> { ): Record<string, unknown> {
const obj: Record<string, unknown> = const obj: Record<string, unknown> =
@ -187,7 +216,7 @@ export function formDataToObject<T extends z.AnyZodObject>(
function handleFormDataGetAll( function handleFormDataGetAll(
key: string, key: string,
formData: FormData, formData: FormData | URLSearchParams,
validator: z.ZodArray<z.ZodUnknown>, validator: z.ZodArray<z.ZodUnknown>,
) { ) {
const entries = Array.from(formData.getAll(key)); const entries = Array.from(formData.getAll(key));
@ -202,7 +231,7 @@ function handleFormDataGetAll(
function handleFormDataGet( function handleFormDataGet(
key: string, key: string,
formData: FormData, formData: FormData | URLSearchParams,
validator: unknown, validator: unknown,
baseValidator: unknown, baseValidator: unknown,
) { ) {
@ -213,7 +242,7 @@ function handleFormDataGet(
return validator instanceof z.ZodNumber ? Number(value) : value; 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) { while (schema instanceof z.ZodEffects || schema instanceof z.ZodPipeline) {
if (schema instanceof z.ZodEffects) { if (schema instanceof z.ZodEffects) {
schema = schema._def.schema; 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. // When running client-side, make a fetch request to the action path.
const headers = new Headers(); const headers = new Headers();
headers.set('Accept', 'application/json'); headers.set('Accept', 'application/json');
let body = param; let method = 'POST'
if (!(body instanceof FormData)) { let search = ''
let body
if (param instanceof URLSearchParams) {
method = 'GET'
search = `?${param.toString()}`
} else if (param instanceof FormData) {
body = param
} else {
try { try {
body = JSON.stringify(param); body = JSON.stringify(param);
} catch (e) { } catch (e) {
@ -83,8 +90,8 @@ async function handleAction(param, path, context) {
headers.set('Content-Length', '0'); headers.set('Content-Length', '0');
} }
} }
const rawResult = await fetch(`${import.meta.env.BASE_URL.replace(/\/$/, '')}/_actions/${path}`, { const rawResult = await fetch(`${import.meta.env.BASE_URL.replace(/\/$/, '')}/_actions/${path}${search}`, {
method: 'POST', method,
body, body,
headers, headers,
}); });

View file

@ -254,8 +254,22 @@ describe('Astro Actions', () => {
assert.equal($('#user').text(), 'Houston'); 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 () => { 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', method: 'POST',
body: new FormData(), body: new FormData(),
headers: { headers: {

View file

@ -75,6 +75,14 @@ export const server = {
return locals.user; return locals.user;
}, },
}), }),
getUserProperty: defineAction({
accept: 'search',
input: z
.object({ property: z.string() }),
handler: async (input, { locals }) => {
return locals.user[input.property];
},
}),
validatePassword: defineAction({ validatePassword: defineAction({
accept: 'form', accept: 'form',
input: z input: z

View file

@ -18,6 +18,7 @@ describe('defineAction accept', () => {
expectTypeOf(jsonAction).parameter(0).toBeAny(); expectTypeOf(jsonAction).parameter(0).toBeAny();
expectTypeOf(jsonAction).parameter(0).not.toEqualTypeOf<FormData>(); expectTypeOf(jsonAction).parameter(0).not.toEqualTypeOf<FormData>();
}); });
it('accepts type `FormData` when input is omitted with accept form', async () => { it('accepts type `FormData` when input is omitted with accept form', async () => {
const action = defineAction({ const action = defineAction({
accept: 'form', accept: 'form',
@ -26,6 +27,14 @@ describe('defineAction accept', () => {
expectTypeOf(action).parameter(0).toEqualTypeOf<FormData>(); 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 () => { it('accept type safe values for input with accept json', async () => {
const action = defineAction({ const action = defineAction({
input: z.object({ name: z.string() }), input: z.object({ name: z.string() }),
@ -42,4 +51,13 @@ describe('defineAction accept', () => {
}); });
expectTypeOf(action).parameter(0).toEqualTypeOf<FormData>(); 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>();
});
}); });