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..f37f526 100644 --- a/src/add-ons/heroku-applink.ts +++ b/src/add-ons/heroku-applink.ts @@ -8,45 +8,47 @@ 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 + * 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 getConnection(name: string): Promise { - if (!name) { - throw Error(`Connection name not provided`); +export async function getAuthorization( + developerName: string, + attachmentNameOrUrl = "HEROKU_APPLINK" +): Promise { + if (!developerName) { + throw Error(`Developer 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}/authorizations/${developerName}`; const opts = { - method: "POST", + method: "GET", headers: { - Authorization: `Bearer ${addonAuthToken}`, + Authorization: `Bearer ${config.token}`, "Content-Type": "application/json", }, - body: JSON.stringify({ - org_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..7742184 --- /dev/null +++ b/src/utils/addon-config.ts @@ -0,0 +1,57 @@ +/* + * 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: string +): AddonConfig { + const apiUrl = process.env[`${attachment.toUpperCase()}_API_URL`]; + const token = process.env[`${attachment.toUpperCase()}_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.toLowerCase() === url.toLowerCase() + ); + + 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 token not found for API URL: ${url}`); + } + + return { + apiUrl: matchingApiUrlEntry[1], + token, + }; +} diff --git a/test/utils/addon-config.test.ts b/test/utils/addon-config.test.ts new file mode 100644 index 0000000..c591c11 --- /dev/null +++ b/test/utils/addon-config.test.ts @@ -0,0 +1,122 @@ +/* + * 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"; + +const ATTACHMENT = "HEROKU_APPLINK"; + +describe("resolveAddonConfigByAttachment", () => { + let originalEnv: NodeJS.ProcessEnv; + + beforeEach(() => { + originalEnv = { ...process.env }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + 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(ATTACHMENT); + expect(config.apiUrl).to.equal("https://api.example.com"); + expect(config.token).to.equal("default-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(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.HEROKU_APPLINK_TOKEN = "token"; + // APPLINK_API_URL intentionally not set + + expect(() => resolveAddonConfigByAttachment(ATTACHMENT)).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(ATTACHMENT)).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 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"; + + 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 token not found for API URL: ${testUrl}` + ); + }); +});