0
Fork 0
mirror of https://github.com/withastro/astro.git synced 2025-01-20 22:12:38 -05:00

Actions: give better guidance when a Response is returned by an Action (#11828)

* feat: add error for invalid handler data

* refactor: remove redirect from ctx object

* chore: changeset

* chore: fix redirect codes

* fix: move redirect out of actionApiContext constructor

* refactor(test): reuse redirects const

* wip: bump timeouts

* wip: more bumps
This commit is contained in:
Ben Holmes 2024-08-26 17:53:51 -04:00 committed by GitHub
parent f1df1b3b46
commit 20d47aa85a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 72 additions and 18 deletions

View file

@ -0,0 +1,5 @@
---
'astro': patch
---
Improves error message when invalid data is returned by an Action.

View file

@ -25,7 +25,9 @@ test.describe('Astro Actions - Blog', () => {
const likeButton = page.getByLabel('Like');
await expect(likeButton, 'like button starts with 10 likes').toContainText('10');
await likeButton.click();
await expect(likeButton, 'like button should increment likes').toContainText('11');
await expect(likeButton, 'like button should increment likes').toContainText('11', {
timeout: 6_000,
});
});
test('Like action - server-side', async ({ page, astro }) => {
@ -36,7 +38,9 @@ test.describe('Astro Actions - Blog', () => {
await expect(likeCount, 'like button starts with 10 likes').toContainText('10');
await likeButton.click();
await expect(likeCount, 'like button should increment likes').toContainText('11');
await expect(likeCount, 'like button should increment likes').toContainText('11', {
timeout: 6_000,
});
});
test('Comment action - validation error', async ({ page, astro }) => {
@ -131,6 +135,6 @@ test.describe('Astro Actions - Blog', () => {
const logoutButton = page.getByTestId('logout-button');
await logoutButton.click();
await expect(page).toHaveURL(astro.resolveUrl('/blog/'));
await expect(page).toHaveURL(astro.resolveUrl('/blog/'), { timeout: 6_000 });
});
});

View file

@ -13,7 +13,7 @@ export default defineConfig({
* Maximum time expect() should wait for the condition to be met.
* For example in `await expect(locator).toHaveText();`
*/
timeout: 4 * 1000,
timeout: 6 * 1000,
},
/* Fail the build on CI if you accidentally left test in the source code. */
forbidOnly: !!process.env.CI,

View file

@ -13,7 +13,7 @@ const config = {
* Maximum time expect() should wait for the condition to be met.
* For example in `await expect(locator).toHaveText();`
*/
timeout: 4 * 1000,
timeout: 6 * 1000,
},
/* Fail the build on CI if you accidentally left test in the source code. */
forbidOnly: !!process.env.CI,

View file

@ -53,7 +53,10 @@ import type {
TransitionBeforeSwapEvent,
} from '../transitions/events.js';
import type { DeepPartial, OmitIndexSignature, Simplify } from '../type-utils.js';
import type { SUPPORTED_MARKDOWN_FILE_EXTENSIONS } from './../core/constants.js';
import type {
SUPPORTED_MARKDOWN_FILE_EXTENSIONS,
REDIRECT_STATUS_CODES,
} from './../core/constants.js';
export type { AstroIntegrationLogger, ToolbarServerHelpers };
@ -2980,7 +2983,7 @@ export interface AstroAdapter {
supportedAstroFeatures: AstroFeatureMap;
}
export type ValidRedirectStatus = 300 | 301 | 302 | 303 | 304 | 307 | 308;
export type ValidRedirectStatus = (typeof REDIRECT_STATUS_CODES)[number];
// Shared types between `Astro` global and API context object
interface AstroSharedContext<

View file

@ -10,7 +10,10 @@ export function hasContentType(contentType: string, expected: string[]) {
return expected.some((t) => type === t);
}
export type ActionAPIContext = Omit<APIContext, 'getActionResult' | 'callAction' | 'props'>;
export type ActionAPIContext = Omit<
APIContext,
'getActionResult' | 'callAction' | 'props' | 'redirect'
>;
export type MaybePromise<T> = T | Promise<T>;
/**

View file

@ -6,6 +6,9 @@ import type {
MaybePromise,
ActionAPIContext as _ActionAPIContext,
} from '../utils.js';
import { REDIRECT_STATUS_CODES } from '../../../core/constants.js';
import { ActionsReturnedInvalidDataError } from '../../../core/errors/errors-data.js';
import { AstroError } from '../../../core/errors/errors.js';
export type ActionAPIContext = _ActionAPIContext;
export const ACTION_QUERY_PARAMS = _ACTION_QUERY_PARAMS;
@ -237,14 +240,30 @@ export function serializeActionResult(res: SafeResult<any, any>): SerializedActi
status: 204,
};
}
let body;
try {
body = devalueStringify(res.data, {
// Add support for URL objects
URL: (value) => value instanceof URL && value.href,
});
} catch (e) {
let hint = ActionsReturnedInvalidDataError.hint;
if (res.data instanceof Response) {
hint = REDIRECT_STATUS_CODES.includes(res.data.status as any)
? 'If you need to redirect when the action succeeds, trigger a redirect where the action is called. See the Actions guide for server and client redirect examples: https://docs.astro.build/en/guides/actions.'
: 'If you need to return a Response object, try using a server endpoint instead. See https://docs.astro.build/en/guides/endpoints/#server-endpoints-api-routes';
}
throw new AstroError({
...ActionsReturnedInvalidDataError,
message: ActionsReturnedInvalidDataError.message(String(e)),
hint,
});
}
return {
type: 'data',
status: 200,
contentType: 'application/json+devalue',
body: devalueStringify(res.data, {
// Add support for URL objects
URL: (value) => value instanceof URL && value.href,
}),
body,
};
}

View file

@ -45,6 +45,11 @@ export const DEFAULT_404_COMPONENT = 'astro-default-404.astro';
*/
export const DEFAULT_500_COMPONENT = 'astro-default-500.astro';
/**
* A response with one of these status codes will create a redirect response.
*/
export const REDIRECT_STATUS_CODES = [301, 302, 303, 307, 308, 300, 304] as const;
/**
* A response with one of these status codes will be rewritten
* with the result of rendering the respective error page.

View file

@ -1680,6 +1680,21 @@ export const ActionsUsedWithForGetError = {
hint: 'Actions are experimental. Visit the RFC for usage instructions: https://github.com/withastro/roadmap/blob/actions/proposals/0046-actions.md',
} satisfies ErrorData;
/**
* @docs
* @see
* - [Actions RFC](https://github.com/withastro/roadmap/blob/actions/proposals/0046-actions.md)
* @description
* Action handler returned invalid data. Handlers should return serializable data types, and cannot return a Response object.
*/
export const ActionsReturnedInvalidDataError = {
name: 'ActionsReturnedInvalidDataError',
title: 'Action handler returned invalid data.',
message: (error: string) =>
`Action handler returned invalid data. Handlers should return serializable data types like objects, arrays, strings, and numbers. Parse error: ${error}`,
hint: 'See the devalue library for all supported types: https://github.com/rich-harris/devalue',
} satisfies ErrorData;
/**
* @docs
* @see

View file

@ -216,8 +216,12 @@ export class RenderContext {
createAPIContext(props: APIContext['props'], isPrerendered: boolean): APIContext {
const context = this.createActionAPIContext();
const redirect = (path: string, status = 302) =>
new Response(null, { status, headers: { Location: path } });
return Object.assign(context, {
props,
redirect,
getActionResult: createGetActionResult(context.locals),
callAction: createCallAction(context),
// Used internally by Actions middleware.
@ -255,8 +259,6 @@ export class RenderContext {
const renderContext = this;
const { cookies, params, pipeline, url } = this;
const generator = `Astro v${ASTRO_VERSION}`;
const redirect = (path: string, status = 302) =>
new Response(null, { status, headers: { Location: path } });
const rewrite = async (reroutePayload: RewritePayload) => {
return await this.#executeRewrite(reroutePayload);
@ -292,7 +294,6 @@ export class RenderContext {
get preferredLocaleList() {
return renderContext.computePreferredLocaleList();
},
redirect,
rewrite,
request: this.request,
site: pipeline.site,

View file

@ -3,6 +3,7 @@ import { after, before, describe, it } from 'node:test';
import * as cheerio from 'cheerio';
import * as devalue from 'devalue';
import { serializeActionResult } from '../dist/actions/runtime/virtual/shared.js';
import { REDIRECT_STATUS_CODES } from '../dist/core/constants.js';
import testAdapter from './test-adapter.js';
import { loadFixture } from './test-utils.js';
@ -436,8 +437,6 @@ describe('Astro Actions', () => {
});
});
const validRedirectStatuses = new Set([301, 302, 303, 304, 307, 308]);
/**
* Follow an expected redirect response.
*
@ -448,7 +447,7 @@ const validRedirectStatuses = new Set([301, 302, 303, 304, 307, 308]);
async function followExpectedRedirect(req, app) {
const redirect = await app.render(req, { addCookieHeader: true });
assert.ok(
validRedirectStatuses.has(redirect.status),
REDIRECT_STATUS_CODES.includes(redirect.status),
`Expected redirect status, got ${redirect.status}`,
);