UNPKG

jsforce

Version:

Salesforce API Library for JavaScript

1,697 lines (1,605 loc) 49.3 kB
/** * */ import { EventEmitter } from 'events'; import jsforce from './jsforce'; import { HttpRequest, HttpResponse, Callback, Record, SaveResult, UpsertResult, DescribeGlobalResult, DescribeSObjectResult, DescribeTab, DescribeTheme, DescribeQuickActionResult, UpdatedResult, DeletedResult, SearchResult, OrganizationLimitsInfo, Optional, SignedRequestObject, SaveError, DmlOptions, RetrieveOptions, Schema, SObjectNames, SObjectInputRecord, SObjectUpdateRecord, SObjectFieldNames, UserInfo, IdentityInfo, LimitInfo, } from './types'; import { StreamPromise } from './util/promise'; import Transport, { CanvasTransport, XdProxyTransport, HttpProxyTransport, } from './transport'; import { Logger, getLogger } from './util/logger'; import { LogLevelConfig } from './util/logger'; import OAuth2, { TokenResponse } from './oauth2'; import { OAuth2Config } from './oauth2'; import Cache, { CachedFunction } from './cache'; import HttpApi from './http-api'; import SessionRefreshDelegate, { SessionRefreshFunc, } from './session-refresh-delegate'; import Query from './query'; import { QueryOptions } from './query'; import SObject from './sobject'; import QuickAction from './quick-action'; import Process from './process'; import { formatDate } from './util/formatter'; import Analytics from './api/analytics'; import Apex from './api/apex'; import { Bulk } from './api/bulk'; import { BulkV2 } from './api/bulk2'; import Chatter from './api/chatter'; import Metadata from './api/metadata'; import SoapApi from './api/soap'; import Streaming from './api/streaming'; import Tooling from './api/tooling'; import FormData from 'form-data'; /** * type definitions */ export type ConnectionConfig<S extends Schema = Schema> = { version?: string; loginUrl?: string; accessToken?: string; refreshToken?: string; instanceUrl?: string; sessionId?: string; serverUrl?: string; signedRequest?: string; oauth2?: OAuth2 | OAuth2Config; maxRequest?: number; proxyUrl?: string; httpProxy?: string; logLevel?: LogLevelConfig; callOptions?: { [name: string]: string }; refreshFn?: SessionRefreshFunc<S>; }; export type ConnectionEstablishOptions = { accessToken?: Optional<string>; refreshToken?: Optional<string>; instanceUrl?: Optional<string>; sessionId?: Optional<string>; serverUrl?: Optional<string>; signedRequest?: Optional<string | SignedRequestObject>; userInfo?: Optional<UserInfo>; }; /** * */ const defaultConnectionConfig: { loginUrl: string; instanceUrl: string; version: string; logLevel: LogLevelConfig; maxRequest: number; } = { loginUrl: 'https://login.salesforce.com', instanceUrl: '', version: '50.0', logLevel: 'NONE', maxRequest: 10, }; /** * */ function esc(str: Optional<string>): string { return String(str || '') .replace(/&/g, '&amp;') .replace(/</g, '&lt;') .replace(/>/g, '&gt;') .replace(/"/g, '&quot;'); } /** * */ function parseSignedRequest(sr: string | Object): SignedRequestObject { if (typeof sr === 'string') { if (sr.startsWith('{')) { // might be JSON return JSON.parse(sr); } // might be original base64-encoded signed request const msg = sr.split('.').pop(); // retrieve latter part if (!msg) { throw new Error('Invalid signed request'); } const json = Buffer.from(msg, 'base64').toString('utf-8'); return JSON.parse(json); } return sr as SignedRequestObject; } /** @private **/ function parseIdUrl(url: string) { const [organizationId, id] = url.split('/').slice(-2); return { id, organizationId, url }; } /** * Session Refresh delegate function for OAuth2 authz code flow * @private */ async function oauthRefreshFn<S extends Schema>( conn: Connection<S>, callback: Callback<string, TokenResponse>, ) { try { if (!conn.refreshToken) { throw new Error('No refresh token found in the connection'); } const res = await conn.oauth2.refreshToken(conn.refreshToken); const userInfo = parseIdUrl(res.id); conn._establish({ instanceUrl: res.instance_url, accessToken: res.access_token, userInfo, }); callback(undefined, res.access_token, res); } catch (err) { if (err instanceof Error) { callback(err); } else { throw err; } } } /** * Session Refresh delegate function for username/password login * @private */ function createUsernamePasswordRefreshFn<S extends Schema>( username: string, password: string, ) { return async ( conn: Connection<S>, callback: Callback<string, TokenResponse>, ) => { try { await conn.login(username, password); if (!conn.accessToken) { throw new Error('Access token not found after login'); } callback(null, conn.accessToken); } catch (err) { if (err instanceof Error) { callback(err); } else { throw err; } } }; } /** * @private */ function toSaveResult(err: SaveError): SaveResult { return { success: false, errors: [err], }; } /** * */ function raiseNoModuleError(name: string): never { throw new Error( `API module '${name}' is not loaded, load 'jsforce/api/${name}' explicitly`, ); } /* * Constant of maximum records num in DML operation (update/delete) */ const MAX_DML_COUNT = 200; /** * */ export class Connection<S extends Schema = Schema> extends EventEmitter { static _logger = getLogger('connection'); version: string; loginUrl: string; instanceUrl: string; accessToken: Optional<string>; refreshToken: Optional<string>; userInfo: Optional<UserInfo>; limitInfo: LimitInfo = {}; oauth2: OAuth2; sobjects: { [N in SObjectNames<S>]?: SObject<S, N> } = {}; cache: Cache; _callOptions: Optional<{ [name: string]: string }>; _maxRequest: number; _logger: Logger; _logLevel: Optional<LogLevelConfig>; _transport: Transport; _sessionType: Optional<'soap' | 'oauth2'>; _refreshDelegate: Optional<SessionRefreshDelegate<S>>; // describe: (name: string) => Promise<DescribeSObjectResult>; describe$: CachedFunction<(name: string) => Promise<DescribeSObjectResult>>; describe$$: CachedFunction<(name: string) => DescribeSObjectResult>; describeSObject: (name: string) => Promise<DescribeSObjectResult>; describeSObject$: CachedFunction< (name: string) => Promise<DescribeSObjectResult> >; describeSObject$$: CachedFunction<(name: string) => DescribeSObjectResult>; // describeGlobal: () => Promise<DescribeGlobalResult>; describeGlobal$: CachedFunction<() => Promise<DescribeGlobalResult>>; describeGlobal$$: CachedFunction<() => DescribeGlobalResult>; // API libs are not instantiated here so that core module to remain without dependencies to them // It is responsible for developers to import api libs explicitly if they are using 'jsforce/core' instead of 'jsforce'. get analytics(): Analytics<S> { return raiseNoModuleError('analytics'); } get apex(): Apex<S> { return raiseNoModuleError('apex'); } get bulk(): Bulk<S> { return raiseNoModuleError('bulk'); } get bulk2(): BulkV2<S> { return raiseNoModuleError('bulk2'); } get chatter(): Chatter<S> { return raiseNoModuleError('chatter'); } get metadata(): Metadata<S> { return raiseNoModuleError('metadata'); } get soap(): SoapApi<S> { return raiseNoModuleError('soap'); } get streaming(): Streaming<S> { return raiseNoModuleError('streaming'); } get tooling(): Tooling<S> { return raiseNoModuleError('tooling'); } /** * */ constructor(config: ConnectionConfig<S> = {}) { super(); const { loginUrl, instanceUrl, version, oauth2, maxRequest, logLevel, proxyUrl, httpProxy, } = config; this.loginUrl = loginUrl || defaultConnectionConfig.loginUrl; this.instanceUrl = instanceUrl || defaultConnectionConfig.instanceUrl; if (this.isLightningInstance()) { throw new Error('lightning URLs are not valid as instance URLs'); } this.version = version || defaultConnectionConfig.version; this.oauth2 = oauth2 instanceof OAuth2 ? oauth2 : new OAuth2({ loginUrl: this.loginUrl, proxyUrl, httpProxy, ...oauth2, }); let refreshFn = config.refreshFn; if (!refreshFn && this.oauth2.clientId) { refreshFn = oauthRefreshFn; } if (refreshFn) { this._refreshDelegate = new SessionRefreshDelegate(this, refreshFn); } this._maxRequest = maxRequest || defaultConnectionConfig.maxRequest; this._logger = logLevel ? Connection._logger.createInstance(logLevel) : Connection._logger; this._logLevel = logLevel; this._transport = proxyUrl ? new XdProxyTransport(proxyUrl) : httpProxy ? new HttpProxyTransport(httpProxy) : new Transport(); this._callOptions = config.callOptions; this.cache = new Cache(); const describeCacheKey = (type?: string) => type ? `describe.${type}` : 'describe'; const describe = Connection.prototype.describe; this.describe = this.cache.createCachedFunction(describe, this, { key: describeCacheKey, strategy: 'NOCACHE', }); this.describe$ = this.cache.createCachedFunction(describe, this, { key: describeCacheKey, strategy: 'HIT', }); this.describe$$ = this.cache.createCachedFunction(describe, this, { key: describeCacheKey, strategy: 'IMMEDIATE', }) as any; this.describeSObject = this.describe; this.describeSObject$ = this.describe$; this.describeSObject$$ = this.describe$$; const describeGlobal = Connection.prototype.describeGlobal; this.describeGlobal = this.cache.createCachedFunction( describeGlobal, this, { key: 'describeGlobal', strategy: 'NOCACHE' }, ); this.describeGlobal$ = this.cache.createCachedFunction( describeGlobal, this, { key: 'describeGlobal', strategy: 'HIT' }, ); this.describeGlobal$$ = this.cache.createCachedFunction( describeGlobal, this, { key: 'describeGlobal', strategy: 'IMMEDIATE' }, ) as any; const { accessToken, refreshToken, sessionId, serverUrl, signedRequest, } = config; this._establish({ accessToken, refreshToken, instanceUrl, sessionId, serverUrl, signedRequest, }); jsforce.emit('connection:new', this); } /* @private */ _establish(options: ConnectionEstablishOptions) { const { accessToken, refreshToken, instanceUrl, sessionId, serverUrl, signedRequest, userInfo, } = options; this.instanceUrl = serverUrl ? serverUrl.split('/').slice(0, 3).join('/') : instanceUrl || this.instanceUrl; this.accessToken = sessionId || accessToken || this.accessToken; this.refreshToken = refreshToken || this.refreshToken; if (this.refreshToken && !this._refreshDelegate) { throw new Error( 'Refresh token is specified without oauth2 client information or refresh function', ); } const signedRequestObject = signedRequest && parseSignedRequest(signedRequest); if (signedRequestObject) { this.accessToken = signedRequestObject.client.oauthToken; if (CanvasTransport.supported) { this._transport = new CanvasTransport(signedRequestObject); } } this.userInfo = userInfo || this.userInfo; this._sessionType = sessionId ? 'soap' : 'oauth2'; this._resetInstance(); } /* @priveate */ _clearSession() { this.accessToken = null; this.refreshToken = null; this.instanceUrl = defaultConnectionConfig.instanceUrl; this.userInfo = null; this._sessionType = null; } /* @priveate */ _resetInstance() { this.limitInfo = {}; this.sobjects = {}; // TODO impl cache this.cache.clear(); this.cache.get('describeGlobal').removeAllListeners('value'); this.cache.get('describeGlobal').on('value', ({ result }) => { if (result) { for (const so of result.sobjects) { this.sobject(so.name); } } }); /* if (this.tooling) { this.tooling._resetInstance(); } */ } /** * Authorize the connection using OAuth2 flow. * Typically, just pass the code returned from authorization server in the first argument to complete authorization. * If you want to authorize with grant types other than `authorization_code`, you can also pass params object with the grant type. * * @returns {Promise<UserInfo>} An object that contains the user ID, org ID and identity URL. * */ async authorize( codeOrParams: string | { grant_type: string; [name: string]: string }, params: { [name: string]: string } = {}, ): Promise<UserInfo> { const res = await this.oauth2.requestToken(codeOrParams, params); const userInfo = parseIdUrl(res.id); this._establish({ instanceUrl: res.instance_url, accessToken: res.access_token, refreshToken: res.refresh_token, userInfo, }); this._logger.debug( `<login> completed. user id = ${userInfo.id}, org id = ${userInfo.organizationId}`, ); return userInfo; } /** * */ async login(username: string, password: string): Promise<UserInfo> { this._refreshDelegate = new SessionRefreshDelegate( this, createUsernamePasswordRefreshFn(username, password), ); if (this.oauth2?.clientId && this.oauth2.clientSecret) { return this.loginByOAuth2(username, password); } return this.loginBySoap(username, password); } /** * Login by OAuth2 username & password flow */ async loginByOAuth2(username: string, password: string): Promise<UserInfo> { const res = await this.oauth2.authenticate(username, password); const userInfo = parseIdUrl(res.id); this._establish({ instanceUrl: res.instance_url, accessToken: res.access_token, userInfo, }); this._logger.info( `<login> completed. user id = ${userInfo.id}, org id = ${userInfo.organizationId}`, ); return userInfo; } /** * Login by SOAP protocol * @deprecated The SOAP login() API will be retired in Summer '27 (API version 65.0). * Please use OAuth 2.0 Username-Password Flow instead. * For more information, see https://help.salesforce.com/s/articleView?id=release-notes.rn_api_upcoming_retirement_258rn.htm&release=258&type=5 */ async loginBySoap(username: string, password: string): Promise<UserInfo> { this._logger.warn( 'DEPRECATION WARNING: The SOAP login() API will be retired in Summer \'27 (API version 65.0). ' + 'Please use OAuth 2.0 Username-Password Flow instead. ' + 'For more information, see https://help.salesforce.com/s/articleView?id=release-notes.rn_api_upcoming_retirement_258rn.htm&release=258&type=5' ); if (!username || !password) { return Promise.reject(new Error('no username password given')); } const body = [ '<se:Envelope xmlns:se="http://schemas.xmlsoap.org/soap/envelope/">', '<se:Header/>', '<se:Body>', '<login xmlns="urn:partner.soap.sforce.com">', `<username>${esc(username)}</username>`, `<password>${esc(password)}</password>`, '</login>', '</se:Body>', '</se:Envelope>', ].join(''); const soapLoginEndpoint = [ this.loginUrl, 'services/Soap/u', this.version, ].join('/'); const response = await this._transport.httpRequest({ method: 'POST', url: soapLoginEndpoint, body, headers: { 'Content-Type': 'text/xml', SOAPAction: '""', }, }); let m; if (response.statusCode >= 400) { m = response.body.match(/<faultstring>([^<]+)<\/faultstring>/); const faultstring = m && m[1]; throw new Error(faultstring || response.body); } // the API will return 200 and a restriced token when using an expired password: // https://developer.salesforce.com/docs/atlas.en-us.api.meta/api/sforce_api_calls_login_loginresult.htm // // we need to throw here to avoid a possible infinite loop with session refresh where: // 1. login happens, `this.accessToken` is set to the restricted token // 2. requests happen, get back 401 // 3. trigger session-refresh (username/password login has a default session refresh delegate function) // 4. gets stuck refreshing a restricted token if (response.body.match(/<passwordExpired>true<\/passwordExpired>/g)) { throw new Error('Unable to login because the used password has expired.') } this._logger.debug(`SOAP response = ${response.body}`); m = response.body.match(/<serverUrl>([^<]+)<\/serverUrl>/); const serverUrl = m && m[1]; m = response.body.match(/<sessionId>([^<]+)<\/sessionId>/); const sessionId = m && m[1]; m = response.body.match(/<userId>([^<]+)<\/userId>/); const userId = m && m[1]; m = response.body.match(/<organizationId>([^<]+)<\/organizationId>/); const organizationId = m && m[1]; if (!serverUrl || !sessionId || !userId || !organizationId) { throw new Error( 'could not extract session information from login response', ); } const idUrl = [this.loginUrl, 'id', organizationId, userId].join('/'); const userInfo = { id: userId, organizationId, url: idUrl }; this._establish({ serverUrl: serverUrl.split('/').slice(0, 3).join('/'), sessionId, userInfo, }); this._logger.info( `<login> completed. user id = ${userId}, org id = ${organizationId}`, ); return userInfo; } /** * Logout the current session */ async logout(revoke?: boolean): Promise<void> { this._refreshDelegate = undefined; if (this._sessionType === 'oauth2') { return this.logoutByOAuth2(revoke); } return this.logoutBySoap(revoke); } /** * Logout the current session by revoking access token via OAuth2 session revoke */ async logoutByOAuth2(revoke?: boolean): Promise<void> { const token = revoke ? this.refreshToken : this.accessToken; if (token) { await this.oauth2.revokeToken(token); } // Destroy the session bound to this connection this._clearSession(); this._resetInstance(); } /** * Logout the session by using SOAP web service API */ async logoutBySoap(revoke?: boolean): Promise<void> { const body = [ '<se:Envelope xmlns:se="http://schemas.xmlsoap.org/soap/envelope/">', '<se:Header>', '<SessionHeader xmlns="urn:partner.soap.sforce.com">', `<sessionId>${esc( revoke ? this.refreshToken : this.accessToken, )}</sessionId>`, '</SessionHeader>', '</se:Header>', '<se:Body>', '<logout xmlns="urn:partner.soap.sforce.com"/>', '</se:Body>', '</se:Envelope>', ].join(''); const response = await this._transport.httpRequest({ method: 'POST', url: [this.instanceUrl, 'services/Soap/u', this.version].join('/'), body, headers: { 'Content-Type': 'text/xml', SOAPAction: '""', }, }); this._logger.debug( `SOAP statusCode = ${response.statusCode}, response = ${response.body}`, ); if (response.statusCode >= 400) { const m = response.body.match(/<faultstring>([^<]+)<\/faultstring>/); const faultstring = m && m[1]; throw new Error(faultstring || response.body); } // Destroy the session bound to this connection this._clearSession(); this._resetInstance(); } /** * Send REST API request with given HTTP request info, with connected session information. * * Endpoint URL can be absolute URL ('https://na1.salesforce.com/services/data/v32.0/sobjects/Account/describe') * , relative path from root ('/services/data/v32.0/sobjects/Account/describe') * , or relative path from version root ('/sobjects/Account/describe'). */ request<R = unknown>( request: string | HttpRequest, options: Object = {}, ): StreamPromise<R> { // if request is simple string, regard it as url in GET method let request_: HttpRequest = typeof request === 'string' ? { method: 'GET', url: request } : request; // if url is given in relative path, prepend base url or instance url before. request_ = { ...request_, url: this._normalizeUrl(request_.url), }; const httpApi = new HttpApi(this, options); // log api usage and its quota httpApi.on('response', (response: HttpResponse) => { if (response.headers && response.headers['sforce-limit-info']) { const apiUsage = response.headers['sforce-limit-info'].match( /api-usage=(\d+)\/(\d+)/, ); if (apiUsage) { this.limitInfo = { apiUsage: { used: parseInt(apiUsage[1], 10), limit: parseInt(apiUsage[2], 10), }, }; } } }); return httpApi.request<R>(request_); } /** * Send HTTP GET request * * Endpoint URL can be absolute URL ('https://na1.salesforce.com/services/data/v32.0/sobjects/Account/describe') * , relative path from root ('/services/data/v32.0/sobjects/Account/describe') * , or relative path from version root ('/sobjects/Account/describe'). */ requestGet<R = unknown>(url: string, options?: Object) { const request: HttpRequest = { method: 'GET', url }; return this.request<R>(request, options); } /** * Send HTTP POST request with JSON body, with connected session information * * Endpoint URL can be absolute URL ('https://na1.salesforce.com/services/data/v32.0/sobjects/Account/describe') * , relative path from root ('/services/data/v32.0/sobjects/Account/describe') * , or relative path from version root ('/sobjects/Account/describe'). */ requestPost<R = unknown>(url: string, body: Object, options?: Object) { const request: HttpRequest = { method: 'POST', url, body: JSON.stringify(body), headers: { 'content-type': 'application/json' }, }; return this.request<R>(request, options); } /** * Send HTTP PUT request with JSON body, with connected session information * * Endpoint URL can be absolute URL ('https://na1.salesforce.com/services/data/v32.0/sobjects/Account/describe') * , relative path from root ('/services/data/v32.0/sobjects/Account/describe') * , or relative path from version root ('/sobjects/Account/describe'). */ requestPut<R>(url: string, body: Object, options?: Object) { const request: HttpRequest = { method: 'PUT', url, body: JSON.stringify(body), headers: { 'content-type': 'application/json' }, }; return this.request<R>(request, options); } /** * Send HTTP PATCH request with JSON body * * Endpoint URL can be absolute URL ('https://na1.salesforce.com/services/data/v32.0/sobjects/Account/describe') * , relative path from root ('/services/data/v32.0/sobjects/Account/describe') * , or relative path from version root ('/sobjects/Account/describe'). */ requestPatch<R = unknown>(url: string, body: Object, options?: Object) { const request: HttpRequest = { method: 'PATCH', url, body: JSON.stringify(body), headers: { 'content-type': 'application/json' }, }; return this.request<R>(request, options); } /** * Send HTTP DELETE request * * Endpoint URL can be absolute URL ('https://na1.salesforce.com/services/data/v32.0/sobjects/Account/describe') * , relative path from root ('/services/data/v32.0/sobjects/Account/describe') * , or relative path from version root ('/sobjects/Account/describe'). */ requestDelete<R>(url: string, options?: Object) { const request: HttpRequest = { method: 'DELETE', url }; return this.request<R>(request, options); } /** @private **/ _baseUrl() { return [this.instanceUrl, 'services/data', `v${this.version}`].join('/'); } /** * Convert path to absolute url * @private */ _normalizeUrl(url: string) { if (url.startsWith('/')) { if (url.startsWith(this.instanceUrl + '/services/')) { return url; } if (url.startsWith('/services/')) { return this.instanceUrl + url; } return this._baseUrl() + url; } return url; } /** * */ query<T extends Record>( soql: string, options?: Partial<QueryOptions>, ): Query<S, SObjectNames<S>, T, 'QueryResult'> { return new Query<S, SObjectNames<S>, T, 'QueryResult'>(this, soql, options); } /** * Execute search by SOSL * * @param {String} sosl - SOSL string * @param {Callback.<Array.<RecordResult>>} [callback] - Callback function * @returns {Promise.<Array.<RecordResult>>} */ search(sosl: string) { const url = this._baseUrl() + '/search?q=' + encodeURIComponent(sosl); return this.request<SearchResult>(url); } /** * */ queryMore<T extends Record>(locator: string, options?: QueryOptions) { return new Query<S, SObjectNames<S>, T, 'QueryResult'>( this, { locator }, options, ); } /* */ _ensureVersion(majorVersion: number) { const versions = this.version.split('.'); return parseInt(versions[0], 10) >= majorVersion; } /* */ _supports(feature: string) { switch (feature) { case 'sobject-collection': // sobject collection is available only in API ver 42.0+ return this._ensureVersion(42); default: return false; } } /** * Retrieve specified records */ retrieve<N extends SObjectNames<S>>( type: N, ids: string, options?: RetrieveOptions, ): Promise<Record>; retrieve<N extends SObjectNames<S>>( type: N, ids: string[], options?: RetrieveOptions, ): Promise<Record[]>; retrieve<N extends SObjectNames<S>>( type: N, ids: string | string[], options?: RetrieveOptions, ): Promise<Record | Record[]>; async retrieve( type: string, ids: string | string[], options: RetrieveOptions = {}, ) { return Array.isArray(ids) ? // check the version whether SObject collection API is supported (42.0) this._ensureVersion(42) ? this._retrieveMany(type, ids, options) : this._retrieveParallel(type, ids, options) : this._retrieveSingle(type, ids, options); } /** @private */ async _retrieveSingle(type: string, id: string, options: RetrieveOptions) { if (!id) { throw new Error('Invalid record ID. Specify valid record ID value'); } let url = [this._baseUrl(), 'sobjects', type, id].join('/'); const { fields, headers } = options; if (fields) { url += `?fields=${fields.join(',')}`; } return this.request({ method: 'GET', url, headers }); } /** @private */ async _retrieveParallel( type: string, ids: string[], options: RetrieveOptions, ) { if (ids.length > this._maxRequest) { throw new Error('Exceeded max limit of concurrent call'); } return Promise.all( ids.map((id) => this._retrieveSingle(type, id, options).catch((err) => { if (options.allOrNone || err.errorCode !== 'NOT_FOUND') { throw err; } return null; }), ), ); } /** @private */ async _retrieveMany(type: string, ids: string[], options: RetrieveOptions) { if (ids.length === 0) { return []; } const url = [this._baseUrl(), 'composite', 'sobjects', type].join('/'); const fields = options.fields || (await this.describe$(type)).fields.map((field) => field.name); return this.request({ method: 'POST', url, body: JSON.stringify({ ids, fields }), headers: { ...(options.headers || {}), 'content-type': 'application/json', }, }); } /** * Create records */ create< N extends SObjectNames<S>, InputRecord extends SObjectInputRecord<S, N> = SObjectInputRecord<S, N> >( type: N, records: InputRecord[], options?: DmlOptions, ): Promise<SaveResult[]>; create< N extends SObjectNames<S>, InputRecord extends SObjectInputRecord<S, N> = SObjectInputRecord<S, N> >(type: N, record: InputRecord, options?: DmlOptions): Promise<SaveResult>; create< N extends SObjectNames<S>, InputRecord extends SObjectInputRecord<S, N> = SObjectInputRecord<S, N> >( type: N, records: InputRecord | InputRecord[], options?: DmlOptions, ): Promise<SaveResult | SaveResult[]>; /** * @param type * @param records * @param options */ async create( type: string, records: Record | Record[], options: DmlOptions = {}, ) { const ret = Array.isArray(records) ? // check the version whether SObject collection API is supported (42.0) this._ensureVersion(42) ? await this._createMany(type, records, options) : await this._createParallel(type, records, options) : await this._createSingle(type, records, options); return ret; } /** @private */ async _createSingle(type: string, record: Record, options: DmlOptions) { const { Id, type: rtype, attributes, ...rec } = record; const sobjectType = type || attributes?.type || rtype; if (!sobjectType) { throw new Error('No SObject Type defined in record'); } const url = [this._baseUrl(), 'sobjects', sobjectType].join('/'); let contentType, body; if (options?.multipartFileFields) { // Send the record as a multipart/form-data request. Useful for fields containing large binary blobs. const form = new FormData(); // Extract the fields requested to be sent separately from the JSON Object.entries(options.multipartFileFields).forEach( ([fieldName, fileDetails]) => { form.append( fieldName, Buffer.from(rec[fieldName], 'base64'), fileDetails, ); delete rec[fieldName]; }, ); // Serialize the remaining fields as JSON form.append(type, JSON.stringify(rec), { contentType: 'application/json', }); contentType = form.getHeaders()['content-type']; // This is necessary to ensure the 'boundary' is present body = form; } else { // Default behavior: send the request as JSON contentType = 'application/json'; body = JSON.stringify(rec); } return this.request({ method: 'POST', url, body: body, headers: { ...(options.headers || {}), 'content-type': contentType, }, }); } /** @private */ async _createParallel(type: string, records: Record[], options: DmlOptions) { if (records.length > this._maxRequest) { throw new Error('Exceeded max limit of concurrent call'); } return Promise.all( records.map((record) => this._createSingle(type, record, options).catch((err) => { // be aware that allOrNone in parallel mode will not revert the other successful requests // it only raises error when met at least one failed request. if (options.allOrNone || !err.errorCode) { throw err; } return toSaveResult(err); }), ), ); } /** @private */ async _createMany( type: string, records: Record[], options: DmlOptions, ): Promise<SaveResult[]> { if (records.length === 0) { return Promise.resolve([]); } if (records.length > MAX_DML_COUNT && options.allowRecursive) { return [ ...(await this._createMany( type, records.slice(0, MAX_DML_COUNT), options, )), ...(await this._createMany( type, records.slice(MAX_DML_COUNT), options, )), ]; } const _records = records.map((record) => { const { Id, type: rtype, attributes, ...rec } = record; const sobjectType = type || attributes?.type || rtype; if (!sobjectType) { throw new Error('No SObject Type defined in record'); } return { attributes: { type: sobjectType }, ...rec }; }); const url = [this._baseUrl(), 'composite', 'sobjects'].join('/'); return this.request({ method: 'POST', url, body: JSON.stringify({ allOrNone: options.allOrNone || false, records: _records, }), headers: { ...(options.headers || {}), 'content-type': 'application/json', }, }); } /** * Synonym of Connection#create() */ insert = this.create; /** * Update records */ update< N extends SObjectNames<S>, UpdateRecord extends SObjectUpdateRecord<S, N> = SObjectUpdateRecord<S, N> >( type: N, records: UpdateRecord[], options?: DmlOptions, ): Promise<SaveResult[]>; update< N extends SObjectNames<S>, UpdateRecord extends SObjectUpdateRecord<S, N> = SObjectUpdateRecord<S, N> >(type: N, record: UpdateRecord, options?: DmlOptions): Promise<SaveResult>; update< N extends SObjectNames<S>, UpdateRecord extends SObjectUpdateRecord<S, N> = SObjectUpdateRecord<S, N> >( type: N, records: UpdateRecord | UpdateRecord[], options?: DmlOptions, ): Promise<SaveResult | SaveResult[]>; /** * @param type * @param records * @param options */ update<N extends SObjectNames<S>>( type: N, records: Record | Record[], options: DmlOptions = {}, ): Promise<SaveResult | SaveResult[]> { return Array.isArray(records) ? // check the version whether SObject collection API is supported (42.0) this._ensureVersion(42) ? this._updateMany(type, records, options) : this._updateParallel(type, records, options) : this._updateSingle(type, records, options); } /** @private */ async _updateSingle( type: string, record: Record, options: DmlOptions, ): Promise<SaveResult> { const { Id: id, type: rtype, attributes, ...rec } = record; if (!id) { throw new Error('Record id is not found in record.'); } const sobjectType = type || attributes?.type || rtype; if (!sobjectType) { throw new Error('No SObject Type defined in record'); } const url = [this._baseUrl(), 'sobjects', sobjectType, id].join('/'); return this.request( { method: 'PATCH', url, body: JSON.stringify(rec), headers: { ...(options.headers || {}), 'content-type': 'application/json', }, }, { noContentResponse: { id, success: true, errors: [] }, }, ); } /** @private */ async _updateParallel(type: string, records: Record[], options: DmlOptions) { if (records.length > this._maxRequest) { throw new Error('Exceeded max limit of concurrent call'); } return Promise.all( records.map((record) => this._updateSingle(type, record, options).catch((err) => { // be aware that allOrNone in parallel mode will not revert the other successful requests // it only raises error when met at least one failed request. if (options.allOrNone || !err.errorCode) { throw err; } return toSaveResult(err); }), ), ); } /** @private */ async _updateMany( type: string, records: Record[], options: DmlOptions, ): Promise<SaveResult[]> { if (records.length === 0) { return []; } if (records.length > MAX_DML_COUNT && options.allowRecursive) { return [ ...(await this._updateMany( type, records.slice(0, MAX_DML_COUNT), options, )), ...(await this._updateMany( type, records.slice(MAX_DML_COUNT), options, )), ]; } const _records = records.map((record) => { const { Id: id, type: rtype, attributes, ...rec } = record; if (!id) { throw new Error('Record id is not found in record.'); } const sobjectType = type || attributes?.type || rtype; if (!sobjectType) { throw new Error('No SObject Type defined in record'); } return { id, attributes: { type: sobjectType }, ...rec }; }); const url = [this._baseUrl(), 'composite', 'sobjects'].join('/'); return this.request({ method: 'PATCH', url, body: JSON.stringify({ allOrNone: options.allOrNone || false, records: _records, }), headers: { ...(options.headers || {}), 'content-type': 'application/json', }, }); } /** * Upsert records */ upsert< N extends SObjectNames<S>, InputRecord extends SObjectInputRecord<S, N> = SObjectInputRecord<S, N>, FieldNames extends SObjectFieldNames<S, N> = SObjectFieldNames<S, N> >( type: N, records: InputRecord[], extIdField: FieldNames, options?: DmlOptions, ): Promise<UpsertResult[]>; upsert< N extends SObjectNames<S>, InputRecord extends SObjectInputRecord<S, N> = SObjectInputRecord<S, N>, FieldNames extends SObjectFieldNames<S, N> = SObjectFieldNames<S, N> >( type: N, record: InputRecord, extIdField: FieldNames, options?: DmlOptions, ): Promise<UpsertResult>; upsert< N extends SObjectNames<S>, InputRecord extends SObjectInputRecord<S, N> = SObjectInputRecord<S, N>, FieldNames extends SObjectFieldNames<S, N> = SObjectFieldNames<S, N> >( type: N, records: InputRecord | InputRecord[], extIdField: FieldNames, options?: DmlOptions, ): Promise<UpsertResult | UpsertResult[]>; /** * * @param type * @param records * @param extIdField * @param options */ async upsert( type: string, records: Record | Record[], extIdField: string, options: DmlOptions = {}, ): Promise<SaveResult | SaveResult[]> { return Array.isArray(records) ? // check the version whether SObject collection API is supported (46.0) this._ensureVersion(46) ? this._upsertMany(type, records, extIdField, options) : this._upsertParallel(type, records, extIdField, options) : this._upsertParallel(type, records, extIdField, options); } /** @private */ async _upsertMany( type: string, records: Record | Record[], extIdField: string, options: DmlOptions = {}, ): Promise<SaveResult | SaveResult[]> { if (records.length === 0) { return []; } if (records.length > MAX_DML_COUNT && options.allowRecursive) { return [ ...((await this._upsertMany( type, records.slice(0, MAX_DML_COUNT), extIdField, options, )) as SaveResult[]), ...((await this._upsertMany(type, records.slice(MAX_DML_COUNT), extIdField, options)) as SaveResult[]), ]; } const _records = records.map((recordItem: Record) => { const { [extIdField]: extId, type: recordType, attributes, ...rec } = recordItem; const sobjectType = recordType || attributes?.type || type; if (!extId) { throw new Error('External ID is not found in record.'); } if (!sobjectType) { throw new Error('No SObject Type defined in record'); } return { [extIdField]: extId, attributes: { type: sobjectType }, ...rec }; }); const url = [this._baseUrl(), 'composite', 'sobjects', type, extIdField].join('/'); return this.request({ method: 'PATCH', url, body: JSON.stringify({ allOrNone: options.allOrNone || false, records: _records, }), headers: { ...(options.headers || {}), 'content-type': 'application/json', } }); } /** @private */ async _upsertParallel( type: string, records: Record | Record[], extIdField: string, options: DmlOptions = {}, ): Promise<SaveResult | SaveResult[]> { const isArray = Array.isArray(records); const _records = Array.isArray(records) ? records : [records]; if (_records.length > this._maxRequest) { throw new Error('Exceeded max limit of concurrent call'); } const results = await Promise.all( _records.map((record) => { const { [extIdField]: extId, type: rtype, attributes, ...rec } = record; const url = [this._baseUrl(), 'sobjects', type, extIdField, extId].join( '/', ); return this.request<SaveResult>( { method: 'PATCH', url, body: JSON.stringify(rec), headers: { ...(options.headers || {}), 'content-type': 'application/json', }, }, { noContentResponse: { success: true, errors: [] }, }, ).catch((err) => { // Be aware that `allOrNone` option in upsert method // will not revert the other successful requests. // It only raises error when met at least one failed request. if (!isArray || options.allOrNone || !err.errorCode) { throw err; } return toSaveResult(err); }); }), ); return isArray ? results : results[0]; } /** * Delete records */ destroy<N extends SObjectNames<S>>( type: N, ids: string[], options?: DmlOptions, ): Promise<SaveResult[]>; destroy<N extends SObjectNames<S>>( type: N, id: string, options?: DmlOptions, ): Promise<SaveResult>; destroy<N extends SObjectNames<S>>( type: N, ids: string | string[], options?: DmlOptions, ): Promise<SaveResult | SaveResult[]>; /** * @param type * @param ids * @param options */ async destroy( type: string, ids: string | string[], options: DmlOptions = {}, ): Promise<SaveResult | SaveResult[]> { return Array.isArray(ids) ? // check the version whether SObject collection API is supported (42.0) this._ensureVersion(42) ? this._destroyMany(type, ids, options) : this._destroyParallel(type, ids, options) : this._destroySingle(type, ids, options); } /** @private */ async _destroySingle( type: string, id: string, options: DmlOptions, ): Promise<SaveResult> { const url = [this._baseUrl(), 'sobjects', type, id].join('/'); return this.request( { method: 'DELETE', url, headers: options.headers || {}, }, { noContentResponse: { id, success: true, errors: [] }, }, ); } /** @private */ async _destroyParallel(type: string, ids: string[], options: DmlOptions) { if (ids.length > this._maxRequest) { throw new Error('Exceeded max limit of concurrent call'); } return Promise.all( ids.map((id) => this._destroySingle(type, id, options).catch((err) => { // Be aware that `allOrNone` option in parallel mode // will not revert the other successful requests. // It only raises error when met at least one failed request. if (options.allOrNone || !err.errorCode) { throw err; } return toSaveResult(err); }), ), ); } /** @private */ async _destroyMany( type: string, ids: string[], options: DmlOptions, ): Promise<SaveResult[]> { if (ids.length === 0) { return []; } if (ids.length > MAX_DML_COUNT && options.allowRecursive) { return [ ...(await this._destroyMany( type, ids.slice(0, MAX_DML_COUNT), options, )), ...(await this._destroyMany(type, ids.slice(MAX_DML_COUNT), options)), ]; } let url = [this._baseUrl(), 'composite', 'sobjects?ids='].join('/') + ids.join(','); if (options.allOrNone) { url += '&allOrNone=true'; } return this.request({ method: 'DELETE', url, headers: options.headers || {}, }); } /** * Synonym of Connection#destroy() */ delete = this.destroy; /** * Synonym of Connection#destroy() */ del = this.destroy; /** * Describe SObject metadata */ async describe(type: string): Promise<DescribeSObjectResult> { const url = [this._baseUrl(), 'sobjects', type, 'describe'].join('/'); const body = await this.request(url); return body as DescribeSObjectResult; } /** * Describe global SObjects */ async describeGlobal() { const url = `${this._baseUrl()}/sobjects`; const body = await this.request(url); return body as DescribeGlobalResult; } /** * Get SObject instance */ sobject<N extends SObjectNames<S>>(type: string|N): SObject<S, N>; sobject<N extends SObjectNames<S>>(type: N | string): SObject<S, N> { const so = this.sobjects[type as N] || new SObject(this, type as N); this.sobjects[type as N] = so; return so; } /** * Get identity information of current user */ async identity(options: { headers?: { [name: string]: string } } = {}) { let url = this.userInfo?.url; if (!url) { const res = await this.request<{ identity: string }>({ method: 'GET', url: this._baseUrl(), headers: options.headers, }); url = res.identity; } url += '?format=json'; if (this.accessToken) { url += `&oauth_token=${encodeURIComponent(this.accessToken)}`; } const res = await this.request<IdentityInfo>({ method: 'GET', url }); this.userInfo = { id: res.user_id, organizationId: res.organization_id, url: res.id, }; return res; } /** * List recently viewed records */ async recent(type?: string | number, limit?: number) { /* eslint-disable no-param-reassign */ if (typeof type === 'number') { limit = type; type = undefined; } let url; if (type) { url = [this._baseUrl(), 'sobjects', type].join('/'); const { recentItems } = await this.request<{ recentItems: Record[] }>( url, ); return limit ? recentItems.slice(0, limit) : recentItems; } url = `${this._baseUrl()}/recent`; if (limit) { url += `?limit=${limit}`; } return this.request<Record[]>(url); } /** * Retrieve updated records */ async updated( type: string, start: string | Date, end: string | Date, ): Promise<UpdatedResult> { /* eslint-disable no-param-reassign */ let url = [this._baseUrl(), 'sobjects', type, 'updated'].join('/'); if (typeof start === 'string') { start = new Date(start); } start = formatDate(start); url += `?start=${encodeURIComponent(start)}`; if (typeof end === 'string') { end = new Date(end); } end = formatDate(end); url += `&end=${encodeURIComponent(end)}`; const body = await this.request(url); return body as UpdatedResult; } /** * Retrieve deleted records */ async deleted( type: string, start: string | Date, end: string | Date, ): Promise<DeletedResult> { /* eslint-disable no-param-reassign */ let url = [this._baseUrl(), 'sobjects', type, 'deleted'].join('/'); if (typeof start === 'string') { start = new Date(start); } start = formatDate(start); url += `?start=${encodeURIComponent(start)}`; if (typeof end === 'string') { end = new Date(end); } end = formatDate(end); url += `&end=${encodeURIComponent(end)}`; const body = await this.request(url); return body as DeletedResult; } /** * Returns a list of all tabs */ async tabs(): Promise<DescribeTab[]> { const url = [this._baseUrl(), 'tabs'].join('/'); const body = await this.request(url); return body as DescribeTab[]; } /** * Returns current system limit in the organization */ async limits(): Promise<OrganizationLimitsInfo> { const url = [this._baseUrl(), 'limits'].join('/'); const body = await this.request(url); return body as OrganizationLimitsInfo; } /** * Returns a theme info */ async theme(): Promise<DescribeTheme> { const url = [this._baseUrl(), 'theme'].join('/'); const body = await this.request(url); return body as DescribeTheme; } /** * Returns all registered global quick actions */ async quickActions(): Promise<DescribeQuickActionResult[]> { const body = await this.request('/quickActions'); return body as DescribeQuickActionResult[]; } /** * Get reference for specified global quick action */ quickAction(actionName: string): QuickAction<S> { return new QuickAction(this, `/quickActions/${actionName}`); } /** * Module which manages process rules and approval processes */ process = new Process(this); private isLightningInstance(): boolean { return ( this.instanceUrl.includes('.lightning.force.com') || this.instanceUrl.includes('.lightning.crmforce.mil') || this.instanceUrl.includes('.lightning.sfcrmapps.cn') ); } } export default Connection;