From 4ad40f815f8397949d77d9238b9a7cabc1b12fa4 Mon Sep 17 00:00:00 2001 From: Colin Doak <75859289+cdoak-gh@users.noreply.github.com> Date: Fri, 12 Jan 2024 11:26:43 -0800 Subject: [PATCH] feat: consolidate authentication and service activities / implement authorize code flow (#1) * feat: revisions based on feedback * feat: clean up request * fix: correct result properties * feat: consolidate authorization in service - test for expired token and refresh when necessary - use authorization code flow * chore: changed sObject display name * fix: correct empty response logic * fix: Updates based on feedback * fix: improve regex matcher * fix: updates from feedback * fix: fix token test on Delete * modified: src/activities/SendSalesforceRequest.ts modified: src/request.ts * fix: correct success path for delete - ensure paths are correct * fix: simplify request pattern and better handle token refresh --- src/SalesforceRequestError.ts | 6 +- src/SalesforceService.ts | 19 +- src/activities/CreateSalesforceObject.ts | 17 +- src/activities/CreateSalesforceService.ts | 176 +++++++++++++-- src/activities/DeleteSalesforceObject.ts | 18 +- src/activities/GetSalesforceObject.ts | 19 +- src/activities/GetSalesforceObjectMetadata.ts | 18 +- src/activities/OAuthSignIn.ts | 185 ---------------- src/activities/QuerySalesforce.ts | 2 +- src/activities/SendSalesforceRequest.ts | 24 +- src/activities/UpdateSalesforceObject.ts | 18 +- src/index.ts | 4 +- src/request.ts | 207 ++++++++---------- 13 files changed, 345 insertions(+), 368 deletions(-) delete mode 100644 src/activities/OAuthSignIn.ts diff --git a/src/SalesforceRequestError.ts b/src/SalesforceRequestError.ts index aa94a47..b7eb91e 100644 --- a/src/SalesforceRequestError.ts +++ b/src/SalesforceRequestError.ts @@ -1,14 +1,14 @@ export class SalesforceRequestError extends Error { - readonly error?: Record; + readonly errors?: Record[]; readonly statusCode: number; constructor( statusCode: number, - error?: Record, + errors?: Record[], message?: string ) { super(message || "Salesforce request failed."); - this.error = error; + this.errors = errors; this.statusCode = statusCode; } } \ No newline at end of file diff --git a/src/SalesforceService.ts b/src/SalesforceService.ts index 278ba26..fc7719b 100644 --- a/src/SalesforceService.ts +++ b/src/SalesforceService.ts @@ -1,6 +1,19 @@ export interface SalesforceService { - accessToken: string; + token: SalesforceToken; instanceUrl: string; version: string; - -} \ No newline at end of file + clientId: string; + redirectUri: string; +} + +export interface SalesforceToken { + access_token: string; + refresh_token: string; + signature: string; + scope: string; + id_token: string; + instance_url: string; + id: string; + token_type: string; + issued_at: string; +} diff --git a/src/activities/CreateSalesforceObject.ts b/src/activities/CreateSalesforceObject.ts index 8f51c01..4e1b9fe 100644 --- a/src/activities/CreateSalesforceObject.ts +++ b/src/activities/CreateSalesforceObject.ts @@ -17,10 +17,12 @@ interface CreateSalesforceObjectInputs { salesforceObject: Record; /** - * @description The name of the object. For example, Account. + * @displayName sObject + * @description The name of the salesforce sObject. For example, Account. + * @helpUrl https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/resources_sobject_basic_info.htm * @required */ - objectType: string; + sObject: string; } @@ -35,13 +37,14 @@ interface CreateSalesforceObjectOutputs { * @category Salesforce * @defaultName sfCreate * @description Creates a Salesforce object. + * @helpUrl https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/resources_sobject_basic_info_post.htm * @clientOnly * @supportedApps EXB, GWV, WAB */ export default class CreateSalesforceObject implements IActivityHandler { async execute(inputs: CreateSalesforceObjectInputs): Promise { - const { salesforceService, salesforceObject, objectType } = inputs; + const { salesforceService, salesforceObject, sObject } = inputs; if (!salesforceService) { throw new Error("salesforceService is required"); @@ -49,10 +52,12 @@ export default class CreateSalesforceObject implements IActivityHandler { if (!salesforceObject) { throw new Error("salesforceObject is required"); } - if (!objectType) { - throw new Error("objectType is required"); + if (!sObject) { + throw new Error("sObject is required"); } - const path = `/services/data/v${salesforceService.version}/sobjects/${objectType}`; + const encodedSObject = encodeURIComponent(sObject); + + const path = `/services/data/v${salesforceService.version}/sobjects/${encodedSObject}`; const response = await post(salesforceService, path, salesforceObject); return { result: response, diff --git a/src/activities/CreateSalesforceService.ts b/src/activities/CreateSalesforceService.ts index 4e0daa3..b68c074 100644 --- a/src/activities/CreateSalesforceService.ts +++ b/src/activities/CreateSalesforceService.ts @@ -1,24 +1,43 @@ +/* eslint-disable @typescript-eslint/no-misused-promises */ import type { IActivityHandler } from "@vertigis/workflow"; -import { SalesforceService } from "../SalesforceService"; +import { SalesforceService, SalesforceToken } from "../SalesforceService"; +import { SalesforceRequestError } from "../SalesforceRequestError"; /** An interface that defines the inputs of the activity. */ interface CreateSalesforceServiceInputs { /** - * @description An Salesforce access token. + * @displayName URL + * @description The full url to your organization's salesforce instance. (e.g. https://acme.my.salesforce.com) * @required */ - token: string; - /** + url: string; + + /** * @description The version of Salesforce to access. * @required */ - version: string; + // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents + version: "59.0" | string | number; /** - * @displayName URL - * @description The URL to the salesforce service. This only needs to be provided when testing against a Salesforce sandbox. + * @displayName Client ID + * @description The Client ID of the OAuth app to sign in to. + * @required */ - url?: string; + clientId: string; + + /** + * @displayName Redirect Url + * @description The redirect URI to which the OAuth 2.0 server will send its response. + * @required + */ + redirectUri: string; + + + /** @description The redirect page timeout in milliseconds (optional). + */ + timeout?: number; + } @@ -38,18 +57,141 @@ interface CreateSalesforceServiceOutputs { * @supportedApps EXB, GWV, WAB */ export default class CreateSalesforceService implements IActivityHandler { - execute(inputs: CreateSalesforceServiceInputs): CreateSalesforceServiceOutputs { - const {token, url = "https://login.salesforce.com/services", version} = inputs; + async execute(inputs: CreateSalesforceServiceInputs): Promise { + const { url, version, clientId, redirectUri, timeout } = inputs; - if (!token) { - throw new Error("token is required"); - } if (!version) { throw new Error("version is required"); } - const saleforceUri = url.replace(/\/*$/, ""); - return { - service: {instanceUrl: saleforceUri, accessToken: token, version: version }, - }; + if (!url) { + throw new Error("url is required"); + } + if (!clientId) { + throw new Error("clientId is required"); + } + if (!redirectUri) { + throw new Error("redirectUri is required"); + } + + const salesforceUri = url.replace(/\/*$/, ""); + const authorizationUri = `${salesforceUri}/services/oauth2/authorize`; + const tokenUri = `${salesforceUri}/services/oauth2/token`; + + const formattedVersion = `${version}${Number.isInteger(version) ? ".0" : ""}`; + + // Assemble OAuth URL + const authorizeUrl = new URL(authorizationUri); + authorizeUrl.searchParams.append("client_id", clientId); + authorizeUrl.searchParams.append("redirect_uri", redirectUri); + authorizeUrl.searchParams.append("response_type", "code"); + authorizeUrl.searchParams.append("state", generateRandomState()); + + const code = await authenticate(authorizeUrl, redirectUri, timeout); + const token = await getToken(tokenUri, + { + code, + grant_type: "authorization_code", + redirect_uri: redirectUri, + client_id: clientId + }); + if (token) { + return { + service: { + token: token, + instanceUrl: url, + version: formattedVersion, + clientId: clientId, + redirectUri: redirectUri, + } + } + } + + throw new Error(`Authentication failed when trying to access: ${url}`); } } + +async function authenticate(uri: URL, redirectUri: string, timeout?: number): Promise { + + // Compute window dimensions and position + const windowArea = { + width: Math.floor(window.outerWidth * 0.8), + height: Math.floor(window.outerHeight * 0.8), + left: 0, + top: 0, + }; + windowArea.left = Math.floor(window.screenX + ((window.outerWidth - windowArea.width) / 2)); + windowArea.top = Math.floor(window.screenY + ((window.outerHeight - windowArea.height) / 2)); + const windowOpts = `toolbar=0,scrollbars=1,status=1,resizable=1,location=1,menuBar=0,width=${windowArea.width},height=${windowArea.height},left=${windowArea.left},top=${windowArea.top}`; + + const authWindow = window.open(uri, "oauth-popup", windowOpts); + return new Promise((resolve, reject) => { + let timeoutHandle: number = 0; + + const checkClosedHandle = setInterval(() => { + if (authWindow?.closed) { + return "canceled"; + } + }, 500); + const onMessage = (e: MessageEvent) => { + window.clearInterval(checkClosedHandle); + window.clearTimeout(timeoutHandle); + // Ensure the message origin matches the expected redirect URI + if (e.data && typeof e.data === "string" && redirectUri.startsWith(e.origin)) { + const parsedUrl = new URL(e.data); + const code = parsedUrl.searchParams.get("code"); + const error = parsedUrl.searchParams.get("error"); + window.clearInterval(checkClosedHandle); + window.removeEventListener("message", onMessage); + if (error) { + reject(error); + } if (code) { + resolve(code); + } else { + reject("OAuth callback did not provide code"); + } + } + }; + window.addEventListener("message", onMessage, { once: false }); + + timeoutHandle = window.setTimeout(() => { + window.clearInterval(checkClosedHandle); + // eslint-disable-next-line @typescript-eslint/no-misused-promises + window.removeEventListener("message", onMessage); + try { + authWindow?.close(); + } catch { + //do nothing + } + return reject("timeout"); + }, timeout || 60000); + + }); + +} + + + +function generateRandomState(): string { + const array = new Uint32Array(10); + window.crypto.getRandomValues(array); + return array.join(""); +} + +async function getToken( + url: string, + body?: Record, +): Promise { + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams(body), + }); + + if (!response.ok) { + throw new SalesforceRequestError(response.status); + } + + return await response.json(); +} \ No newline at end of file diff --git a/src/activities/DeleteSalesforceObject.ts b/src/activities/DeleteSalesforceObject.ts index db3d4f1..106792e 100644 --- a/src/activities/DeleteSalesforceObject.ts +++ b/src/activities/DeleteSalesforceObject.ts @@ -17,16 +17,19 @@ interface DeleteSalesforceObjectInputs { id: string; /** - * @description The name of the object. For example, Account. + * @displayName sObject + * @description The name of the salesforce sObject. For example, Account. + * @helpUrl https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/resources_sobject_basic_info.htm * @required */ - objectType: string; + sObject: string; } /** * @category Salesforce * @defaultName sfDelete * @description Deletes the record specified by the provided type and ID. + * @helpUrl https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/resources_sobject_retrieve_delete.htm * @clientOnly * @supportedApps EXB, GWV, WAB */ @@ -34,7 +37,7 @@ export default class DeleteSalesforceObject implements IActivityHandler { /** Perform the execution logic of the activity. */ async execute(inputs: DeleteSalesforceObjectInputs): Promise { - const { salesforceService, id, objectType } = inputs; + const { salesforceService, id, sObject } = inputs; if (!salesforceService) { throw new Error("salesforceService is required"); @@ -42,10 +45,13 @@ export default class DeleteSalesforceObject implements IActivityHandler { if (!id) { throw new Error("id is required"); } - if (!objectType) { - throw new Error("objectType is required"); + if (!sObject) { + throw new Error("sObject is required"); } - const path = `/services/data/v${salesforceService.version}/sobjects/${objectType}/${id}`; + + const encodedSObject = encodeURIComponent(sObject); + const encodedId = encodeURIComponent(id); + const path = `/services/data/v${salesforceService.version}/sobjects/${encodedSObject}/${encodedId}`; await httpDelete(salesforceService, path); } diff --git a/src/activities/GetSalesforceObject.ts b/src/activities/GetSalesforceObject.ts index c52e30c..430fc69 100644 --- a/src/activities/GetSalesforceObject.ts +++ b/src/activities/GetSalesforceObject.ts @@ -17,10 +17,12 @@ interface GetSalesforceObjectInputs { id: string; /** - * @description The name of the object. For example, Account. + * @displayName sObject + * @description The name of the salesforce sObject. For example, Account. + * @helpUrl https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/resources_sobject_basic_info.htm * @required */ - objectType: string; + sObject: string; /** * @description The list of fields to be returned with the object. @@ -29,7 +31,6 @@ interface GetSalesforceObjectInputs { } -/** An interface that defines the outputs of the activity. */ interface GetSalesforceObjectOutputs { /** * @description The salesforce object. @@ -41,6 +42,7 @@ interface GetSalesforceObjectOutputs { * @category Salesforce * @defaultName sfObject * @description Gets a salesforce object given its Url. You can specify the fields you want to retrieve with the optional fields parameter. If you don’t use the fields parameter, the request retrieves all standard and custom fields from the record. + * @helpUrl https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/resources_sobject_retrieve_get.htm * @clientOnly * @supportedApps EXB, GWV, WAB */ @@ -48,19 +50,20 @@ export default class GetSalesforceObject implements IActivityHandler { /** Perform the execution logic of the activity. */ async execute(inputs: GetSalesforceObjectInputs): Promise { - const { salesforceService, objectType, id, fields } = inputs; + const { salesforceService, sObject, id, fields } = inputs; if (!salesforceService) { throw new Error("salesforceService is required"); } - if (!objectType) { - throw new Error("objectType is required"); + if (!sObject) { + throw new Error("sObject is required"); } if (!id) { throw new Error("id is required"); } - const path = `/services/data/v${salesforceService.version}/sobjects/${objectType}/${id}`; - + const encodedSObject = encodeURIComponent(sObject); + const encodedId = encodeURIComponent(id); + const path = `/services/data/v${salesforceService.version}/sobjects/${encodedSObject}/${encodedId}`; const query = fields ? { fields: fields?.join(","), } : undefined; diff --git a/src/activities/GetSalesforceObjectMetadata.ts b/src/activities/GetSalesforceObjectMetadata.ts index 9fb602e..de47221 100644 --- a/src/activities/GetSalesforceObjectMetadata.ts +++ b/src/activities/GetSalesforceObjectMetadata.ts @@ -11,10 +11,12 @@ interface GetSalesforceObjectMetadataInputs { salesforceService: SalesforceService; /** - * @description The name of the object. For example, Account. + * @displayName sObject + * @description The name of the salesforce sObject. For example, Account. + * @helpUrl https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/resources_sobject_basic_info.htm * @required */ - objectType: string; + sObject: string; } interface GetSalesforceObjectMetadataOutputs { @@ -28,24 +30,24 @@ interface GetSalesforceObjectMetadataOutputs { * @category Salesforce * @defaultName sfMetadata * @description Gets basic metadata for a specified object, including some object properties, recent items, and URIs for other resources related to the object. + * @helpUrl https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/resources_sobject_basic_info_get.htm * @clientOnly * @supportedApps EXB, GWV, WAB */ export default class GetSalesforceObjectMetadata implements IActivityHandler { async execute(inputs: GetSalesforceObjectMetadataInputs): Promise { - const { salesforceService, objectType} = inputs; + const { salesforceService, sObject} = inputs; if (!salesforceService) { throw new Error("salesforceService is required"); } - if (!objectType) { - throw new Error("objectType is required"); + if (!sObject) { + throw new Error("sObject is required"); } - - const path = `/services/data/v${salesforceService.version}/sobjects/${objectType}`; - + const encodedSObject = encodeURIComponent(sObject); + const path = `/services/data/v${salesforceService.version}/sobjects/${encodedSObject}`; const response = await get(salesforceService, path); return { result: response, diff --git a/src/activities/OAuthSignIn.ts b/src/activities/OAuthSignIn.ts deleted file mode 100644 index d1b0c44..0000000 --- a/src/activities/OAuthSignIn.ts +++ /dev/null @@ -1,185 +0,0 @@ -/* eslint-disable @typescript-eslint/no-redundant-type-constituents */ -import type { IActivityHandler } from "@vertigis/workflow"; - -const thisScript = (document.currentScript as HTMLScriptElement)?.src; - -/** An interface that defines the inputs of the activity. */ -export interface OAuthSignInInputs { - /** - * @displayName Authorize URL - * @description The URL to the authorize endpoint of the OAuth service. - * @required - */ - authorizeUrl: "https://login.salesforce.com/services/oauth2/authorize" | string; - - /** - * @required - */ - responseType: "code" | "token" | string; - - /** - * @displayName Client ID - * @description The Client ID of the OAuth app to sign in to. - * @required - */ - clientId: string; - - /** - * @displayName Redirect Url - * @description The redirect URI to which the OAuth 2.0 server will send its response. - * @required - */ - redirectUri: string; - - - scope?: string; - audience?: string; - state?: string; - prompt?: "none" | "login" | "consent" | "select_account" | string; - additionalParameters?: { - [key: string]: string; - } - timeout: number; -} - -/** An interface that defines the outputs of the activity. */ -export interface OAuthSignInOutputs { - /** - * @description The result of the activity. - */ - result: { - [key: string]: string; - } -} - -/** - * @category Salesforce - * @displayName OAuth Sign In - * @description Sign in to an OAuth enabled service. - * @clientOnly - * @supportedApps EXB, GWV, WAB - */ -export default class OAuthSignIn implements IActivityHandler { - async execute(inputs: OAuthSignInInputs): Promise { - const { additionalParameters, audience, authorizeUrl, clientId, prompt, responseType, scope, state, timeout, redirectUri } = inputs; - - // Validate inputs - if (!authorizeUrl) { - throw new Error("authorizeUrl is required"); - } - if (!clientId) { - throw new Error("clientId is required"); - } - if (!responseType) { - throw new Error("responseType is required"); - } - - // TODO: dynamically determine this - console.log(thisScript); - - // Assemble OAuth URL - const qs = objectToQueryString({ - ...additionalParameters, - client_id: clientId, - ...(audience ? { audience } : undefined), - ...(prompt ? { prompt } : undefined), - redirect_uri: redirectUri, - response_type: responseType, - ...(scope ? { scope } : undefined), - state: state != undefined ? state : generateRandomState(), - }) - const url = `${authorizeUrl}?${qs}` - - // Compute window dimensions and position - const windowArea = { - width: Math.floor(window.outerWidth * 0.8), - height: Math.floor(window.outerHeight * 0.8), - left: 0, - top: 0, - }; - windowArea.left = Math.floor(window.screenX + ((window.outerWidth - windowArea.width) / 2)); - windowArea.top = Math.floor(window.screenY + ((window.outerHeight - windowArea.height) / 2)); - const windowOpts = `toolbar=0,scrollbars=1,status=1,resizable=1,location=1,menuBar=0,width=${windowArea.width},height=${windowArea.height},left=${windowArea.left},top=${windowArea.top}`; - - const authWindow = window.open(url, "oauth-popup", windowOpts); - - return new Promise((resolve, reject) => { - let timeoutHandle: number = 0; - - const checkClosedHandle = setInterval(() => { - if (authWindow?.closed) { - return reject("canceled"); - } - }, 500); - - const onMessage = (e: MessageEvent) => { - // Compare current script origin to the origin and source of the post message - // Compare the state parameter - console.log("message", e) - window.clearInterval(checkClosedHandle); - window.clearTimeout(timeoutHandle); - const result: Record = {}; - - if (e.data && typeof e.data === "string" && e.data.startsWith(redirectUri)) { - // Copy all querystring and hash parameters to the result - const parsedUrl = new URL(e.data); - for (const [key, value] of parsedUrl.searchParams.entries()) { - result[key] = value; - } - const hashParams = new URLSearchParams(parsedUrl.hash.substring(1)); - for (const [key, value] of hashParams.entries()) { - result[key] = value; - } - window.clearInterval(checkClosedHandle); - window.removeEventListener("message", onMessage); - if (result.error) { - return reject(result); - } else { - return resolve({ - result, - }); - } - } - - - }; - - window.addEventListener("message", onMessage, { once: false }); - - timeoutHandle = window.setTimeout(() => { - window.clearInterval(checkClosedHandle); - window.removeEventListener("message", onMessage); - try { - authWindow?.close(); - } catch { - //do nothing - } - return reject("timeout"); - }, timeout || 60000); - }); - } -} - -function generateRandomState(): string { - const array = new Uint32Array(10); - window.crypto.getRandomValues(array); - return array.join(""); -} - -function objectToQueryString( - data?: Record -): string { - if (!data) { - return ""; - } - return Object.keys(data) - .map((k) => { - const value = data[k]; - const valueToEncode = - value === undefined || value === null ? "" : value; - return `${encodeURIComponent(k)}=${encodeURIComponent( - valueToEncode - )}`; - }) - .join("&"); -} diff --git a/src/activities/QuerySalesforce.ts b/src/activities/QuerySalesforce.ts index 3b3e55d..021f901 100644 --- a/src/activities/QuerySalesforce.ts +++ b/src/activities/QuerySalesforce.ts @@ -11,7 +11,7 @@ interface QuerySalesforceInputs { salesforceService: SalesforceService; /** * @displayName SOQL - * @description The Salesforce Object Query Language (SOQL) to search your organization’s Salesforce data for specific information. + * @description The Salesforce Object Query Language (SOQL) to search your organization’s Salesforce data for specific information. * @required */ soql: string; diff --git a/src/activities/SendSalesforceRequest.ts b/src/activities/SendSalesforceRequest.ts index 31c2331..e69524a 100644 --- a/src/activities/SendSalesforceRequest.ts +++ b/src/activities/SendSalesforceRequest.ts @@ -17,10 +17,11 @@ export interface SendSalesforceRequestInputs { method: "GET" | "POST" | "PATCH" | "DELETE"; /** - * @description The Salesforce REST API object or operation to request. + * @description The Salesforce sObject REST API uri to request. This part of the sObject Basic Information attributes. + * @helpUrl https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/resources_list.htm * @required */ - path: string; + uri: string; /** * @description The query string parameters to send on the request. @@ -42,6 +43,12 @@ export interface SendSalesforceRequestInputs { headers?: { [key: string]: any; }; + + /** + * @description The content expected to be returned in the response (json or blob). + */ + // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents + expectedResponse?: "json" | "blob"; } /** An interface that defines the outputs of the activity. */ @@ -56,6 +63,7 @@ export interface SendSalesforceRequestOutputs { * @category Salesforce * @defaultName sfRequest * @description Sends a request to the Salesforce REST API. + * @helpUrl https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/resources_list.htm * @clientOnly * @supportedApps EXB, GWV, WAB */ @@ -63,19 +71,23 @@ export default class SendSalesforceRequest implements IActivityHandler { async execute( inputs: SendSalesforceRequestInputs ): Promise { - const { body, headers, method, path, query, service } = inputs; + const { body, headers, method, uri, query, service, expectedResponse } = inputs; if (!service) { throw new Error("service is required"); } if (!method) { throw new Error("method is required"); } - if (!path) { - throw new Error("path is required"); + if (!uri) { + throw new Error("uri is required"); } + //force the current version + let path = uri.replace(/\/v\d+\.\d+\//, `/v${service.version}/`); + path = "/" + path.replace(/^\/|\/$/g, ""); + if (method == "GET") { - const response = await get(service, path, query, headers); + const response = await get(service, path, query, headers, expectedResponse); return { result: response, }; diff --git a/src/activities/UpdateSalesforceObject.ts b/src/activities/UpdateSalesforceObject.ts index 70d76a8..70eccf1 100644 --- a/src/activities/UpdateSalesforceObject.ts +++ b/src/activities/UpdateSalesforceObject.ts @@ -24,10 +24,12 @@ interface UpdateSalesforceObjectInputs { id: string; /** - * @description The name of the object. For example, Account. + * @displayName sObject + * @description The name of the salesforce sObject. For example, Account. + * @helpUrl https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/resources_sobject_basic_info.htm * @required */ - objectType: string; + sObject: string; } @@ -35,12 +37,13 @@ interface UpdateSalesforceObjectInputs { * @category Salesforce * @defaultName sfUpdate * @description Updates a Salesforce Object record. + * @helpUrl https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/resources_sobject_retrieve_patch.htm * @clientOnly * @supportedApps EXB, GWV, WAB */ export default class UpdateSalesforceObject implements IActivityHandler { async execute(inputs: UpdateSalesforceObjectInputs): Promise { - const { salesforceService, salesforceObjectFields, objectType, id } = inputs; + const { salesforceService, salesforceObjectFields, sObject, id } = inputs; if (!salesforceService) { throw new Error("salesforceService is required"); @@ -48,15 +51,18 @@ export default class UpdateSalesforceObject implements IActivityHandler { if (!salesforceObjectFields) { throw new Error("salesforceObjectFields is required"); } - if (!objectType) { - throw new Error("objectType is required"); + if (!sObject) { + throw new Error("sObject is required"); } if (!id) { throw new Error("id is required"); } - const path = `/services/data/v${salesforceService.version}/sobjects/${objectType}/${id}`; + const encodedSObject = encodeURIComponent(sObject); + const encodedId = encodeURIComponent(id); + const path = `/services/data/v${salesforceService.version}/sobjects/${encodedSObject}/${encodedId}`; + await patch(salesforceService, path, salesforceObjectFields); } diff --git a/src/index.ts b/src/index.ts index a88be93..49a7ec1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,11 +11,9 @@ export { default as GetSalesforceObject } from "./activities/GetSalesforceObject export { default as GetSalesforceObjectMetadata } from "./activities/GetSalesforceObjectMetadata"; -export { default as OAuthSignIn } from "./activities/OAuthSignIn"; - export { default as QuerySalesforce } from "./activities/QuerySalesforce"; export { default as SendSalesforceRequest } from "./activities/SendSalesforceRequest"; -export { default as UpdateSalesforceObject } from "./activities/UpdateSalesforceObject"; +export { default as UpdateSalesforceObject } from "./activities/UpdateSalesforceObject"; diff --git a/src/request.ts b/src/request.ts index caab62b..7f6b030 100644 --- a/src/request.ts +++ b/src/request.ts @@ -1,182 +1,157 @@ -import { SalesforceService } from "./SalesforceService"; +import { SalesforceService, SalesforceToken } from "./SalesforceService"; import { SalesforceRequestError } from "./SalesforceRequestError"; function getAuthHeaders(salesforceService: SalesforceService) { return { - Authorization: `Bearer ${salesforceService.accessToken}`, + Authorization: `Bearer ${salesforceService.token.access_token}`, }; } -export async function get( +export function get( service: SalesforceService, path: string, query?: Record, - headers?: Record + headers?: Record, + expectedResponse?: "blob" | "json" ): Promise { - if (!service.instanceUrl) { - throw new Error("instanceUrl is required"); - } - if (!service.accessToken) { - throw new Error("accessToken is required"); - } - const qs = objectToQueryString({ lean: 1, ...query }); - const url = `${service.instanceUrl}/${path}${ - qs ? "?" + qs : "" - }`; - const response = await fetch(url, { - headers: { - Accept: "application/json", - ...getAuthHeaders(service), - ...headers, - }, - }); - - await checkResponse(response); - - return await response.json(); + return httpRequest(service, "GET", path, query, undefined, headers, expectedResponse); } -export async function post( +export function post( service: SalesforceService, path: string, body?: Record, headers?: Record ): Promise { - if (!service.instanceUrl) { - throw new Error("url is required"); - } - if (!service.accessToken) { - throw new Error("accessToken is required"); - } - const url = `${service.instanceUrl}${path}`; - const response = await fetch(url, { - method: "POST", - headers: { - Accept: "application/json", - "Content-Type": "application/json", - ...getAuthHeaders(service), - ...headers, - }, - body: JSON.stringify(body), - }); - - await checkResponse(response); - - if ( - response.status === 204 || - response.headers.get("content-length") === "0" - ) { - // No content - return {} as T; - } - - return await response.json(); + return httpRequest(service, "POST", path, undefined, body, headers); } -export async function patch( +export function patch( service: SalesforceService, path: string, body?: Record, headers?: Record ): Promise { - if (!service.instanceUrl) { - throw new Error("url is required"); - } - if (!service.accessToken) { - throw new Error("accessToken is required"); - } - const url = `${service.instanceUrl}${path}`; - const response = await fetch(url, { - method: "PATCH", - headers: { - Accept: "application/json", - "Content-Type": "application/json", - ...getAuthHeaders(service), - ...headers, - }, - body: JSON.stringify(body), - }); - - await checkResponse(response); - - if ( - response.status === 204 || - response.headers.get("content-length") === "0" - ) { - // No content - return {} as T; - } - - return await response.json(); + return httpRequest(service, "PATCH", path, undefined, body, headers); } -export async function httpDelete( +export function httpDelete( service: SalesforceService, path: string, body?: Record, headers?: Record +): Promise { + return httpRequest(service, "DELETE", path, undefined, body, headers); +} + +async function httpRequest( + service: SalesforceService, + method: "GET" | "POST" | "PATCH" | "DELETE", + path: string, + query?: Record, + body?: Record, + headers?: Record, + expectedResponse?: "blob" | "json", + allowTokenRefresh?: boolean, ): Promise { if (!service.instanceUrl) { throw new Error("url is required"); } - if (!service.accessToken) { + if (!service.token) { throw new Error("accessToken is required"); } - const url = `${service.instanceUrl}${path}`; + + const url = new URL(`${service.instanceUrl}${path}`); + + if (query) { + for (const [key, value] of Object.entries(query)) { + url.searchParams.append(key, value?.toString() || ""); + } + } + const response = await fetch(url, { - method: "DELETE", + method, headers: { - Accept: "application/json", + Accept: expectedResponse === "blob" ? "*/*" : "application/json", ...getAuthHeaders(service), ...headers, }, - body: JSON.stringify(body), + body: body ? JSON.stringify(body) : undefined, }); - await checkResponse(response); + const error = await getResponseError(response); + if (error) { + if (error.statusCode === 401 && allowTokenRefresh !== false) { + if (await tryRefreshToken(service)) { + return httpRequest(service, method, path, query, body, headers, expectedResponse, false); + } + } + throw error; + } - if (response.status === 204) { + if (response && response.status === 204) { // No content return {} as T; + } else { + return await response.json(); } +} - return await response.json(); +async function tryRefreshToken(service: SalesforceService): Promise { + try { + if (service.token.refresh_token) { + const token = await refreshToken(service); + if (token) { + service.token = token; + return true; + } + } + } catch { + // Swallow errors + } + return false } -export async function checkResponse( - response: Response, - message?: string -): Promise { +export async function getResponseError(response: Response) { if (!response.ok) { // Try to read the error body of the response - let error: Record | undefined; + let errors: Record[] | undefined; const contentType = response.headers.get("content-type"); if (contentType && contentType.indexOf("application/json") !== -1) { try { const responseJson = await response.json(); - error = responseJson?.Error || responseJson; + errors = responseJson?.errors || responseJson; } catch { // Swallow errors reading the response so that we don't mask the original failure } } - throw new SalesforceRequestError(response.status, error, message); + return new SalesforceRequestError(response.status, errors); } } -function objectToQueryString( - data?: Record -): string { - if (!data) { - return ""; +async function refreshToken(service: SalesforceService): Promise { + const refreshUri = `${service.instanceUrl}/services/oauth2/token`; + const body = { + refresh_token: service.token.refresh_token, + grant_type: "refresh_token", + client_id: service.clientId, + redirect_uri: service.redirectUri, } - return Object.keys(data) - .map((k) => { - const value = data[k]; - const valueToEncode = - value === undefined || value === null ? "" : value; - return `${encodeURIComponent(k)}=${encodeURIComponent( - valueToEncode - )}`; - }) - .join("&"); + const response = await fetch(refreshUri, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams(body), + }); + + if ( + response.status === 204 || + response.headers.get("content-length") === "0" + ) { + // No content + return undefined; + } + + return await response.json(); } \ No newline at end of file