Skip to content

Commit

Permalink
feat(actions): add support for accept: 'search' for GET requests
Browse files Browse the repository at this point in the history
  • Loading branch information
WesSouza committed Dec 1, 2024
1 parent 74ee2e4 commit e76bc82
Show file tree
Hide file tree
Showing 6 changed files with 88 additions and 12 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ export const server = {
}),
},
sum: defineAction({
accept: 'form',
accept: 'search',
input: z.object({
a: z.number(),
b: z.number(),
Expand Down
41 changes: 35 additions & 6 deletions packages/astro/src/actions/runtime/virtual/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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> =
Expand Down Expand Up @@ -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));
Expand All @@ -202,7 +231,7 @@ function handleFormDataGetAll(

function handleFormDataGet(
key: string,
formData: FormData,
formData: FormData | URLSearchParams,
validator: unknown,
baseValidator: unknown,
) {
Expand All @@ -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;
Expand Down
15 changes: 11 additions & 4 deletions packages/astro/templates/actions.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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,
});
Expand Down
16 changes: 15 additions & 1 deletion packages/astro/test/actions.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
8 changes: 8 additions & 0 deletions packages/astro/test/fixtures/actions/src/actions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions packages/astro/test/types/define-action-accept.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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() }),
Expand All @@ -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>();
});
});

0 comments on commit e76bc82

Please sign in to comment.