add new error classes, add types and services for api keys, use new error classes in webhook api

This commit is contained in:
Matthias Nannt
2023-06-13 15:07:15 +02:00
parent c16708d12a
commit 0feaadcbc9
18 changed files with 311 additions and 93 deletions

View File

@@ -1,6 +1,6 @@
import { hashApiKey } from "@/lib/api/apiHelper";
import { responses } from "@/lib/api/response";
import { prisma } from "@formbricks/database";
import { getApiKeyFromKey } from "@formbricks/lib/services/apiKey";
import { deleteWebhook, getWebhook } from "@formbricks/lib/services/webhook";
import { headers } from "next/headers";
export async function GET(_: Request, { params }: { params: { webhookId: string } }) {
@@ -8,24 +8,13 @@ export async function GET(_: Request, { params }: { params: { webhookId: string
if (!apiKey) {
return responses.notAuthenticatedResponse();
}
const apiKeyData = await prisma.apiKey.findUnique({
where: {
hashedKey: hashApiKey(apiKey),
},
select: {
environmentId: true,
},
});
const apiKeyData = await getApiKeyFromKey(apiKey);
if (!apiKeyData) {
return responses.notAuthenticatedResponse();
}
// add webhook to database
const webhook = await prisma.webhook.findUnique({
where: {
id: params.webhookId,
},
});
const webhook = await getWebhook(params.webhookId);
if (!webhook) {
return responses.notFoundResponse("Webhook", params.webhookId);
}
@@ -37,26 +26,17 @@ export async function DELETE(_: Request, { params }: { params: { webhookId: stri
if (!apiKey) {
return responses.notAuthenticatedResponse();
}
const apiKeyData = await prisma.apiKey.findUnique({
where: {
hashedKey: hashApiKey(apiKey),
},
select: {
environmentId: true,
},
});
const apiKeyData = await getApiKeyFromKey(apiKey);
if (!apiKeyData) {
return responses.notAuthenticatedResponse();
}
// add webhook to database
const webhook = await prisma.webhook.delete({
where: {
id: params.webhookId,
},
});
if (!webhook) {
try {
const webhook = await deleteWebhook(params.webhookId);
return responses.successResponse(webhook);
} catch (e) {
console.error(e.message);
return responses.notFoundResponse("Webhook", params.webhookId);
}
return responses.successResponse(webhook);
}

View File

@@ -1,33 +1,32 @@
import { headers } from "next/headers";
import { prisma } from "@formbricks/database";
import { NextResponse } from "next/server";
import { hashApiKey } from "@/lib/api/apiHelper";
import { responses } from "@/lib/api/response";
import { transformErrorToDetails } from "@/lib/api/validator";
import { DatabaseError, InvalidInputError } from "@formbricks/errors";
import { getApiKeyFromKey } from "@formbricks/lib/services/apiKey";
import { createWebhook, getWebhooks } from "@formbricks/lib/services/webhook";
import { ZWebhookInput } from "@formbricks/types/v1/webhooks";
import { headers } from "next/headers";
import { NextResponse } from "next/server";
export async function GET() {
const apiKey = headers().get("x-api-key");
if (!apiKey) {
return responses.notAuthenticatedResponse();
}
const apiKeyData = await prisma.apiKey.findUnique({
where: {
hashedKey: hashApiKey(apiKey),
},
select: {
environmentId: true,
},
});
const apiKeyData = await getApiKeyFromKey(apiKey);
if (!apiKeyData) {
return responses.notAuthenticatedResponse();
}
// add webhook to database
const webhooks = await prisma.webhook.findMany({
where: {
environmentId: apiKeyData.environmentId,
},
});
return NextResponse.json({ data: webhooks });
// get webhooks from database
try {
const webhooks = await getWebhooks(apiKeyData.environmentId);
return NextResponse.json({ data: webhooks });
} catch (error) {
if (error instanceof DatabaseError) {
return responses.badRequestResponse(error.message);
}
throw error;
}
}
export async function POST(request: Request) {
@@ -35,37 +34,32 @@ export async function POST(request: Request) {
if (!apiKey) {
return responses.notAuthenticatedResponse();
}
const apiKeyData = await prisma.apiKey.findUnique({
where: {
hashedKey: hashApiKey(apiKey),
},
select: {
environmentId: true,
},
});
const apiKeyData = await getApiKeyFromKey(apiKey);
if (!apiKeyData) {
return responses.notAuthenticatedResponse();
}
const { url, trigger } = await request.json();
if (!url) {
return responses.missingFieldResponse("url");
}
const webhookInput = await request.json();
const inputValidation = ZWebhookInput.safeParse(webhookInput);
if (!trigger) {
return responses.missingFieldResponse("trigger");
if (!inputValidation.success) {
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error),
true
);
}
// add webhook to database
const webhook = await prisma.webhook.create({
data: {
url,
triggers: [trigger],
environment: {
connect: {
id: apiKeyData.environmentId,
},
},
},
});
return responses.successResponse(webhook);
try {
const webhook = await createWebhook(apiKeyData.environmentId, inputValidation.data);
return responses.successResponse(webhook);
} catch (error) {
if (error instanceof InvalidInputError) {
return responses.badRequestResponse(error.message);
}
if (error instanceof DatabaseError) {
return responses.internalServerErrorResponse(error.message);
}
throw error;
}
}

View File

@@ -112,8 +112,22 @@ const successResponse = (data: Object, cors: boolean = false) =>
}
);
const internalServerErrorResponse = (message: string, cors: boolean = false) =>
NextResponse.json(
{
code: "internal_server_error",
message,
details: {},
} as ApiErrorResponse,
{
status: 500,
...(cors && { headers: corsHeaders }),
}
);
export const responses = {
badRequestResponse,
internalServerErrorResponse,
missingFieldResponse,
methodNotAllowedResponse,
notAuthenticatedResponse,

View File

@@ -12,7 +12,13 @@ const nextConfig = {
experimental: {
serverActions: true,
},
transpilePackages: ["@formbricks/database", "@formbricks/ee", "@formbricks/ui", "@formbricks/lib"],
transpilePackages: [
"@formbricks/database",
"@formbricks/ee",
"@formbricks/ui",
"@formbricks/lib",
"@formbricks/errors",
],
images: {
remotePatterns: [
{

View File

@@ -11,6 +11,7 @@
},
"dependencies": {
"@formbricks/ee": "workspace:*",
"@formbricks/errors": "workspace:*",
"@formbricks/js": "workspace:*",
"@formbricks/lib": "workspace:*",
"@formbricks/ui": "workspace:*",

View File

@@ -0,0 +1,83 @@
class ResourceNotFoundError extends Error {
statusCode = 404;
constructor(resource: string, id: string) {
super(`${resource} with ID ${id} not found`);
this.name = "ResourceNotFoundError";
}
}
class InvalidInputError extends Error {
statusCode = 400;
constructor(message: string) {
super(message);
this.name = "InvalidInputError";
}
}
class ValidationError extends Error {
statusCode = 400;
constructor(message: string) {
super(message);
this.name = "ValidationError";
}
}
class DatabaseError extends Error {
statusCode = 500;
constructor(message: string) {
super(message);
this.name = "DatabaseError";
}
}
class UniqueConstraintError extends Error {
statusCode = 409;
constructor(message: string) {
super(message);
this.name = "UniqueConstraintError";
}
}
class ForeignKeyConstraintError extends Error {
statusCode = 409;
constructor(message: string) {
super(message);
this.name = "ForeignKeyConstraintError";
}
}
class OperationNotAllowedError extends Error {
statusCode = 403;
constructor(message: string) {
super(message);
this.name = "OperationNotAllowedError";
}
}
class AuthenticationError extends Error {
statusCode = 401;
constructor(message: string) {
super(message);
this.name = "AuthenticationError";
}
}
class AuthorizationError extends Error {
statusCode = 403;
constructor(message: string) {
super(message);
this.name = "AuthorizationError";
}
}
export {
ResourceNotFoundError,
InvalidInputError,
ValidationError,
DatabaseError,
UniqueConstraintError,
ForeignKeyConstraintError,
OperationNotAllowedError,
AuthenticationError,
AuthorizationError,
};

View File

@@ -1,2 +1,3 @@
export * from "./functions";
export * from "./result";
export * from "./errors";

3
packages/lib/crypto.ts Normal file
View File

@@ -0,0 +1,3 @@
import { createHash } from "crypto";
export const getHash = (key: string): string => createHash("sha256").update(key).digest("hex");

View File

@@ -12,11 +12,12 @@
"lint:report": "eslint . --format json --output-file ../../lint-results/app-store.json"
},
"dependencies": {
"@formbricks/database": "*"
"@formbricks/database": "*",
"@formbricks/types": "*",
"@formbricks/errors": "*"
},
"devDependencies": {
"@formbricks/tsconfig": "*",
"@formbricks/types": "*",
"eslint": "^8.41.0",
"eslint-config-formbricks": "workspace:*",
"typescript": "^5.0.4"

View File

@@ -0,0 +1,19 @@
import { prisma } from "@formbricks/database";
import { getHash } from "../crypto";
import { TApiKey } from "@formbricks/types/v1/apiKeys";
export const getApiKey = async (apiKey: string): Promise<TApiKey | null> => {
return await prisma.apiKey.findUnique({
where: {
hashedKey: getHash(apiKey),
},
});
};
export const getApiKeyFromKey = async (apiKey: string): Promise<TApiKey | null> => {
return await prisma.apiKey.findUnique({
where: {
hashedKey: getHash(apiKey),
},
});
};

View File

@@ -47,8 +47,6 @@ export const createResponse = async (responseInput: TResponseInput): Promise<TRe
const response: TResponse = {
...responsePrisma,
createdAt: responsePrisma.createdAt.toISOString(),
updatedAt: responsePrisma.updatedAt.toISOString(),
person: transformPrismaPerson(responsePrisma.person),
};
@@ -91,8 +89,6 @@ export const getResponse = async (responseId: string): Promise<TResponse | null>
const response: TResponse = {
...responsePrisma,
createdAt: responsePrisma.createdAt.toISOString(),
updatedAt: responsePrisma.updatedAt.toISOString(),
person: transformPrismaPerson(responsePrisma.person),
};
@@ -150,8 +146,6 @@ export const updateResponse = async (
const response: TResponse = {
...responsePrisma,
createdAt: responsePrisma.createdAt.toISOString(),
updatedAt: responsePrisma.updatedAt.toISOString(),
person: transformPrismaPerson(responsePrisma.person),
};

View File

@@ -0,0 +1,77 @@
import { TWebhook, TWebhookInput } from "@formbricks/types/v1/webhooks";
import { prisma } from "@formbricks/database";
import { Prisma } from "@prisma/client";
import { ResourceNotFoundError, DatabaseError, InvalidInputError } from "@formbricks/errors";
export const getWebhooks = async (environmentId: string): Promise<TWebhook[]> => {
try {
return await prisma.webhook.findMany({
where: {
environmentId: environmentId,
},
});
} catch (error) {
throw new DatabaseError(`Database error when fetching webhooks for environment ${environmentId}`);
}
};
export const getWebhook = async (id: string): Promise<TWebhook | null> => {
try {
const webhook = await prisma.webhook.findUnique({
where: {
id,
},
});
if (!webhook) {
throw new ResourceNotFoundError("Webhook", id);
}
return webhook;
} catch (error) {
if (!(error instanceof ResourceNotFoundError)) {
throw new DatabaseError(`Database error when fetching webhook with ID ${id}`);
}
throw error;
}
};
export const createWebhook = async (
environmentId: string,
webhookInput: TWebhookInput
): Promise<TWebhook> => {
try {
if (!webhookInput.url || !webhookInput.trigger) {
throw new InvalidInputError("Missing URL or trigger in webhook input");
}
return await prisma.webhook.create({
data: {
url: webhookInput.url,
triggers: [webhookInput.trigger],
environment: {
connect: {
id: environmentId,
},
},
},
});
} catch (error) {
if (!(error instanceof InvalidInputError)) {
throw new DatabaseError(`Database error when creating webhook for environment ${environmentId}`);
}
throw error;
}
};
export const deleteWebhook = async (id: string): Promise<TWebhook> => {
try {
return await prisma.webhook.delete({
where: {
id,
},
});
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2025") {
throw new ResourceNotFoundError("Webhook", id);
}
throw new DatabaseError(`Database error when deleting webhook with ID ${id}`);
}
};

View File

@@ -1,5 +1,11 @@
{
"extends": "@formbricks/tsconfig/js-library.json",
"include": ["."],
"exclude": ["dist", "build", "node_modules"]
"exclude": ["dist", "build", "node_modules"],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@prisma/client/*": ["@formbricks/database/client/*"]
}
}
}

View File

@@ -0,0 +1,12 @@
import { z } from "zod";
export const ZApiKey = z.object({
id: z.string().cuid2(),
createdAt: z.date(),
lastUsedAt: z.date().nullable(),
label: z.string().nullable(),
hashedKey: z.string(),
environmentId: z.string().cuid2(),
});
export type TApiKey = z.infer<typeof ZApiKey>;

View File

@@ -6,8 +6,8 @@ export type TResponseData = z.infer<typeof ZResponseData>;
const ZResponse = z.object({
id: z.string().cuid2(),
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),
createdAt: z.date(),
updatedAt: z.date(),
surveyId: z.string().cuid2(),
person: z
.object({

View File

@@ -178,9 +178,9 @@ export const ZSurveyAttributeFilter = z.object({
});
export const ZSurvey = z.object({
id: z.string(),
createdAt: z.string(),
updatedAt: z.string(),
id: z.string().cuid2(),
createdAt: z.date(),
updatedAt: z.date(),
name: z.string(),
type: z.union([z.literal("web"), z.literal("email"), z.literal("link"), z.literal("mobile")]),
environmentId: z.string(),

View File

@@ -0,0 +1,21 @@
import { z } from "zod";
export const ZWebhookTrigger = z.enum(["responseFinished", "responseCreated", "responseUpdated"]);
export const ZWebhook = z.object({
id: z.string().cuid2(),
createdAt: z.date(),
updatedAt: z.date(),
url: z.string().url(),
environmentId: z.string().cuid2(),
triggers: z.array(ZWebhookTrigger),
});
export type TWebhook = z.infer<typeof ZWebhook>;
export const ZWebhookInput = z.object({
url: z.string().url(),
trigger: ZWebhookTrigger,
});
export type TWebhookInput = z.infer<typeof ZWebhookInput>;

12
pnpm-lock.yaml generated
View File

@@ -192,6 +192,9 @@ importers:
'@formbricks/ee':
specifier: workspace:*
version: link:../../packages/ee
'@formbricks/errors':
specifier: workspace:*
version: link:../../packages/errors
'@formbricks/js':
specifier: workspace:*
version: link:../../packages/js
@@ -569,13 +572,16 @@ importers:
'@formbricks/database':
specifier: '*'
version: link:../database
'@formbricks/errors':
specifier: '*'
version: link:../errors
'@formbricks/types':
specifier: '*'
version: link:../types
devDependencies:
'@formbricks/tsconfig':
specifier: '*'
version: link:../tsconfig
'@formbricks/types':
specifier: '*'
version: link:../types
eslint:
specifier: ^8.41.0
version: 8.41.0