Skip to content

Commit

Permalink
Actions: Allow effect chaining on form input validators (#11809)
Browse files Browse the repository at this point in the history
* 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()
  • Loading branch information
bholmesdev committed Aug 22, 2024
1 parent 260c4be commit 62e97a2
Show file tree
Hide file tree
Showing 6 changed files with 178 additions and 21 deletions.
5 changes: 5 additions & 0 deletions .changeset/pink-kids-taste.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': patch
---

Fixes usage of `.transform()`, `.refine()`, `.passthrough()`, and other effects on Action form inputs.
6 changes: 3 additions & 3 deletions packages/astro/src/@types/astro.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { OutgoingHttpHeaders } from 'node:http';
import type { z } from 'zod';
import type { AddressInfo } from 'node:net';
import type {
MarkdownHeading,
Expand All @@ -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';
Expand Down Expand Up @@ -3010,7 +3010,7 @@ interface AstroSharedContext<
*/
getActionResult: <
TAccept extends ActionAccept,
TInputSchema extends ActionInputSchema<TAccept>,
TInputSchema extends z.ZodType,
TAction extends ActionClient<unknown, TAccept, TInputSchema>,
>(
action: TAction,
Expand All @@ -3020,7 +3020,7 @@ interface AstroSharedContext<
*/
callAction: <
TAccept extends ActionAccept,
TInputSchema extends ActionInputSchema<TAccept>,
TInputSchema extends z.ZodType,
TOutput,
TAction extends
| ActionClient<TOutput, TAccept, TInputSchema>
Expand Down
35 changes: 25 additions & 10 deletions packages/astro/src/actions/runtime/virtual/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,6 @@ export * from './shared.js';
export { z } from 'zod';

export type ActionAccept = 'form' | 'json';
export type ActionInputSchema<T extends ActionAccept | undefined> = T extends 'form'
? z.AnyZodObject | z.ZodType<FormData>
: z.ZodType;

export type ActionHandler<TInputSchema, TOutput> = TInputSchema extends z.ZodType
? (input: z.infer<TInputSchema>, context: ActionAPIContext) => MaybePromise<TOutput>
Expand All @@ -22,7 +19,7 @@ export type ActionReturnType<T extends ActionHandler<any, any>> = Awaited<Return
export type ActionClient<
TOutput,
TAccept extends ActionAccept | undefined,
TInputSchema extends ActionInputSchema<TAccept> | undefined,
TInputSchema extends z.ZodType | undefined,
> = TInputSchema extends z.ZodType
? ((
input: TAccept extends 'form' ? FormData : z.input<TInputSchema>,
Expand All @@ -46,7 +43,7 @@ export type ActionClient<
export function defineAction<
TOutput,
TAccept extends ActionAccept | undefined = undefined,
TInputSchema extends ActionInputSchema<ActionAccept> | 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<FormData>
: undefined,
Expand Down Expand Up @@ -83,7 +80,7 @@ export function defineAction<
return safeServerHandler as ActionClient<TOutput, TAccept, TInputSchema> & string;
}

function getFormServerHandler<TOutput, TInputSchema extends ActionInputSchema<'form'>>(
function getFormServerHandler<TOutput, TInputSchema extends z.ZodType>(
handler: ActionHandler<TInputSchema, TOutput>,
inputSchema?: TInputSchema,
) {
Expand All @@ -95,17 +92,22 @@ function getFormServerHandler<TOutput, TInputSchema extends ActionInputSchema<'f
});
}

if (!(inputSchema instanceof z.ZodObject)) return await handler(unparsedInput, context);
if (!inputSchema) return await handler(unparsedInput, context);

const parsed = await inputSchema.safeParseAsync(formDataToObject(unparsedInput, inputSchema));
const baseSchema = unwrapSchemaEffects(inputSchema);
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);
};
}

function getJsonServerHandler<TOutput, TInputSchema extends ActionInputSchema<'json'>>(
function getJsonServerHandler<TOutput, TInputSchema extends z.ZodType>(
handler: ActionHandler<TInputSchema, TOutput>,
inputSchema?: TInputSchema,
) {
Expand All @@ -131,7 +133,8 @@ export function formDataToObject<T extends z.AnyZodObject>(
formData: FormData,
schema: T,
): Record<string, unknown> {
const obj: Record<string, unknown> = {};
const obj: Record<string, unknown> =
schema._def.unknownKeys === 'passthrough' ? Object.fromEntries(formData.entries()) : {};
for (const [key, baseValidator] of Object.entries(schema.shape)) {
let validator = baseValidator;

Expand Down Expand Up @@ -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;
}
65 changes: 65 additions & 0 deletions packages/astro/test/actions.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
70 changes: 62 additions & 8 deletions packages/astro/test/fixtures/actions/src/actions/index.ts
Original file line number Diff line number Diff line change
@@ -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() }),
Expand Down Expand Up @@ -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',
Expand All @@ -57,30 +111,30 @@ 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 () => {
return {
date: new Date(),
set: new Set(),
url: new URL('https://example.com'),
}
}
})
};
},
}),
};
18 changes: 18 additions & 0 deletions packages/astro/test/units/actions/form-data-to-object.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
});
});
});

0 comments on commit 62e97a2

Please sign in to comment.