Skip to content

Commit

Permalink
feat: improved HTTP errors for REST API (denoland#444)
Browse files Browse the repository at this point in the history
This change adds middleware for the REST API that catches HTTP errors,
as defined in the Standard Library, and returns non-HTML responses. This
will make future REST API development cleaner.

Currently, responses are still responding with HTML. Perhaps, the
middleware is set up incorrectly.
  • Loading branch information
iuioiua authored Sep 3, 2023
1 parent ece9ef2 commit 0270017
Show file tree
Hide file tree
Showing 11 changed files with 121 additions and 91 deletions.
14 changes: 9 additions & 5 deletions e2e_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ const handler = await createHandler(manifest, options);

function assertResponseNotFound(resp: Response) {
assertFalse(resp.ok);
assertEquals(resp.body, null);
assertEquals(resp.status, Status.NotFound);
}

Expand Down Expand Up @@ -204,7 +203,9 @@ Deno.test("[e2e]", async (test) => {
const req = new Request("http://localhost/api/items/" + item.id);

const resp1 = await handler(req);
assertResponseNotFound(resp1);
assertFalse(resp1.ok);
assertEquals(await resp1.text(), "Item not found");
assertEquals(resp1.status, Status.NotFound);

await createItem(item);
const resp2 = await handler(req);
Expand All @@ -221,7 +222,9 @@ Deno.test("[e2e]", async (test) => {
const req = new Request(`http://localhost/api/items/${item.id}/comments`);

const resp1 = await handler(req);
assertResponseNotFound(resp1);
assertFalse(resp1.ok);
assertEquals(await resp1.text(), "Item not found");
assertEquals(resp1.status, Status.NotFound);

await createItem(item);
await createComment(comment);
Expand Down Expand Up @@ -250,7 +253,9 @@ Deno.test("[e2e]", async (test) => {
const req = new Request("http://localhost/api/users/" + user.login);

const resp1 = await handler(req);
assertResponseNotFound(resp1);
assertFalse(resp1.ok);
assertEquals(await resp1.text(), "User not found");
assertEquals(resp1.status, Status.NotFound);

await createUser(user);
const resp2 = await handler(req);
Expand Down Expand Up @@ -288,7 +293,6 @@ Deno.test("[e2e]", async (test) => {

const resp1 = await handler(new Request(url));
assertFalse(resp1.ok);
assertEquals(resp1.body, null);
assertEquals(resp1.status, Status.Unauthorized);

await createUser(user);
Expand Down
106 changes: 54 additions & 52 deletions fresh.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,32 +10,33 @@ import * as $4 from "./routes/account/_middleware.ts";
import * as $5 from "./routes/account/index.tsx";
import * as $6 from "./routes/account/manage.ts";
import * as $7 from "./routes/account/upgrade.ts";
import * as $8 from "./routes/api/items/[id]/comments.ts";
import * as $9 from "./routes/api/items/[id]/index.ts";
import * as $10 from "./routes/api/items/[id]/vote.ts";
import * as $11 from "./routes/api/items/index.ts";
import * as $12 from "./routes/api/me/notifications.ts";
import * as $13 from "./routes/api/me/votes.ts";
import * as $14 from "./routes/api/stripe-webhooks.ts";
import * as $15 from "./routes/api/users/[login]/index.ts";
import * as $16 from "./routes/api/users/[login]/items.ts";
import * as $17 from "./routes/api/users/index.ts";
import * as $18 from "./routes/blog/[slug].tsx";
import * as $19 from "./routes/blog/index.tsx";
import * as $20 from "./routes/dashboard/_middleware.ts";
import * as $21 from "./routes/dashboard/index.tsx";
import * as $22 from "./routes/dashboard/stats.tsx";
import * as $23 from "./routes/dashboard/users.tsx";
import * as $24 from "./routes/feed.ts";
import * as $25 from "./routes/index.tsx";
import * as $26 from "./routes/items/[id].tsx";
import * as $27 from "./routes/notifications/[id].ts";
import * as $28 from "./routes/notifications/_middleware.ts";
import * as $29 from "./routes/notifications/index.tsx";
import * as $30 from "./routes/pricing.tsx";
import * as $31 from "./routes/submit/_middleware.tsx";
import * as $32 from "./routes/submit/index.tsx";
import * as $33 from "./routes/users/[login].tsx";
import * as $8 from "./routes/api/_middleware.ts";
import * as $9 from "./routes/api/items/[id]/comments.ts";
import * as $10 from "./routes/api/items/[id]/index.ts";
import * as $11 from "./routes/api/items/[id]/vote.ts";
import * as $12 from "./routes/api/items/index.ts";
import * as $13 from "./routes/api/me/notifications.ts";
import * as $14 from "./routes/api/me/votes.ts";
import * as $15 from "./routes/api/stripe-webhooks.ts";
import * as $16 from "./routes/api/users/[login]/index.ts";
import * as $17 from "./routes/api/users/[login]/items.ts";
import * as $18 from "./routes/api/users/index.ts";
import * as $19 from "./routes/blog/[slug].tsx";
import * as $20 from "./routes/blog/index.tsx";
import * as $21 from "./routes/dashboard/_middleware.ts";
import * as $22 from "./routes/dashboard/index.tsx";
import * as $23 from "./routes/dashboard/stats.tsx";
import * as $24 from "./routes/dashboard/users.tsx";
import * as $25 from "./routes/feed.ts";
import * as $26 from "./routes/index.tsx";
import * as $27 from "./routes/items/[id].tsx";
import * as $28 from "./routes/notifications/[id].ts";
import * as $29 from "./routes/notifications/_middleware.ts";
import * as $30 from "./routes/notifications/index.tsx";
import * as $31 from "./routes/pricing.tsx";
import * as $32 from "./routes/submit/_middleware.tsx";
import * as $33 from "./routes/submit/index.tsx";
import * as $34 from "./routes/users/[login].tsx";
import * as $$0 from "./islands/Chart.tsx";
import * as $$1 from "./islands/CommentsList.tsx";
import * as $$2 from "./islands/ItemsList.tsx";
Expand All @@ -53,32 +54,33 @@ const manifest = {
"./routes/account/index.tsx": $5,
"./routes/account/manage.ts": $6,
"./routes/account/upgrade.ts": $7,
"./routes/api/items/[id]/comments.ts": $8,
"./routes/api/items/[id]/index.ts": $9,
"./routes/api/items/[id]/vote.ts": $10,
"./routes/api/items/index.ts": $11,
"./routes/api/me/notifications.ts": $12,
"./routes/api/me/votes.ts": $13,
"./routes/api/stripe-webhooks.ts": $14,
"./routes/api/users/[login]/index.ts": $15,
"./routes/api/users/[login]/items.ts": $16,
"./routes/api/users/index.ts": $17,
"./routes/blog/[slug].tsx": $18,
"./routes/blog/index.tsx": $19,
"./routes/dashboard/_middleware.ts": $20,
"./routes/dashboard/index.tsx": $21,
"./routes/dashboard/stats.tsx": $22,
"./routes/dashboard/users.tsx": $23,
"./routes/feed.ts": $24,
"./routes/index.tsx": $25,
"./routes/items/[id].tsx": $26,
"./routes/notifications/[id].ts": $27,
"./routes/notifications/_middleware.ts": $28,
"./routes/notifications/index.tsx": $29,
"./routes/pricing.tsx": $30,
"./routes/submit/_middleware.tsx": $31,
"./routes/submit/index.tsx": $32,
"./routes/users/[login].tsx": $33,
"./routes/api/_middleware.ts": $8,
"./routes/api/items/[id]/comments.ts": $9,
"./routes/api/items/[id]/index.ts": $10,
"./routes/api/items/[id]/vote.ts": $11,
"./routes/api/items/index.ts": $12,
"./routes/api/me/notifications.ts": $13,
"./routes/api/me/votes.ts": $14,
"./routes/api/stripe-webhooks.ts": $15,
"./routes/api/users/[login]/index.ts": $16,
"./routes/api/users/[login]/items.ts": $17,
"./routes/api/users/index.ts": $18,
"./routes/blog/[slug].tsx": $19,
"./routes/blog/index.tsx": $20,
"./routes/dashboard/_middleware.ts": $21,
"./routes/dashboard/index.tsx": $22,
"./routes/dashboard/stats.tsx": $23,
"./routes/dashboard/users.tsx": $24,
"./routes/feed.ts": $25,
"./routes/index.tsx": $26,
"./routes/items/[id].tsx": $27,
"./routes/notifications/[id].ts": $28,
"./routes/notifications/_middleware.ts": $29,
"./routes/notifications/index.tsx": $30,
"./routes/pricing.tsx": $31,
"./routes/submit/_middleware.tsx": $32,
"./routes/submit/index.tsx": $33,
"./routes/users/[login].tsx": $34,
},
islands: {
"./islands/Chart.tsx": $$0,
Expand Down
20 changes: 20 additions & 0 deletions routes/api/_middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// Copyright 2023 the Deno authors. All rights reserved. MIT license.

import { type MiddlewareHandlerContext, Status } from "$fresh/server.ts";
import { isHttpError } from "std/http/http_errors.ts";

export async function handler(
_req: Request,
ctx: MiddlewareHandlerContext,
) {
try {
return await ctx.next();
} catch (error) {
return isHttpError(error)
? new Response(error.message, {
status: error.status,
headers: error.headers,
})
: new Response(error.message, { status: Status.InternalServerError });
}
}
5 changes: 3 additions & 2 deletions routes/api/items/[id]/comments.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { Handlers, Status } from "$fresh/server.ts";
import type { Handlers } from "$fresh/server.ts";
import { collectValues, getItem, listCommentsByItem } from "@/utils/db.ts";
import { errors } from "std/http/http_errors.ts";
import { getCursor } from "@/utils/http.ts";

// Copyright 2023 the Deno authors. All rights reserved. MIT license.
export const handler: Handlers = {
async GET(req, ctx) {
const itemId = ctx.params.id;
const item = await getItem(itemId);
if (item === null) return new Response(null, { status: Status.NotFound });
if (item === null) throw new errors.NotFound("Item not found");

const url = new URL(req.url);
const iter = listCommentsByItem(itemId, {
Expand Down
8 changes: 4 additions & 4 deletions routes/api/items/[id]/index.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
// Copyright 2023 the Deno authors. All rights reserved. MIT license.
import { type Handlers, Status } from "$fresh/server.ts";
import type { Handlers } from "$fresh/server.ts";
import { getItem } from "@/utils/db.ts";
import { errors } from "std/http/http_errors.ts";

export const handler: Handlers = {
async GET(_req, ctx) {
const item = await getItem(ctx.params.id);
return item === null
? new Response(null, { status: Status.NotFound })
: Response.json(item);
if (item === null) throw new errors.NotFound("Item not found");
return Response.json(item);
},
};
13 changes: 7 additions & 6 deletions routes/api/items/[id]/vote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,19 @@ import {
newNotificationProps,
newVoteProps,
} from "@/utils/db.ts";
import { errors } from "std/http/http_errors.ts";

export const handler: Handlers<undefined, State> = {
async POST(_req, ctx) {
const itemId = ctx.params.id;
const item = await getItem(itemId);
if (item === null) return new Response(null, { status: Status.NotFound });
if (item === null) throw new errors.NotFound("Item not found");

if (ctx.state.sessionId === undefined) {
return new Response(null, { status: Status.Unauthorized });
throw new errors.Unauthorized("User must be signed in");
}
const user = await getUserBySession(ctx.state.sessionId);
if (user === null) return new Response(null, { status: Status.NotFound });
if (user === null) throw new errors.NotFound("User not found");

await createVote({
itemId,
Expand All @@ -44,13 +45,13 @@ export const handler: Handlers<undefined, State> = {
async DELETE(_req, ctx) {
const itemId = ctx.params.id;
const item = await getItem(itemId);
if (item === null) return new Response(null, { status: Status.NotFound });
if (item === null) throw new errors.NotFound("Item not found");

if (ctx.state.sessionId === undefined) {
return new Response(null, { status: Status.Unauthorized });
throw new errors.Unauthorized("User must be signed in");
}
const user = await getUserBySession(ctx.state.sessionId);
if (user === null) return new Response(null, { status: Status.NotFound });
if (user === null) throw new errors.NotFound("User not found");

await deleteVote({ itemId, userLogin: user.login });

Expand Down
7 changes: 4 additions & 3 deletions routes/api/me/notifications.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,22 @@
// Copyright 2023 the Deno authors. All rights reserved. MIT license.
import { type Handlers, Status } from "$fresh/server.ts";
import type { Handlers } from "$fresh/server.ts";
import {
collectValues,
getUserBySession,
listNotificationsByUser,
} from "@/utils/db.ts";
import { getCursor } from "@/utils/http.ts";
import { State } from "@/routes/_middleware.ts";
import { errors } from "std/http/http_errors.ts";

export const handler: Handlers<undefined, State> = {
async GET(req, ctx) {
if (ctx.state.sessionId === undefined) {
return new Response(null, { status: Status.Unauthorized });
throw new errors.Unauthorized("User must be signed in");
}

const user = await getUserBySession(ctx.state.sessionId);
if (user === null) return new Response(null, { status: Status.NotFound });
if (user === null) throw new errors.NotFound("User not found");

const url = new URL(req.url);
const iter = listNotificationsByUser(user.login, {
Expand Down
7 changes: 4 additions & 3 deletions routes/api/me/votes.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,21 @@
// Copyright 2023 the Deno authors. All rights reserved. MIT license.
import { type Handlers, Status } from "$fresh/server.ts";
import type { Handlers } from "$fresh/server.ts";
import type { State } from "@/routes/_middleware.ts";
import {
collectValues,
getUserBySession,
listItemsVotedByUser,
} from "@/utils/db.ts";
import { errors } from "std/http/http_errors.ts";

export const handler: Handlers<undefined, State> = {
async GET(_req, ctx) {
if (ctx.state.sessionId === undefined) {
return new Response(null, { status: Status.Unauthorized });
throw new errors.Unauthorized("User must be signed in");
}

const user = await getUserBySession(ctx.state.sessionId);
if (user === null) return new Response(null, { status: Status.NotFound });
if (user === null) throw new errors.NotFound("User not found");

const iter = listItemsVotedByUser(user.login);
const items = await collectValues(iter);
Expand Down
19 changes: 9 additions & 10 deletions routes/api/stripe-webhooks.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
// Copyright 2023 the Deno authors. All rights reserved. MIT license.
import type { Handlers } from "$fresh/server.ts";
import { type Handlers, Status } from "$fresh/server.ts";
import { stripe } from "@/utils/payments.ts";
import Stripe from "stripe";
import { getUserByStripeCustomer, updateUser } from "@/utils/db.ts";
import { errors } from "std/http/http_errors.ts";

const cryptoProvider = Stripe.createSubtleCryptoProvider();

Expand All @@ -12,8 +13,8 @@ export const handler: Handlers = {
* 1. customer.subscription.created (when a user subscribes to the premium plan)
* 2. customer.subscription.deleted (when a user cancels the premium plan)
*/
async POST(req, ctx) {
if (stripe === undefined) return await ctx.renderNotFound();
async POST(req) {
if (stripe === undefined) throw new errors.NotFound();

const body = await req.text();
const signature = req.headers.get("stripe-signature")!;
Expand All @@ -39,20 +40,18 @@ export const handler: Handlers = {
switch (event.type) {
case "customer.subscription.created": {
const user = await getUserByStripeCustomer(customer);
if (!user) return new Response(null, { status: 400 });
if (user === null) throw new errors.NotFound("User not found");
await updateUser({ ...user, isSubscribed: true });
return new Response(null, { status: 201 });
return new Response(null, { status: Status.Created });
}
case "customer.subscription.deleted": {
const user = await getUserByStripeCustomer(customer);
if (!user) return new Response(null, { status: 400 });
if (user === null) throw new errors.NotFound("User not found");
await updateUser({ ...user, isSubscribed: false });
return new Response(null, { status: 202 });
return new Response(null, { status: Status.Accepted });
}
default: {
const message = `Event type not supported: ${event.type}`;
console.error(message);
return new Response(message, { status: 400 });
throw new errors.BadRequest("Event type not supported");
}
}
},
Expand Down
8 changes: 4 additions & 4 deletions routes/api/users/[login]/index.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
// Copyright 2023 the Deno authors. All rights reserved. MIT license.
import { Handlers, Status } from "$fresh/server.ts";
import type { Handlers } from "$fresh/server.ts";
import { getUser } from "@/utils/db.ts";
import { errors } from "std/http/http_errors.ts";

export const handler: Handlers = {
async GET(_req, ctx) {
const user = await getUser(ctx.params.login);
return user === null
? new Response(null, { status: Status.NotFound })
: Response.json(user);
if (user === null) throw new errors.NotFound("User not found");
return Response.json(user);
},
};
5 changes: 3 additions & 2 deletions routes/api/users/[login]/items.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
// Copyright 2023 the Deno authors. All rights reserved. MIT license.
import { Handlers, Status } from "$fresh/server.ts";
import type { Handlers } from "$fresh/server.ts";
import { collectValues, getUser, listItemsByUser } from "@/utils/db.ts";
import { getCursor } from "@/utils/http.ts";
import { errors } from "std/http/http_errors.ts";

export const handler: Handlers = {
async GET(req, ctx) {
const user = await getUser(ctx.params.login);
if (user === null) return new Response(null, { status: Status.NotFound });
if (user === null) throw new errors.NotFound("User not found");

const url = new URL(req.url);
const iter = listItemsByUser(ctx.params.login, {
Expand Down

0 comments on commit 0270017

Please sign in to comment.