diff --git a/apps/web/next.config.js b/apps/web/next.config.js index f79098c90..b5bf1628c 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -7,7 +7,10 @@ const nextConfig = { distDir: "build", }; -const withTM = require("next-transpile-modules")(["@documenso/prisma"]); +const withTM = require("next-transpile-modules")([ + "@documenso/prisma", + "@documenso/lib", +]); const plugins = []; plugins.push(withTM); diff --git a/apps/web/pages/api/documents/index.ts b/apps/web/pages/api/documents/index.ts index 57b64d6bd..cb0ff5c3b 100644 --- a/apps/web/pages/api/documents/index.ts +++ b/apps/web/pages/api/documents/index.ts @@ -1,49 +1 @@ -import PrismaClient from "@documenso/prisma"; -import type { NextApiRequest, NextApiResponse } from "next"; - -export default async function userHandler( - req: NextApiRequest, - res: NextApiResponse -) { - const { method, body } = req; - const prisma = new PrismaClient(); - - // Check Session - - switch (method) { - case "POST": - if (!body.userId) { - res.status(400).end("Owner ID cannot be empty."); - } - - try { - const newDocument: any = await prisma.document - .create({ - data: { userId: body.userId, document: body.document }, - }) - .then(async () => { - await prisma.$disconnect(); - res.status(200).send(newDocument); - }); - } catch (error) { - await prisma.$disconnect(); - res.status(500).end("An error has occured."); - } - - break; - - case "GET": - // GET all docs for user in session - let documents = await prisma.document.findMany({ - where: { - userId: body.userId, - }, - }); - res.status(200).send(documents); - break; - - default: - res.setHeader("Allow", ["GET", "POST"]); - res.status(405).end(`Method ${method} Not Allowed`); - } -} +export {}; diff --git a/apps/web/pages/api/health.ts b/apps/web/pages/api/health.ts index ff1b0b575..1e8db5c37 100644 --- a/apps/web/pages/api/health.ts +++ b/apps/web/pages/api/health.ts @@ -1,12 +1,19 @@ import type { NextApiRequest, NextApiResponse } from "next"; -type Data = { +import { defaultHandler, defaultResponder } from "@documenso/lib/server"; +import prisma from "@documenso/prisma"; + +type responseData = { status: string; }; -export default function handler( - req: NextApiRequest, - res: NextApiResponse -) { - res.status(200).json({ status: "Api up and running :)" }); +// Return a healthy 200 status code for uptime monitoring and render.com zero-downtime-deploy +async function getHandler(req: NextApiRequest, res: NextApiResponse) { + // A generic database access to make sure the service is healthy. + const users = await prisma.user.findFirst(); + res.status(200).json({ message: "Api up and running :)" }); } + +export default defaultHandler({ + GET: Promise.resolve({ default: defaultResponder(getHandler) }), +}); diff --git a/apps/web/pages/api/users/index.ts b/apps/web/pages/api/users/index.ts index 6ddc760fa..7ca38db51 100644 --- a/apps/web/pages/api/users/index.ts +++ b/apps/web/pages/api/users/index.ts @@ -1,42 +1,31 @@ // POST to create -import PrismaClient from "@documenso/prisma"; -import User from "@documenso/prisma"; +import { + defaultHandler, + defaultResponder, + HttpError, +} from "@documenso/lib/server"; +import prisma from "@documenso/prisma"; import type { NextApiRequest, NextApiResponse } from "next"; import { json } from "stream/consumers"; -export default async function userHandler( - req: NextApiRequest, - res: NextApiResponse -) { +async function postHandler(req: NextApiRequest, res: NextApiResponse) { const { method, body } = req; - const prisma = new PrismaClient(); - switch (method) { - case "POST": - if (!body.email) { - res.status(400).end("Email cannot be empty."); - } - - try { - let newUser: any; - newUser = await prisma.user - .create({ - data: { email: body.email }, - }) - .then(async () => { - await prisma.$disconnect(); - res.status(200).send(newUser); - }); - } catch (error) { - await prisma.$disconnect(); - res.status(500).end("An error has occured. Error: " + error); - } - - break; - - default: - res.setHeader("Allow", ["POST"]); - res.status(405).end(`Method ${method} Not Allowed`); + if (!body.email) { + return res.status(400).json({ message: "Email cannot be empty." }); } + + let newUser: any; + newUser = await prisma.user + .create({ + data: { email: body.email }, + }) + .then(async () => { + return res.status(201).send(newUser); + }); } + +export default defaultHandler({ + POST: Promise.resolve({ default: defaultResponder(postHandler) }), +}); diff --git a/packages/lib/server/defaultHandler.ts b/packages/lib/server/defaultHandler.ts new file mode 100644 index 000000000..1cfa351bd --- /dev/null +++ b/packages/lib/server/defaultHandler.ts @@ -0,0 +1,24 @@ +import type { NextApiHandler, NextApiRequest, NextApiResponse } from "next"; + +type Handlers = { + [method in "GET" | "POST" | "PATCH" | "PUT" | "DELETE"]?: Promise<{ default: NextApiHandler }>; +}; + +/** Allows us to split big API handlers by method */ +export const defaultHandler = (handlers: Handlers) => async (req: NextApiRequest, res: NextApiResponse) => { + const handler = (await handlers[req.method as keyof typeof handlers])?.default; + // auto catch unsupported methods. + if (!handler) { + return res + .status(405) + .json({ message: `Method Not Allowed (Allow: ${Object.keys(handlers).join(",")})` }); + } + + try { + await handler(req, res); + return; + } catch (error) { + console.error(error); + return res.status(500).json({ message: "Something went wrong" }); + } +}; diff --git a/packages/lib/server/defaultResponder.ts b/packages/lib/server/defaultResponder.ts new file mode 100644 index 000000000..b598a977f --- /dev/null +++ b/packages/lib/server/defaultResponder.ts @@ -0,0 +1,20 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + +import { getServerErrorFromUnknown } from "@documenso/lib/server"; + +type Handle = (req: NextApiRequest, res: NextApiResponse) => Promise; + +/** Allows us to get type inference from API handler responses */ +export function defaultResponder(f: Handle) { + return async (req: NextApiRequest, res: NextApiResponse) => { + try { + const result = await f(req, res); + if (result) res.json(result); + } catch (err) { + console.error(err); + const error = getServerErrorFromUnknown(err); + res.statusCode = error.statusCode; + res.json({ message: error.message }); + } + }; +} diff --git a/packages/lib/server/getServerErrorFromUnknown.ts b/packages/lib/server/getServerErrorFromUnknown.ts new file mode 100644 index 000000000..1ec9d93d8 --- /dev/null +++ b/packages/lib/server/getServerErrorFromUnknown.ts @@ -0,0 +1,43 @@ +import { + PrismaClientKnownRequestError, + NotFoundError, +} from "@prisma/client/runtime"; + +import { HttpError } from "@documenso/lib/server"; + +export function getServerErrorFromUnknown(cause: unknown): HttpError { + // Error was manually thrown and does not need to be parsed. + if (cause instanceof HttpError) { + return cause; + } + + if (cause instanceof SyntaxError) { + return new HttpError({ + statusCode: 500, + message: "Unexpected error, please reach out for our customer support.", + }); + } + + if (cause instanceof PrismaClientKnownRequestError) { + return new HttpError({ statusCode: 400, message: cause.message, cause }); + } + + if (cause instanceof NotFoundError) { + return new HttpError({ statusCode: 404, message: cause.message, cause }); + } + + if (cause instanceof Error) { + return new HttpError({ statusCode: 500, message: cause.message, cause }); + } + + if (typeof cause === "string") { + // @ts-expect-error https://github.com/tc39/proposal-error-cause + return new Error(cause, { cause }); + } + + // Catch-All if none of the above triggered and something (even more) unexpected happend + return new HttpError({ + statusCode: 500, + message: `Unhandled error of type '${typeof cause}'. Please reach out for our customer support.`, + }); +} diff --git a/packages/lib/server/http-error.ts b/packages/lib/server/http-error.ts new file mode 100644 index 000000000..223761480 --- /dev/null +++ b/packages/lib/server/http-error.ts @@ -0,0 +1,33 @@ +export class HttpError extends Error { + public readonly cause?: Error; + public readonly statusCode: TCode; + public readonly message: string; + public readonly url: string | undefined; + public readonly method: string | undefined; + + constructor(opts: { url?: string; method?: string; message?: string; statusCode: TCode; cause?: Error }) { + super(opts.message ?? `HTTP Error ${opts.statusCode} `); + + Object.setPrototypeOf(this, HttpError.prototype); + this.name = HttpError.prototype.constructor.name; + + this.cause = opts.cause; + this.statusCode = opts.statusCode; + this.url = opts.url; + this.method = opts.method; + this.message = opts.message ?? `HTTP Error ${opts.statusCode}`; + + if (opts.cause instanceof Error && opts.cause.stack) { + this.stack = opts.cause.stack; + } + } + + public static fromRequest(request: Request, response: Response) { + return new HttpError({ + message: response.statusText, + url: response.url, + method: request.method, + statusCode: response.status, + }); + } +} diff --git a/packages/lib/server/index.ts b/packages/lib/server/index.ts new file mode 100644 index 000000000..66dc23a21 --- /dev/null +++ b/packages/lib/server/index.ts @@ -0,0 +1,4 @@ +export { defaultHandler } from "./defaultHandler"; +export { defaultResponder } from "./defaultResponder"; +export { HttpError } from "./http-error"; +export { getServerErrorFromUnknown } from "./getServerErrorFromUnknown"; diff --git a/packages/prisma/index.ts b/packages/prisma/index.ts index 6d2e8144d..fbf1a6de1 100644 --- a/packages/prisma/index.ts +++ b/packages/prisma/index.ts @@ -1,3 +1,9 @@ import { PrismaClient } from "@prisma/client"; -export default PrismaClient; +declare global { + var prismaClientSingleton: PrismaClient | undefined; +} + +export const prisma = globalThis.prismaClientSingleton || new PrismaClient(); + +export default prisma;