Skip to content

Commit 18e7f33

Browse files
authoredMay 13, 2024··
Actions: fix custom error message on client (#11030)
* feat(test): error throwing on server * feat: correctly parse custom errors for the client * feat(test): custom errors on client * chore: changeset
1 parent c135cd5 commit 18e7f33

File tree

11 files changed

+112
-31
lines changed

11 files changed

+112
-31
lines changed
 

‎.changeset/slimy-comics-thank.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"astro": patch
3+
---
4+
5+
Actions: Fix missing message for custom Action errors.

‎packages/astro/e2e/actions-blog.test.js

+16
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,22 @@ test.describe('Astro Actions - Blog', () => {
3838
await expect(page.locator('p[data-error="body"]')).toBeVisible();
3939
});
4040

41+
test('Comment action - custom error', async ({ page, astro }) => {
42+
await page.goto(astro.resolveUrl('/blog/first-post/?commentPostIdOverride=bogus'));
43+
44+
const authorInput = page.locator('input[name="author"]');
45+
const bodyInput = page.locator('textarea[name="body"]');
46+
await authorInput.fill('Ben');
47+
await bodyInput.fill('This should be long enough.');
48+
49+
const submitButton = page.getByLabel('Post comment');
50+
await submitButton.click();
51+
52+
const unexpectedError = page.locator('p[data-error="unexpected"]');
53+
await expect(unexpectedError).toBeVisible();
54+
await expect(unexpectedError).toContainText('NOT_FOUND: Post not found');
55+
});
56+
4157
test('Comment action - success', async ({ page, astro }) => {
4258
await page.goto(astro.resolveUrl('/blog/first-post/'));
4359

‎packages/astro/e2e/fixtures/actions-blog/src/actions/index.ts

+9-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { db, Comment, Likes, eq, sql } from 'astro:db';
2-
import { defineAction, z } from 'astro:actions';
2+
import { ActionError, defineAction, z } from 'astro:actions';
3+
import { getCollection } from 'astro:content';
34

45
export const server = {
56
blog: {
@@ -29,6 +30,13 @@ export const server = {
2930
body: z.string().min(10),
3031
}),
3132
handler: async ({ postId, author, body }) => {
33+
if (!(await getCollection('blog')).find(b => b.id === postId)) {
34+
throw new ActionError({
35+
code: 'NOT_FOUND',
36+
message: 'Post not found',
37+
});
38+
}
39+
3240
const comment = await db
3341
.insert(Comment)
3442
.values({

‎packages/astro/e2e/fixtures/actions-blog/src/components/PostComment.tsx

+6-4
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export function PostComment({
1010
}) {
1111
const [comments, setComments] = useState<{ author: string; body: string }[]>([]);
1212
const [bodyError, setBodyError] = useState<string | undefined>(serverBodyError);
13+
const [unexpectedError, setUnexpectedError] = useState<string | undefined>(undefined);
1314

1415
return (
1516
<>
@@ -22,14 +23,15 @@ export function PostComment({
2223
const { data, error } = await actions.blog.comment.safe(formData);
2324
if (isInputError(error)) {
2425
return setBodyError(error.fields.body?.join(' '));
26+
} else if (error) {
27+
return setUnexpectedError(`${error.code}: ${error.message}`);
2528
}
26-
if (data) {
27-
setBodyError(undefined);
28-
setComments((c) => [data, ...c]);
29-
}
29+
setBodyError(undefined);
30+
setComments((c) => [data, ...c]);
3031
form.reset();
3132
}}
3233
>
34+
{unexpectedError && <p data-error="unexpected" style={{ color: 'red' }}>{unexpectedError}</p>}
3335
<input {...getActionProps(actions.blog.comment)} />
3436
<input type="hidden" name="postId" value={postId} />
3537
<label className="sr-only" htmlFor="author">

‎packages/astro/e2e/fixtures/actions-blog/src/pages/blog/[...slug].astro

+4-1
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ const comment = Astro.getActionResult(actions.blog.comment);
2727
const comments = await db.select().from(Comment).where(eq(Comment.postId, post.id));
2828
2929
const initialLikes = await db.select().from(Likes).where(eq(Likes.postId, post.id)).get();
30+
31+
// Used to force validation errors for testing
32+
const commentPostIdOverride = Astro.url.searchParams.get('commentPostIdOverride');
3033
---
3134

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

3740
<h2>Comments</h2>
3841
<PostComment
39-
postId={post.id}
42+
postId={commentPostIdOverride ?? post.id}
4043
serverBodyError={isInputError(comment?.error)
4144
? comment.error.fields.body?.toString()
4245
: undefined}

‎packages/astro/src/actions/runtime/route.ts

+13-10
Original file line numberDiff line numberDiff line change
@@ -20,16 +20,19 @@ export const POST: APIRoute = async (context) => {
2020
}
2121
const result = await ApiContextStorage.run(context, () => callSafely(() => action(args)));
2222
if (result.error) {
23-
if (import.meta.env.PROD) {
24-
// Avoid leaking stack trace in production
25-
result.error.stack = undefined;
26-
}
27-
return new Response(JSON.stringify(result.error), {
28-
status: result.error.status,
29-
headers: {
30-
'Content-Type': 'application/json',
31-
},
32-
});
23+
return new Response(
24+
JSON.stringify({
25+
...result.error,
26+
message: result.error.message,
27+
stack: import.meta.env.PROD ? undefined : result.error.stack,
28+
}),
29+
{
30+
status: result.error.status,
31+
headers: {
32+
'Content-Type': 'application/json',
33+
},
34+
}
35+
);
3336
}
3437
return new Response(JSON.stringify(result.data), {
3538
headers: {

‎packages/astro/src/actions/runtime/virtual/shared.ts

+13-12
Original file line numberDiff line numberDiff line change
@@ -47,10 +47,13 @@ export class ActionError<T extends ErrorInferenceObject = ErrorInferenceObject>
4747
code: ActionErrorCode = 'INTERNAL_SERVER_ERROR';
4848
status = 500;
4949

50-
constructor(params: { message?: string; code: ActionErrorCode }) {
50+
constructor(params: { message?: string; code: ActionErrorCode; stack?: string }) {
5151
super(params.message);
5252
this.code = params.code;
5353
this.status = ActionError.codeToStatus(params.code);
54+
if (params.stack) {
55+
this.stack = params.stack;
56+
}
5457
}
5558

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

6467
static async fromResponse(res: Response) {
68+
const body = await res.clone().json();
6569
if (
66-
res.status === 400 &&
67-
res.headers.get('Content-Type')?.toLowerCase().startsWith('application/json')
70+
typeof body === 'object' &&
71+
body?.type === 'AstroActionInputError' &&
72+
Array.isArray(body.issues)
6873
) {
69-
const body = await res.json();
70-
if (
71-
typeof body === 'object' &&
72-
body?.type === 'AstroActionInputError' &&
73-
Array.isArray(body.issues)
74-
) {
75-
return new ActionInputError(body.issues);
76-
}
74+
return new ActionInputError(body.issues);
75+
}
76+
if (typeof body === 'object' && body?.type === 'AstroActionError') {
77+
return new ActionError(body);
7778
}
7879
return new ActionError({
7980
message: res.statusText,
80-
code: this.statusToCode(res.status),
81+
code: ActionError.statusToCode(res.status),
8182
});
8283
}
8384
}

‎packages/astro/test/actions.test.js

+18-1
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,7 @@ describe('Astro Actions', () => {
174174
it('Respects user middleware', async () => {
175175
const formData = new FormData();
176176
formData.append('_astroAction', '/_actions/getUser');
177-
const req = new Request('http://example.com/middleware', {
177+
const req = new Request('http://example.com/user', {
178178
method: 'POST',
179179
body: formData,
180180
});
@@ -185,5 +185,22 @@ describe('Astro Actions', () => {
185185
let $ = cheerio.load(html);
186186
assert.equal($('#user').text(), 'Houston');
187187
});
188+
189+
it('Respects custom errors', async () => {
190+
const formData = new FormData();
191+
formData.append('_astroAction', '/_actions/getUserOrThrow');
192+
const req = new Request('http://example.com/user-or-throw', {
193+
method: 'POST',
194+
body: formData,
195+
});
196+
const res = await app.render(req);
197+
assert.equal(res.ok, false);
198+
assert.equal(res.status, 401);
199+
200+
const html = await res.text();
201+
let $ = cheerio.load(html);
202+
assert.equal($('#error-message').text(), 'Not logged in');
203+
assert.equal($('#error-code').text(), 'UNAUTHORIZED');
204+
})
188205
});
189206
});

‎packages/astro/test/fixtures/actions/src/actions/index.ts

+16-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { defineAction, getApiContext, z } from 'astro:actions';
1+
import { defineAction, getApiContext, ActionError, z } from 'astro:actions';
22

33
export const server = {
44
subscribe: defineAction({
@@ -35,5 +35,19 @@ export const server = {
3535
const { locals } = getApiContext();
3636
return locals.user;
3737
}
38-
})
38+
}),
39+
getUserOrThrow: defineAction({
40+
accept: 'form',
41+
handler: async () => {
42+
const { locals } = getApiContext();
43+
if (locals.user?.name !== 'admin') {
44+
// Expected to throw
45+
throw new ActionError({
46+
code: 'UNAUTHORIZED',
47+
message: 'Not logged in',
48+
});
49+
}
50+
return locals.user;
51+
}
52+
}),
3953
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
import { actions } from 'astro:actions';
3+
4+
const res = Astro.getActionResult(actions.getUserOrThrow);
5+
6+
if (res?.error) {
7+
Astro.response.status = res.error.status;
8+
}
9+
---
10+
11+
<p id="error-message">{res?.error?.message}</p>
12+
<p id="error-code">{res?.error?.code}</p>

0 commit comments

Comments
 (0)
Please sign in to comment.