@ringcentral/sdk
Version:
- [Installation](#installation) - [Getting Started](#getting-started) - [API Calls](#api-calls) - [Advanced SDK Configuration & Polyfills](#advanced-sdk-configuration--polyfills) - [Making telephony calls](#making-telephony-calls) - [Call management using
621 lines (491 loc) • 19.2 kB
text/typescript
import EventEmitter from 'events';
import * as qs from 'querystring';
import Auth, {AuthOptions} from './Auth';
import * as Constants from '../core/Constants';
import Cache from '../core/Cache';
import Client, {ApiError} from '../http/Client';
import Externals from '../core/Externals';
declare const screen: any; //FIXME TS Crap
const delay = (timeout): Promise<any> =>
new Promise((resolve, reject) => {
setTimeout(() => {
resolve(null);
}, timeout);
});
const getParts = (url, separator) => url.split(separator).reverse()[0];
export enum events {
beforeLogin = 'beforeLogin',
loginSuccess = 'loginSuccess',
loginError = 'loginError',
beforeRefresh = 'beforeRefresh',
refreshSuccess = 'refreshSuccess',
refreshError = 'refreshError',
beforeLogout = 'beforeLogout',
logoutSuccess = 'logoutSuccess',
logoutError = 'logoutError',
rateLimitError = 'rateLimitError',
}
export default class Platform extends EventEmitter {
public static _cacheId = 'platform';
public events = events;
private _server: string;
private _clientId: string;
private _clientSecret: string;
private _redirectUri: string;
private _refreshDelayMs: number;
private _clearCacheOnRefreshError: boolean;
private _userAgent: string;
private _externals: Externals;
private _cache: Cache;
private _client: Client;
private _refreshPromise: Promise<any>;
private _auth: Auth;
private _tokenEndpoint;
private _revokeEndpoint;
private _authorizeEndpoint;
private _authProxy;
private _urlPrefix;
public constructor({
server,
clientId,
clientSecret,
redirectUri = '',
refreshDelayMs = 100,
clearCacheOnRefreshError = true,
appName = '',
appVersion = '',
externals,
cache,
client,
refreshHandicapMs,
tokenEndpoint = '/restapi/oauth/token',
revokeEndpoint = '/restapi/oauth/revoke',
authorizeEndpoint = '/restapi/oauth/authorize',
authProxy = false,
urlPrefix = '',
}: PlatformOptionsConstructor) {
super();
this._server = server;
this._clientId = clientId;
this._clientSecret = clientSecret;
this._redirectUri = redirectUri;
this._refreshDelayMs = refreshDelayMs;
this._clearCacheOnRefreshError = clearCacheOnRefreshError;
this._authProxy = authProxy;
this._urlPrefix = urlPrefix;
this._userAgent = `${appName ? `${appName + (appVersion ? `/${appVersion}` : '')} ` : ''}RCJSSDK/${
Constants.version
}`;
this._externals = externals;
this._cache = cache;
this._client = client;
this._refreshPromise = null;
this._auth = new Auth({
cache: this._cache,
cacheId: Platform._cacheId,
refreshHandicapMs,
});
this._tokenEndpoint = tokenEndpoint;
this._revokeEndpoint = revokeEndpoint;
this._authorizeEndpoint = authorizeEndpoint;
}
public on(event: events.beforeLogin, listener: () => void);
public on(event: events.loginSuccess, listener: (response: Response) => void);
public on(event: events.loginError, listener: (error: ApiError | Error) => void);
public on(event: events.beforeRefresh, listener: () => void);
public on(event: events.refreshSuccess, listener: (response: Response) => void);
public on(event: events.refreshError, listener: (error: ApiError | Error) => void);
public on(event: events.beforeLogout, listener: () => void);
public on(event: events.logoutSuccess, listener: (response: Response) => void);
public on(event: events.logoutError, listener: (error: ApiError | Error) => void);
public on(event: events.rateLimitError, listener: (error: ApiError | Error) => void);
public on(event: string, listener: (...args) => void) {
return super.on(event, listener);
}
public auth() {
return this._auth;
}
public createUrl(path = '', options: CreateUrlOptions = {}) {
let builtUrl = '';
const hasHttp = path.startsWith('http://') || path.startsWith('https://');
if (options.addServer && !hasHttp) builtUrl += this._server;
if (this._urlPrefix) builtUrl += this._urlPrefix;
builtUrl += path;
if (options.addMethod) builtUrl += `${path.includes('?') ? '&' : '?'}_method=${options.addMethod}`;
return builtUrl;
}
public async signUrl(path: string) {
return `${path + (path.includes('?') ? '&' : '?')}access_token=${(await this._auth.data()).access_token}`;
}
public loginUrl({implicit, state, brandId, display, prompt, uiOptions, uiLocales, localeId}: LoginUrlOptions = {}) {
return this.createUrl(
`${this._authorizeEndpoint}?${qs.stringify({
response_type: implicit ? 'token' : 'code',
redirect_uri: this._redirectUri,
client_id: this._clientId,
state,
brand_id: brandId,
display,
prompt,
ui_options: uiOptions,
ui_locales: uiLocales,
localeId,
})}`,
{addServer: true},
);
}
/**
* @param {string} url
* @return {Object}
*/
public parseLoginRedirect(url: string) {
const response =
(url.startsWith('#') && getParts(url, '#')) || (url.startsWith('?') && getParts(url, '?')) || null;
if (!response) throw new Error('Unable to parse response');
const queryString = qs.parse(response);
if (!queryString) throw new Error('Unable to parse response');
const error = queryString.error_description || queryString.error;
if (error) {
const e: any = new Error(error.toString());
e.error = queryString.error;
throw e;
}
return queryString;
}
/**
* Convenience method to handle 3-legged OAuth
*
* Attention! This is an experimental method and it's signature and behavior may change without notice.
*/
public loginWindow({
url,
width = 400,
height = 600,
origin = window.location.origin,
property = Constants.authResponseProperty,
target = '_blank',
}: LoginWindowOptions): Promise<LoginOptions> {
return new Promise((resolve, reject) => {
if (typeof window === 'undefined') throw new Error('This method can be used only in browser');
if (!url) throw new Error('Missing mandatory URL parameter');
const dualScreenLeft = window.screenLeft !== undefined ? window.screenLeft : 0;
const dualScreenTop = window.screenTop !== undefined ? window.screenTop : 0;
const screenWidth = screen.width;
const screenHeight = screen.height;
const left = screenWidth / 2 - width / 2 + dualScreenLeft;
const top = screenHeight / 2 - height / 2 + dualScreenTop;
const win = window.open(
url,
'_blank',
target === '_blank'
? `scrollbars=yes, status=yes, width=${width}, height=${height}, left=${left}, top=${top}`
: '',
);
if (!win) {
throw new Error('Could not open login window. Please allow popups for this site');
}
if (win.focus) win.focus();
const eventListener = e => {
try {
if (e.origin !== origin) return;
if (!e.data || !e.data[property]) return; // keep waiting
win.close();
window.addEventListener('message', eventListener);
const loginOptions = this.parseLoginRedirect(e.data[property]);
if (!loginOptions.code && !loginOptions.access_token)
throw new Error('No authorization code or token');
resolve(loginOptions);
} catch (e) {
reject(e);
}
};
window.addEventListener('message', eventListener, false);
});
}
/**
* @return {Promise<boolean>}
*/
public async loggedIn() {
try {
if (this._authProxy) {
await this.get('/restapi/v1.0/client-info'); // we only can determine the status if we actually make request
} else {
await this.ensureLoggedIn();
}
return true;
} catch (e) {
return false;
}
}
public async login({
username,
password,
extension = '',
code,
access_token_ttl,
refresh_token_ttl,
access_token,
endpoint_id,
...options
}: LoginOptions = {}): Promise<Response> {
try {
this.emit(this.events.beforeLogin);
const body: any = {};
let response = null;
let json;
if (access_token) {
//TODO Potentially make a request to /oauth/tokeninfo
json = {access_token, ...options};
} else {
if (!code) {
body.grant_type = 'password';
body.username = username;
body.password = password;
body.extension = extension;
} else if (code) {
//@see https://developers.ringcentral.com/legacy-api-reference/index.html#!#RefAuthorizationCodeFlow
body.grant_type = 'authorization_code';
body.code = code;
body.redirect_uri = this._redirectUri;
}
if (access_token_ttl) body.access_token_ttl = access_token_ttl;
if (refresh_token_ttl) body.refresh_token_ttl = refresh_token_ttl;
if (endpoint_id) body.endpoint_id = endpoint_id;
response = await this._tokenRequest(this._tokenEndpoint, body);
json = await response.clone().json();
}
await this._auth.setData(json);
this.emit(this.events.loginSuccess, response);
return response;
} catch (e) {
if (this._clearCacheOnRefreshError) await this._cache.clean();
this.emit(this.events.loginError, e);
throw e;
}
}
private async _refresh(): Promise<Response> {
try {
this.emit(this.events.beforeRefresh);
await delay(this._refreshDelayMs);
const authData = await this.auth().data();
// Perform sanity checks
if (!authData.refresh_token) throw new Error('Refresh token is missing');
if (!this._auth.refreshTokenValid()) throw new Error('Refresh token has expired');
const res = await this._tokenRequest(this._tokenEndpoint, {
grant_type: 'refresh_token',
refresh_token: authData.refresh_token,
access_token_ttl: authData.expires_in + 1,
refresh_token_ttl: authData.refresh_token_expires_in + 1,
});
const json = await res.clone().json();
if (!json.access_token) {
throw await this._client.makeError(new Error('Malformed OAuth response'), res);
}
await this._auth.setData(json);
this.emit(this.events.refreshSuccess, res);
return res;
} catch (e) {
if (this._clearCacheOnRefreshError) {
await this._cache.clean();
}
this.emit(this.events.refreshError, e);
throw e;
}
}
public async refresh(): Promise<Response> {
if (this._authProxy) {
throw new Error('Refresh is not supported in Auth Proxy mode');
}
if (!this._refreshPromise) {
this._refreshPromise = (async () => {
try {
const res = await this._refresh();
this._refreshPromise = null;
return res;
} catch (e) {
this._refreshPromise = null;
throw e;
}
})();
}
return this._refreshPromise;
}
public async logout(): Promise<Response> {
if (this._authProxy) {
throw new Error('Logout is not supported in Auth Proxy mode');
}
try {
this.emit(this.events.beforeLogout);
let res = null;
//FIXME https://developers.ringcentral.com/legacy-api-reference/index.html#!#RefRevokeToken.html requires secret
if (this._revokeEndpoint && this._clientSecret) {
res = await this._tokenRequest(this._revokeEndpoint, {
token: (await this._auth.data()).access_token,
});
}
await this._cache.clean();
this.emit(this.events.logoutSuccess, res);
return res;
} catch (e) {
this.emit(this.events.logoutError, e);
throw e;
}
}
public async inflateRequest(request: Request, options: SendOptions = {}): Promise<Request> {
options = options || {};
request.headers.set('X-User-Agent', this._userAgent);
if (options.skipAuthCheck) return request;
await this.ensureLoggedIn();
request.headers.set('Client-Id', this._clientId);
if (!this._authProxy) request.headers.set('Authorization', await this.authHeader());
return request;
}
public async sendRequest(request: Request, options: SendOptions = {}): Promise<Response> {
try {
request = await this.inflateRequest(request, options);
return await this._client.sendRequest(request);
} catch (e) {
const {retry, handleRateLimit} = options;
// Guard is for errors that come from polling
if (!e.response || retry) throw e;
const {response} = e;
const {status} = response;
if ((status !== Client._unauthorizedStatus && status !== Client._rateLimitStatus) || this._authProxy)
throw e;
options.retry = true;
let retryAfter = 0;
if (status === Client._unauthorizedStatus) {
await this._auth.cancelAccessToken();
}
if (status === Client._rateLimitStatus) {
const defaultRetryAfter =
!handleRateLimit || typeof handleRateLimit === 'boolean' ? 60 : handleRateLimit;
// FIXME retry-after is custom header, by default, it can't be retrieved. Server should add header: 'Access-Control-Expose-Headers: retry-after'.
retryAfter = parseFloat(response.headers.get('retry-after') || defaultRetryAfter) * 1000;
e.retryAfter = retryAfter;
this.emit(this.events.rateLimitError, e);
if (!options.handleRateLimit) throw e;
}
await delay(retryAfter);
return this.sendRequest(this._client.createRequest(options), options);
}
}
public send(options: SendOptions = {}) {
//FIXME https://github.com/bitinn/node-fetch/issues/43
options.url = this.createUrl(options.url, {addServer: true});
return this.sendRequest(this._client.createRequest(options), options);
}
public async get(url, query?, options?: SendOptions): Promise<Response> {
return this.send({method: 'GET', url, query, ...options});
}
public async post(url, body?, query?, options?: SendOptions): Promise<Response> {
return this.send({method: 'POST', url, query, body, ...options});
}
public async put(url, body?, query?, options?: SendOptions): Promise<Response> {
return this.send({method: 'PUT', url, query, body, ...options});
}
public async delete(url, query?, options?: SendOptions): Promise<Response> {
return this.send({method: 'DELETE', url, query, ...options});
}
public async ensureLoggedIn(): Promise<Response | null> {
if (this._authProxy) return null;
if (await this._auth.accessTokenValid()) return null;
await this.refresh();
return null;
}
protected async _tokenRequest(url, body): Promise<Response> {
return this.send({
url,
body,
skipAuthCheck: true,
method: 'POST',
headers: {
Authorization: this.basicAuthHeader(),
'Content-Type': Client._urlencodedContentType,
},
});
}
public basicAuthHeader(): string {
const apiKey = this._clientId + (this._clientSecret ? `:${this._clientSecret}` : '');
return `Basic ${typeof btoa === 'function' ? btoa(apiKey) : Buffer.from(apiKey).toString('base64')}`;
}
public async authHeader(): Promise<string> {
const data = await this._auth.data();
return (data.token_type || 'Bearer') + (data.access_token ? ` ${data.access_token}` : '');
}
}
export interface PlatformOptions extends AuthOptions {
server?: string;
clientId?: string;
clientSecret?: string;
redirectUri?: string;
refreshDelayMs?: number;
refreshHandicapMs?: number;
clearCacheOnRefreshError?: boolean;
appName?: string;
appVersion?: string;
tokenEndpoint?: string;
revokeEndpoint?: string;
authorizeEndpoint?: string;
authProxy?: boolean;
urlPrefix?: string;
}
export interface PlatformOptionsConstructor extends PlatformOptions {
externals: Externals;
cache: Cache;
client: Client;
}
export interface SendOptions {
url?: any;
body?: any;
method?: string;
query?: any;
headers?: any;
skipAuthCheck?: boolean;
handleRateLimit?: boolean | number;
retry?: boolean; // Will be set by this method if SDK makes second request
}
export interface LoginOptions {
username?: string;
password?: string;
extension?: string;
code?: string;
access_token?: string;
access_token_ttl?: number;
refresh_token_ttl?: number;
endpoint_id?: string;
}
export interface LoginUrlOptions {
state?: string;
brandId?: string;
display?: LoginUrlDisplay | string;
prompt?: LoginUrlPrompt | string;
implicit?: boolean;
uiOptions?: string;
uiLocales?: string;
localeId?: string;
}
export enum LoginUrlPrompt {
login = 'login',
sso = 'sso',
consent = 'consent',
none = 'none',
}
export enum LoginUrlDisplay {
page = 'page',
popup = 'popup',
touch = 'touch',
mobile = 'mobile',
}
export interface CreateUrlOptions {
addServer?: boolean;
addMethod?: string;
}
export interface LoginWindowOptions {
url: string;
width?: number;
height?: number;
origin?: string;
property?: string;
target?: string;
}