Refactor/webhooks (#186)

refs #175
This commit is contained in:
Omar López
2022-10-19 12:26:12 -06:00
committed by GitHub
parent ce2df7641f
commit 03d5f51ceb
13 changed files with 327 additions and 296 deletions
+1
View File
@@ -5,6 +5,7 @@ import { baseApiParams } from "./baseApiParams";
// Extracted out as utility function so can be reused
// at different endpoints that require this validation.
/** Used for UUID style id queries */
export const schemaQueryIdAsString = baseApiParams
.extend({
id: z.string(),
+23 -26
View File
@@ -1,49 +1,40 @@
import { z } from "zod";
import { WEBHOOK_TRIGGER_EVENTS } from "@calcom/features/webhooks/lib/constants";
import { _WebhookModel as Webhook } from "@calcom/prisma/zod";
export const WebhookTriggerEvents = {
BOOKING_CREATED: "BOOKING_CREATED",
BOOKING_RESCHEDULED: "BOOKING_RESCHEDULED",
BOOKING_CANCELLED: "BOOKING_CANCELLED",
};
export const WEBHOOK_TRIGGER_EVENTS = [
WebhookTriggerEvents.BOOKING_CANCELLED,
WebhookTriggerEvents.BOOKING_CREATED,
WebhookTriggerEvents.BOOKING_RESCHEDULED,
] as ["BOOKING_CANCELLED", "BOOKING_CREATED", "BOOKING_RESCHEDULED"];
const schemaWebhookBaseBodyParams = Webhook.pick({
id: true,
userId: true,
eventTypeId: true,
eventTriggers: true,
active: true,
subscriberUrl: true,
payloadTemplate: true,
}).partial();
});
export const schemaWebhookCreateParams = z
.object({
subscriberUrl: z.string().url(),
eventTriggers: z.enum(WEBHOOK_TRIGGER_EVENTS).array(),
active: z.boolean(),
// subscriberUrl: z.string().url(),
// eventTriggers: z.enum(WEBHOOK_TRIGGER_EVENTS).array(),
// active: z.boolean(),
payloadTemplate: z.string().optional().nullable(),
eventTypeId: z.number().optional(),
appId: z.string().optional().nullable(),
userId: z.number().optional(),
// API shouldn't mess with Apps webhooks yet (ie. Zapier)
// appId: z.string().optional().nullable(),
})
.strict();
export const schemaWebhookCreateBodyParams = schemaWebhookBaseBodyParams.merge(schemaWebhookCreateParams);
export const schemaWebhookEditBodyParams = schemaWebhookBaseBodyParams.merge(
z.object({
payloadTemplate: z.string().optional(),
eventTriggers: z.enum(WEBHOOK_TRIGGER_EVENTS).array().optional(),
subscriberUrl: z.string().optional(),
})
);
export const schemaWebhookEditBodyParams = schemaWebhookBaseBodyParams
.merge(
z.object({
eventTriggers: z.enum(WEBHOOK_TRIGGER_EVENTS).array().optional(),
})
)
.partial()
.strict();
export const schemaWebhookReadPublic = Webhook.pick({
id: true,
@@ -51,8 +42,14 @@ export const schemaWebhookReadPublic = Webhook.pick({
eventTypeId: true,
payloadTemplate: true,
eventTriggers: true,
// FIXME: We have some invalid urls saved in the DB
// subscriberUrl: true,
/** @todo: find out how to properly add back and validate those. */
// eventType: true,
// app: true,
appId: true,
});
}).merge(
z.object({
subscriberUrl: z.string(),
})
);
+5
View File
@@ -30,6 +30,11 @@ module.exports = withAxiom(
source: "/api/v:version/:rest*",
destination: "/api/:rest*?version=:version",
},
// Keeps backwards compatibility with old webhook URLs
{
source: "/api/hooks/:rest*",
destination: "/api/webhooks/:rest*",
},
],
fallback: [
// These rewrites are checked after both pages/public files
-177
View File
@@ -1,177 +0,0 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { withMiddleware } from "@lib/helpers/withMiddleware";
import type { WebhookResponse } from "@lib/types";
import { schemaQueryIdAsString } from "@lib/validations/shared/queryIdString";
import { schemaWebhookEditBodyParams, schemaWebhookReadPublic } from "@lib/validations/webhook";
export async function WebhookById(
{ method, query, body, userId, prisma }: NextApiRequest,
res: NextApiResponse<WebhookResponse>
) {
const safeQuery = schemaQueryIdAsString.safeParse(query);
if (!safeQuery.success) {
res.status(400).json({ message: "Your query was invalid" });
return;
}
const data = await prisma.webhook.findMany({ where: { userId } });
const userWebhooks = data.map((webhook) => webhook.id);
if (!userWebhooks.includes(safeQuery.data.id)) res.status(401).json({ message: "Unauthorized" });
else {
switch (method) {
/**
* @swagger
* /hooks/{id}:
* get:
* summary: Find a webhook
* operationId: getWebhookById
* parameters:
* - in: path
* name: id
* schema:
* type: integer
* required: true
* description: Numeric ID of the webhook to get
* security:
* - ApiKeyAuth: []
* tags:
* - hooks
* externalDocs:
* url: https://docs.cal.com/hooks
* responses:
* 200:
* description: OK
* 401:
* description: Authorization information is missing or invalid.
* 404:
* description: Webhook was not found
*/
case "GET":
await prisma.webhook
.findUnique({ where: { id: safeQuery.data.id } })
.then((data) => schemaWebhookReadPublic.parse(data))
.then((webhook) => res.status(200).json({ webhook }))
.catch((error: Error) =>
res.status(404).json({
message: `Webhook with id: ${safeQuery.data.id} not found`,
error,
})
);
break;
/**
* @swagger
* /hooks/{id}:
* patch:
* summary: Edit an existing webhook
* operationId: editWebhookById
* parameters:
* - in: path
* name: id
* schema:
* type: integer
* required: true
* description: Numeric ID of the webhook to edit
* security:
* - ApiKeyAuth: []
* tags:
* - hooks
* externalDocs:
* url: https://docs.cal.com/hooks
* responses:
* 201:
* description: OK, webhook edited successfuly
* 400:
* description: Bad request. Webhook body is invalid.
* 401:
* description: Authorization information is missing or invalid.
*/
case "PATCH":
const safeBody = schemaWebhookEditBodyParams.safeParse(body);
if (!safeBody.success) {
{
res.status(400).json({ message: "Invalid request body" });
return;
}
}
if (safeBody.data.eventTypeId) {
const team = await prisma.team.findFirst({
where: {
eventTypes: {
some: {
id: safeBody.data.eventTypeId,
},
},
},
include: {
members: true,
},
});
// Team should be available and the user should be a member of the team
if (!team?.members.some((membership) => membership.userId === userId)) {
res.status(401).json({ message: "Unauthorized" });
return;
}
}
await prisma.webhook
.update({ where: { id: safeQuery.data.id }, data: safeBody.data })
.then((data) => schemaWebhookReadPublic.parse(data))
.then((webhook) => res.status(200).json({ webhook }))
.catch((error: Error) =>
res.status(404).json({
message: `Webhook with id: ${safeQuery.data.id} not found`,
error,
})
);
break;
/**
* @swagger
* /hooks/{id}:
* delete:
* summary: Remove an existing hook
* operationId: removeWebhookById
* parameters:
* - in: path
* name: id
* schema:
* type: integer
* required: true
* description: Numeric ID of the hooks to delete
* security:
* - ApiKeyAuth: []
* tags:
* - hooks
* externalDocs:
* url: https://docs.cal.com/hooks
* responses:
* 201:
* description: OK, hook removed successfuly
* 400:
* description: Bad request. hook id is invalid.
* 401:
* description: Authorization information is missing or invalid.
*/
case "DELETE":
await prisma.webhook
.delete({ where: { id: safeQuery.data.id } })
.then(() =>
res.status(200).json({
message: `Webhook with id: ${safeQuery.data.id} deleted`,
})
)
.catch((error: Error) =>
res.status(404).json({
message: `Webhook with id: ${safeQuery.data.id} not found`,
error,
})
);
break;
default:
res.status(405).json({ message: "Method not allowed" });
break;
}
}
}
export default withMiddleware("HTTP_GET_DELETE_PATCH")(WebhookById);
-93
View File
@@ -1,93 +0,0 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { v4 as uuidv4 } from "uuid";
import { withMiddleware } from "@lib/helpers/withMiddleware";
import { WebhookResponse, WebhooksResponse } from "@lib/types";
import { schemaWebhookCreateBodyParams } from "@lib/validations/webhook";
async function createOrlistAllWebhooks(
{ method, body, userId, prisma }: NextApiRequest,
res: NextApiResponse<WebhooksResponse | WebhookResponse>
) {
if (method === "GET") {
/**
* @swagger
* /hooks:
* get:
* summary: Find all webhooks
* operationId: listWebhooks
* tags:
* - hooks
* externalDocs:
* url: https://docs.cal.com/webhooks
* responses:
* 200:
* description: OK
* 401:
* description: Authorization information is missing or invalid.
* 404:
* description: No webhooks were found
*/
const webhooks = await prisma.webhook
.findMany({ where: { userId } })
.catch((error) => console.log(error));
if (!webhooks) {
console.log();
res.status(404).json({ message: "No webhooks were found" });
} else res.status(200).json({ webhooks });
} else if (method === "POST") {
/**
* @swagger
* /hooks:
* post:
* summary: Creates a new webhook
* operationId: addWebhook
* tags:
* - webhooks
* externalDocs:
* url: https://docs.cal.com/webhooks
* responses:
* 201:
* description: OK, webhook created
* 400:
* description: Bad request. webhook body is invalid.
* 401:
* description: Authorization information is missing or invalid.
*/
const safe = schemaWebhookCreateBodyParams.safeParse(body);
if (!safe.success) {
res.status(400).json({ message: "Invalid request body" });
return;
}
if (safe.data.eventTypeId) {
const team = await prisma.team.findFirst({
where: {
eventTypes: {
some: {
id: safe.data.eventTypeId,
},
},
},
include: {
members: true,
},
});
// Team should be available and the user should be a member of the team
if (!team?.members.some((membership) => membership.userId === userId)) {
res.status(401).json({ message: "Unauthorized" });
return;
}
}
const data = await prisma.webhook.create({ data: { id: uuidv4(), ...safe.data, userId } });
if (data) res.status(201).json({ webhook: data, message: "Webhook created successfully" });
else
(error: Error) =>
res.status(400).json({
message: "Could not create new webhook",
error,
});
} else res.status(405).json({ message: `Method ${method} not allowed` });
}
export default withMiddleware("HTTP_GET_OR_POST")(createOrlistAllWebhooks);
@@ -0,0 +1,19 @@
import type { NextApiRequest } from "next";
import { HttpError } from "@calcom/lib/http-error";
import { schemaQueryIdAsString } from "@lib/validations/shared/queryIdString";
async function authMiddleware(req: NextApiRequest) {
const { userId, isAdmin, prisma } = req;
const { id } = schemaQueryIdAsString.parse(req.query);
// Admins can just skip this check
if (isAdmin) return;
// Check if the current user can access the webhook
const webhook = await prisma.webhook.findFirst({
where: { id, appId: null, OR: [{ userId }, { eventType: { team: { members: { some: { userId } } } } }] },
});
if (!webhook) throw new HttpError({ statusCode: 403, message: "Forbidden" });
}
export default authMiddleware;
+41
View File
@@ -0,0 +1,41 @@
import type { NextApiRequest } from "next";
import { defaultResponder } from "@calcom/lib/server";
import { schemaQueryIdAsString } from "@lib/validations/shared/queryIdString";
/**
* @swagger
* /webhooks/{id}:
* delete:
* summary: Remove an existing hook
* operationId: removeWebhookById
* parameters:
* - in: path
* name: id
* schema:
* type: integer
* required: true
* description: Numeric ID of the hooks to delete
* security:
* - ApiKeyAuth: []
* tags:
* - hooks
* externalDocs:
* url: https://docs.cal.com/hooks
* responses:
* 201:
* description: OK, hook removed successfully
* 400:
* description: Bad request. hook id is invalid.
* 401:
* description: Authorization information is missing or invalid.
*/
export async function deleteHandler(req: NextApiRequest) {
const { prisma, query } = req;
const { id } = schemaQueryIdAsString.parse(query);
await prisma.webhook.delete({ where: { id } });
return { message: `Schedule with id: ${id} deleted successfully` };
}
export default defaultResponder(deleteHandler);
+42
View File
@@ -0,0 +1,42 @@
import type { NextApiRequest } from "next";
import { defaultResponder } from "@calcom/lib/server";
import { schemaQueryIdAsString } from "@lib/validations/shared/queryIdString";
import { schemaWebhookReadPublic } from "@lib/validations/webhook";
/**
* @swagger
* /webhooks/{id}:
* get:
* summary: Find a webhook
* operationId: getWebhookById
* parameters:
* - in: path
* name: id
* schema:
* type: integer
* required: true
* description: Numeric ID of the webhook to get
* security:
* - ApiKeyAuth: []
* tags:
* - hooks
* externalDocs:
* url: https://docs.cal.com/hooks
* responses:
* 200:
* description: OK
* 401:
* description: Authorization information is missing or invalid.
* 404:
* description: Webhook was not found
*/
export async function getHandler(req: NextApiRequest) {
const { prisma, query } = req;
const { id } = schemaQueryIdAsString.parse(query);
const data = await prisma.webhook.findUniqueOrThrow({ where: { id } });
return { webhook: schemaWebhookReadPublic.parse(data) };
}
export default defaultResponder(getHandler);
+62
View File
@@ -0,0 +1,62 @@
import type { Prisma } from "@prisma/client";
import type { NextApiRequest } from "next";
import { HttpError } from "@calcom/lib/http-error";
import { defaultResponder } from "@calcom/lib/server";
import { schemaQueryIdAsString } from "@lib/validations/shared/queryIdString";
import { schemaWebhookEditBodyParams, schemaWebhookReadPublic } from "@lib/validations/webhook";
/**
* @swagger
* /webhooks/{id}:
* patch:
* summary: Edit an existing webhook
* operationId: editWebhookById
* parameters:
* - in: path
* name: id
* schema:
* type: integer
* required: true
* description: Numeric ID of the webhook to edit
* security:
* - ApiKeyAuth: []
* tags:
* - hooks
* externalDocs:
* url: https://docs.cal.com/hooks
* responses:
* 201:
* description: OK, webhook edited successfully
* 400:
* description: Bad request. Webhook body is invalid.
* 401:
* description: Authorization information is missing or invalid.
*/
export async function patchHandler(req: NextApiRequest) {
const { prisma, query, userId, isAdmin } = req;
const { id } = schemaQueryIdAsString.parse(query);
const { eventTypeId, userId: bodyUserId, ...data } = schemaWebhookEditBodyParams.parse(req.body);
const args: Prisma.WebhookUpdateArgs = { where: { id }, data };
if (eventTypeId) {
const where: Prisma.EventTypeWhereInput = { id: eventTypeId };
if (!isAdmin) where.userId = userId;
await prisma.eventType.findFirstOrThrow({ where });
args.data.eventTypeId = eventTypeId;
}
if (!isAdmin && bodyUserId) throw new HttpError({ statusCode: 403, message: `ADMIN required for userId` });
if (isAdmin && bodyUserId) {
const where: Prisma.UserWhereInput = { id: userId };
await prisma.user.findFirstOrThrow({ where });
args.data.userId = userId;
}
const result = await prisma.webhook.update(args);
return { webhook: schemaWebhookReadPublic.parse(result) };
}
export default defaultResponder(patchHandler);
+18
View File
@@ -0,0 +1,18 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
import { withMiddleware } from "@lib/helpers/withMiddleware";
import authMiddleware from "./_auth-middleware";
export default withMiddleware("HTTP_GET_DELETE_PATCH")(
defaultResponder(async (req: NextApiRequest, res: NextApiResponse) => {
await authMiddleware(req);
return defaultHandler({
GET: import("./_get"),
PATCH: import("./_patch"),
DELETE: import("./_delete"),
})(req, res);
})
);
+47
View File
@@ -0,0 +1,47 @@
import type { Prisma } from "@prisma/client";
import type { NextApiRequest } from "next";
import { HttpError } from "@calcom/lib/http-error";
import { defaultResponder } from "@calcom/lib/server";
import { schemaQuerySingleOrMultipleUserIds } from "@lib/validations/shared/queryUserId";
import { schemaWebhookReadPublic } from "@lib/validations/webhook";
/**
* @swagger
* /webhooks:
* get:
* summary: Find all webhooks
* operationId: listWebhooks
* tags:
* - hooks
* externalDocs:
* url: https://docs.cal.com/webhooks
* responses:
* 200:
* description: OK
* 401:
* description: Authorization information is missing or invalid.
* 404:
* description: No webhooks were found
*/
async function getHandler(req: NextApiRequest) {
const { userId, isAdmin, prisma } = req;
const args: Prisma.WebhookFindManyArgs = isAdmin
? {}
: { where: { OR: [{ eventType: { userId } }, { userId }] } };
/** Only admins can query other users */
if (!isAdmin && req.query.userId) throw new HttpError({ statusCode: 403, message: "ADMIN required" });
if (isAdmin && req.query.userId) {
const query = schemaQuerySingleOrMultipleUserIds.parse(req.query);
const userIds = Array.isArray(query.userId) ? query.userId : [query.userId || userId];
args.where = { OR: [{ eventType: { userId: { in: userIds } } }, { userId: { in: userIds } }] };
if (Array.isArray(query.userId)) args.orderBy = { userId: "asc", eventType: { userId: "asc" } };
}
const data = await prisma.webhook.findMany(args);
return { webhooks: data.map((v) => schemaWebhookReadPublic.parse(v)) };
}
export default defaultResponder(getHandler);
+59
View File
@@ -0,0 +1,59 @@
import type { Prisma } from "@prisma/client";
import type { NextApiRequest } from "next";
import { v4 as uuidv4 } from "uuid";
import { HttpError } from "@calcom/lib/http-error";
import { defaultResponder } from "@calcom/lib/server";
import { schemaWebhookCreateBodyParams, schemaWebhookReadPublic } from "@lib/validations/webhook";
/**
* @swagger
* /hooks:
* post:
* summary: Creates a new webhook
* operationId: addWebhook
* tags:
* - webhooks
* externalDocs:
* url: https://docs.cal.com/webhooks
* responses:
* 201:
* description: OK, webhook created
* 400:
* description: Bad request. webhook body is invalid.
* 401:
* description: Authorization information is missing or invalid.
*/
async function postHandler(req: NextApiRequest) {
const { userId, isAdmin, prisma } = req;
const { eventTypeId, userId: bodyUserId, ...body } = schemaWebhookCreateBodyParams.parse(req.body);
const args: Prisma.WebhookCreateArgs = { data: { id: uuidv4(), ...body } };
// If no event type, we assume is for the current user. If admin we run more checks below...
if (!eventTypeId) args.data.userId = userId;
if (eventTypeId) {
const where: Prisma.EventTypeWhereInput = { id: eventTypeId };
if (!isAdmin) where.userId = userId;
await prisma.eventType.findFirstOrThrow({ where });
args.data.eventTypeId = eventTypeId;
}
if (!isAdmin && bodyUserId) throw new HttpError({ statusCode: 403, message: `ADMIN required for userId` });
if (isAdmin && bodyUserId) {
const where: Prisma.UserWhereInput = { id: userId };
await prisma.user.findFirstOrThrow({ where });
args.data.userId = userId;
}
const data = await prisma.webhook.create(args);
return {
webhook: schemaWebhookReadPublic.parse(data),
message: "Webhook created successfully",
};
}
export default defaultResponder(postHandler);
+10
View File
@@ -0,0 +1,10 @@
import { defaultHandler } from "@calcom/lib/server";
import { withMiddleware } from "@lib/helpers/withMiddleware";
export default withMiddleware("HTTP_GET_OR_POST")(
defaultHandler({
GET: import("./_get"),
POST: import("./_post"),
})
);