Home Reference Source

packages/skygear-core/lib/container.js

/**
 * Copyright 2015 Oursky Ltd.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
/* eslint camelcase: 0 */
const request = require('superagent');
const _ = require('lodash');
const ee = require('event-emitter');

import Asset from './asset';
import Role from './role';
import ACL from './acl';
import Record from './record';
import Reference from './reference';
import Query from './query';
import {Database} from './database';
import Geolocation from './geolocation';
import getStore from './store';
import {Sequence} from './type';
import {
  SkygearError,
  ErrorCodes
} from './error';

import {AuthContainer} from './auth';
import {RelationContainer} from './relation';
import {DatabaseContainer} from './database';
import {PubsubContainer} from './pubsub';
import {PushContainer} from './push';
import {fromJSON, toJSON} from './util';

/**
 * @type {Record}
 */
export const UserRecord = Record.extend('user');

/**
 * BaseContainer provides the basic configuration for connecting to a
 * Skygear server.
 *
 * For development under different environments, developer may refer to these
 * classes:
 * - Web developement: {@link Container}
 * - React Native: {@link ReactNativeContainer}
 * - Cloud development: {@link CloudCodeContainer}
 *
 * It also proxies other Skygear classes, like {@link BaseContainer#Query}.
 * Thus developer who install Skygear with <script> tag in browser can have
 * access to those classes.
 */
export class BaseContainer {

  constructor() {
    /**
     * @private
     */
    this.url = '/* @echo API_URL */' || null;

    /**
     * API key of the skygear container
     * @type {String}
     */
    this.apiKey = null;

    /**
     * @private
     */
    this.request = request;

    /**
     * @private
     */
    this.ee = ee({});
  }

  /**
   * The version of Skygear.
   *
   * @type {String}
   */
  static get VERSION() {
    return '/* @echo SKYGEAR_VERSION */';
  }

  /**
   * The version of Skygear. Convenient getter.
   *
   * @type {String}
   */
  get VERSION() {
    return this.constructor.VERSION;
  }

  /**
   * Sets a new end point and new API key to the container.
   *
   * @param {Object} options - configuration options of the skygear container
   * @param {String} options.apiKey - api key
   * @param {String} options.endPoint - end point
   * @return {Promise<BaseContainer>} promise with the skygear container
   */
  async config(options) {
    if (options.apiKey) {
      this.apiKey = options.apiKey;
    }
    if (options.endPoint) {
      this.endPoint = options.endPoint;
    }

    return this;
  }

  /**
   * Sets a new API key to the container.
   *
   * @param  {String} apiKey - api key of the skygear container
   */
  configApiKey(apiKey) {
    this.apiKey = apiKey;
  }

  /**
   * Sets a new end point to the container.
   *
   * @param  {String} endPoint - end point of the skygear container
   */
  configEndPoint(endPoint) {
    this.endPoint = endPoint;
  }

  /**
   * @private
   */
  async makeRequest(action, data) {
    let requestObject = this._prepareRequestObject(action, data);
    let requestData = this._prepareRequestData(action, data);
    const response = await new Promise((resolve) => {
      requestObject.send(requestData).end((err, res) => {
        resolve({
          err: err,
          res: res
        });
      });
    });
    return this._handleResponse(response.err, response.res);
  }

  /**
   * Calls a registered lambda function without arguments.
   *
   * @param  {String} name - name of the lambda function being called
   * @param  {Object} data - data passed to the lambda function
   * @return {Promise<Object>} promise with result of the lambda function
   */
  async lambda(name, data) {
    const presavedData = await this.publicDB._presave(
      this.publicDB._presaveSingleValue.bind(this.publicDB),
      data
    );

    const resp = await this.makeRequest(name, {
      args: presavedData ? toJSON(presavedData) : undefined
    });
    return fromJSON(resp.result);
  }

  _prepareRequestObject(action) {
    if (this.endPoint === null) {
      throw Error('Please config endpoint');
    }

    if (this.apiKey === null) {
      throw Error('Please config ApiKey');
    }

    let _action = action.replace(/:/g, '/');
    return this.request
      .post(this.url + _action)
      .set({
        'Content-Type': 'application/json',
        Accept: 'application/json',
        'X-Skygear-API-Key': this.apiKey,
        'X-Skygear-SDK-Version': `skygear-SDK-JS/${this.VERSION}`
      });
  }

  _prepareRequestData(action, data) {
    if (this.apiKey === null) {
      throw Error('Please config ApiKey');
    }

    return _.assign({
      action: action,
      api_key: this.apiKey
    }, data);
  }

  async _handleResponse(err, res) {
    // Do an application JSON parse because in some condition, the
    // content-type header will got strip and it will not deserial
    // the json for us.
    let body = getRespJSON(res);

    if (err) {
      let skyErr = body.error || err;
      throw {
        status: err.status,
        error: skyErr
      };
    } else {
      return body;
    }
  }

  /**
   * @type {Query}
   */
  get Query() {
    return Query;
  }

  /**
   * @type {Role}
   */
  get Role() {
    return Role;
  }

  /**
   * @type {ACL}
   */
  get ACL() {
    return ACL;
  }

  /**
   * @type {Record}
   */
  get Record() {
    return Record;
  }

  /**
   * @type {Record}
   */
  get UserRecord() {
    return UserRecord;
  }

  /**
   * @type {Sequence}
   */
  get Sequence() {
    return Sequence;
  }

  /**
   * @type {Asset}
   */
  get Asset() {
    return Asset;
  }

  /**
   * @type {Reference}
   */
  get Reference() {
    return Reference;
  }

  /**
   * @type {Geolocation}
   */
  get Geolocation() {
    return Geolocation;
  }

  /**
   * @type {Database}
   */
  get Database() {
    return Database;
  }

  /**
   * @type {Relation}
   */
  get Friend() {
    return this.relation.Friend;
  }

  /**
   * @type {Relation}
   */
  get Follower() {
    return this.relation.Follower;
  }

  /**
   * @type {Relation}
   */
  get Following() {
    return this.relation.Following;
  }

  /**
   * @type {SkygearError}
   */
  get Error() {
    return SkygearError;
  }

  /**
   * @type {ErrorCodes}
   */
  get ErrorCodes() {
    return ErrorCodes;
  }

  /**
   * @type {AuthContainer}
   */
  get AuthContainer() {
    return AuthContainer;
  }

  /**
   * @type {RelationContainer}
   */
  get RelationContainer() {
    return RelationContainer;
  }

  /**
   * @type {DatabaseContainer}
   */
  get DatabaseContainer() {
    return DatabaseContainer;
  }

  /**
   * @type {PubsubContainer}
   */
  get PubsubContainer() {
    return PubsubContainer;
  }

  /**
   * @type {PushContainer}
   */
  get PushContainer() {
    return PushContainer;
  }

  /**
   * Endpoint of the skygear container
   *
   * @type {String}
   */
  get endPoint() {
    return this.url;
  }

  /**
   * Endpoint of the skygear container
   *
   * @type {String}
   */
  set endPoint(newEndPoint) {
    // TODO: Check the format
    if (newEndPoint) {
      if (!_.endsWith(newEndPoint, '/')) {
        newEndPoint = newEndPoint + '/';
      }
      this.url = newEndPoint;
    }
  }

  /**
   * @private
   */
  get store() {
    if (!this._store) {
      this._store = getStore();
    }
    return this._store;
  }

  /**
   * Clears all cache in skygear container store.
   *
   * @return {Promise} resolve when cache is cleared successfully
   */
  async clearCache() {
    return this.store.clearPurgeableItems();
  }

}

/**
 * Container provides configuration for connecting to Skygear server, and
 * accessors to other containers, providing various functionalities:
 * - `skygear.auth` - {@link AuthContainer}: User authentications and user
 * roles API.
 * - `skygear.relation` - {@link RelationContainer}: User relation API, like
 * add and query Friends.
 * - `skygear.privateDB` - {@link Database}: Private database of the current
 * user, with record API, like query, save and delete.
 * - `skygear.publicDB` - {@link PublicDatabase}: Public database, providing
 * the same record API as {@link Database}, but with additional record role
 * API.
 * - `skygear.pubsub` - {@link PubsubContainer}: A publish-subscribe interface,
 * providing real-time message-based communication with other users.
 * - `skygear.push` - {@link PushContainer}: Push Notifications.
 */
export default class Container extends BaseContainer {

  constructor() {
    super();

    this._auth = new AuthContainer(this);
    this._relation = new RelationContainer(this);
    this._db = new DatabaseContainer(this);
    this._pubsub = new PubsubContainer(this);
    this._push = new PushContainer(this);
    /**
     * Options for how much time to wait for client request to complete.
     *
     * @type {Object}
     * @property {number} [timeoutOptions.deadline] - deadline for the request
     * and response to complete (in milliseconds)
     * @property {number} [timeoutOptions.response=60000] - maximum time to
     * wait for an response (in milliseconds)
     *
     * @see http://visionmedia.github.io/superagent/#timeouts
     */
    this.timeoutOptions = {
      response: 60000
    };
  }

  /**
   * @type {AuthContainer}
   */
  get auth() {
    return this._auth;
  }

  /**
   * @type {RelationContainer}
   */
  get relation() {
    return this._relation;
  }

  /**
   * @type {PublicDatabase}
   */
  get publicDB() {
    return this._db.public;
  }

  /**
   * @type {Database}
   */
  get privateDB() {
    return this._db.private;
  }

  /**
   * @type {PubsubContainer}
   */
  get pubsub() {
    return this._pubsub;
  }

  /**
   * @type {PushContainer}
   */
  get push() {
    return this._push;
  }

  /**
   * Sets a new end point and new API key to the container.
   *
   * After configuration,
   * - it tries to restore the user, access token and device id, and,
   * - the pubsub client connects to skygear server if a user is restored.
   *
   * @param {Object} options - configuration options of the skygear container
   * @param {String} options.apiKey - api key
   * @param {String} options.endPoint - end point
   * @return {Promise<Container>} promise with the skygear container
   */
  async config(options) {
    try {
      await super.config(options);
      let promises = [
        this.auth._getUser(),
        this.auth._getAccessToken(),
        this.push._getDeviceID()
      ];
      await Promise.all(promises);
      this.pubsub._reconfigurePubsubIfNeeded();
    } catch (err) {
      // do nothing
    }
    return this;
  }

  _prepareRequestObject(action, data) {
    let requestObject = super._prepareRequestObject(action, data);

    if (this.auth.accessToken) {
      requestObject = requestObject
        .set('X-Skygear-Access-Token', this.auth.accessToken);
    }

    if (this.timeoutOptions !== undefined && this.timeoutOptions !== null) {
      requestObject = requestObject.timeout(this.timeoutOptions);
    }

    return requestObject;
  }

  _prepareRequestData(action, data) {
    let requestData = super._prepareRequestData(action, data);

    return _.assign({
      access_token: this.auth.accessToken
    }, requestData);
  }

  async _handleResponse(err, res) {
    try {
      return await super._handleResponse(err, res);
    } catch (innerError) {
      // Logout user implicitly if
      let errorCode = innerError.error.code;
      if (errorCode === this.ErrorCodes.AccessTokenNotAccepted) {
        await Promise.all([
          this.auth._setAccessToken(null),
          this.auth._setUser(null)
        ]);
      }
      throw innerError;
    }
  }

}

function getRespJSON(res) {
  if (res && res.body) {
    return res.body;
  }
  if (res && res.text) {
    try {
      return JSON.parse(res.text);
    } catch (err) {
      console.log('getRespJSON error. error: ', err);
    }
  }

  return {};
}