From bba2951bd8ef7fe19adec6a5bd52dcbdff1d6170 Mon Sep 17 00:00:00 2001 From: Jorge Cortes Date: Tue, 26 Nov 2024 14:36:08 -0500 Subject: [PATCH] [Components] launchdarkly - new components --- .../evaluate-feature-flag.mjs | 105 ++++++++ .../toggle-feature-flag.mjs | 77 ++++++ .../update-feature-flag.mjs | 90 +++++++ components/launchdarkly/common/constants.mjs | 11 + components/launchdarkly/common/utils.mjs | 50 ++++ components/launchdarkly/launchdarkly.app.mjs | 228 +++++++++++++++++- components/launchdarkly/package.json | 18 ++ .../launchdarkly/sources/common/webhook.mjs | 125 ++++++++++ .../new-access-token-event.mjs | 47 ++++ .../sources/new-flag-event/new-flag-event.mjs | 39 +++ .../sources/new-user-event/new-user-event.mjs | 39 +++ pnpm-lock.yaml | 9 +- 12 files changed, 832 insertions(+), 6 deletions(-) create mode 100644 components/launchdarkly/actions/evaluate-feature-flag/evaluate-feature-flag.mjs create mode 100644 components/launchdarkly/actions/toggle-feature-flag/toggle-feature-flag.mjs create mode 100644 components/launchdarkly/actions/update-feature-flag/update-feature-flag.mjs create mode 100644 components/launchdarkly/common/constants.mjs create mode 100644 components/launchdarkly/common/utils.mjs create mode 100644 components/launchdarkly/package.json create mode 100644 components/launchdarkly/sources/common/webhook.mjs create mode 100644 components/launchdarkly/sources/new-access-token-event/new-access-token-event.mjs create mode 100644 components/launchdarkly/sources/new-flag-event/new-flag-event.mjs create mode 100644 components/launchdarkly/sources/new-user-event/new-user-event.mjs diff --git a/components/launchdarkly/actions/evaluate-feature-flag/evaluate-feature-flag.mjs b/components/launchdarkly/actions/evaluate-feature-flag/evaluate-feature-flag.mjs new file mode 100644 index 0000000000000..310f7ad6f9c1d --- /dev/null +++ b/components/launchdarkly/actions/evaluate-feature-flag/evaluate-feature-flag.mjs @@ -0,0 +1,105 @@ +import app from "../../launchdarkly.app.mjs"; + +export default { + key: "launchdarkly-evaluate-feature-flag", + name: "Evaluate Feature Flag", + description: "Evaluates an existing feature flag for a specific user or in a general context. [See the documentation](https://apidocs.launchdarkly.com/tag/Contexts#operation/evaluateContextInstance).", + version: "0.0.1", + type: "action", + props: { + app, + projectKey: { + propDefinition: [ + app, + "project", + ], + }, + environmentKey: { + propDefinition: [ + app, + "environment", + ({ projectKey }) => ({ + projectKey, + }), + ], + }, + flagKey: { + propDefinition: [ + app, + "flag", + ({ + projectKey, environmentKey, + }) => ({ + projectKey, + environmentKey, + }), + ], + }, + contextKind: { + propDefinition: [ + app, + "contextKind", + ({ projectKey }) => ({ + projectKey, + }), + ], + }, + contextKey: { + label: "Context Key", + description: "The key of the context to evaluate the feature flag against.", + propDefinition: [ + app, + "context", + ({ + projectKey, environmentKey, flagKey, contextKind, + }) => ({ + projectKey, + environmentKey, + key: flagKey, + kind: contextKind, + }), + ], + }, + otherAttributes: { + type: "object", + label: "Other Attributes", + description: "Additional attributes to include in the context.", + optional: true, + }, + }, + methods: { + evaluateFeatureFlag({ + projectKey, environmentKey, ...args + }) { + return this.app.post({ + path: `/projects/${projectKey}/environments/${environmentKey}/flags/evaluate`, + ...args, + }); + }, + }, + async run({ $ }) { + const { + evaluateFeatureFlag, + projectKey, + environmentKey, + contextKind, + contextKey, + otherAttributes, + } = this; + + const response = await evaluateFeatureFlag({ + $, + projectKey, + environmentKey, + data: { + key: contextKey, + kind: contextKind, + ...otherAttributes, + }, + }); + + $.export("$summary", `Successfully evaluated feature flag with \`${response.items.length}\` item(s).`); + + return response; + }, +}; diff --git a/components/launchdarkly/actions/toggle-feature-flag/toggle-feature-flag.mjs b/components/launchdarkly/actions/toggle-feature-flag/toggle-feature-flag.mjs new file mode 100644 index 0000000000000..890a97f43ecc7 --- /dev/null +++ b/components/launchdarkly/actions/toggle-feature-flag/toggle-feature-flag.mjs @@ -0,0 +1,77 @@ +import app from "../../launchdarkly.app.mjs"; + +export default { + key: "launchdarkly-toggle-feature-flag", + name: "Toggle Feature Flag", + description: "Toggles the status of a feature flag, switching it from active to inactive, or vice versa. [See the documentation](https://apidocs.launchdarkly.com/tag/Feature-flags#operation/patchFeatureFlag)", + version: "0.0.1", + type: "action", + props: { + app, + projectKey: { + propDefinition: [ + app, + "project", + ], + }, + environmentKey: { + propDefinition: [ + app, + "environment", + ({ projectKey }) => ({ + projectKey, + }), + ], + }, + featureFlagKey: { + propDefinition: [ + app, + "flag", + ({ + projectKey, environmentKey, + }) => ({ + projectKey, + environmentKey, + }), + ], + }, + }, + async run({ $ }) { + const { + app, + projectKey, + environmentKey, + featureFlagKey, + } = this; + + const { environments: { [environmentKey]: { on: isOn } } } = + await app.getFeatureFlag({ + $, + projectKey, + featureFlagKey, + }); + + const response = await app.updateFeatureFlag({ + $, + projectKey, + featureFlagKey, + headers: { + "Content-Type": "application/json; domain-model=launchdarkly.semanticpatch", + }, + data: { + environmentKey, + instructions: [ + { + kind: isOn + ? "turnFlagOff" + : "turnFlagOn", + }, + ], + }, + }); + + $.export("$summary", "Successfully toggled the feature flag."); + + return response; + }, +}; diff --git a/components/launchdarkly/actions/update-feature-flag/update-feature-flag.mjs b/components/launchdarkly/actions/update-feature-flag/update-feature-flag.mjs new file mode 100644 index 0000000000000..96ba7203d488f --- /dev/null +++ b/components/launchdarkly/actions/update-feature-flag/update-feature-flag.mjs @@ -0,0 +1,90 @@ +import app from "../../launchdarkly.app.mjs"; +import utils from "../../common/utils.mjs"; + +export default { + key: "launchdarkly-update-feature-flag", + name: "Update Feature Flag", + description: "Updates an existing feature flag using a JSON object. [See the documentation](https://apidocs.launchdarkly.com/tag/Feature-flags#operation/patchFeatureFlag)", + version: "0.0.1", + type: "action", + props: { + app, + projectKey: { + propDefinition: [ + app, + "project", + ], + }, + environmentKey: { + propDefinition: [ + app, + "environment", + ({ projectKey }) => ({ + projectKey, + }), + ], + }, + featureFlagKey: { + propDefinition: [ + app, + "flag", + ({ + projectKey, environmentKey, + }) => ({ + projectKey, + environmentKey, + }), + ], + }, + patch: { + type: "string[]", + label: "Patch", + description: "An array of JSON patch operations to apply to the feature flag. [See the documentation](https://apidocs.launchdarkly.com/#section/Overview/Updates).", + default: [ + JSON.stringify({ + op: "replace", + path: "/description", + value: "New description for this flag", + }), + ], + }, + ignoreConflicts: { + type: "boolean", + label: "Ignore Conflicts", + description: "If a flag configuration change made through this endpoint would cause a pending scheduled change or approval request to fail, this endpoint will return a 400. You can ignore this check by setting this parameter to `true`.", + optional: true, + }, + comment: { + type: "string", + label: "Comment", + description: "A comment to associate with the flag update.", + optional: true, + }, + }, + async run({ $ }) { + const { + app, + projectKey, + featureFlagKey, + patch, + ignoreConflicts, + comment, + } = this; + + const response = await app.updateFeatureFlag({ + $, + projectKey, + featureFlagKey, + params: { + ignoreConflicts, + }, + data: { + patch: utils.parseArray(patch), + comment, + }, + }); + + $.export("$summary", "Successfully updated feature flag"); + return response; + }, +}; diff --git a/components/launchdarkly/common/constants.mjs b/components/launchdarkly/common/constants.mjs new file mode 100644 index 0000000000000..1b024c162d50e --- /dev/null +++ b/components/launchdarkly/common/constants.mjs @@ -0,0 +1,11 @@ +const BASE_URL = "https://app.launchdarkly.com"; +const VERSION_PATH = "/api/v2"; +const WEBHOOK_ID = "webhookId"; +const SECRET = "secret"; + +export default { + BASE_URL, + VERSION_PATH, + WEBHOOK_ID, + SECRET, +}; diff --git a/components/launchdarkly/common/utils.mjs b/components/launchdarkly/common/utils.mjs new file mode 100644 index 0000000000000..e6ed4fa271055 --- /dev/null +++ b/components/launchdarkly/common/utils.mjs @@ -0,0 +1,50 @@ +import { ConfigurationError } from "@pipedream/platform"; + +function isJson(value) { + try { + JSON.parse(value); + } catch (e) { + return false; + } + + return true; +} + +function valueToObject(value) { + if (typeof(value) === "object") { + return value; + } + + if (!isJson(value)) { + throw new ConfigurationError(`Make sure the custom expression contains a valid JSON object: \`${value}\``); + } + + return JSON.parse(value); +} + +function parseArray(value) { + try { + if (!value) { + return []; + } + + if (Array.isArray(value)) { + return value; + } + + const parsedValue = JSON.parse(value); + + if (!Array.isArray(parsedValue)) { + throw new Error("Not an array"); + } + + return parsedValue; + + } catch (e) { + throw new ConfigurationError("Make sure the custom expression contains a valid array object"); + } +} + +export default { + parseArray: (value) => parseArray(value).map(valueToObject), +}; diff --git a/components/launchdarkly/launchdarkly.app.mjs b/components/launchdarkly/launchdarkly.app.mjs index 6a11f359dea99..8868132f45f22 100644 --- a/components/launchdarkly/launchdarkly.app.mjs +++ b/components/launchdarkly/launchdarkly.app.mjs @@ -1,11 +1,231 @@ +import { axios } from "@pipedream/platform"; +import constants from "./common/constants.mjs"; + export default { type: "app", app: "launchdarkly", - propDefinitions: {}, + propDefinitions: { + project: { + type: "string", + label: "Project Key", + description: "The project key.", + async options({ + mapper = ({ + key: value, name: label, + }) => ({ + label, + value, + }), + }) { + const { items } = await this.listProjects(); + return items.map(mapper); + }, + }, + environment: { + type: "string", + label: "Environment Key", + description: "The environment key.", + async options({ + projectKey, mapper = ({ + key: value, name: label, + }) => ({ + label, + value, + }), + }) { + if (!projectKey) { + return []; + } + const { items } = await this.listEnvironments({ + projectKey, + }); + return items.map(mapper); + }, + }, + flag: { + type: "string", + label: "Feature Flag Key", + description: "The key of the feature flag to evaluate or update.", + async options({ + projectKey, environmentKey: env, + mapper = ({ + key: value, name: label, + }) => ({ + label, + value, + }), + }) { + if (!projectKey) { + return []; + } + const { items } = await this.listFeatureFlags({ + projectKey, + params: { + env, + }, + }); + return items.map(mapper); + }, + }, + contextKind: { + type: "string", + label: "Context Kind", + description: "The kind of context to evaluate the flag.", + async options({ + projectKey, + mapper = ({ + key: value, name: label, + }) => ({ + label, + value, + }), + }) { + if (!projectKey) { + return []; + } + const { items } = await this.getContextKinds({ + projectKey, + }); + return items.map(mapper); + }, + }, + context: { + type: "string", + label: "Context", + description: "Contextual information for evaluating the flag.", + async options({ + projectKey, environmentKey, + mapper = ({ + key: value, name: label, + }) => ({ + label, + value, + }), + }) { + if (!projectKey || !environmentKey) { + return []; + } + const { items } = await this.searchContexts({ + projectKey, + environmentKey, + }); + return items.map(mapper); + }, + }, + memberId: { + type: "string", + label: "Member ID", + description: "The member ID.", + async options({ + mapper = ({ + _id: value, email: label, + }) => ({ + label, + value, + }), + }) { + const { items } = await this.listAccountMembers(); + return items.map(mapper); + }, + }, + }, methods: { - // this.$auth contains connected account data - authKeys() { - console.log(Object.keys(this.$auth)); + getUrl(path) { + return `${constants.BASE_URL}${constants.VERSION_PATH}${path}`; + }, + getHeaders(headers) { + return { + "Content-Type": "application/json", + ...headers, + "Authorization": this.$auth.access_token, + }; + }, + _makeRequest({ + $ = this, path, headers, ...args + } = {}) { + return axios($, { + ...args, + url: this.getUrl(path), + headers: this.getHeaders(headers), + }); + }, + post(args = {}) { + return this._makeRequest({ + method: "POST", + ...args, + }); + }, + patch(args = {}) { + return this._makeRequest({ + method: "PATCH", + ...args, + }); + }, + delete(args = {}) { + return this._makeRequest({ + method: "DELETE", + ...args, + }); + }, + listProjects(args = {}) { + return this._makeRequest({ + path: "/projects", + ...args, + }); + }, + listEnvironments({ + projectKey, ...args + } = {}) { + return this._makeRequest({ + path: `/projects/${projectKey}/environments`, + ...args, + }); + }, + listFeatureFlags({ + projectKey, ...args + } = {}) { + return this._makeRequest({ + path: `/flags/${projectKey}`, + ...args, + }); + }, + getContextKinds({ + projectKey, ...args + } = {}) { + return this._makeRequest({ + path: `/projects/${projectKey}/context-kinds`, + ...args, + }); + }, + searchContexts({ + projectKey, environmentKey, ...args + } = {}) { + return this.post({ + path: `/projects/${projectKey}/environments/${environmentKey}/contexts/search`, + ...args, + }); + }, + updateFeatureFlag({ + projectKey, featureFlagKey, ...args + } = {}) { + return this.patch({ + path: `/flags/${projectKey}/${featureFlagKey}`, + ...args, + }); + }, + getFeatureFlag({ + projectKey, featureFlagKey, ...args + } = {}) { + return this._makeRequest({ + path: `/flags/${projectKey}/${featureFlagKey}`, + ...args, + }); + }, + listAccountMembers(args = {}) { + return this._makeRequest({ + path: "/members", + ...args, + }); }, }, }; diff --git a/components/launchdarkly/package.json b/components/launchdarkly/package.json new file mode 100644 index 0000000000000..4c2789a3025a5 --- /dev/null +++ b/components/launchdarkly/package.json @@ -0,0 +1,18 @@ +{ + "name": "@pipedream/launchdarkly", + "version": "0.0.1", + "description": "Pipedream LaunchDarkly Components", + "main": "launchdarkly.app.mjs", + "keywords": [ + "pipedream", + "launchdarkly" + ], + "homepage": "https://pipedream.com/apps/launchdarkly", + "author": "Pipedream (https://pipedream.com/)", + "publishConfig": { + "access": "public" + }, + "dependencies": { + "@pipedream/platform": "3.0.3" + } +} diff --git a/components/launchdarkly/sources/common/webhook.mjs b/components/launchdarkly/sources/common/webhook.mjs new file mode 100644 index 0000000000000..d8f0ccc968472 --- /dev/null +++ b/components/launchdarkly/sources/common/webhook.mjs @@ -0,0 +1,125 @@ +import { createHmac } from "crypto"; +import { v4 as uuid } from "uuid"; +import { ConfigurationError } from "@pipedream/platform"; +import app from "../../launchdarkly.app.mjs"; +import constants from "../../common/constants.mjs"; + +export default { + props: { + app, + db: "$.service.db", + http: { + type: "$.interface.http", + customResponse: true, + }, + }, + hooks: { + async activate() { + const { + http: { endpoint: url }, + createWebhook, + setWebhookId, + setSecret, + getStatements, + } = this; + + const secret = uuid(); + const response = + await createWebhook({ + data: { + name: "Pipedream Webhook", + url, + statements: getStatements(), + secret, + sign: true, + on: true, + }, + }); + + setWebhookId(response._id); + setSecret(secret); + }, + async deactivate() { + const webhookId = this.getWebhookId(); + if (webhookId) { + await this.deleteWebhook({ + webhookId, + }); + } + }, + }, + methods: { + setWebhookId(value) { + this.db.set(constants.WEBHOOK_ID, value); + }, + getWebhookId() { + return this.db.get(constants.WEBHOOK_ID); + }, + setSecret(value) { + this.db.set(constants.SECRET, value); + }, + getSecret() { + return this.db.get(constants.SECRET); + }, + generateMeta() { + throw new ConfigurationError("generateMeta is not implemented"); + }, + getStatements() { + throw new ConfigurationError("getStatements is not implemented"); + }, + processResource(resource) { + this.$emit(resource, this.generateMeta(resource)); + }, + createWebhook(args = {}) { + return this.app.post({ + path: "/webhooks", + ...args, + }); + }, + deleteWebhook({ + webhookId, ...args + } = {}) { + return this.app.delete({ + path: `/webhooks/${webhookId}`, + ...args, + }); + }, + isSignatureValid(signature, bodyRaw) { + if (!signature) { + return false; + } + + const secret = this.getSecret(); + + const computedSignature = + createHmac("sha256", secret) + .update(bodyRaw) + .digest("hex"); + + return signature === computedSignature; + }, + }, + async run({ + headers, body, bodyRaw, + }) { + const { + http, + isSignatureValid, + } = this; + + const signature = headers["x-ld-signature"]; + + if (!isSignatureValid(signature, bodyRaw)) { + console.log("Invalid signature"); + return http.respond({ + status: 401, + }); + } + + http.respond({ + status: 200, + }); + + this.processResource(body); + }, +}; diff --git a/components/launchdarkly/sources/new-access-token-event/new-access-token-event.mjs b/components/launchdarkly/sources/new-access-token-event/new-access-token-event.mjs new file mode 100644 index 0000000000000..1488f57efbcfb --- /dev/null +++ b/components/launchdarkly/sources/new-access-token-event/new-access-token-event.mjs @@ -0,0 +1,47 @@ +import common from "../common/webhook.mjs"; + +export default { + ...common, + key: "launchdarkly-new-access-token-event", + name: "New Access Token Event", + description: "Emit new event when a new access token activity happens. [See the documentation](https://apidocs.launchdarkly.com/tag/Webhooks#operation/postWebhook).", + version: "0.0.1", + type: "source", + dedupe: "unique", + props: { + ...common.props, + memberId: { + propDefinition: [ + common.props.app, + "memberId", + ], + }, + }, + methods: { + ...common.methods, + getStatements() { + return [ + { + resources: [ + `member/${this.memberId}:token/*`, + ], + actions: [ + "*", + ], + effect: "allow", + }, + ]; + }, + generateMeta(resource) { + const { + _id: id, + date: ts, + } = resource; + return { + id, + summary: `New Access Token Event ${id}`, + ts, + }; + }, + }, +}; diff --git a/components/launchdarkly/sources/new-flag-event/new-flag-event.mjs b/components/launchdarkly/sources/new-flag-event/new-flag-event.mjs new file mode 100644 index 0000000000000..3d82355ee93ae --- /dev/null +++ b/components/launchdarkly/sources/new-flag-event/new-flag-event.mjs @@ -0,0 +1,39 @@ +import common from "../common/webhook.mjs"; + +export default { + ...common, + key: "launchdarkly-new-flag-event", + name: "New Flag Event", + description: "Emit new event when flag activity occurs. [See the documentation](https://apidocs.launchdarkly.com/tag/Webhooks#operation/postWebhook).", + version: "0.0.1", + type: "source", + dedupe: "unique", + methods: { + ...common.methods, + getStatements() { + return [ + { + resources: [ + "proj/*:env/*:flag/*", + ], + actions: [ + "*", + ], + effect: "allow", + }, + ]; + }, + generateMeta(resource) { + const { + _id: id, + name, + date: ts, + } = resource; + return { + id, + summary: `New Flag ${name}`, + ts, + }; + }, + }, +}; diff --git a/components/launchdarkly/sources/new-user-event/new-user-event.mjs b/components/launchdarkly/sources/new-user-event/new-user-event.mjs new file mode 100644 index 0000000000000..36239f3d33daa --- /dev/null +++ b/components/launchdarkly/sources/new-user-event/new-user-event.mjs @@ -0,0 +1,39 @@ +import common from "../common/webhook.mjs"; + +export default { + ...common, + key: "launchdarkly-new-user-event", + name: "New User Event", + description: "Emit new event when user activity is noted. [See the documentation](https://apidocs.launchdarkly.com/tag/Webhooks#operation/postWebhook).", + version: "0.0.1", + type: "source", + dedupe: "unique", + methods: { + ...common.methods, + getStatements() { + return [ + { + resources: [ + "proj/*:env/*:user/*", + ], + actions: [ + "*", + ], + effect: "allow", + }, + ]; + }, + generateMeta(resource) { + const { + _id: id, + name, + date: ts, + } = resource; + return { + id, + summary: `New User ${name}`, + ts, + }; + }, + }, +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4043235b43205..7bbb45b85630b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -586,8 +586,7 @@ importers: components/amazon_polly: {} - components/amazon_selling_partner: - specifiers: {} + components/amazon_selling_partner: {} components/amazon_ses: dependencies: @@ -5543,6 +5542,12 @@ importers: components/lattice: {} + components/launchdarkly: + dependencies: + '@pipedream/platform': + specifier: 3.0.3 + version: 3.0.3 + components/launchnotes: dependencies: '@pipedream/platform':