diff --git a/apps/docs/docs/api.mdx b/apps/docs/docs/api.mdx index 85bff8e012..6d3da992cc 100644 --- a/apps/docs/docs/api.mdx +++ b/apps/docs/docs/api.mdx @@ -45,54 +45,42 @@ curl -i -X GET https://typebot.io/api/typebots \ } ``` -### GET /api/typebots/typebotId/webhookSteps +### GET /api/typebots/typebotId/webhookBlocks -List webhook steps in a typebot. These are the steps you can, later on, register your Webhook URL: +List webhook blocks in a typebot. These are the blocks you can register a Webhook URL: ```bash title="Try it yourself" -curl -i -X GET https://typebot.io/api/typebots/$TYPEBOT_ID/webhookSteps \ +curl -i -X GET https://typebot.io/api/typebots/$TYPEBOT_ID/webhookBlocks \ -H 'Authorization: Bearer ${TOKEN}' ``` ```json title="Response 200 OK" { - "steps": [ + "blocks": [ { "blockId": "blockId", - "id": "stepId", - "name": "Block #2 > stepId" + "name": "Group #2 > blockId", + "url": "https://my-webhook.com/webhook" } ] } ``` -### GET /api/typebots/typebotId/blocks/blockId/steps/stepId/sampleResult +### GET /api/typebots/typebotId/blocks/blockId/sampleResult Get a sample of what the webhook body will look like when triggered ```bash title="Try it yourself" -curl -i -X GET https://typebot.io/api/typebots/$TYPEBOT_ID/blocks/$BLOCK_ID/steps/$STEP_ID/sampleResult \ +curl -i -X GET https://typebot.io/api/typebots/$TYPEBOT_ID/blocks/$BLOCK_ID/sampleResult \ -H 'Authorization: Bearer ${TOKEN}' ``` -```json title="Response 200 OK" -{ - "steps": [ - { - "blockId": "blockId", - "id": "stepId", - "name": "Block #2 > stepId" - } - ] -} -``` - -### POST /api/typebots/typebotId/blocks/blockId/steps/stepId/subscribeWebhook +### POST /api/typebots/typebotId/blocks/blockId/subscribeWebhook -Subscribe the step to a specified webhook URL +Subscribe the block to a specified webhook URL ```bash title="Try it yourself" -curl -i -X POST https://typebot.io/api/typebots/$TYPEBOT_ID/webhookSteps \ +curl -i -X POST https://typebot.io/api/typebots/$TYPEBOT_ID/blocks/$BLOCK_ID/subscribeWebhook \ -H 'Authorization: Bearer ${TOKEN}'\ --header 'Content-Type: application/json' \ --data '{"url": "https://domain.com/my-webhook"}' @@ -114,12 +102,12 @@ The url you want to subscribe to.
-### POST /api/typebots/typebotId/blocks/blockId/steps/stepId/unsubscribeWebhook +### POST /api/typebots/typebotId/blocks/blockId/unsubscribeWebhook -Unsubscribe the current webhook on step +Unsubscribe the current webhook on block ```bash title="Try it yourself" -curl -i -X POST https://typebot.io/api/typebots/$TYPEBOT_ID/webhookSteps \ +curl -i -X POST https://typebot.io/api/typebots/$TYPEBOT_ID/blocks/$BLOCK_ID/unsubscribeWebhook \ -H 'Authorization: Bearer ${TOKEN}'\ ``` diff --git a/apps/docs/src/js/api-helpers.js b/apps/docs/src/js/api-helpers.js index 298ddc3c64..6684e51366 100644 --- a/apps/docs/src/js/api-helpers.js +++ b/apps/docs/src/js/api-helpers.js @@ -1,5 +1,6 @@ // Taken from https://github.com/plausible/docs/blob/master/src/js/api-helpers.js 💙 import React from 'react' +import { useColorMode } from '@docusaurus/theme-common' export const Required = () => ( ( ) export const Tag = ({ children, color }) => { - let backgroundColor = '#CBD5E0' + const { isDarkTheme } = useColorMode() + let backgroundColor = isDarkTheme ? '#2d60b4' : '#CBD5E0' switch (color) { case 'green': backgroundColor = '#68D391' diff --git a/apps/viewer/pages/api/typebots/[typebotId]/blocks/[blockId]/executeWebhook.ts b/apps/viewer/pages/api/typebots/[typebotId]/blocks/[blockId]/executeWebhook.ts new file mode 100644 index 0000000000..0c05edd77d --- /dev/null +++ b/apps/viewer/pages/api/typebots/[typebotId]/blocks/[blockId]/executeWebhook.ts @@ -0,0 +1,204 @@ +import prisma from 'libs/prisma' +import { + defaultWebhookAttributes, + HttpMethod, + KeyValue, + PublicTypebot, + ResultValues, + Typebot, + Variable, + Webhook, + WebhookOptions, + WebhookResponse, + WebhookStep, +} from 'models' +import { parseVariables } from 'bot-engine' +import { NextApiRequest, NextApiResponse } from 'next' +import got, { Method, Headers, HTTPError } from 'got' +import { + byId, + initMiddleware, + methodNotAllowed, + notFound, + parseAnswers, +} from 'utils' +import { stringify } from 'qs' +import { withSentry } from '@sentry/nextjs' +import Cors from 'cors' +import { parseSampleResult } from 'services/api/webhooks' + +const cors = initMiddleware(Cors()) +const handler = async (req: NextApiRequest, res: NextApiResponse) => { + await cors(req, res) + if (req.method === 'POST') { + const typebotId = req.query.typebotId.toString() + const stepId = req.query.blockId.toString() + const { resultValues, variables } = ( + typeof req.body === 'string' ? JSON.parse(req.body) : req.body + ) as { + resultValues: ResultValues | undefined + variables: Variable[] + } + const typebot = (await prisma.typebot.findUnique({ + where: { id: typebotId }, + include: { webhooks: true }, + })) as unknown as (Typebot & { webhooks: Webhook[] }) | null + if (!typebot) return notFound(res) + const step = typebot.blocks + .flatMap((b) => b.steps) + .find(byId(stepId)) as WebhookStep + const webhook = typebot.webhooks.find(byId(step.webhookId)) + if (!webhook) + return res + .status(404) + .send({ statusCode: 404, data: { message: `Couldn't find webhook` } }) + const preparedWebhook = prepareWebhookAttributes(webhook, step.options) + const result = await executeWebhook(typebot)( + preparedWebhook, + variables, + step.blockId, + resultValues + ) + return res.status(200).send(result) + } + return methodNotAllowed(res) +} + +const prepareWebhookAttributes = ( + webhook: Webhook, + options: WebhookOptions +): Webhook => { + if (options.isAdvancedConfig === false) { + return { ...webhook, body: '{{state}}', ...defaultWebhookAttributes } + } else if (options.isCustomBody === false) { + return { ...webhook, body: '{{state}}' } + } + return webhook +} + +const executeWebhook = + (typebot: Typebot) => + async ( + webhook: Webhook, + variables: Variable[], + blockId: string, + resultValues?: ResultValues + ): Promise => { + if (!webhook.url || !webhook.method) + return { + statusCode: 400, + data: { message: `Webhook doesn't have url or method` }, + } + const basicAuth: { username?: string; password?: string } = {} + const basicAuthHeaderIdx = webhook.headers.findIndex( + (h) => + h.key?.toLowerCase() === 'authorization' && + h.value?.toLowerCase()?.includes('basic') + ) + const isUsernamePasswordBasicAuth = + basicAuthHeaderIdx !== -1 && + webhook.headers[basicAuthHeaderIdx].value?.includes(':') + if (isUsernamePasswordBasicAuth) { + const [username, password] = + webhook.headers[basicAuthHeaderIdx].value?.slice(6).split(':') ?? [] + basicAuth.username = username + basicAuth.password = password + webhook.headers.splice(basicAuthHeaderIdx, 1) + } + const headers = convertKeyValueTableToObject(webhook.headers, variables) as + | Headers + | undefined + const queryParams = stringify( + convertKeyValueTableToObject(webhook.queryParams, variables) + ) + const contentType = headers ? headers['Content-Type'] : undefined + const body = + webhook.method !== HttpMethod.GET + ? getBodyContent(typebot)({ + body: webhook.body, + resultValues, + blockId, + }) + : undefined + try { + const response = await got( + parseVariables(variables)( + webhook.url + (queryParams !== '' ? `?${queryParams}` : '') + ), + { + method: webhook.method as Method, + headers, + ...basicAuth, + json: + contentType !== 'x-www-form-urlencoded' && body + ? JSON.parse(parseVariables(variables)(body)) + : undefined, + form: + contentType === 'x-www-form-urlencoded' && body + ? JSON.parse(parseVariables(variables)(body)) + : undefined, + } + ) + return { + statusCode: response.statusCode, + data: parseBody(response.body), + } + } catch (error) { + if (error instanceof HTTPError) { + return { + statusCode: error.response.statusCode, + data: parseBody(error.response.body as string), + } + } + console.error(error) + return { + statusCode: 500, + data: { message: `Error from Typebot server: ${error}` }, + } + } + } + +const getBodyContent = + (typebot: Pick) => + ({ + body, + resultValues, + blockId, + }: { + body?: string | null + resultValues?: ResultValues + blockId: string + }): string | undefined => { + if (!body) return + return body === '{{state}}' + ? JSON.stringify( + resultValues + ? parseAnswers(typebot)(resultValues) + : parseSampleResult(typebot)(blockId) + ) + : body + } + +const parseBody = (body: string) => { + try { + return JSON.parse(body) + } catch (err) { + return body + } +} + +const convertKeyValueTableToObject = ( + keyValues: KeyValue[] | undefined, + variables: Variable[] +) => { + if (!keyValues) return + return keyValues.reduce((object, item) => { + if (!item.key) return {} + return { + ...object, + [item.key]: parseVariables(variables)(item.value ?? ''), + } + }, {}) +} + +export default withSentry(handler) diff --git a/apps/viewer/pages/api/typebots/[typebotId]/blocks/[blockId]/sampleResult.ts b/apps/viewer/pages/api/typebots/[typebotId]/blocks/[blockId]/sampleResult.ts new file mode 100644 index 0000000000..85f400c858 --- /dev/null +++ b/apps/viewer/pages/api/typebots/[typebotId]/blocks/[blockId]/sampleResult.ts @@ -0,0 +1,27 @@ +import prisma from 'libs/prisma' +import { Typebot } from 'models' +import { NextApiRequest, NextApiResponse } from 'next' +import { authenticateUser } from 'services/api/utils' +import { parseSampleResult } from 'services/api/webhooks' +import { methodNotAllowed } from 'utils' + +const handler = async (req: NextApiRequest, res: NextApiResponse) => { + if (req.method === 'GET') { + const user = await authenticateUser(req) + if (!user) return res.status(401).json({ message: 'Not authenticated' }) + const typebotId = req.query.typebotId.toString() + const stepId = req.query.blockId.toString() + const typebot = (await prisma.typebot.findUnique({ + where: { id_ownerId: { id: typebotId, ownerId: user.id } }, + })) as unknown as Typebot | undefined + if (!typebot) return res.status(400).send({ message: 'Typebot not found' }) + const step = typebot.blocks + .flatMap((b) => b.steps) + .find((s) => s.id === stepId) + if (!step) return res.status(404).send({ message: 'Block not found' }) + return res.send(parseSampleResult(typebot)(step.blockId)) + } + methodNotAllowed(res) +} + +export default handler diff --git a/apps/viewer/pages/api/typebots/[typebotId]/blocks/[blockId]/subscribeWebhook.ts b/apps/viewer/pages/api/typebots/[typebotId]/blocks/[blockId]/subscribeWebhook.ts new file mode 100644 index 0000000000..fba8c2f345 --- /dev/null +++ b/apps/viewer/pages/api/typebots/[typebotId]/blocks/[blockId]/subscribeWebhook.ts @@ -0,0 +1,42 @@ +import { withSentry } from '@sentry/nextjs' +import prisma from 'libs/prisma' +import { Typebot, WebhookStep } from 'models' +import { NextApiRequest, NextApiResponse } from 'next' +import { authenticateUser } from 'services/api/utils' +import { byId, methodNotAllowed } from 'utils' + +const handler = async (req: NextApiRequest, res: NextApiResponse) => { + if (req.method === 'POST') { + const user = await authenticateUser(req) + if (!user) return res.status(401).json({ message: 'Not authenticated' }) + const body = req.body as Record + if (!('url' in body)) + return res.status(403).send({ message: 'url is missing in body' }) + const { url } = body + const typebotId = req.query.typebotId.toString() + const stepId = req.query.blockId.toString() + const typebot = (await prisma.typebot.findUnique({ + where: { id_ownerId: { id: typebotId, ownerId: user.id } }, + })) as unknown as Typebot | undefined + if (!typebot) return res.status(400).send({ message: 'Typebot not found' }) + try { + const { webhookId } = typebot.blocks + .flatMap((b) => b.steps) + .find(byId(stepId)) as WebhookStep + await prisma.webhook.upsert({ + where: { id: webhookId }, + update: { url, body: '{{state}}', method: 'POST' }, + create: { url, body: '{{state}}', method: 'POST', typebotId }, + }) + + return res.send({ message: 'success' }) + } catch (err) { + return res + .status(400) + .send({ message: "blockId doesn't point to a Webhook step" }) + } + } + return methodNotAllowed(res) +} + +export default withSentry(handler) diff --git a/apps/viewer/pages/api/typebots/[typebotId]/blocks/[blockId]/unsubscribeWebhook.ts b/apps/viewer/pages/api/typebots/[typebotId]/blocks/[blockId]/unsubscribeWebhook.ts new file mode 100644 index 0000000000..f0bdd480ca --- /dev/null +++ b/apps/viewer/pages/api/typebots/[typebotId]/blocks/[blockId]/unsubscribeWebhook.ts @@ -0,0 +1,37 @@ +import { withSentry } from '@sentry/nextjs' +import prisma from 'libs/prisma' +import { Typebot, WebhookStep } from 'models' +import { NextApiRequest, NextApiResponse } from 'next' +import { authenticateUser } from 'services/api/utils' +import { byId, methodNotAllowed } from 'utils' + +const handler = async (req: NextApiRequest, res: NextApiResponse) => { + if (req.method === 'POST') { + const user = await authenticateUser(req) + if (!user) return res.status(401).json({ message: 'Not authenticated' }) + const typebotId = req.query.typebotId.toString() + const stepId = req.query.blockId.toString() + const typebot = (await prisma.typebot.findUnique({ + where: { id_ownerId: { id: typebotId, ownerId: user.id } }, + })) as unknown as Typebot | undefined + if (!typebot) return res.status(400).send({ message: 'Typebot not found' }) + try { + const { webhookId } = typebot.blocks + .flatMap((b) => b.steps) + .find(byId(stepId)) as WebhookStep + await prisma.webhook.update({ + where: { id: webhookId }, + data: { url: null }, + }) + + return res.send({ message: 'success' }) + } catch (err) { + return res + .status(400) + .send({ message: "blockId doesn't point to a Webhook step" }) + } + } + return methodNotAllowed(res) +} + +export default withSentry(handler) diff --git a/apps/viewer/pages/api/typebots/[typebotId]/webhookBlocks.ts b/apps/viewer/pages/api/typebots/[typebotId]/webhookBlocks.ts new file mode 100644 index 0000000000..804e397837 --- /dev/null +++ b/apps/viewer/pages/api/typebots/[typebotId]/webhookBlocks.ts @@ -0,0 +1,37 @@ +import { withSentry } from '@sentry/nextjs' +import prisma from 'libs/prisma' +import { Block, WebhookStep } from 'models' +import { NextApiRequest, NextApiResponse } from 'next' +import { authenticateUser } from 'services/api/utils' +import { byId, isWebhookStep, methodNotAllowed } from 'utils' + +const handler = async (req: NextApiRequest, res: NextApiResponse) => { + if (req.method === 'GET') { + const user = await authenticateUser(req) + if (!user) return res.status(401).json({ message: 'Not authenticated' }) + const typebotId = req.query.typebotId.toString() + const typebot = await prisma.typebot.findUnique({ + where: { id_ownerId: { id: typebotId, ownerId: user.id } }, + select: { blocks: true, webhooks: true }, + }) + const emptyWebhookSteps = (typebot?.blocks as Block[]).reduce< + { blockId: string; name: string; url: string | undefined }[] + >((emptyWebhookSteps, block) => { + const steps = block.steps.filter((step) => + isWebhookStep(step) + ) as WebhookStep[] + return [ + ...emptyWebhookSteps, + ...steps.map((s) => ({ + blockId: s.id, + name: `${block.title} > ${s.id}`, + url: typebot?.webhooks.find(byId(s.webhookId))?.url ?? undefined, + })), + ] + }, []) + return res.send({ blocks: emptyWebhookSteps }) + } + return methodNotAllowed(res) +} + +export default withSentry(handler) diff --git a/apps/viewer/playwright/tests/api.spec.ts b/apps/viewer/playwright/tests/api.spec.ts index 76e781f3dd..181818880c 100644 --- a/apps/viewer/playwright/tests/api.spec.ts +++ b/apps/viewer/playwright/tests/api.spec.ts @@ -36,19 +36,18 @@ test('can list typebots', async ({ request }) => { test('can get webhook steps', async ({ request }) => { expect( - (await request.get(`/api/typebots/${typebotId}/webhookSteps`)).status() + (await request.get(`/api/typebots/${typebotId}/webhookBlocks`)).status() ).toBe(401) const response = await request.get( - `/api/typebots/${typebotId}/webhookSteps`, + `/api/typebots/${typebotId}/webhookBlocks`, { headers: { Authorization: 'Bearer userToken' }, } ) - const { steps } = await response.json() - expect(steps).toHaveLength(1) - expect(steps[0]).toEqual({ - id: 'webhookStep', - blockId: 'webhookBlock', + const { blocks } = await response.json() + expect(blocks).toHaveLength(1) + expect(blocks[0]).toEqual({ + blockId: 'webhookStep', name: 'Webhook > webhookStep', }) }) @@ -57,13 +56,13 @@ test('can subscribe webhook', async ({ request }) => { expect( ( await request.post( - `/api/typebots/${typebotId}/blocks/webhookBlock/steps/webhookStep/subscribeWebhook`, + `/api/typebots/${typebotId}/blocks/webhookStep/subscribeWebhook`, { data: { url: 'https://test.com' } } ) ).status() ).toBe(401) const response = await request.post( - `/api/typebots/${typebotId}/blocks/webhookBlock/steps/webhookStep/subscribeWebhook`, + `/api/typebots/${typebotId}/blocks/webhookStep/subscribeWebhook`, { headers: { Authorization: 'Bearer userToken', @@ -81,12 +80,12 @@ test('can unsubscribe webhook', async ({ request }) => { expect( ( await request.post( - `/api/typebots/${typebotId}/blocks/webhookBlock/steps/webhookStep/unsubscribeWebhook` + `/api/typebots/${typebotId}/blocks/webhookStep/unsubscribeWebhook` ) ).status() ).toBe(401) const response = await request.post( - `/api/typebots/${typebotId}/blocks/webhookBlock/steps/webhookStep/unsubscribeWebhook`, + `/api/typebots/${typebotId}/blocks/webhookStep/unsubscribeWebhook`, { headers: { Authorization: 'Bearer userToken' }, } @@ -101,12 +100,12 @@ test('can get a sample result', async ({ request }) => { expect( ( await request.get( - `/api/typebots/${typebotId}/blocks/webhookBlock/steps/webhookStep/sampleResult` + `/api/typebots/${typebotId}/blocks/webhookStep/sampleResult` ) ).status() ).toBe(401) const response = await request.get( - `/api/typebots/${typebotId}/blocks/webhookBlock/steps/webhookStep/sampleResult`, + `/api/typebots/${typebotId}/blocks/webhookStep/sampleResult`, { headers: { Authorization: 'Bearer userToken' }, }