From 9688f14007c6835c0fbae9d46a7c66b9b31ef8d6 Mon Sep 17 00:00:00 2001 From: shmck Date: Sun, 29 Aug 2021 10:31:28 -0700 Subject: [PATCH 1/5] webhook demo Signed-off-by: shmck --- src/services/hooks/index.ts | 25 +++++++++++- src/services/hooks/webhooks.ts | 73 ++++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 src/services/hooks/webhooks.ts diff --git a/src/services/hooks/index.ts b/src/services/hooks/index.ts index ddcc7757..d22ec3b3 100644 --- a/src/services/hooks/index.ts +++ b/src/services/hooks/index.ts @@ -7,14 +7,18 @@ import runCommands from './utils/runCommands' import runVSCodeCommands from './utils/runVSCodeCommands' import * as telemetry from '../telemetry' import { runTest } from '../../actions/onTest' -import logger from '../logger' import { VERSION } from '../../environment' +import * as webhooks from './webhooks' // run at the end of when a tutorial is configured export const onInit = async (actions: TT.StepActions): Promise => { await loadCommits(actions?.commits) await runCommands(actions?.commands) await runVSCodeCommands(actions?.vscodeCommands) + webhooks.onInit({ + // tutorialId, + version: VERSION, + }) } // run when a level starts @@ -47,6 +51,10 @@ export const onReset = async (actions: TT.StepActions): Promise => { await resetWatchers() await runCommands(actions?.commands) await runVSCodeCommands(actions?.vscodeCommands) + webhooks.onReset({ + // tutorialId, + version: VERSION, + }) } // run when an uncaught exception is thrown @@ -66,6 +74,12 @@ export const onStepComplete = async ({ }): Promise => { git.saveCommit(`Save progress: ${stepId}`) telemetry.onEvent('step_complete', { tutorialId, stepId, levelId, version: VERSION }) + webhooks.onStepComplete({ + tutorialId, + version: VERSION, + levelId, + stepId, + }) } // run when a level is complete (all tasks pass or no tasks) @@ -77,9 +91,18 @@ export const onLevelComplete = async ({ levelId: string }): Promise => { telemetry.onEvent('level_complete', { tutorialId, levelId, version: VERSION }) + webhooks.onLevelComplete({ + tutorialId, + version: VERSION, + levelId, + }) } // run when all levels are complete export const onTutorialComplete = async ({ tutorialId }: { tutorialId: string }): Promise => { telemetry.onEvent('tutorial_complete', { tutorialId, version: VERSION }) + webhooks.onTutorialComplete({ + tutorialId, + version: VERSION, + }) } diff --git a/src/services/hooks/webhooks.ts b/src/services/hooks/webhooks.ts new file mode 100644 index 00000000..dc39b302 --- /dev/null +++ b/src/services/hooks/webhooks.ts @@ -0,0 +1,73 @@ +import fetch from 'node-fetch' +import logger from '../logger' + +const WEBHOOKS = { + init: true, + reset: true, + step_complete: true, + level_complete: true, + tutorial_complete: true, +} + +const callWebhookEndpoint = async (bodyObject: B): Promise => { + const endpoint = 'http://localhost:3000/' + const body = JSON.stringify(bodyObject) + try { + const sendEvent = await fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body, + }) + if (!sendEvent.ok) { + throw new Error('Error sending event') + } + } catch (err: unknown) { + logger(`Failed to call webhook endpoint ${endpoint} with body ${body}`) + } +} + +type InitEvent = { + // tutorialId: string; + version: string +} + +export const onInit = (event: InitEvent): void => { + if (WEBHOOKS.init) { + callWebhookEndpoint(event) + } +} + +type ResetEvent = { + // tutorialId: string; + version: string +} + +export const onReset = (event: ResetEvent): void => { + if (WEBHOOKS.reset) { + callWebhookEndpoint(event) + } +} + +type StepCompleteEvent = { tutorialId: string; version: string; levelId: string; stepId: string } + +export const onStepComplete = (event: StepCompleteEvent): void => { + if (WEBHOOKS.step_complete) { + callWebhookEndpoint(event) + } +} + +type LevelCompleteEvent = { tutorialId: string; version: string; levelId: string } + +export const onLevelComplete = (event: LevelCompleteEvent): void => { + if (WEBHOOKS.level_complete) { + callWebhookEndpoint(event) + } +} + +type TutorialCompleteEvent = { tutorialId: string; version: string } + +export const onTutorialComplete = (event: TutorialCompleteEvent): void => { + if (WEBHOOKS.tutorial_complete) { + callWebhookEndpoint(event) + } +} From 2eb1c0a76059ddc8bf538466cebac6a934fa01e9 Mon Sep 17 00:00:00 2001 From: shmck Date: Sun, 29 Aug 2021 10:39:34 -0700 Subject: [PATCH 2/5] remove unnecessary version Signed-off-by: shmck --- src/services/hooks/index.ts | 6 +----- src/services/hooks/webhooks.ts | 3 +-- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/src/services/hooks/index.ts b/src/services/hooks/index.ts index d22ec3b3..b775b947 100644 --- a/src/services/hooks/index.ts +++ b/src/services/hooks/index.ts @@ -17,7 +17,7 @@ export const onInit = async (actions: TT.StepActions): Promise => { await runVSCodeCommands(actions?.vscodeCommands) webhooks.onInit({ // tutorialId, - version: VERSION, + coderoadVersion: VERSION, }) } @@ -53,7 +53,6 @@ export const onReset = async (actions: TT.StepActions): Promise => { await runVSCodeCommands(actions?.vscodeCommands) webhooks.onReset({ // tutorialId, - version: VERSION, }) } @@ -76,7 +75,6 @@ export const onStepComplete = async ({ telemetry.onEvent('step_complete', { tutorialId, stepId, levelId, version: VERSION }) webhooks.onStepComplete({ tutorialId, - version: VERSION, levelId, stepId, }) @@ -93,7 +91,6 @@ export const onLevelComplete = async ({ telemetry.onEvent('level_complete', { tutorialId, levelId, version: VERSION }) webhooks.onLevelComplete({ tutorialId, - version: VERSION, levelId, }) } @@ -103,6 +100,5 @@ export const onTutorialComplete = async ({ tutorialId }: { tutorialId: string }) telemetry.onEvent('tutorial_complete', { tutorialId, version: VERSION }) webhooks.onTutorialComplete({ tutorialId, - version: VERSION, }) } diff --git a/src/services/hooks/webhooks.ts b/src/services/hooks/webhooks.ts index dc39b302..e1a749f6 100644 --- a/src/services/hooks/webhooks.ts +++ b/src/services/hooks/webhooks.ts @@ -28,7 +28,7 @@ const callWebhookEndpoint = async (bodyObject: B): Promise => { type InitEvent = { // tutorialId: string; - version: string + coderoadVersion: string } export const onInit = (event: InitEvent): void => { @@ -39,7 +39,6 @@ export const onInit = (event: InitEvent): void => { type ResetEvent = { // tutorialId: string; - version: string } export const onReset = (event: ResetEvent): void => { From 124a0923e26e752bf2a3500997d04b8457b013e0 Mon Sep 17 00:00:00 2001 From: shmck Date: Sun, 29 Aug 2021 14:13:38 -0700 Subject: [PATCH 3/5] add webhook typings Signed-off-by: shmck --- typings/tutorial.d.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/typings/tutorial.d.ts b/typings/tutorial.d.ts index 3f45e558..53b14a04 100644 --- a/typings/tutorial.d.ts +++ b/typings/tutorial.d.ts @@ -14,6 +14,7 @@ export type TutorialConfig = { dependencies?: TutorialDependency[] setup?: StepActions reset?: ConfigReset + webhook?: WebhookConfig } /** Logical groupings of tasks */ @@ -92,3 +93,17 @@ export interface TutorialAppVersions { } export type VSCodeCommand = string | [string, any] + +export interface WebhookConfig { + url: string + config: { + token: boolean + } + events: { + init?: boolean + reset?: boolean + step_complete?: boolean + level_complete?: boolean + tutorial_complete?: boolean + } +} From 82eed3d492f3387edda75aa38c8b8a981d546317 Mon Sep 17 00:00:00 2001 From: shmck Date: Sun, 29 Aug 2021 15:28:14 -0700 Subject: [PATCH 4/5] add tutorial id to webhooks Signed-off-by: shmck --- src/actions/onRunReset.ts | 5 ++++- src/commands.ts | 2 +- src/services/hooks/index.ts | 8 ++++---- src/services/hooks/webhooks.ts | 34 +++++++++++++++++----------------- 4 files changed, 26 insertions(+), 23 deletions(-) diff --git a/src/actions/onRunReset.ts b/src/actions/onRunReset.ts index 890b084e..4ae140a2 100644 --- a/src/actions/onRunReset.ts +++ b/src/actions/onRunReset.ts @@ -32,7 +32,10 @@ const onRunReset = async (action: ResetAction, context: Context): Promise // if tutorial.config.reset.command, run it const resetActions = tutorial?.config?.reset if (resetActions) { - hooks.onReset({ commands: resetActions?.commands, vscodeCommands: resetActions?.vscodeCommands }) + hooks.onReset( + { commands: resetActions?.commands, vscodeCommands: resetActions?.vscodeCommands }, + tutorial?.id as string, + ) } } diff --git a/src/commands.ts b/src/commands.ts index c7ba69a5..5eaaeab2 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -65,7 +65,7 @@ export const createCommands = ({ extensionPath, workspaceState }: CreateCommandP if (!alreadyConfigured) { const setupActions = data.config.setup if (setupActions) { - hooks.onInit(setupActions) + hooks.onInit(setupActions, data.id) } } testRunner = createTestRunner(data, { diff --git a/src/services/hooks/index.ts b/src/services/hooks/index.ts index b775b947..309d3ae3 100644 --- a/src/services/hooks/index.ts +++ b/src/services/hooks/index.ts @@ -11,12 +11,12 @@ import { VERSION } from '../../environment' import * as webhooks from './webhooks' // run at the end of when a tutorial is configured -export const onInit = async (actions: TT.StepActions): Promise => { +export const onInit = async (actions: TT.StepActions, tutorialId: string): Promise => { await loadCommits(actions?.commits) await runCommands(actions?.commands) await runVSCodeCommands(actions?.vscodeCommands) webhooks.onInit({ - // tutorialId, + tutorialId, coderoadVersion: VERSION, }) } @@ -47,12 +47,12 @@ export const onSolutionEnter = async (actions: TT.StepActions): Promise => } // run when "reset" is triggered -export const onReset = async (actions: TT.StepActions): Promise => { +export const onReset = async (actions: TT.StepActions, tutorialId: string): Promise => { await resetWatchers() await runCommands(actions?.commands) await runVSCodeCommands(actions?.vscodeCommands) webhooks.onReset({ - // tutorialId, + tutorialId, }) } diff --git a/src/services/hooks/webhooks.ts b/src/services/hooks/webhooks.ts index e1a749f6..f6ab680f 100644 --- a/src/services/hooks/webhooks.ts +++ b/src/services/hooks/webhooks.ts @@ -26,47 +26,47 @@ const callWebhookEndpoint = async (bodyObject: B): Promise => { } } -type InitEvent = { - // tutorialId: string; +type WebhookEventInit = { + tutorialId: string coderoadVersion: string } -export const onInit = (event: InitEvent): void => { +export const onInit = (event: WebhookEventInit): void => { if (WEBHOOKS.init) { - callWebhookEndpoint(event) + callWebhookEndpoint(event) } } -type ResetEvent = { - // tutorialId: string; +type WebhookEventReset = { + tutorialId: string } -export const onReset = (event: ResetEvent): void => { +export const onReset = (event: WebhookEventReset): void => { if (WEBHOOKS.reset) { - callWebhookEndpoint(event) + callWebhookEndpoint(event) } } -type StepCompleteEvent = { tutorialId: string; version: string; levelId: string; stepId: string } +type WebhookEventStepComplete = { tutorialId: string; version: string; levelId: string; stepId: string } -export const onStepComplete = (event: StepCompleteEvent): void => { +export const onStepComplete = (event: WebhookEventStepComplete): void => { if (WEBHOOKS.step_complete) { - callWebhookEndpoint(event) + callWebhookEndpoint(event) } } -type LevelCompleteEvent = { tutorialId: string; version: string; levelId: string } +type WebhookEventLevelComplete = { tutorialId: string; version: string; levelId: string } -export const onLevelComplete = (event: LevelCompleteEvent): void => { +export const onLevelComplete = (event: WebhookEventLevelComplete): void => { if (WEBHOOKS.level_complete) { - callWebhookEndpoint(event) + callWebhookEndpoint(event) } } -type TutorialCompleteEvent = { tutorialId: string; version: string } +type WebhookEevntTutorialComplete = { tutorialId: string; version: string } -export const onTutorialComplete = (event: TutorialCompleteEvent): void => { +export const onTutorialComplete = (event: WebhookEevntTutorialComplete): void => { if (WEBHOOKS.tutorial_complete) { - callWebhookEndpoint(event) + callWebhookEndpoint(event) } } From ae5345ce9db4578ec029e339addce35d82a7279b Mon Sep 17 00:00:00 2001 From: shmck Date: Sun, 29 Aug 2021 15:57:52 -0700 Subject: [PATCH 5/5] configure webhook with token Signed-off-by: shmck --- docs/docs/env-vars.md | 2 + src/actions/onTutorialConfigContinue.ts | 6 +++ src/actions/onTutorialConfigNew.ts | 6 +++ src/environment.ts | 3 ++ src/services/hooks/webhooks.ts | 59 ++++++++++++++++++------- typings/tutorial.d.ts | 18 ++++---- 6 files changed, 69 insertions(+), 25 deletions(-) diff --git a/docs/docs/env-vars.md b/docs/docs/env-vars.md index 1b49ea55..58561f6c 100644 --- a/docs/docs/env-vars.md +++ b/docs/docs/env-vars.md @@ -18,6 +18,8 @@ CodeRoad has a number of configurations: - `CODEROAD_CONTENT_SECURITY_POLICY_EXEMPTIONS` - a list of CSP exemption hashes. For multiples, separate the list with a space. +- `CODEROAD_WEBHOOK_TOKEN` - an optional token for authenticating/authorizing webhook endpoints. Passed to the webhook endpoint in a `CodeRoad-User-Token` header. + ## How to Use Variables ### Local diff --git a/src/actions/onTutorialConfigContinue.ts b/src/actions/onTutorialConfigContinue.ts index 397e96ad..f5e173b0 100644 --- a/src/actions/onTutorialConfigContinue.ts +++ b/src/actions/onTutorialConfigContinue.ts @@ -5,6 +5,7 @@ import Context from '../services/context/context' import tutorialConfig from './utils/tutorialConfig' import { COMMANDS, send } from '../commands' import logger from '../services/logger' +import { setupWebhook } from '../services/hooks/webhooks' const onTutorialConfigContinue = async (action: T.Action, context: Context): Promise => { logger('onTutorialConfigContinue', action) @@ -19,6 +20,11 @@ const onTutorialConfigContinue = async (action: T.Action, context: Context): Pro data: tutorialToContinue, alreadyConfigured: true, }) + + // configure webhook + if (tutorialToContinue.config?.webhook) { + setupWebhook(tutorialToContinue.config.webhook) + } } catch (e) { const error = { type: 'UnknownError', diff --git a/src/actions/onTutorialConfigNew.ts b/src/actions/onTutorialConfigNew.ts index 5c2774ac..171bee1a 100644 --- a/src/actions/onTutorialConfigNew.ts +++ b/src/actions/onTutorialConfigNew.ts @@ -8,6 +8,7 @@ import { version, compareVersions } from '../services/dependencies' import Context from '../services/context/context' import tutorialConfig from './utils/tutorialConfig' import { send } from '../commands' +import { setupWebhook } from '../services/hooks/webhooks' const onTutorialConfigNew = async (action: T.Action, context: Context): Promise => { try { @@ -108,6 +109,11 @@ const onTutorialConfigNew = async (action: T.Action, context: Context): Promise< return } + // configure webhook + if (data.config?.webhook) { + setupWebhook(data.config.webhook) + } + // report back to the webview that setup is complete send({ type: 'TUTORIAL_CONFIGURED' }) } catch (e) { diff --git a/src/environment.ts b/src/environment.ts index 8b7f5f3f..59077d61 100644 --- a/src/environment.ts +++ b/src/environment.ts @@ -43,3 +43,6 @@ export const DISABLE_RUN_ON_SAVE = (process.env.CODEROAD_DISABLE_RUN_ON_SAVE || // for multiple exemptions, separate each with a space "a1 b1" export const CONTENT_SECURITY_POLICY_EXEMPTIONS: string | null = process.env.CODEROAD_CONTENT_SECURITY_POLICY_EXEMPTIONS || null + +// optional token for authorization/authentication of webhook calls +export const WEBHOOK_TOKEN = process.env.CODEROAD_WEBHOOK_TOKEN || null diff --git a/src/services/hooks/webhooks.ts b/src/services/hooks/webhooks.ts index f6ab680f..a245d4cb 100644 --- a/src/services/hooks/webhooks.ts +++ b/src/services/hooks/webhooks.ts @@ -1,28 +1,57 @@ +import * as TT from 'typings/tutorial' import fetch from 'node-fetch' import logger from '../logger' +import { WEBHOOK_TOKEN } from '../../environment' -const WEBHOOKS = { - init: true, - reset: true, - step_complete: true, - level_complete: true, - tutorial_complete: true, +const WEBHOOK_EVENTS = { + init: false, + reset: false, + step_complete: false, + level_complete: false, + tutorial_complete: false, +} + +// varaibles set on init +let WEBHOOK_URI: string | undefined + +export const setupWebhook = (webhookConfig: TT.WebhookConfig) => { + if (!webhookConfig.url) { + return + } + // set webhook uri + WEBHOOK_URI = webhookConfig.url + + // set webhook event triggers + const events = webhookConfig.events as TT.WebhookConfigEvents + for (const eventName of Object.keys(events || {})) { + WEBHOOK_EVENTS[eventName] = events[eventName] + } } const callWebhookEndpoint = async (bodyObject: B): Promise => { - const endpoint = 'http://localhost:3000/' + if (!WEBHOOK_URI) { + return + } + + const headers = { 'Content-Type': 'application/json' } + // if the webhook token is specified as env var, sends a token with the request + if (WEBHOOK_TOKEN) { + headers['CodeRoad-User-Token'] = WEBHOOK_TOKEN + } + const body = JSON.stringify(bodyObject) + try { - const sendEvent = await fetch(endpoint, { + const sendEvent = await fetch(WEBHOOK_URI, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers, body, }) if (!sendEvent.ok) { throw new Error('Error sending event') } } catch (err: unknown) { - logger(`Failed to call webhook endpoint ${endpoint} with body ${body}`) + logger(`Failed to call webhook endpoint ${WEBHOOK_URI} with body ${body}`) } } @@ -32,7 +61,7 @@ type WebhookEventInit = { } export const onInit = (event: WebhookEventInit): void => { - if (WEBHOOKS.init) { + if (WEBHOOK_EVENTS.init) { callWebhookEndpoint(event) } } @@ -42,7 +71,7 @@ type WebhookEventReset = { } export const onReset = (event: WebhookEventReset): void => { - if (WEBHOOKS.reset) { + if (WEBHOOK_EVENTS.reset) { callWebhookEndpoint(event) } } @@ -50,7 +79,7 @@ export const onReset = (event: WebhookEventReset): void => { type WebhookEventStepComplete = { tutorialId: string; version: string; levelId: string; stepId: string } export const onStepComplete = (event: WebhookEventStepComplete): void => { - if (WEBHOOKS.step_complete) { + if (WEBHOOK_EVENTS.step_complete) { callWebhookEndpoint(event) } } @@ -58,7 +87,7 @@ export const onStepComplete = (event: WebhookEventStepComplete): void => { type WebhookEventLevelComplete = { tutorialId: string; version: string; levelId: string } export const onLevelComplete = (event: WebhookEventLevelComplete): void => { - if (WEBHOOKS.level_complete) { + if (WEBHOOK_EVENTS.level_complete) { callWebhookEndpoint(event) } } @@ -66,7 +95,7 @@ export const onLevelComplete = (event: WebhookEventLevelComplete): void => { type WebhookEevntTutorialComplete = { tutorialId: string; version: string } export const onTutorialComplete = (event: WebhookEevntTutorialComplete): void => { - if (WEBHOOKS.tutorial_complete) { + if (WEBHOOK_EVENTS.tutorial_complete) { callWebhookEndpoint(event) } } diff --git a/typings/tutorial.d.ts b/typings/tutorial.d.ts index 53b14a04..3a32599a 100644 --- a/typings/tutorial.d.ts +++ b/typings/tutorial.d.ts @@ -94,16 +94,14 @@ export interface TutorialAppVersions { export type VSCodeCommand = string | [string, any] +export interface WebhookConfigEvents { + init?: boolean + reset?: boolean + step_complete?: boolean + level_complete?: boolean + tutorial_complete?: boolean +} export interface WebhookConfig { url: string - config: { - token: boolean - } - events: { - init?: boolean - reset?: boolean - step_complete?: boolean - level_complete?: boolean - tutorial_complete?: boolean - } + events?: WebhookConfigEvents }