Skip to content

Commit

Permalink
Actions: fix custom error message on client (#11030)
Browse files Browse the repository at this point in the history
* feat(test): error throwing on server

* feat: correctly parse custom errors for the client

* feat(test): custom errors on client

* chore: changeset
  • Loading branch information
bholmesdev authored May 13, 2024
1 parent c135cd5 commit 18e7f33
Show file tree
Hide file tree
Showing 11 changed files with 112 additions and 31 deletions.
5 changes: 5 additions & 0 deletions .changeset/slimy-comics-thank.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"astro": patch
---

Actions: Fix missing message for custom Action errors.
16 changes: 16 additions & 0 deletions packages/astro/e2e/actions-blog.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,22 @@ test.describe('Astro Actions - Blog', () => {
await expect(page.locator('p[data-error="body"]')).toBeVisible();
});

test('Comment action - custom error', async ({ page, astro }) => {
await page.goto(astro.resolveUrl('/blog/first-post/?commentPostIdOverride=bogus'));

const authorInput = page.locator('input[name="author"]');
const bodyInput = page.locator('textarea[name="body"]');
await authorInput.fill('Ben');
await bodyInput.fill('This should be long enough.');

const submitButton = page.getByLabel('Post comment');
await submitButton.click();

const unexpectedError = page.locator('p[data-error="unexpected"]');
await expect(unexpectedError).toBeVisible();
await expect(unexpectedError).toContainText('NOT_FOUND: Post not found');
});

test('Comment action - success', async ({ page, astro }) => {
await page.goto(astro.resolveUrl('/blog/first-post/'));

Expand Down
10 changes: 9 additions & 1 deletion packages/astro/e2e/fixtures/actions-blog/src/actions/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { db, Comment, Likes, eq, sql } from 'astro:db';
import { defineAction, z } from 'astro:actions';
import { ActionError, defineAction, z } from 'astro:actions';
import { getCollection } from 'astro:content';

export const server = {
blog: {
Expand Down Expand Up @@ -29,6 +30,13 @@ export const server = {
body: z.string().min(10),
}),
handler: async ({ postId, author, body }) => {
if (!(await getCollection('blog')).find(b => b.id === postId)) {
throw new ActionError({
code: 'NOT_FOUND',
message: 'Post not found',
});
}

const comment = await db
.insert(Comment)
.values({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export function PostComment({
}) {
const [comments, setComments] = useState<{ author: string; body: string }[]>([]);
const [bodyError, setBodyError] = useState<string | undefined>(serverBodyError);
const [unexpectedError, setUnexpectedError] = useState<string | undefined>(undefined);

return (
<>
Expand All @@ -22,14 +23,15 @@ export function PostComment({
const { data, error } = await actions.blog.comment.safe(formData);
if (isInputError(error)) {
return setBodyError(error.fields.body?.join(' '));
} else if (error) {
return setUnexpectedError(`${error.code}: ${error.message}`);
}
if (data) {
setBodyError(undefined);
setComments((c) => [data, ...c]);
}
setBodyError(undefined);
setComments((c) => [data, ...c]);
form.reset();
}}
>
{unexpectedError && <p data-error="unexpected" style={{ color: 'red' }}>{unexpectedError}</p>}
<input {...getActionProps(actions.blog.comment)} />
<input type="hidden" name="postId" value={postId} />
<label className="sr-only" htmlFor="author">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ const comment = Astro.getActionResult(actions.blog.comment);
const comments = await db.select().from(Comment).where(eq(Comment.postId, post.id));
const initialLikes = await db.select().from(Likes).where(eq(Likes.postId, post.id)).get();
// Used to force validation errors for testing
const commentPostIdOverride = Astro.url.searchParams.get('commentPostIdOverride');
---

<BlogPost {...post.data}>
Expand All @@ -36,7 +39,7 @@ const initialLikes = await db.select().from(Likes).where(eq(Likes.postId, post.i

<h2>Comments</h2>
<PostComment
postId={post.id}
postId={commentPostIdOverride ?? post.id}
serverBodyError={isInputError(comment?.error)
? comment.error.fields.body?.toString()
: undefined}
Expand Down
23 changes: 13 additions & 10 deletions packages/astro/src/actions/runtime/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,19 @@ export const POST: APIRoute = async (context) => {
}
const result = await ApiContextStorage.run(context, () => callSafely(() => action(args)));
if (result.error) {
if (import.meta.env.PROD) {
// Avoid leaking stack trace in production
result.error.stack = undefined;
}
return new Response(JSON.stringify(result.error), {
status: result.error.status,
headers: {
'Content-Type': 'application/json',
},
});
return new Response(
JSON.stringify({
...result.error,
message: result.error.message,
stack: import.meta.env.PROD ? undefined : result.error.stack,
}),
{
status: result.error.status,
headers: {
'Content-Type': 'application/json',
},
}
);
}
return new Response(JSON.stringify(result.data), {
headers: {
Expand Down
25 changes: 13 additions & 12 deletions packages/astro/src/actions/runtime/virtual/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,13 @@ export class ActionError<T extends ErrorInferenceObject = ErrorInferenceObject>
code: ActionErrorCode = 'INTERNAL_SERVER_ERROR';
status = 500;

constructor(params: { message?: string; code: ActionErrorCode }) {
constructor(params: { message?: string; code: ActionErrorCode; stack?: string }) {
super(params.message);
this.code = params.code;
this.status = ActionError.codeToStatus(params.code);
if (params.stack) {
this.stack = params.stack;
}
}

static codeToStatus(code: ActionErrorCode): number {
Expand All @@ -62,22 +65,20 @@ export class ActionError<T extends ErrorInferenceObject = ErrorInferenceObject>
}

static async fromResponse(res: Response) {
const body = await res.clone().json();
if (
res.status === 400 &&
res.headers.get('Content-Type')?.toLowerCase().startsWith('application/json')
typeof body === 'object' &&
body?.type === 'AstroActionInputError' &&
Array.isArray(body.issues)
) {
const body = await res.json();
if (
typeof body === 'object' &&
body?.type === 'AstroActionInputError' &&
Array.isArray(body.issues)
) {
return new ActionInputError(body.issues);
}
return new ActionInputError(body.issues);
}
if (typeof body === 'object' && body?.type === 'AstroActionError') {
return new ActionError(body);
}
return new ActionError({
message: res.statusText,
code: this.statusToCode(res.status),
code: ActionError.statusToCode(res.status),
});
}
}
Expand Down
19 changes: 18 additions & 1 deletion packages/astro/test/actions.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ describe('Astro Actions', () => {
it('Respects user middleware', async () => {
const formData = new FormData();
formData.append('_astroAction', '/_actions/getUser');
const req = new Request('http://example.com/middleware', {
const req = new Request('http://example.com/user', {
method: 'POST',
body: formData,
});
Expand All @@ -185,5 +185,22 @@ describe('Astro Actions', () => {
let $ = cheerio.load(html);
assert.equal($('#user').text(), 'Houston');
});

it('Respects custom errors', async () => {
const formData = new FormData();
formData.append('_astroAction', '/_actions/getUserOrThrow');
const req = new Request('http://example.com/user-or-throw', {
method: 'POST',
body: formData,
});
const res = await app.render(req);
assert.equal(res.ok, false);
assert.equal(res.status, 401);

const html = await res.text();
let $ = cheerio.load(html);
assert.equal($('#error-message').text(), 'Not logged in');
assert.equal($('#error-code').text(), 'UNAUTHORIZED');
})
});
});
18 changes: 16 additions & 2 deletions packages/astro/test/fixtures/actions/src/actions/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { defineAction, getApiContext, z } from 'astro:actions';
import { defineAction, getApiContext, ActionError, z } from 'astro:actions';

export const server = {
subscribe: defineAction({
Expand Down Expand Up @@ -35,5 +35,19 @@ export const server = {
const { locals } = getApiContext();
return locals.user;
}
})
}),
getUserOrThrow: defineAction({
accept: 'form',
handler: async () => {
const { locals } = getApiContext();
if (locals.user?.name !== 'admin') {
// Expected to throw
throw new ActionError({
code: 'UNAUTHORIZED',
message: 'Not logged in',
});
}
return locals.user;
}
}),
};
12 changes: 12 additions & 0 deletions packages/astro/test/fixtures/actions/src/pages/user-or-throw.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
import { actions } from 'astro:actions';
const res = Astro.getActionResult(actions.getUserOrThrow);
if (res?.error) {
Astro.response.status = res.error.status;
}
---

<p id="error-message">{res?.error?.message}</p>
<p id="error-code">{res?.error?.code}</p>

0 comments on commit 18e7f33

Please sign in to comment.