From ce169a47b41dc8d022b1edc44ed6a31532ece9c8 Mon Sep 17 00:00:00 2001 From: Elliot Date: Thu, 8 May 2025 10:29:16 -0500 Subject: [PATCH 1/3] accept attachment name or url to get addon config --- README.md | 2 +- src/add-ons/heroku-applink.ts | 39 ++++++----- src/index.ts | 4 +- src/utils/addon-config.ts | 56 ++++++++++++++++ test/utils/addon-config.test.ts | 110 ++++++++++++++++++++++++++++++++ 5 files changed, 191 insertions(+), 20 deletions(-) create mode 100644 src/utils/addon-config.ts create mode 100644 test/utils/addon-config.test.ts diff --git a/README.md b/README.md index 140db09..ae2ad68 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ fastify.get('/accounts', async function (request, reply) { const applinkAddon = request.sdk.addons.applink; logger.info(`Getting org '${orgName}' connection from Heroku Applink add-on...`); - const anotherOrg = await applinkAddon.getConnection(orgName); + const anotherOrg = await applinkAddon.getAuthorization(orgName); logger.info(`Querying org '${orgName}' (${anotherOrg.id}) Accounts...`); try { diff --git a/src/add-ons/heroku-applink.ts b/src/add-ons/heroku-applink.ts index 984707c..5f09ee0 100644 --- a/src/add-ons/heroku-applink.ts +++ b/src/add-ons/heroku-applink.ts @@ -8,44 +8,49 @@ import { HttpRequestUtil } from "../utils/request"; import { OrgImpl } from "../sdk/org"; import { Org } from "../index"; +import { + resolveAddonConfigByAttachment, + resolveAddonConfigByUrl, +} from "~/utils/addon-config"; const HTTP_REQUEST = new HttpRequestUtil(); /** * Get stored Salesforce or Data Cloud org user credentials for given name or alias. * @param name or alias + * @param attachmentNameOrUrl Either an attachment name (e.g. "APPLINK") or a full URL * @returns Org */ -export async function getConnection(name: string): Promise { +export async function getAuthorization( + name: string, + attachmentNameOrUrl = "APPLINK" +): Promise { if (!name) { throw Error(`Connection name not provided`); } - const addonEndpoint = - process.env.HEROKU_APPLINK_API_URL || - process.env.HEROKU_APPLINK_STAGING_API_URL; - if (!addonEndpoint) { - throw Error( - `Heroku Applink add-on not provisioned on app or endpoint not provided` - ); + // Check if the attachmentNameOrUrl is a URL by attempting to parse it + let resolveConfigByUrl = false; + try { + new URL(attachmentNameOrUrl); + resolveConfigByUrl = true; + } catch { + resolveConfigByUrl = false; } - const addonAuthToken = process.env.HEROKU_APPLINK_TOKEN; - if (!addonAuthToken) { - throw Error( - `Heroku Applink add-on not provisioned on app or authorization token not found` - ); - } + const config = resolveConfigByUrl + ? resolveAddonConfigByUrl(attachmentNameOrUrl) + : resolveAddonConfigByAttachment(attachmentNameOrUrl); - const authUrl = `${addonEndpoint}/invocations/authorization`; + const authUrl = `${config.apiUrl}/invocations/authorization`; const opts = { method: "POST", headers: { - Authorization: `Bearer ${addonAuthToken}`, + Authorization: `Bearer ${config.token}`, "Content-Type": "application/json", }, body: JSON.stringify({ - org_name: name, + developer_name: name, }), retry: { limit: 1, diff --git a/src/index.ts b/src/index.ts index 73f9606..1d12674 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,7 +9,7 @@ import { ContextImpl } from "./sdk/context.js"; import { InvocationEventImpl } from "./sdk/invocation-event.js"; import { LoggerImpl } from "./sdk/logger.js"; import { QueryOperation } from "jsforce/lib/api/bulk"; -import { getConnection } from "./add-ons/heroku-applink.js"; +import { getAuthorization } from "./add-ons/heroku-applink.js"; const CONTENT_TYPE_HEADER = "Content-Type"; const X_CLIENT_CONTEXT_HEADER = "x-client-context"; @@ -25,7 +25,7 @@ export function init() { return { addons: { applink: { - getConnection, + getAuthorization, }, }, dataCloud: { diff --git a/src/utils/addon-config.ts b/src/utils/addon-config.ts new file mode 100644 index 0000000..7595488 --- /dev/null +++ b/src/utils/addon-config.ts @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2024, salesforce.com, inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ + +interface AddonConfig { + apiUrl: string; + token: string; +} + +export function resolveAddonConfigByAttachment( + attachment = "HEROKU_APPLINK" +): AddonConfig { + const apiUrl = process.env[`${attachment}_API_URL`]; + const token = process.env[`${attachment}_TOKEN`]; + + if (!apiUrl || !token) { + throw Error( + `Heroku Applink config not found under attachment ${attachment}` + ); + } + + return { + apiUrl, + token, + }; +} + +export function resolveAddonConfigByUrl(url: string): AddonConfig { + // Find the environment variable ending with _API_URL that matches the given URL + const envVarEntries = Object.entries(process.env); + const matchingApiUrlEntry = envVarEntries.find( + ([key, value]) => key.endsWith("_API_URL") && value === url + ); + + if (!matchingApiUrlEntry) { + throw Error(`Heroku Applink config not found for API URL: ${url}`); + } + + // Extract the prefix from the API_URL environment variable name + const [envVarName] = matchingApiUrlEntry; + const prefix = envVarName.slice(0, -"_API_URL".length); // Remove '_API_URL' suffix + + // Look for corresponding token + const token = process.env[`${prefix}_TOKEN`]; + if (!token) { + throw Error(`Heroku Applink config not found for API URL: ${url}`); + } + + return { + apiUrl: url, + token, + }; +} diff --git a/test/utils/addon-config.test.ts b/test/utils/addon-config.test.ts new file mode 100644 index 0000000..a517376 --- /dev/null +++ b/test/utils/addon-config.test.ts @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2024, salesforce.com, inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ + +import { expect } from "chai"; +import { + resolveAddonConfigByAttachment, + resolveAddonConfigByUrl, +} from "../../src/utils/addon-config"; + +describe("resolveAddonConfigByAttachment", () => { + let originalEnv: NodeJS.ProcessEnv; + + beforeEach(() => { + originalEnv = { ...process.env }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it("gets config for default HEROKU_APPLINK attachment", () => { + process.env.HEROKU_APPLINK_API_URL = "https://api.example.com"; + process.env.HEROKU_APPLINK_TOKEN = "default-token"; + + const config = resolveAddonConfigByAttachment(); + expect(config.apiUrl).to.equal("https://api.example.com"); + expect(config.token).to.equal("default-token"); + }); + + it("gets config for specified attachment", () => { + process.env.CUSTOM_API_URL = "https://custom.example.com"; + process.env.CUSTOM_TOKEN = "custom-token"; + + const config = resolveAddonConfigByAttachment("CUSTOM"); + expect(config.apiUrl).to.equal("https://custom.example.com"); + expect(config.token).to.equal("custom-token"); + }); + + it("throws if API_URL config not found", () => { + process.env.APPLINK_TOKEN = "token"; + // APPLINK_API_URL intentionally not set + + expect(() => resolveAddonConfigByAttachment()).to.throw( + "Heroku Applink config not found under attachment HEROKU_APPLINK" + ); + }); + + it("throws if TOKEN config not found", () => { + process.env.HEROKU_APPLINK_API_URL = "https://api.example.com"; + // APPLINK_TOKEN intentionally not set + + expect(() => resolveAddonConfigByAttachment()).to.throw( + "Heroku Applink config not found under attachment HEROKU_APPLINK" + ); + }); +}); + +describe("resolveAddonConfigByUrl", () => { + let originalEnv: NodeJS.ProcessEnv; + + beforeEach(() => { + originalEnv = { ...process.env }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it("finds config for matching URL", () => { + const testUrl = "https://api.example.com"; + process.env.HEROKU_APPLINK_API_URL = testUrl; + process.env.HEROKU_APPLINK_TOKEN = "test-token"; + + const config = resolveAddonConfigByUrl(testUrl); + expect(config.apiUrl).to.equal(testUrl); + expect(config.token).to.equal("test-token"); + }); + + it("finds config for matching URL with custom prefix", () => { + const testUrl = "https://custom.example.com"; + process.env.CUSTOM_ATTACHMENT_API_URL = testUrl; + process.env.CUSTOM_ATTACHMENT_TOKEN = "custom-token"; + + const config = resolveAddonConfigByUrl(testUrl); + expect(config.apiUrl).to.equal(testUrl); + expect(config.token).to.equal("custom-token"); + }); + + it("throws if no matching URL is found", () => { + const testUrl = "https://nonexistent.example.com"; + + expect(() => resolveAddonConfigByUrl(testUrl)).to.throw( + `Heroku Applink config not found for API URL: ${testUrl}` + ); + }); + + it("throws if matching URL found but no corresponding token", () => { + const testUrl = "https://api.example.com"; + process.env.SOME_API_URL = testUrl; + // SOME_TOKEN intentionally not set + + expect(() => resolveAddonConfigByUrl(testUrl)).to.throw( + `Heroku Applink config not found for API URL: ${testUrl}` + ); + }); +}); From 529612352fa9aa4745c88dab4eccf49a06a655fb Mon Sep 17 00:00:00 2001 From: Elliot Date: Tue, 13 May 2025 14:16:16 -0500 Subject: [PATCH 2/3] pr feedback --- src/add-ons/heroku-applink.ts | 16 +++++++------- src/utils/addon-config.ts | 12 +++++------ test/utils/addon-config.test.ts | 38 ++++++++++++++++++++++----------- 3 files changed, 39 insertions(+), 27 deletions(-) diff --git a/src/add-ons/heroku-applink.ts b/src/add-ons/heroku-applink.ts index 5f09ee0..511ff91 100644 --- a/src/add-ons/heroku-applink.ts +++ b/src/add-ons/heroku-applink.ts @@ -16,17 +16,17 @@ import { const HTTP_REQUEST = new HttpRequestUtil(); /** - * Get stored Salesforce or Data Cloud org user credentials for given name or alias. - * @param name or alias - * @param attachmentNameOrUrl Either an attachment name (e.g. "APPLINK") or a full URL + * Get stored Salesforce or Data Cloud org user credentials for given developer name or alias. + * @param developerName or alias + * @param attachmentNameOrUrl Either an attachment name (e.g. "HEROKU_APPLINK") or a full URL. Defaults to "HEROKU_APPLINK" * @returns Org */ export async function getAuthorization( - name: string, - attachmentNameOrUrl = "APPLINK" + developerName: string, + attachmentNameOrUrl = "HEROKU_APPLINK" ): Promise { - if (!name) { - throw Error(`Connection name not provided`); + if (!developerName) { + throw Error(`Developer name not provided`); } // Check if the attachmentNameOrUrl is a URL by attempting to parse it @@ -50,7 +50,7 @@ export async function getAuthorization( "Content-Type": "application/json", }, body: JSON.stringify({ - developer_name: name, + developer_name: developerName, }), retry: { limit: 1, diff --git a/src/utils/addon-config.ts b/src/utils/addon-config.ts index 7595488..f7005e3 100644 --- a/src/utils/addon-config.ts +++ b/src/utils/addon-config.ts @@ -11,10 +11,10 @@ interface AddonConfig { } export function resolveAddonConfigByAttachment( - attachment = "HEROKU_APPLINK" + attachment: string ): AddonConfig { - const apiUrl = process.env[`${attachment}_API_URL`]; - const token = process.env[`${attachment}_TOKEN`]; + const apiUrl = process.env[`${attachment.toUpperCase()}_API_URL`]; + const token = process.env[`${attachment.toUpperCase()}_TOKEN`]; if (!apiUrl || !token) { throw Error( @@ -32,7 +32,7 @@ export function resolveAddonConfigByUrl(url: string): AddonConfig { // Find the environment variable ending with _API_URL that matches the given URL const envVarEntries = Object.entries(process.env); const matchingApiUrlEntry = envVarEntries.find( - ([key, value]) => key.endsWith("_API_URL") && value === url + ([key, value]) => key.endsWith("_API_URL") && value.toLowerCase() === url.toLowerCase() ); if (!matchingApiUrlEntry) { @@ -46,11 +46,11 @@ export function resolveAddonConfigByUrl(url: string): AddonConfig { // Look for corresponding token const token = process.env[`${prefix}_TOKEN`]; if (!token) { - throw Error(`Heroku Applink config not found for API URL: ${url}`); + throw Error(`Heroku Applink token not found for API URL: ${url}`); } return { - apiUrl: url, + apiUrl: matchingApiUrlEntry[1], token, }; } diff --git a/test/utils/addon-config.test.ts b/test/utils/addon-config.test.ts index a517376..c591c11 100644 --- a/test/utils/addon-config.test.ts +++ b/test/utils/addon-config.test.ts @@ -11,6 +11,8 @@ import { resolveAddonConfigByUrl, } from "../../src/utils/addon-config"; +const ATTACHMENT = "HEROKU_APPLINK"; + describe("resolveAddonConfigByAttachment", () => { let originalEnv: NodeJS.ProcessEnv; @@ -22,29 +24,29 @@ describe("resolveAddonConfigByAttachment", () => { process.env = originalEnv; }); - it("gets config for default HEROKU_APPLINK attachment", () => { + it("finds config for specified attachment", () => { process.env.HEROKU_APPLINK_API_URL = "https://api.example.com"; process.env.HEROKU_APPLINK_TOKEN = "default-token"; - const config = resolveAddonConfigByAttachment(); + const config = resolveAddonConfigByAttachment(ATTACHMENT); expect(config.apiUrl).to.equal("https://api.example.com"); expect(config.token).to.equal("default-token"); }); - it("gets config for specified attachment", () => { - process.env.CUSTOM_API_URL = "https://custom.example.com"; - process.env.CUSTOM_TOKEN = "custom-token"; + it("finds config ignoring case of attachment name", () => { + process.env.HEROKU_APPLINK_API_URL = "https://api.example.com"; + process.env.HEROKU_APPLINK_TOKEN = "default-token"; - const config = resolveAddonConfigByAttachment("CUSTOM"); - expect(config.apiUrl).to.equal("https://custom.example.com"); - expect(config.token).to.equal("custom-token"); + const config = resolveAddonConfigByAttachment(ATTACHMENT.toLowerCase()); + expect(config.apiUrl).to.equal("https://api.example.com"); + expect(config.token).to.equal("default-token"); }); it("throws if API_URL config not found", () => { - process.env.APPLINK_TOKEN = "token"; + process.env.HEROKU_APPLINK_TOKEN = "token"; // APPLINK_API_URL intentionally not set - expect(() => resolveAddonConfigByAttachment()).to.throw( + expect(() => resolveAddonConfigByAttachment(ATTACHMENT)).to.throw( "Heroku Applink config not found under attachment HEROKU_APPLINK" ); }); @@ -53,7 +55,7 @@ describe("resolveAddonConfigByAttachment", () => { process.env.HEROKU_APPLINK_API_URL = "https://api.example.com"; // APPLINK_TOKEN intentionally not set - expect(() => resolveAddonConfigByAttachment()).to.throw( + expect(() => resolveAddonConfigByAttachment(ATTACHMENT)).to.throw( "Heroku Applink config not found under attachment HEROKU_APPLINK" ); }); @@ -80,7 +82,17 @@ describe("resolveAddonConfigByUrl", () => { expect(config.token).to.equal("test-token"); }); - it("finds config for matching URL with custom prefix", () => { + it("finds config ignoring url case", () => { + const testUrl = "https://api.example.com"; + process.env.HEROKU_APPLINK_API_URL = testUrl; + process.env.HEROKU_APPLINK_TOKEN = "test-token"; + + const config = resolveAddonConfigByUrl(testUrl.toUpperCase()); + expect(config.apiUrl).to.equal(testUrl); + expect(config.token).to.equal("test-token"); + }); + + it("finds custom prefix config for matching URL", () => { const testUrl = "https://custom.example.com"; process.env.CUSTOM_ATTACHMENT_API_URL = testUrl; process.env.CUSTOM_ATTACHMENT_TOKEN = "custom-token"; @@ -104,7 +116,7 @@ describe("resolveAddonConfigByUrl", () => { // SOME_TOKEN intentionally not set expect(() => resolveAddonConfigByUrl(testUrl)).to.throw( - `Heroku Applink config not found for API URL: ${testUrl}` + `Heroku Applink token not found for API URL: ${testUrl}` ); }); }); From 6ae41dc8588b18a37fbcec896e90250335d34d00 Mon Sep 17 00:00:00 2001 From: Elliot Date: Tue, 13 May 2025 14:41:47 -0500 Subject: [PATCH 3/3] update authorization request shape --- src/add-ons/heroku-applink.ts | 7 ++----- src/utils/addon-config.ts | 3 ++- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/add-ons/heroku-applink.ts b/src/add-ons/heroku-applink.ts index 511ff91..f37f526 100644 --- a/src/add-ons/heroku-applink.ts +++ b/src/add-ons/heroku-applink.ts @@ -42,16 +42,13 @@ export async function getAuthorization( ? resolveAddonConfigByUrl(attachmentNameOrUrl) : resolveAddonConfigByAttachment(attachmentNameOrUrl); - const authUrl = `${config.apiUrl}/invocations/authorization`; + const authUrl = `${config.apiUrl}/authorizations/${developerName}`; const opts = { - method: "POST", + method: "GET", headers: { Authorization: `Bearer ${config.token}`, "Content-Type": "application/json", }, - body: JSON.stringify({ - developer_name: developerName, - }), retry: { limit: 1, }, diff --git a/src/utils/addon-config.ts b/src/utils/addon-config.ts index f7005e3..7742184 100644 --- a/src/utils/addon-config.ts +++ b/src/utils/addon-config.ts @@ -32,7 +32,8 @@ export function resolveAddonConfigByUrl(url: string): AddonConfig { // Find the environment variable ending with _API_URL that matches the given URL const envVarEntries = Object.entries(process.env); const matchingApiUrlEntry = envVarEntries.find( - ([key, value]) => key.endsWith("_API_URL") && value.toLowerCase() === url.toLowerCase() + ([key, value]) => + key.endsWith("_API_URL") && value.toLowerCase() === url.toLowerCase() ); if (!matchingApiUrlEntry) {