From 76729439dfa86f55c76dccff316736120d1a63e7 Mon Sep 17 00:00:00 2001 From: bholmesdev Date: Tue, 23 Apr 2024 18:40:44 -0400 Subject: [PATCH] feat: standard error types and safe() wrapper --- packages/actions/package.json | 8 +++- packages/actions/src/index.ts | 3 +- packages/actions/src/runtime/config.ts | 35 +++++--------- packages/actions/src/runtime/errors.ts | 48 +++++++++++++++++++ packages/actions/src/runtime/middleware.ts | 16 +++++-- packages/actions/src/runtime/route.ts | 10 +++- packages/actions/src/runtime/utils.ts | 16 ------- .../basics/src/pages/blog/[...slug].astro | 7 ++- packages/actions/tsconfig.json | 1 + packages/actions/virtual.d.ts | 2 +- .../actions/{src/virtual.ts => virtual.js} | 19 +++++--- 11 files changed, 105 insertions(+), 60 deletions(-) create mode 100644 packages/actions/src/runtime/errors.ts rename packages/actions/{src/virtual.ts => virtual.js} (67%) diff --git a/packages/actions/package.json b/packages/actions/package.json index d1e01e109b..2f669c30f3 100644 --- a/packages/actions/package.json +++ b/packages/actions/package.json @@ -23,9 +23,12 @@ "types": "./dist/runtime/config.d.ts", "import": "./dist/runtime/config.js" }, + "./errors": { + "types": "./dist/runtime/errors.d.ts", + "import": "./dist/runtime/errors.js" + }, "./route.js": "./dist/runtime/route.js", - "./middleware.js": "./dist/runtime/middleware.js", - "./package.json": "./package.json" + "./middleware.js": "./dist/runtime/middleware.js" }, "typesVersions": { "*": { @@ -40,6 +43,7 @@ "files": [ "index.d.ts", "virtual.d.ts", + "virtual.js", "dist" ], "keywords": [ diff --git a/packages/actions/src/index.ts b/packages/actions/src/index.ts index 46b577cd92..73a9c014d4 100644 --- a/packages/actions/src/index.ts +++ b/packages/actions/src/index.ts @@ -33,6 +33,7 @@ export default function astroActions(): AstroIntegration { name: 'astro-actions', content: `declare module "astro:actions" { type Actions = typeof import(${stringifiedActionsPath})["default"]; + export * from '@astrojs/actions/errors'; export const actions: Actions; }`, @@ -49,7 +50,7 @@ export default function astroActions(): AstroIntegration { }, async load(id) { if (id === RESOLVED_VIRTUAL_MODULE_ID) { - return await readFile(new URL('./virtual.js', import.meta.url), 'utf-8'); + return await readFile(new URL('../virtual.js', import.meta.url), 'utf-8'); } }, }, diff --git a/packages/actions/src/runtime/config.ts b/packages/actions/src/runtime/config.ts index 91ec8abc3d..52e9c03797 100644 --- a/packages/actions/src/runtime/config.ts +++ b/packages/actions/src/runtime/config.ts @@ -1,6 +1,7 @@ import type { APIContext } from 'astro'; import { z } from 'zod'; import { ApiContextStorage } from './utils.js'; +import { ActionError, ValidationError } from './errors.js'; export function enhanceProps(action: T) { return { @@ -26,42 +27,28 @@ export function defineAction({ const ContentType = context.request.headers.get('content-type'); if (!enhance && (ContentType !== 'application/json' || unparsedInput instanceof FormData)) { // TODO: prettify dev server error - throw new Response( - 'This action only accepts JSON. To enhance this action to accept form data, add `enhance: true` to your `defineAction()` config.', - { - status: 400, - headers: { - 'Content-Type': 'text/plain', - }, - } - ); + throw new ActionError({ + status: 'BAD_REQUEST', + message: + 'This action only accepts JSON. To enhance this action to accept form data, add `enhance: true` to your `defineAction()` config.', + }); } if (!inputSchema) return await handler(unparsedInput, context); if (enhance && unparsedInput instanceof FormData) { if (!(inputSchema instanceof z.ZodObject)) { - throw new Response( - '`input` must use a Zod object schema (z.object) when `enhance` is enabled.', - { - status: 400, - headers: { - 'Content-Type': 'text/plain', - }, - } - ); + throw new ActionError({ + status: 'BAD_REQUEST', + message: '`input` must use a Zod object schema (z.object) when `enhance` is enabled.', + }); } unparsedInput = enhanceFormData(unparsedInput, inputSchema); } const parsed = inputSchema.safeParse(unparsedInput); if (!parsed.success) { - throw new Response(JSON.stringify(parsed.error), { - status: 400, - headers: { - 'Content-Type': 'application/json', - }, - }); + throw new ValidationError(parsed.error); } return await handler(parsed.data, context); }; diff --git a/packages/actions/src/runtime/errors.ts b/packages/actions/src/runtime/errors.ts new file mode 100644 index 0000000000..1a71bd08a7 --- /dev/null +++ b/packages/actions/src/runtime/errors.ts @@ -0,0 +1,48 @@ +import type { ZodError } from 'zod'; + +type ActionErrorStatus = + | 'BAD_REQUEST' + | 'UNAUTHORIZED' + | 'FORBIDDEN' + | 'NOT_FOUND' + | 'INTERNAL_SERVER_ERROR'; + +export class ActionError extends Error { + type = 'AstroActionError'; + status: ActionErrorStatus = 'INTERNAL_SERVER_ERROR'; + + constructor(params: { message?: string; status: ActionErrorStatus }) { + super(params.message); + this.status = params.status; + } +} + +export class ValidationError extends ActionError { + type = 'AstroValidationError'; + fieldErrors: ZodError; + + constructor(fieldErrors: ZodError) { + super({ message: 'Failed to validate', status: 'BAD_REQUEST' }); + this.fieldErrors = fieldErrors; + } +} + +export async function safe( + actionResult: Promise +): Promise<{ success: true; data: T } | { success: false; error: ActionError }> { + try { + const data = await actionResult; + return { success: true, data }; + } catch (e) { + if (e instanceof ActionError) { + return { success: false, error: e }; + } + return { + success: false, + error: new ActionError({ + message: e instanceof Error ? e.message : 'Unknown error', + status: 'INTERNAL_SERVER_ERROR', + }), + }; + } +} diff --git a/packages/actions/src/runtime/middleware.ts b/packages/actions/src/runtime/middleware.ts index 4402fb8e42..eccb987700 100644 --- a/packages/actions/src/runtime/middleware.ts +++ b/packages/actions/src/runtime/middleware.ts @@ -1,8 +1,9 @@ import { defineMiddleware } from 'astro:middleware'; import { ApiContextStorage, formContentTypes, getAction } from './utils.js'; +import { ActionError } from './errors.js'; export const onRequest = defineMiddleware(async (context, next) => { - context.locals.getActionResult = (action) => undefined; + context.locals.getActionResult = (action) => Promise.resolve(undefined); const { request } = context; const contentType = request.headers.get('Content-Type'); @@ -15,16 +16,21 @@ export const onRequest = defineMiddleware(async (context, next) => { const actionPathKeys = actionPath.replace('/_actions/', '').split('.'); const action = await getAction(actionPathKeys); let result: any; + // TODO: throw unhandled actionError. + // Maybe use post middleware to throw if `getActionResult()` is not called. + let actionError: ActionError | undefined; try { result = await ApiContextStorage.run(context, () => action(formData)); } catch (e) { - if (e instanceof Response) { - return e; + if (!(e instanceof ActionError)) { + throw e; } - throw e; + actionError = e; } context.locals.getActionResult = (action) => { - if (action.toString() === actionPath) return result; + if (action.toString() !== actionPath) return Promise.resolve(undefined); + if (actionError) return Promise.reject(actionError); + return Promise.resolve(result); }; return next(); }); diff --git a/packages/actions/src/runtime/route.ts b/packages/actions/src/runtime/route.ts index a9a6c52e15..77144204ff 100644 --- a/packages/actions/src/runtime/route.ts +++ b/packages/actions/src/runtime/route.ts @@ -1,5 +1,6 @@ import type { APIRoute } from 'astro'; import { ApiContextStorage, formContentTypes, getAction } from './utils.js'; +import { ActionError } from './errors.js'; export const POST: APIRoute = async (context) => { const { request, url, redirect } = context; @@ -20,8 +21,13 @@ export const POST: APIRoute = async (context) => { try { result = await ApiContextStorage.run(context, () => action(args)); } catch (e) { - if (e instanceof Response) { - return e; + if (e instanceof ActionError) { + return new Response(JSON.stringify(e), { + status: 400, + headers: { + 'Content-Type': 'application/json', + }, + }); } throw e; } diff --git a/packages/actions/src/runtime/utils.ts b/packages/actions/src/runtime/utils.ts index 24048c870c..73a2c2b1ec 100644 --- a/packages/actions/src/runtime/utils.ts +++ b/packages/actions/src/runtime/utils.ts @@ -1,6 +1,5 @@ import type { APIContext } from 'astro'; import { AsyncLocalStorage } from 'node:async_hooks'; -import type { ZodError } from 'zod'; export const ApiContextStorage = new AsyncLocalStorage(); @@ -19,18 +18,3 @@ export async function getAction(pathKeys: string[]): Promise { } return actionLookup; } - -export class ActionError extends Error { - constructor(message: string) { - super(message); - } -} - -export class ValidationError extends ActionError { - error: ZodError; - - constructor(error: ZodError) { - super('Failed to validate'); - this.error = error; - } -} diff --git a/packages/actions/test/fixtures/basics/src/pages/blog/[...slug].astro b/packages/actions/test/fixtures/basics/src/pages/blog/[...slug].astro index 134651840c..3c05deeb2a 100644 --- a/packages/actions/test/fixtures/basics/src/pages/blog/[...slug].astro +++ b/packages/actions/test/fixtures/basics/src/pages/blog/[...slug].astro @@ -3,7 +3,7 @@ import { type CollectionEntry, getCollection, getEntry } from "astro:content"; import BlogPost from "../../layouts/BlogPost.astro"; import { db, eq, Comment, Likes } from "astro:db"; import { Like } from "../../components/Like"; -import { actions } from "astro:actions"; +import { actions, safe } from "astro:actions"; import { enhanceProps } from "@astrojs/actions/config"; export const prerender = false; @@ -18,9 +18,11 @@ export async function getStaticPaths() { type Props = CollectionEntry<"blog">; -const post = (await getEntry("blog", Astro.params.slug))!; +const post = await getEntry("blog", Astro.params.slug); const { Content } = await post.render(); +const commentResult = await safe(Astro.locals.getActionResult(actions.blog.comment)); + const comments = await db .select() .from(Comment) @@ -40,6 +42,7 @@ const initialLikes = await db

Comments

+ {!commentResult.success &&

{commentResult.error}

} diff --git a/packages/actions/tsconfig.json b/packages/actions/tsconfig.json index 93a4b3350c..9622b8988b 100644 --- a/packages/actions/tsconfig.json +++ b/packages/actions/tsconfig.json @@ -2,6 +2,7 @@ "extends": "../../tsconfig.base.json", "include": ["src"], "compilerOptions": { + "rootDir": "./src", "outDir": "./dist", } } diff --git a/packages/actions/virtual.d.ts b/packages/actions/virtual.d.ts index b5db4a3cee..087e67e52c 100644 --- a/packages/actions/virtual.d.ts +++ b/packages/actions/virtual.d.ts @@ -2,6 +2,6 @@ declare namespace App { interface Locals { getActionResult: any>( action: T - ) => Awaited> | undefined; + ) => Promise> | undefined>; } } diff --git a/packages/actions/src/virtual.ts b/packages/actions/virtual.js similarity index 67% rename from packages/actions/src/virtual.ts rename to packages/actions/virtual.js index a43f14976d..0b526afb49 100644 --- a/packages/actions/src/virtual.ts +++ b/packages/actions/virtual.js @@ -1,21 +1,23 @@ -import { ValidationError } from './runtime/utils.js'; +import { ActionError, ValidationError } from '@astrojs/actions/errors'; + +export * from '@astrojs/actions/errors'; function toActionProxy( actionCallback = {}, aggregatedPath = '/_actions/' -): Record { +) { return new Proxy(actionCallback, { - get(target: Record, objKey) { + get(target, objKey) { const path = aggregatedPath + objKey.toString(); if (objKey in target) { return target[objKey]; } - async function action(param?: BodyInit) { + async function action(param) { const headers = new Headers(); headers.set('Accept', 'application/json'); let body = param; if (!(body instanceof FormData)) { - body = JSON.stringify(param); + body = param ? JSON.stringify(param) : undefined; headers.set('Content-Type', 'application/json'); } const res = await fetch(path, { @@ -24,8 +26,11 @@ function toActionProxy( headers, }); const json = await res.json(); - if (res.status === 400) { - throw new ValidationError(json); + if (!res.ok) { + if (json.type === 'ValidationError') { + throw new ValidationError(json.fieldErrors); + } + throw new ActionError(json); } return json; }