0
Fork 0
mirror of https://github.com/withastro/astro.git synced 2024-12-16 21:46:22 -05:00

feat: standard error types and safe() wrapper

This commit is contained in:
bholmesdev 2024-04-23 18:40:44 -04:00
parent 340c9d6101
commit 76729439df
11 changed files with 105 additions and 60 deletions

View file

@ -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": [

View file

@ -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');
}
},
},

View file

@ -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<T extends Function>(action: T) {
return {
@ -26,42 +27,28 @@ export function defineAction<TOutput, TInputSchema extends z.ZodType>({
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);
};

View file

@ -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<T>(
actionResult: Promise<T>
): 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',
}),
};
}
}

View file

@ -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();
});

View file

@ -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;
}

View file

@ -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<APIContext>();
@ -19,18 +18,3 @@ export async function getAction(pathKeys: string[]): Promise<Function> {
}
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;
}
}

View file

@ -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
<h2>Comments</h2>
<form method="POST">
{!commentResult.success && <p class="error">{commentResult.error}</p>}
<input {...enhanceProps(actions.blog.comment)} />
<input type="hidden" name="postId" value={post.id} />
<label class="sr-only" for="author">Author</label>

View file

@ -2,6 +2,7 @@
"extends": "../../tsconfig.base.json",
"include": ["src"],
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
}
}

View file

@ -2,6 +2,6 @@ declare namespace App {
interface Locals {
getActionResult: <T extends (...args: any) => any>(
action: T
) => Awaited<ReturnType<T>> | undefined;
) => Promise<Awaited<ReturnType<T>> | undefined>;
}
}

View file

@ -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<string | symbol, any> {
) {
return new Proxy(actionCallback, {
get(target: Record<string | symbol, any>, 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;
}