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:
parent
340c9d6101
commit
76729439df
11 changed files with 105 additions and 60 deletions
|
@ -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": [
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
},
|
||||
},
|
||||
|
|
|
@ -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);
|
||||
};
|
||||
|
|
48
packages/actions/src/runtime/errors.ts
Normal file
48
packages/actions/src/runtime/errors.ts
Normal 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',
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
"extends": "../../tsconfig.base.json",
|
||||
"include": ["src"],
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
}
|
||||
}
|
||||
|
|
2
packages/actions/virtual.d.ts
vendored
2
packages/actions/virtual.d.ts
vendored
|
@ -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>;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
Loading…
Reference in a new issue