Home Reference Source

packages/skygear-core/lib/cloud/transport/common.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.
 */
import {
  btoa,
  atob
} from 'Base64';
import _ from 'lodash';
import stream from 'stream';
import { IncomingForm } from 'formidable';
import { parse } from 'url';
import { pool } from '../pg';
import Record from '../../record';
import { fromJSON, toJSON } from '../../util';

import skyconfig from '../skyconfig';
import { getContainer } from '../container';

/**
 * Encode 16-bit unicode strings or a buffer to base64 string.
 *
 * @see https://developer.mozilla.org/en-US/docs/Web/API/WindowBase64/Base64_encoding_and_decoding#The_Unicode_Problem
 */
function b64EncodeUnicode(data) {
  // If the data is a buffer, use its base64 encoding instead of using
  // our unicode to base64 encoding.
  if (data instanceof Buffer) {
    return data.toString('base64');
  }

  return btoa(
    encodeURIComponent(data).replace(/%([0-9A-F]{2})/g,
    function (match, p1) {
      return String.fromCharCode('0x' + p1);
    })
  );
}

/**
 * Decode 16-bit unicode strings from base64 string.
 *
 * @see https://developer.mozilla.org/en-US/docs/Web/API/WindowBase64/Base64_encoding_and_decoding#The_Unicode_Problem
 */
function b64DecodeUnicode(str) {
  return decodeURIComponent(
    Array.prototype.map.call(
      atob(str),
      function (c) {
        return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
      }
    ).join('')
  );
}

/**
 * Create a CloudCodeContainer from the specified request context.
 */
function containerFromContext(context) {
  const {
    user_id: userId
  } = context || {};
  return userId ? getContainer(userId) : getContainer();
}

/**
 * This is thin request object trying to provide a http.IncomingMessage like
 * object for access http request properties.
 */
class SkygearRequest {
  constructor(param) {
    this.headers = param.header;
    this.method = param.method;
    this.path = param.path;
    this.queryString = param.query_string;
    this.body = b64DecodeUnicode(param.body);
    if (this.queryString) {
      this.url = parse(`${this.path}?${this.queryString}`, true);
    } else {
      this.url = parse(`${this.path}`, true);
    }
  }

  get query() {
    return this.url.query;
  }

  form(callback) {
    const req = new stream.PassThrough();
    req.headers = {
      'content-type': this.headers['Content-Type'][0],
      'content-length': this.headers['Content-Length'][0]
    };
    req.end(this.body);
    const f = new IncomingForm();
    return f.parse(req, callback);
  }

  get json() {
    return JSON.parse(this.body);
  }
}

/**
 * This is thin response object trying to provide a http.ServerResponse like
 * interface for setting response headers and body.
 */
export class SkygearResponse {
  /**
   * Creates an instance of SkygearResponse.
   *
   * @param {Object} [options={}] - options to initialize the response
   * @param {number} [options.statusCode=200] - HTTP status code of the response
   * @param {string} [options.body=''] - HTTP response body
   * @param {Object} [options.headers={}] - HTTP response headers
   */
  constructor(options = {}) {
    /**
     * The HTTP status code of the response.
     *
     * @type {number}
     */
    this.statusCode = options.statusCode || 200;

    /**
     * The HTTP headers of the response.
     *
     * @type {Object}
     */
    this.headers = options.headers || {};

    /**
     * The HTTP body of the response.
     *
     * @type {string}
     */
    this.body = options.body || '';

    this._isSkygearResponse = true;
  }

  /**
   * Set a HTTP header to the response.
   *
   * @param {string} name - HTTP header name
   * @param {string} value - HTTP header value
   */
  setHeader(name, value) {
    this.headers[name] = value;
  }

  /**
   * Get a HTTP header from the response.
   *
   * @param {string} name - HTTP header name
   * @return {string} HTTP header value
   */
  getHeader(name) {
    return this.headers[name];
  }

  /**
   * Remove a HTTP header from the response.
   *
   * @param {string} name - HTTP header name
   */
  removeHeader(name) {
    delete this.headers[name];
  }

  /**
   * Write a chunk of data into the response. The chunk will be appended
   * to any existing data in the response body.
   *
   * @param {string} chunk - data to append to the response body
   */
  write(chunk) {
    this.body += chunk;
  }

  /**
   * Convert the response to a result JSON that is suitable for plugin
   * transport.
   *
   * @return {Object} result JSON for plugin transport
   */
  toResultJSON() {
    const header = {};
    const status = this.statusCode || 200;
    const body = b64EncodeUnicode(this.body);

    Object.keys(this.headers).forEach((perKey) => {
      var headerValue = this.headers[perKey];
      if (!_.isArray(headerValue)) {
        headerValue = [headerValue];
      }
      header[perKey] = headerValue;
    });

    return {
      header,
      status,
      body
    };
  }

  /**
   * Wrap response body into a SkygearResponse.
   *
   * If the specified value is a SkygearResponse, the same object will
   * be returned.
   *
   * @param result - SkygearResponse or response body
   * @return {!SkygearResponse} a SkygearResponse
   */
  static wrap(result) {
    if (SkygearResponse.isInstance(result)) {
      return result;
    } else if (typeof result === 'string') {
      return new SkygearResponse({
        statusCode: 200,
        body: result,
        headers: {
          'Content-Type': 'text/plain; charset=utf-8'
        }
      });
    }

    return new SkygearResponse({
      statusCode: 200,
      body: JSON.stringify(result),
      headers: {
        'Content-Type': 'application/json'
      }
    });
  }

  /**
   * Returns whether the specified object is a SkygearResponse.
   *
   * @param obj - object to be determined
   * @return {boolean} true if the object is a SkygearResponse
   *
   */
  static isInstance(obj) {
    if (obj === undefined || obj === null) {
      return false;
    }
    return obj instanceof SkygearResponse || !!obj._isSkygearResponse;
  }
}

export default class CommonTransport {
  constructor(registry) {
    this.registry = registry;
    this._registerInitEvent = this._registerInitEvent.bind(this);

    this.registry.registerEvent('init', this._registerInitEvent);
  }

  _registerInitEvent(param) {
    const config = param.config || {};
    Object.keys(config).forEach((perKey) => {
      skyconfig[perKey] = config[perKey];
    });

    return this.registry.funcList();
  }

  start() {
    throw new Error('Not implemented');
  }

  _promisify(func, ...param) { // do not mark as async
    try {
      const result = func(...param);
      if (result instanceof Promise) {
        return result;
      }
      return Promise.resolve(result);
    } catch (err) {
      return Promise.reject(err);
    }
  }

  async initHandler() {
    throw new Error('Init trigger is deprecated, use init event instead');
  }

  async hookHandler(payload) {
    const {
      name,
      param,
      context
    } = payload;

    const func = this.registry.getFunc('hook', name);
    const _type = this.registry.getHookType(name);
    if (!func) {
      throw new Error('Database hook does not exist');
    }

    const incomingRecord = new Record(_type, param.record);
    let originalRecord = null;
    if (param.original) {
      originalRecord = new Record(_type, param.original);
    }

    const options = {
      context,
      container: containerFromContext(context)
    };
    const _record = await this._promisify(
      func,
      incomingRecord,
      originalRecord,
      pool,
      options
    );
    const record = _record || incomingRecord;
    return {
      result: record.toJSON()
    };
  }

  async opHandler(payload) {
    const {
      name,
      param,
      context
    } = payload;

    const func = this.registry.getFunc('op', name);
    if (!func) {
      throw new Error('Lambda function does not exist');
    }

    const options = {
      context,
      container: containerFromContext(context)
    };
    const result = await this._promisify(
      func,
      fromJSON(param),
      options
    );
    return {
      result: toJSON(result)
    };
  }

  async eventHandler(payload) {
    const funcList = this.registry.getEventFunctions(payload.name);

    if (!funcList) {
      // It is okay that the sending event has no handlers
      return {
        result: []
      };
    }

    const funcPromises = funcList.map(
      (eachFunc) => this._promisify(eachFunc, payload.param)
    );

    const results = await Promise.all(funcPromises);
    const result = results.length > 1 ? results : results[0];
    return { result };
  }

  async timerHandler(payload) {
    const func = this.registry.getFunc('timer', payload.name);
    if (!func) {
      throw new Error('Cronjob not exist');
    }

    const result = await this._promisify(
      func,
      payload.param
    );
    return { result };
  }

  async handlerHandler(payload) {
    const {
      name,
      param,
      context
    } = payload;

    const func = this.registry.getHandler(name, param.method);
    if (!func) {
      throw new Error('Handler not exist');
    }

    const options = {
      context,
      container: containerFromContext(context)
    };
    const req = new SkygearRequest(param);
    const result = await this._promisify(
      func,
      req,
      options
    );
    return {
      result: SkygearResponse.wrap(result).toResultJSON()
    };
  }

  async providerHandler(payload) {
    const {
      name,
      param
    } = payload;

    const provider = this.registry.getProvider(name);
    if (!provider) {
      throw new Error('Provider not exist');
    }

    const result = await this._promisify(
      provider.handleAction.bind(provider),
      param.action,
      param
    );
    return { result };
  }
}