diff --git a/packages/backend/src/apps/webhook/assets/favicon.svg b/packages/backend/src/apps/webhook/assets/favicon.svg new file mode 100644 index 00000000..140ebd66 --- /dev/null +++ b/packages/backend/src/apps/webhook/assets/favicon.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/packages/backend/src/apps/webhook/index.d.ts b/packages/backend/src/apps/webhook/index.d.ts new file mode 100644 index 00000000..e69de29b diff --git a/packages/backend/src/apps/webhook/index.ts b/packages/backend/src/apps/webhook/index.ts new file mode 100644 index 00000000..107ee47a --- /dev/null +++ b/packages/backend/src/apps/webhook/index.ts @@ -0,0 +1,14 @@ +import defineApp from '../../helpers/define-app'; +import triggers from './triggers'; + +export default defineApp({ + name: 'Webhook', + key: 'webhook', + iconUrl: '{BASE_URL}/apps/webhook/assets/favicon.svg', + authDocUrl: 'https://automatisch.io/docs/apps/webhook/connection', + supportsConnections: false, + baseUrl: '', + apiBaseUrl: '', + primaryColor: '0059F7', + triggers, +}); diff --git a/packages/backend/src/apps/webhook/triggers/catch-raw-webhook/index.ts b/packages/backend/src/apps/webhook/triggers/catch-raw-webhook/index.ts new file mode 100644 index 00000000..88c52f9f --- /dev/null +++ b/packages/backend/src/apps/webhook/triggers/catch-raw-webhook/index.ts @@ -0,0 +1,20 @@ +import isEmpty from 'lodash/isEmpty'; +import defineTrigger from '../../../../helpers/define-trigger'; + +export default defineTrigger({ + name: 'Catch raw webhook', + key: 'catchRawWebhook', + type: 'webhook', + description: 'Triggers when the webhook receives a request.', + + async testRun($) { + if (!isEmpty($.lastExecutionStep?.dataOut)) { + $.pushTriggerItem({ + raw: $.lastExecutionStep.dataOut, + meta: { + internalId: '', + } + }); + } + }, +}); diff --git a/packages/backend/src/apps/webhook/triggers/index.ts b/packages/backend/src/apps/webhook/triggers/index.ts new file mode 100644 index 00000000..49159004 --- /dev/null +++ b/packages/backend/src/apps/webhook/triggers/index.ts @@ -0,0 +1,3 @@ +import catchRawWebhook from './catch-raw-webhook'; + +export default [catchRawWebhook]; diff --git a/packages/backend/src/controllers/webhooks/handler.ts b/packages/backend/src/controllers/webhooks/handler.ts index 72329adc..48717696 100644 --- a/packages/backend/src/controllers/webhooks/handler.ts +++ b/packages/backend/src/controllers/webhooks/handler.ts @@ -13,20 +13,21 @@ export default async (request: IRequest, response: Response) => { .findById(request.params.flowId) .throwIfNotFound(); - if (!flow.active) { - return response.send(404); - } - + const testRun = !flow.active; const triggerStep = await flow.getTriggerStep(); const triggerCommand = await triggerStep.getTriggerCommand(); + const app = await triggerStep.getApp(); + const isWebhookApp = app.key === 'webhook'; - if (triggerCommand.type !== 'webhook') { - return response.send(404); + if (testRun && !isWebhookApp) { + return response.sendStatus(404); } - const app = await triggerStep.getApp(); + if (triggerCommand.type !== 'webhook') { + return response.sendStatus(404); + } - if (app.auth.verifyWebhook) { + if (app.auth?.verifyWebhook) { const $ = await globalVariable({ flow, connection: await triggerStep.$relatedQuery('connection'), @@ -42,8 +43,20 @@ export default async (request: IRequest, response: Response) => { } } + // in case trigger type is 'webhook' + let payload = request.body; + + // in case it's our built-in generic webhook trigger + if (isWebhookApp) { + payload = { + headers: request.headers, + body: request.body, + query: request.query, + } + } + const triggerItem: ITriggerItem = { - raw: request.body, + raw: payload, meta: { internalId: await bcrypt.hash(request.rawBody, 1), }, @@ -53,8 +66,13 @@ export default async (request: IRequest, response: Response) => { flowId: flow.id, stepId: triggerStep.id, triggerItem, + testRun }); + if (testRun) { + return response.sendStatus(200); + } + const nextStep = await triggerStep.getNextStep(); const jobName = `${executionId}-${nextStep.id}`; diff --git a/packages/backend/src/graphql/mutations/update-flow-status.ts b/packages/backend/src/graphql/mutations/update-flow-status.ts index 1cb80d5a..99a504c2 100644 --- a/packages/backend/src/graphql/mutations/update-flow-status.ts +++ b/packages/backend/src/graphql/mutations/update-flow-status.ts @@ -49,9 +49,9 @@ const updateFlowStatus = async ( testRun: false, }); - if (flow.active) { + if (flow.active && trigger.registerHook) { await trigger.registerHook($); - } else { + } else if (!flow.active && trigger.unregisterHook) { await trigger.unregisterHook($); } } else { diff --git a/packages/backend/src/graphql/schema.graphql b/packages/backend/src/graphql/schema.graphql index bd0c39b9..3f9714a3 100644 --- a/packages/backend/src/graphql/schema.graphql +++ b/packages/backend/src/graphql/schema.graphql @@ -342,6 +342,7 @@ type Step { key: String appKey: String iconUrl: String + webhookUrl: String type: StepEnumType parameters: JSONObject connection: Connection diff --git a/packages/backend/src/helpers/global-variable.ts b/packages/backend/src/helpers/global-variable.ts index de9a09e8..6834b081 100644 --- a/packages/backend/src/helpers/global-variable.ts +++ b/packages/backend/src/helpers/global-variable.ts @@ -77,6 +77,7 @@ const globalVariable = async ( id: execution?.id, testRun, }, + lastExecutionStep: (await step?.getLastExecutionStep())?.toJSON(), triggerOutput: { data: [], }, diff --git a/packages/backend/src/models/step.ts b/packages/backend/src/models/step.ts index 719fbbf3..294671af 100644 --- a/packages/backend/src/models/step.ts +++ b/packages/backend/src/models/step.ts @@ -1,10 +1,11 @@ +import { URL } from 'node:url'; import { QueryContext, ModelOptions } from 'objection'; +import type { IJSONObject, IStep } from '@automatisch/types'; import Base from './base'; import App from './app'; import Flow from './flow'; import Connection from './connection'; import ExecutionStep from './execution-step'; -import type { IJSONObject, IStep } from '@automatisch/types'; import Telemetry from '../helpers/telemetry'; import appConfig from '../config/app'; @@ -46,7 +47,7 @@ class Step extends Base { }; static get virtualAttributes() { - return ['iconUrl']; + return ['iconUrl', 'webhookUrl']; } static relationMappings = () => ({ @@ -82,6 +83,13 @@ class Step extends Base { return `${appConfig.baseUrl}/apps/${this.appKey}/assets/favicon.svg`; } + get webhookUrl() { + if (this.appKey !== 'webhook') return null; + + const url = new URL(`/webhooks/${this.flowId}`, appConfig.webhookUrl); + return url.toString(); + } + async $afterInsert(queryContext: QueryContext) { await super.$afterInsert(queryContext); Telemetry.stepCreated(this); @@ -106,6 +114,14 @@ class Step extends Base { return await App.findOneByKey(this.appKey); } + async getLastExecutionStep() { + const lastExecutionStep = await this.$relatedQuery('executionSteps') + .orderBy('created_at', 'desc') + .first(); + + return lastExecutionStep; + } + async getNextStep() { const flow = await this.$relatedQuery('flow'); diff --git a/packages/backend/src/routes/webhooks.ts b/packages/backend/src/routes/webhooks.ts index c26f4add..ca040cc0 100644 --- a/packages/backend/src/routes/webhooks.ts +++ b/packages/backend/src/routes/webhooks.ts @@ -3,6 +3,8 @@ import webhookHandler from '../controllers/webhooks/handler'; const router = Router(); +router.get('/:flowId', webhookHandler); +router.put('/:flowId', webhookHandler); router.post('/:flowId', webhookHandler); export default router; diff --git a/packages/types/index.d.ts b/packages/types/index.d.ts index 0b2fb1f8..1386f962 100644 --- a/packages/types/index.d.ts +++ b/packages/types/index.d.ts @@ -54,6 +54,7 @@ export interface IStep { key?: string; appKey?: string; iconUrl: string; + webhookUrl: string; type: 'action' | 'trigger'; connectionId?: string; status: string; @@ -180,23 +181,16 @@ export interface IDynamicData { export interface IAuth { generateAuthUrl?($: IGlobalVariable): Promise; - verifyCredentials($: IGlobalVariable): Promise; - isStillVerified($: IGlobalVariable): Promise; + verifyCredentials?($: IGlobalVariable): Promise; + isStillVerified?($: IGlobalVariable): Promise; refreshToken?($: IGlobalVariable): Promise; verifyWebhook?($: IGlobalVariable): Promise; isRefreshTokenRequested?: boolean; - fields: IField[]; + fields?: IField[]; authenticationSteps?: IAuthenticationStep[]; reconnectionSteps?: IAuthenticationStep[]; } -export interface IService { - authenticationClient?: IAuthentication; - triggers?: any; - actions?: any; - data?: any; -} - export interface ITriggerOutput { data: ITriggerItem[]; error?: IJSONObject; @@ -300,6 +294,7 @@ export type IGlobalVariable = { id: string; testRun: boolean; }; + lastExecutionStep?: IExecutionStep; webhookUrl?: string; triggerOutput?: ITriggerOutput; actionOutput?: IActionOutput; diff --git a/packages/web/src/components/AddAppConnection/index.tsx b/packages/web/src/components/AddAppConnection/index.tsx index 7b54e0fe..38ba620b 100644 --- a/packages/web/src/components/AddAppConnection/index.tsx +++ b/packages/web/src/components/AddAppConnection/index.tsx @@ -6,22 +6,15 @@ import DialogContentText from '@mui/material/DialogContentText'; import Dialog from '@mui/material/Dialog'; import LoadingButton from '@mui/lab/LoadingButton'; import { FieldValues, SubmitHandler } from 'react-hook-form'; -import { IJSONObject } from '@automatisch/types'; +import type { IApp, IJSONObject, IField } from '@automatisch/types'; import useFormatMessage from 'hooks/useFormatMessage'; import computeAuthStepVariables from 'helpers/computeAuthStepVariables'; import { processStep } from 'helpers/authenticationSteps'; import InputCreator from 'components/InputCreator'; -import type { IApp, IField } from '@automatisch/types'; +import { generateExternalLink } from '../../helpers/translation-values'; import { Form } from './style'; -const generateDocsLink = (link: string) => (str: string) => - ( - - {str} - - ); - type AddAppConnectionProps = { onClose: (response: Record) => void; application: IApp; @@ -112,7 +105,7 @@ export default function AddAppConnection( {formatMessage('addAppConnection.callToDocs', { appName: name, - docsLink: generateDocsLink(authDocUrl), + docsLink: generateExternalLink(authDocUrl), })} )} diff --git a/packages/web/src/components/TestSubstep/index.tsx b/packages/web/src/components/TestSubstep/index.tsx index f2b895c3..09a5f0c7 100644 --- a/packages/web/src/components/TestSubstep/index.tsx +++ b/packages/web/src/components/TestSubstep/index.tsx @@ -9,8 +9,9 @@ import LoadingButton from '@mui/lab/LoadingButton'; import { EditorContext } from 'contexts/Editor'; import useFormatMessage from 'hooks/useFormatMessage'; -import JSONViewer from 'components/JSONViewer'; import { EXECUTE_FLOW } from 'graphql/mutations/execute-flow'; +import JSONViewer from 'components/JSONViewer'; +import WebhookUrlInfo from 'components/WebhookUrlInfo'; import FlowSubstepTitle from 'components/FlowSubstepTitle'; import type { IStep, ISubstep } from '@automatisch/types'; @@ -115,6 +116,8 @@ function TestSubstep(props: TestSubstepProps): React.ReactElement { )} + {step.webhookUrl && } + {hasNoOutput && ( diff --git a/packages/web/src/components/WebhookUrlInfo/index.tsx b/packages/web/src/components/WebhookUrlInfo/index.tsx new file mode 100644 index 00000000..cc5ca10d --- /dev/null +++ b/packages/web/src/components/WebhookUrlInfo/index.tsx @@ -0,0 +1,44 @@ +import * as React from 'react'; +import { FormattedMessage } from 'react-intl'; +import Typography from '@mui/material/Typography'; + +import { generateExternalLink } from '../../helpers/translation-values'; +import { WEBHOOK_DOCS } from '../../config/urls'; +import TextField from '../TextField'; +import { Alert } from './style'; + +type WebhookUrlInfoProps = { + webhookUrl: string; +}; + +function WebhookUrlInfo(props: WebhookUrlInfoProps): React.ReactElement { + const { webhookUrl } = props; + + return ( + + + + + + + + + + + } + /> + + ); +} + +export default WebhookUrlInfo; diff --git a/packages/web/src/components/WebhookUrlInfo/style.ts b/packages/web/src/components/WebhookUrlInfo/style.ts new file mode 100644 index 00000000..b74a3834 --- /dev/null +++ b/packages/web/src/components/WebhookUrlInfo/style.ts @@ -0,0 +1,14 @@ +import { styled } from '@mui/material/styles'; +import MuiAlert, { alertClasses } from '@mui/material/Alert'; + +export const Alert = styled(MuiAlert)(({ theme }) => ({ + [`&.${alertClasses.root}`]: { + fontWeight: 300, + width: '100%', + display: 'flex', + flexDirection: 'column' + }, + [`& .${alertClasses.message}`]: { + width: '100%' + } +})); diff --git a/packages/web/src/config/urls.ts b/packages/web/src/config/urls.ts index 27214fe0..5f0830f0 100644 --- a/packages/web/src/config/urls.ts +++ b/packages/web/src/config/urls.ts @@ -65,3 +65,6 @@ export const UPDATES = '/updates'; export const SETTINGS_PROFILE = `${SETTINGS}/${PROFILE}`; export const DASHBOARD = FLOWS; + +// External links +export const WEBHOOK_DOCS = 'https://automatisch.io/docs' diff --git a/packages/web/src/graphql/queries/get-flow.ts b/packages/web/src/graphql/queries/get-flow.ts index 34345984..f3e86446 100644 --- a/packages/web/src/graphql/queries/get-flow.ts +++ b/packages/web/src/graphql/queries/get-flow.ts @@ -11,6 +11,8 @@ export const GET_FLOW = gql` type key appKey + iconUrl + webhookUrl status position connection { diff --git a/packages/web/src/helpers/translation-values.tsx b/packages/web/src/helpers/translation-values.tsx new file mode 100644 index 00000000..86e84ce5 --- /dev/null +++ b/packages/web/src/helpers/translation-values.tsx @@ -0,0 +1,6 @@ +export const generateExternalLink = (link: string) => (str: string) => + ( + + {str} + + ); diff --git a/packages/web/src/locales/en.json b/packages/web/src/locales/en.json index 4d79ee2b..1732120f 100644 --- a/packages/web/src/locales/en.json +++ b/packages/web/src/locales/en.json @@ -87,5 +87,9 @@ "profileSettings.updatedPassword": "Your password has been updated.", "profileSettings.updatePassword": "Update password", "notifications.title": "Notifications", - "notification.releasedAt": "Released {relativeDate}" + "notification.releasedAt": "Released {relativeDate}", + "webhookUrlInfo.title": "Your webhook URL", + "webhookUrlInfo.description": "You'll need to configure your application with this webhook URL.", + "webhookUrlInfo.helperText": "We've generated a custom webhook URL for you to send requests to. Learn more about webhooks.", + "webhookUrlInfo.copy": "Copy" } \ No newline at end of file