Kinvey/js-sdk

View on GitHub
packages/js-sdk/src/http/request.ts

Summary

Maintainability
C
7 hrs
Test Coverage
import { isString } from 'lodash-es';
import PQueue from 'p-queue';
import { Base64 } from 'js-base64';
import { InvalidCredentialsError } from '../errors/invalidCredentials';
import { getAppSecret, getAppKey } from '../kinvey';
import { logger } from '../log';
import { HttpHeaders, KinveyHttpHeaders, KinveyHttpAuth } from './headers';
import { HttpResponse } from './response';
import { send } from './http';
import { getSession, setSession, removeSession, getKinveyMICSession, setKinveyMICSession } from './session';
import { DataStoreCache, QueryCache, SyncCache } from '../datastore/cache';
import { formatKinveyAuthUrl, formatKinveyBaasUrl, KinveyBaasNamespace } from './utils';

const REQUEST_QUEUE = new PQueue();
let refreshTokenRequestInProgress = false;

export enum HttpRequestMethod {
  GET = 'GET',
  POST = 'POST',
  PUT = 'PUT',
  DELETE = 'DELETE'
};

export interface HttpRequestConfig {
  headers?: HttpHeaders;
  method: HttpRequestMethod;
  url: string;
  body?: string | object;
  timeout?: number;
}

export function serialize(contentType: string, body?: any) {
  if (body && !isString(body)) {
    if (contentType.indexOf('application/x-www-form-urlencoded') === 0) {
      const str: string[] = [];
      Object.keys(body).forEach((key) => {
        str.push(`${encodeURIComponent(key)}=${encodeURIComponent(body[key])}`);
      });
      return str.join('&');
    } else if (contentType.indexOf('application/json') === 0) {
      return JSON.stringify(body);
    }
  }

  return body;
}

export class HttpRequest {
  public headers: HttpHeaders;
  public method: HttpRequestMethod = HttpRequestMethod.GET;
  public url: string;
  public body?: any;
  public timeout?: number;

  constructor(config: HttpRequestConfig) {
    this.headers = new HttpHeaders(config.headers);

    if (config.method) {
      this.method = config.method;
    }

    this.url = config.url;
    this.body = config.body;
    this.timeout = config.timeout;
  }

  async execute(): Promise<HttpResponse> {
    // Make http request
    const response = await send(this.toPlainObject());

    // Return the response if it was successful
    if (response.isSuccess()) {
      return response;
    }

    // Else throw the error
    throw response.error;
  }

  toPlainObject() {
    return {
      headers: this.headers.toPlainObject(),
      method: this.method,
      url: this.url,
      body: this.body ? serialize(this.headers.contentType!, this.body) : undefined,
      timeout: this.timeout
    };
  }
}

function isRefreshTokenRequestInProgress() {
  return refreshTokenRequestInProgress === true;
}

function markRefreshTokenRequestInProgress() {
  REQUEST_QUEUE.pause();
  refreshTokenRequestInProgress = true;
}

function markRefreshTokenRequestComplete() {
  refreshTokenRequestInProgress = false;
  REQUEST_QUEUE.start();
}

export interface KinveyHttpRequestConfig extends HttpRequestConfig {
  headers?: KinveyHttpHeaders;
  auth?: KinveyHttpAuth;
}

async function cleanUp() {
  try {
    // TODO: Unregister from live service

    // Remove the session
    await removeSession();

    // Clear cache's
    await QueryCache.clear();
    await SyncCache.clear();
    await DataStoreCache.clear();
  } catch (error) {
    logger.error(error.message);
  }
}

async function sendSessionRefreshRequest(req) {
  const response = await send(req.toPlainObject());

  if (response.statusCode > 399) {
    if (response.statusCode < 500) {
      await cleanUp();
    }

    return null;
  }

  return response.data;
}

export class KinveyHttpRequest extends HttpRequest {
  public headers: KinveyHttpHeaders;
  public auth?: KinveyHttpAuth;

  constructor(config: KinveyHttpRequestConfig) {
    super(config);
    this.headers = new KinveyHttpHeaders(config.headers);

    if (config.auth) {
      this.auth = config.auth;
    }
  }

  async _refreshMICSession(micSession) {
    const kinveyMICIdentityKey = 'kinveyAuth';

    try {
      // Refresh the MIC session
      const refreshRequest = new KinveyHttpRequest({
        method: HttpRequestMethod.POST,
        headers: new KinveyHttpHeaders({
          'Content-Type': () => 'application/x-www-form-urlencoded',
          Authorization: () => {
            const credentials = Base64.encode(`${micSession.client_id}:${getAppSecret()}`);
            return `Basic ${credentials}`;
          }
        }),
        url: formatKinveyAuthUrl('/oauth/token'),
        body: {
          grant_type: 'refresh_token',
          client_id: micSession.client_id,
          redirect_uri: micSession.redirect_uri,
          refresh_token: micSession.refresh_token
        }
      });

      const refreshData = await sendSessionRefreshRequest(refreshRequest);
      if (!refreshData) {
        return false;
      }

      // Persist the new access and refresh tokens
      await setKinveyMICSession(refreshData);

      const newMICSession = await getKinveyMICSession();

      // Login with the new MIC session
      const loginRequest = new KinveyHttpRequest({
        method: HttpRequestMethod.POST,
        headers: new KinveyHttpHeaders({
          'Content-Type': () => 'application/json',
          Authorization: () => {
            const credentials = Base64.encode(`${getAppKey()}:${getAppSecret()}`);
            return `Basic ${credentials}`;
          }
        }),
        url: formatKinveyBaasUrl(KinveyBaasNamespace.User, '/login'),
        body: {
          _socialIdentity: {
            [kinveyMICIdentityKey]: newMICSession
          }
        }
      });

      const newSession = await sendSessionRefreshRequest(loginRequest);
      if (!newSession) {
        return false;
      }

      newSession._socialIdentity[kinveyMICIdentityKey] = Object.assign({}, newSession._socialIdentity[kinveyMICIdentityKey], newMICSession);

      // Set the new session
      await setSession(newSession);

      return true;
    } catch (error) {
      logger.error(error.message);
      return false;
    }
  }

  async execute(retry = true): Promise<HttpResponse> {
    if (this.auth) {
      await this.headers.setAuthorization(this.auth);
    }

    try {
      return await super.execute();
    } catch (error) {
      if (retry) {
        // Received an InvalidCredentialsError
        if (error instanceof InvalidCredentialsError) {
          if (isRefreshTokenRequestInProgress()) {
            return REQUEST_QUEUE.add(() => {
              const request = new KinveyHttpRequest(this);
              return request.execute(false).catch(() => Promise.reject(error));
            });
          }

          // Mark refresh token request in progress
          markRefreshTokenRequestInProgress();

          // Get existing mic session
          const micSession = await getKinveyMICSession();

          if (micSession) {
            try {
              const isSuccess = await this._refreshMICSession(micSession);

              if (isSuccess) {
                // Redo the original request
                const request = new KinveyHttpRequest(this);
                const response = await request.execute(false);

                // Mark the refresh token as complete
                markRefreshTokenRequestComplete();

                // Return the response
                return response;
              }
            } catch (error) {
              logger.error(error.message);
            }
          } else {
            await cleanUp();
          }

          // Mark the refresh token as complete
          markRefreshTokenRequestComplete();
        }

        // Throw the error
        throw error;
      }
    }
  }
}