Skip to content

Commit

Permalink
feat(webhooks): add webhook support
Browse files Browse the repository at this point in the history
Adds webhooks without consuming response; future stories could act upon response and/or show status
of webhooks in document history panel.
  • Loading branch information
benforshey committed Nov 19, 2024
1 parent 362a161 commit 2640478
Show file tree
Hide file tree
Showing 10 changed files with 225 additions and 2 deletions.
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,6 @@ LOG_LEVEL=

# used when the origin URL of mEditor's host is needed
MEDITOR_ORIGIN=http://localhost

# Escaped JSON array as a string. E.g., "[{ \"token\":\"dGhpcy1pcy1hLXRva2VuLWZvci1lbmRwb2ludC0x\",\"URL\":\"http://example.com/1\" }]"
UI_WEBHOOKS="[]"
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,7 @@ Thumbs.db
# Docker Bind Mounts
/mongo-data/
/nats-data/

# Python
env
venv
62 changes: 60 additions & 2 deletions packages/app/documents/service.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import Fuse from 'fuse.js'
import { immutableJSONPatch, JSONPatchDocument } from 'immutable-json-patch'
import { validate } from 'jsonschema'
import { getWebhookConfig, invokeWebhook } from 'webhooks/service'
import type { User, UserRole } from '../auth/types'
import type { ErrorData } from '../declarations'
import {
Expand All @@ -23,7 +25,6 @@ import type {
DocumentPublications,
DocumentState,
} from './types'
import { JSONPatchDocument, immutableJSONPatch } from 'immutable-json-patch'

const EMAIL_NOTIFICATIONS_QUEUE_CHANNEL =
process.env.MEDITOR_NATS_NOTIFICATIONS_CHANNEL || 'meditor-notifications'
Expand Down Expand Up @@ -101,7 +102,6 @@ export async function createDocument(
rootState.modifiedOn = document['x-meditor'].modifiedOn
document['x-meditor'].modifiedOn = new Date().toISOString()
document['x-meditor'].modifiedBy = user.uid
// TODO: replace with actual model init state
document['x-meditor'].states = [rootState]
document['x-meditor'].publishedTo = []

Expand All @@ -116,6 +116,35 @@ export async function createDocument(
const [last] = insertedDocument['x-meditor'].states.slice(-1)
const targetState = last.target

// Get the optional webhook URL from the workflow.
const [firstCurrentEdge] = modelWithWorkflow.workflow.currentEdges
const [_webhookConfigError, webhookConfig = null] = getWebhookConfig(
firstCurrentEdge.webhookURL
)

// If there is a webhook URL, get the underlying webhook config and invoke it.
if (webhookConfig) {
const [_error, response] = await invokeWebhook(webhookConfig, {
model: modelWithWorkflow,
document: insertedDocument,
state: targetState,
})

// NOTE: Right now, we're not doing anything with the webhook response; a future story would be to include this and the previous document status notifications into document history. While we're changing document history, we should make sure that "Init" state is represented:
/**
"states": [
{
"source": "Init",
"target": "Draft",
"modifiedOn": "2027-08-03T16:57:26.401Z",
"modifiedBy": "username"
}
]
*/

log.debug(response)
}

safelyPublishDocumentChangeToQueue(
modelWithWorkflow,
insertedDocument,
Expand Down Expand Up @@ -536,6 +565,35 @@ export async function changeDocumentState(
)
}

// Get the optional webhook URL from the workflow.
const [firstCurrentEdge] = model.workflow.currentEdges
const [_webhookConfigError, webhookConfig = null] = getWebhookConfig(
firstCurrentEdge.webhookURL
)

// If there is a webhook URL, get the underlying webhook config and invoke it.
if (webhookConfig) {
const [_error, response] = await invokeWebhook(webhookConfig, {
model,
document,
state: newState,
})

// NOTE: Right now, we're not doing anything with the webhook response; a future story would be to include this and the previous document status notifications into document history. While we're changing document history, we should make sure that "Init" state is represented:
/**
"states": [
{
"source": "Init",
"target": "Draft",
"modifiedOn": "2027-08-03T16:57:26.401Z",
"modifiedBy": "username"
}
]
*/

log.debug(response)
}

if (!options?.disableQueuePublication) {
safelyPublishDocumentChangeToQueue(model, document, newState)
} else {
Expand Down
19 changes: 19 additions & 0 deletions packages/app/macros/service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import cloneDeep from 'lodash.clonedeep'
import { getAllWebhookURLs } from 'webhooks/service'
import type { ErrorData } from '../declarations'
import log from '../lib/log'
import type { Model, PopulatedTemplate, Template } from '../models/types'
Expand Down Expand Up @@ -119,8 +120,26 @@ async function listUniqueFieldValues(
}
}

async function getWebhookConfig(): Promise<ErrorData<PopulatedTemplate['result']>> {
try {
// NOTE: Get only the webhook URLs to avoid exposing the bearer tokens to the frontend.
const [error, webhookURLs] = getAllWebhookURLs()

if (error) {
throw error
}

return [null, webhookURLs]
} catch (error) {
log.error(error)

return [error, null]
}
}

//* The exported "runModelTemplate" below and these macro names (consumed programmatically in "runModelTemplate" are the exposed interface that mEditor template macros will use. See the ReadMe in this file for more context.
macros.set('list', listUniqueFieldValues)
macros.set('listDependenciesByTitle', listDependenciesByTitle)
macros.set('webhooks', getWebhookConfig)

export { runModelTemplates }
4 changes: 4 additions & 0 deletions packages/app/models/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ export async function getModel(
// execute the macro templates for this model and get their values
const [error, populatedTemplates] = await runModelTemplates(model)

if (error) {
throw error
}

// parse the schema into an object
let schema =
typeof model.schema === 'string'
Expand Down
19 changes: 19 additions & 0 deletions packages/app/utils/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ const parser = new AsyncParser()

type Serializable = string | object | number | boolean

enum MIMETypes {
Text = 'text/plain',
JSON = 'application/json',
}

export function respondAsJson(
payload: Serializable,
request: NextApiRequest,
Expand Down Expand Up @@ -87,3 +92,17 @@ export async function respondAs(
})
}
}

export async function parseResponse(response: Response) {
const [mimeType = null, _mediaTypeOrBoundary] = response.headers
.get('content-type')
?.split(';')

switch (mimeType) {
case MIMETypes.JSON:
return await response.json()
case MIMETypes.Text:
default:
return await response.text()
}
}
9 changes: 9 additions & 0 deletions packages/app/webhooks/schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { z } from 'zod'

//* https://datatracker.ietf.org/doc/html/rfc6750 does not require base64 encoding of Bearer tokens.
export const WebhookConfigSchema = z.object({
URL: z.string().url(),
token: z.string().min(1),
})

export const WebhookConfigsSchema = z.array(WebhookConfigSchema)
93 changes: 93 additions & 0 deletions packages/app/webhooks/service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import type { ErrorData } from 'declarations'
import { parseResponse } from 'utils/api'
import { ErrorCode, HttpException, parseZodAsErrorData } from 'utils/errors'
import { safeParseJSON } from 'utils/json'
import log from '../lib/log'
import { WebhookConfigsSchema } from './schema'
import type { WebhookConfig, WebhookPayload } from './types'

const WEBHOOK_ENV_VAR = 'UI_WEBHOOKS'

function getAllWebhookConfigs(): ErrorData<WebhookConfig[]> {
try {
const fromEnvironment = process.env[WEBHOOK_ENV_VAR] ?? []
const [parseError, JSON] = safeParseJSON(fromEnvironment)
const [schemaError, webhooks] = parseZodAsErrorData(
WebhookConfigsSchema,
JSON
)

if (parseError || schemaError) {
throw parseError || schemaError
}

return [null, webhooks as WebhookConfig[]]
} catch (error) {
log.error(error)

return [error, null]
}
}

function getAllWebhookURLs(): ErrorData<string[]> {
try {
const [error, webhooks] = getAllWebhookConfigs()

if (error) {
throw error
}

const webhookURLs = webhooks.map(webhook => webhook.URL)

return [null, webhookURLs]
} catch (error) {
log.error(error)

return [error, null]
}
}

function getWebhookConfig(URL: WebhookConfig['URL']): ErrorData<WebhookConfig> {
try {
const [error, webhooks] = getAllWebhookConfigs()

if (error) {
throw error
}

const [webhook = null] = webhooks.filter(webhook => webhook.URL === URL)

return [null, webhook]
} catch (error) {
log.error(error)

return [error, null]
}
}

async function invokeWebhook(
webhook: WebhookConfig,
payload: WebhookPayload
): Promise<ErrorData<any>> {
try {
const response = await fetch(webhook.URL, {
method: 'POST',
headers: {
accept: 'application/json',
authorization: `Bearer ${webhook.token}`,
'content-type': 'application/json',
},
body: JSON.stringify(payload),
})

if (!response.ok) {
throw new HttpException(ErrorCode.BadRequest, response.statusText)
}

return [null, await parseResponse(response)]
} catch (error) {
return [error, null]
}
}

export { getAllWebhookURLs, getWebhookConfig, invokeWebhook }
13 changes: 13 additions & 0 deletions packages/app/webhooks/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type { Document } from 'documents/types'
import type { ModelWithWorkflow } from 'models/types'
import type { z } from 'zod'

import type { WebhookConfigSchema } from './schema'

export type WebhookConfig = z.infer<typeof WebhookConfigSchema>

export type WebhookPayload = {
model: ModelWithWorkflow
document: Document
state: string
}
1 change: 1 addition & 0 deletions packages/app/workflows/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export interface WorkflowEdge {
label: string
notify?: boolean
notifyRoles?: string
webhookURL?: string
}

export interface WorkflowState {
Expand Down

0 comments on commit 2640478

Please sign in to comment.