From d7fdad14c84e5c8ff2c543d95b232beeb4ce6557 Mon Sep 17 00:00:00 2001 From: Pierniki Date: Tue, 5 Sep 2023 16:17:10 +0200 Subject: [PATCH] refactor: swap HoF with pipe --- app/api/algolia-webhook/httpError.ts | 22 +++++++++ app/api/algolia-webhook/publish/route.ts | 46 ++++++++++--------- app/api/algolia-webhook/route.ts | 41 ----------------- app/api/algolia-webhook/unpublish/route.ts | 46 ++++++++++--------- app/api/algolia-webhook/validateBody.ts | 23 ++++++++++ app/api/algolia-webhook/validateSignature.ts | 25 ++++++++++ app/api/algolia-webhook/withBodySchema.ts | 22 --------- app/api/algolia-webhook/withValidSignature.ts | 26 ----------- utils/pipe.ts | 13 ++++++ 9 files changed, 133 insertions(+), 131 deletions(-) create mode 100644 app/api/algolia-webhook/httpError.ts delete mode 100644 app/api/algolia-webhook/route.ts create mode 100644 app/api/algolia-webhook/validateBody.ts create mode 100644 app/api/algolia-webhook/validateSignature.ts delete mode 100644 app/api/algolia-webhook/withBodySchema.ts delete mode 100644 app/api/algolia-webhook/withValidSignature.ts create mode 100644 utils/pipe.ts diff --git a/app/api/algolia-webhook/httpError.ts b/app/api/algolia-webhook/httpError.ts new file mode 100644 index 00000000..2a644969 --- /dev/null +++ b/app/api/algolia-webhook/httpError.ts @@ -0,0 +1,22 @@ +import { NextResponse } from "next/server" + +export class HttpError extends Error { + status: number + tag: string + readonly isHttpError = true + + constructor(tag: string, status: number) { + super() + this.tag = tag + this.status = status + } +} + +export function isHttpError(error: unknown): error is HttpError { + return (error as HttpError).isHttpError +} + +export function errorToNextResponse(error: unknown) { + if (!isHttpError(error)) return NextResponse.json({ message: "UnexpectedError" }, { status: 500 }) + return NextResponse.json({ message: error.tag }, { status: error.status }) +} diff --git a/app/api/algolia-webhook/publish/route.ts b/app/api/algolia-webhook/publish/route.ts index 5d4d2355..570ac950 100644 --- a/app/api/algolia-webhook/publish/route.ts +++ b/app/api/algolia-webhook/publish/route.ts @@ -1,33 +1,39 @@ import algolia from "algoliasearch" import { env } from "env.mjs" -import { NextResponse } from "next/server" +import { NextRequest, NextResponse } from "next/server" +import { pipe } from "utils/pipe" import { slateToText } from "utils/slateToText" import { z } from "zod" -import { withValidSignature } from "../withValidSignature" -import { NextRequestWithValidBody, withBodySchema } from "../withBodySchema" +import { errorToNextResponse } from "../httpError" +import { NextRequestWithValidBody, validateBody } from "../validateBody" +import { validateSignature } from "../validateSignature" const client = algolia(env.ALGOLIA_API_ID, env.ALGOLIA_API_KEY) async function handleAlgoliaWebhook(req: NextRequestWithValidBody>) { - try { - const article = req.validBody.data - - const indexingResults = await Promise.allSettled( - article.localizations.map(async ({ locale, title, content }) => { - const index = client.initIndex(`articles-${locale}`) - await index.saveObject({ - objectID: article.id, - title, - content: slateToText(content), - }) + const article = req.validBody.data - return { title, locale } + const indexingResults = await Promise.allSettled( + article.localizations.map(async ({ locale, title, content }) => { + const index = client.initIndex(`articles-${locale}`) + await index.saveObject({ + objectID: article.id, + title, + content: slateToText(content), }) - ) - return NextResponse.json({ result: indexingResults }, { status: 201 }) - } catch (err) { - return NextResponse.json({ message: "Unexpected Error" }, { status: 500 }) + return { title, locale } + }) + ) + + return NextResponse.json({ result: indexingResults }, { status: 201 }) +} + +export async function POST(req: NextRequest) { + try { + return await pipe(req, validateSignature, validateBody(bodySchema), handleAlgoliaWebhook) + } catch (error) { + return errorToNextResponse(error) } } @@ -37,5 +43,3 @@ const bodySchema = z.object({ id: z.string(), }), }) - -export const POST = withValidSignature(withBodySchema(handleAlgoliaWebhook, bodySchema)) diff --git a/app/api/algolia-webhook/route.ts b/app/api/algolia-webhook/route.ts deleted file mode 100644 index 27d14002..00000000 --- a/app/api/algolia-webhook/route.ts +++ /dev/null @@ -1,41 +0,0 @@ -import algolia from "algoliasearch" -import { env } from "env.mjs" -import { NextRequest, NextResponse } from "next/server" -import { slateToText } from "utils/slateToText" -import { z } from "zod" -import { NextRequestWithValidBody, withBodySchema } from "./withBodySchema" -import { withValidSignature } from "./withValidSignature" - -const client = algolia(env.ALGOLIA_API_ID, env.ALGOLIA_API_KEY) - -async function handleAlgoliaWebhook(req: NextRequestWithValidBody>) { - try { - const article = req.validBody.data - - const indexingResults = await Promise.allSettled( - article.localizations.map(async ({ locale, title, content }) => { - const index = client.initIndex(`articles-${locale}`) - await index.saveObject({ - objectID: article.id, - title, - content: slateToText(content), - }) - - return { title, locale } - }) - ) - - return NextResponse.json({ result: indexingResults }, { status: 201 }) - } catch (err) { - return NextResponse.json({ message: "Unexpected Error" }, { status: 500 }) - } -} - -const bodySchema = z.object({ - data: z.object({ - localizations: z.array(z.object({ content: z.any(), title: z.string(), locale: z.string() })), - id: z.string(), - }), -}) - -export const POST = withValidSignature(withBodySchema(handleAlgoliaWebhook, bodySchema)) diff --git a/app/api/algolia-webhook/unpublish/route.ts b/app/api/algolia-webhook/unpublish/route.ts index 5d4d2355..570ac950 100644 --- a/app/api/algolia-webhook/unpublish/route.ts +++ b/app/api/algolia-webhook/unpublish/route.ts @@ -1,33 +1,39 @@ import algolia from "algoliasearch" import { env } from "env.mjs" -import { NextResponse } from "next/server" +import { NextRequest, NextResponse } from "next/server" +import { pipe } from "utils/pipe" import { slateToText } from "utils/slateToText" import { z } from "zod" -import { withValidSignature } from "../withValidSignature" -import { NextRequestWithValidBody, withBodySchema } from "../withBodySchema" +import { errorToNextResponse } from "../httpError" +import { NextRequestWithValidBody, validateBody } from "../validateBody" +import { validateSignature } from "../validateSignature" const client = algolia(env.ALGOLIA_API_ID, env.ALGOLIA_API_KEY) async function handleAlgoliaWebhook(req: NextRequestWithValidBody>) { - try { - const article = req.validBody.data - - const indexingResults = await Promise.allSettled( - article.localizations.map(async ({ locale, title, content }) => { - const index = client.initIndex(`articles-${locale}`) - await index.saveObject({ - objectID: article.id, - title, - content: slateToText(content), - }) + const article = req.validBody.data - return { title, locale } + const indexingResults = await Promise.allSettled( + article.localizations.map(async ({ locale, title, content }) => { + const index = client.initIndex(`articles-${locale}`) + await index.saveObject({ + objectID: article.id, + title, + content: slateToText(content), }) - ) - return NextResponse.json({ result: indexingResults }, { status: 201 }) - } catch (err) { - return NextResponse.json({ message: "Unexpected Error" }, { status: 500 }) + return { title, locale } + }) + ) + + return NextResponse.json({ result: indexingResults }, { status: 201 }) +} + +export async function POST(req: NextRequest) { + try { + return await pipe(req, validateSignature, validateBody(bodySchema), handleAlgoliaWebhook) + } catch (error) { + return errorToNextResponse(error) } } @@ -37,5 +43,3 @@ const bodySchema = z.object({ id: z.string(), }), }) - -export const POST = withValidSignature(withBodySchema(handleAlgoliaWebhook, bodySchema)) diff --git a/app/api/algolia-webhook/validateBody.ts b/app/api/algolia-webhook/validateBody.ts new file mode 100644 index 00000000..0e098a97 --- /dev/null +++ b/app/api/algolia-webhook/validateBody.ts @@ -0,0 +1,23 @@ +import { NextRequest } from "next/server" + +import { HttpError } from "./httpError" +import { NextRequestWithBody, hasParsedBody } from "./validateSignature" + +export const validateBody = + (schema: Zod.Schema) => + async (req: NextRequest | NextRequestWithBody) => { + try { + const hasBody = hasParsedBody(req) + + const parseResult = schema.safeParse(hasBody ? req.body : await req.json()) + if (!parseResult.success) throw new HttpError("BadRequest", 400) + + const reqWithBody: NextRequestWithValidBody = Object.assign(req, { validBody: parseResult.data }) + + return reqWithBody + } catch { + throw new HttpError("BadRequest", 400) + } + } + +export type NextRequestWithValidBody = NextRequest & { validBody: T } diff --git a/app/api/algolia-webhook/validateSignature.ts b/app/api/algolia-webhook/validateSignature.ts new file mode 100644 index 00000000..4c4d3c05 --- /dev/null +++ b/app/api/algolia-webhook/validateSignature.ts @@ -0,0 +1,25 @@ +import { NextRequest } from "next/server" +import { HttpError } from "./httpError" +import { verifyWebhookSignature } from "@hygraph/utils" +import { env } from "env.mjs" + +export const validateSignature = async (req: NextRequest) => { + const authHeader = req.headers.get("gcms-signature") + if (!authHeader) throw new HttpError("Unauthorized", 401) + const parsedBody = await req.json() + + const isSignatureValid = verifyWebhookSignature({ + body: parsedBody, + signature: authHeader, + secret: env.HYGRAPH_WEBOOK_SECRET, + }) + if (!isSignatureValid) throw new HttpError("Unauthorized", 401) + + const reqWithBody = Object.assign(req, { parsedBody }) + return reqWithBody +} + +export type NextRequestWithBody = NextRequest & { parsedBody: unknown } + +export const hasParsedBody = (req: NextRequestWithBody | NextRequest): req is NextRequestWithBody => + Boolean((req as NextRequestWithBody).body) diff --git a/app/api/algolia-webhook/withBodySchema.ts b/app/api/algolia-webhook/withBodySchema.ts deleted file mode 100644 index 80b6d462..00000000 --- a/app/api/algolia-webhook/withBodySchema.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { NextRequest, NextResponse } from "next/server" -import { hasParsedBody, NextRequestWithBody } from "./withValidSignature" - -export const withBodySchema = - ( - fun: (req: NextRequestWithValidBody, context?: TContext) => Promise, - schema: Zod.Schema - ) => - async (req: NextRequest | NextRequestWithBody, context?: TContext) => { - try { - const hasBody = hasParsedBody(req) - const parseResult = schema.safeParse(hasBody ? req.body : await req.json()) - if (!parseResult.success) return NextResponse.json({ message: "Bad Request" }, { status: 400 }) - - const reqWithBody: NextRequestWithValidBody = Object.assign(req, { validBody: parseResult.data }) - return fun(reqWithBody, context) - } catch { - return NextResponse.json({ message: "Bad Request" }, { status: 400 }) - } - } - -export type NextRequestWithValidBody = NextRequest & { validBody: T } diff --git a/app/api/algolia-webhook/withValidSignature.ts b/app/api/algolia-webhook/withValidSignature.ts deleted file mode 100644 index 762f4d44..00000000 --- a/app/api/algolia-webhook/withValidSignature.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { verifyWebhookSignature } from "@hygraph/utils" -import { env } from "env.mjs" -import { NextRequest, NextResponse } from "next/server" - -export const withValidSignature = - (fun: (req: NextRequestWithBody, context?: TContext) => Promise) => - async (req: NextRequest, context?: TContext) => { - const authHeader = req.headers.get("gcms-signature") - if (!authHeader) return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) - const parsedBody = await req.json() - - const isSignatureValid = verifyWebhookSignature({ - body: parsedBody, - signature: authHeader, - secret: env.HYGRAPH_WEBOOK_SECRET, - }) - if (!isSignatureValid) return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) - - const reqWithBody: NextRequestWithBody = Object.assign(req, { parsedBody }) - return fun(reqWithBody, context) - } - -export type NextRequestWithBody = NextRequest & { parsedBody: unknown } - -export const hasParsedBody = (req: NextRequestWithBody | NextRequest): req is NextRequestWithBody => - Boolean((req as NextRequestWithBody).body) diff --git a/utils/pipe.ts b/utils/pipe.ts new file mode 100644 index 00000000..52535bbe --- /dev/null +++ b/utils/pipe.ts @@ -0,0 +1,13 @@ +type Func = (a: A) => B | Promise + +export async function pipe(a: A): Promise +export async function pipe(a: A, ab: Func): Promise +export async function pipe(a: A, ab: Func, bc: Func): Promise +export async function pipe(a: A, ab: Func, bc: Func, cd: Func): Promise + +export async function pipe(...args: any[]): Promise { + return await args.slice(1).reduce(async (accPromise, fn) => { + const acc = await accPromise + return fn(acc) + }, Promise.resolve(args[0])) +}