Show:
'use strict';

var assert      = require('assert');
var debug       = require('debug')('azure:table');
var querystring = require('querystring');
var crypto      = require('crypto');
var events      = require('events');
var util        = require('util');
var agent       = require('./agent');
var utils       = require('./utils');
var auth        = require('./authorization');

/* Transient error codes (we'll retry request when encountering these codes */
var TRANSIENT_ERROR_CODES = [
  // Error code for when we encounter a 5xx error, but the XML document doesn't
  // have a code property, or we fail to parse the XML payload. This is unlikely
  // to happen unless you have an HTTP proxy that returns 5xx for some reason.
  'InternalErrorWithoutCode',

  // Azure error codes we should retry on according to azure docs
  'InternalError',
  'ServerBusy'
].concat(utils.TRANSIENT_HTTP_ERROR_CODES);


/*
 * Authorize the request with shared key
 * Intended to define `Table.prototype.authorize`.
 */
var authorizeWithSharedKey = function (method, path, query, headers) {
  // Find account id
  var accountId = this.options.accountId;

  // Build list of lines to sign, we'll join with '\n' before signing the list
  var stringToSign = (
    method + '\n' +
    (headers['content-md5']  || '') + '\n' +
    (headers['content-type'] || '') + '\n' +
    headers['x-ms-date']
  );

  // Added lines from canonicalized resource and query-string parameters
  // supported by this library in lexicographical order as presorted in
  // QUERY_PARAMS_SUPPORTED
  stringToSign += '\n/' + accountId + path;
  if (query.comp !== undefined) {
    stringToSign += '?comp=' + query.comp;
  }

  // Compute signature
  var signature = utils.hmacSha256(this._accessKey, stringToSign);

  // Set authorization header
  headers.authorization = 'SharedKey ' + accountId + ':' + signature;

  // Encode query string
  var qs = querystring.stringify(query);

  // Construct request options
  return Promise.resolve({
    host:       this.hostname,
    method:     method,
    path:       (qs.length > 0 ? path + '?' + qs : path),
    headers:    headers,
    agent:      this.options.agent,
  });
}

/**
 * Table client class for interacting with Azure Table Storage.
 *
 * Subclasses `EventEmitter` and emits the `error` event on failure to refresh
 * shared-access-signature, if `options.sas` is a function.
 *
 * @class Table
 * @constructor
 * @param {object} options - Options on the following form:
 * ```js
 * {
 *   // Value for the `x-ms-version` header fixing the API version
 *   version:              '2014-02-14',
 *
 *   // OData Service version, must work with API version, refer to azure
 *   // documentation. This just specifies the `DataServiceVersion` header.
 *   dataServiceVersion:   '3.0',
 *
 *   // Value for the x-ms-client-request-id header identifying the client
 *   clientId:             'fast-azure-storage',
 *
 *   // Server-side request timeout
 *   timeout:              30 * 1000,
 *
 *   // Delay between client- and server-side timeout
 *   clientTimeoutDelay:   500,
 *
 *   // Set meta-data level for responses (use full to get eTag in queryEntities)
 *   metadata:             'fullmetadata',
 *
 *   // HTTP Agent to use (defaults to a global azure.Agent instance)
 *   agent:                agent.globalAgent,
 *
 *   // Max number of request retries
 *   retries:              5,
 *
 *   // Multiplier for computation of retry delay: 2 ^ retry * delayFactor
 *   delayFactor:          100,
 *
 *   // Randomization factor added as:
 *   // delay = delay * random([1 - randomizationFactor; 1 + randomizationFactor])
 *   randomizationFactor:  0.25,
 *
 *   // Maximum retry delay in ms (defaults to 30 seconds)
 *   maxDelay:             30 * 1000,
 *
 *   // Error codes for which we should retry
 *   transientErrorCodes:  TRANSIENT_ERROR_CODES,
 *
 *   // Azure storage accountId (required)
 *   accountId:            undefined,
 *
 *   // Azure shared accessKey, required unless options.sas is given
 *   accessKey:            undefined,
 *
 *   // Function that returns SAS string or promise for SAS string, in which
 *   // case we will refresh SAS when a request occurs less than
 *   // minSASAuthExpiry from signature expiry. This property may also be a
 *   // SAS string.
 *   sas:                  undefined,
 *
 *   // Minimum SAS expiry before refreshing SAS credentials, if a function for
 *   // refreshing SAS credentials is given as options.sas
 *   minSASAuthExpiry:     15 * 60 * 1000
 * }
 * ```
 */
function Table(options) {
  // Initialize EventEmitter parent class
  events.EventEmitter.call(this);

  // Set default options
  this.options = {
    version:              '2014-02-14',
    dataServiceVersion:   '3.0',
    clientId:             'fast-azure-storage',
    timeout:              30 * 1000,
    clientTimeoutDelay:   500,
    metadata:             'fullmetadata',
    agent:                agent.globalAgent,
    retries:              5,
    delayFactor:          100,
    randomizationFactor:  0.25,
    maxDelay:             30 * 1000,
    transientErrorCodes:  TRANSIENT_ERROR_CODES,
    accountId:            undefined,
    accessKey:            undefined,
    sas:                  undefined,
    minSASAuthExpiry:     15 * 60 * 1000,
  };

  // Overwrite default options
  for (var key in options) {
    if (options.hasOwnProperty(key) && options[key] !== undefined) {
      this.options[key] = options[key];
    }
  }

  // Validate options
  assert(this.options.accountId, "`options.accountId` must be given");
  assert(
    this.options.metadata === 'nometadata' ||
    this.options.metadata === 'minimalmetadata' ||
    this.options.metadata === 'fullmetadata',
    "options.metadata must be 'nometadata', 'minimalmetadata' or 'fullmetadata'"
  );

  // Construct hostname
  this.hostname = this.options.accountId + '.table.core.windows.net';

  // Compute `timeout` for client-side timeout (in ms), and `timeoutInSeconds`
  // for server-side timeout in seconds.
  this.timeout = this.options.timeout + this.options.clientTimeoutDelay;
  this.timeoutInSeconds = Math.floor(this.options.timeout / 1000);

  // Define `this.authorize`
  if (this.options.accessKey) {
    // If set authorize to use shared key signatures
    this.authorize = auth.authorizeWithSharedKey.call(this, 'table');
    // Decode accessKey
    this._accessKey = new Buffer(this.options.accessKey, 'base64');
  } else if (this.options.sas instanceof Function) {
    // Set authorize to use shared-access-signatures with refresh function
    this.authorize = auth.authorizeWithRefreshSAS;
    // Set state with _nextSASRefresh = -1, we'll refresh on the first request
    this._nextSASRefresh = -1;
    this._sas = '';
  } else if (typeof(this.options.sas) === 'string') {
    // Set authorize to use shared-access-signature as hardcoded
    this.authorize = auth.authorizeWithSAS;
  } else {
    throw new Error("Either options.accessKey, options.sas as function or " +
                    "options.sas as string must be given!");
  }
};

// Subclass EventEmitter
util.inherits(Table, events.EventEmitter);

// Export Table
module.exports = Table;

/**
 * Generate a SAS string on the form `'key1=val1&key2=val2&...'`.
 *
 * @method sas
 * @param {string} table - Name of table that this SAS string applies to.
 * @param {object} options - Options for the following form:
 * ```js
 * {
 *   start:           new Date(), // Time from which signature is valid
 *   expiry:          new Date(), // Expiration of signature (required)
 *   permissions: {               // Set of permissions delegated (required)
 *     read:          false,      // Read entities
 *     add:           false,      // Insert new entities
 *     update:        false,      // Update entities
 *     delete:        false       // Delete entities
 *   },
 *   first: {                     // Start of accessible range (optional)
 *     partitionKey:  '...',      // First accessible partition key (required)
 *     rowKey:        '...'       // First accessible row key (required)
 *   },
 *   last: {                      // End of accessible range (optional)
 *     partitionKey:  '...',      // Last accessible partition key (required)
 *     rowKey:        '...'       // Last accessible row key (required)
 *   },
 *   accessPolicy:    '...'       // Reference to stored access policy
 * }
 * ```
 * @returns {string} Shared-Access-Signature on string form.
 */
Table.prototype.sas = function(table, options) {
  assert(options, "options is required");
  assert(options.expiry instanceof Date,
         "options.expiry must be a Date object");
  assert(options.permissions, "options.permissions is required");

  // Check that we have credentials
  if (!this.options.accountId ||
      !this.options.accessKey) {
    throw new Error("accountId and accessKey are required for SAS creation!");
  }

  // Construct permissions string (in correct order)
  var permissions = '';
  if (options.permissions.read)   permissions += 'r';
  if (options.permissions.add)    permissions += 'a';
  if (options.permissions.update) permissions += 'u';
  if (options.permissions.delete) permissions += 'd';

  // Construct query-string with required parameters
  var query = {
    sv:   '2014-02-14',
    tn:   table,
    se:   utils.dateToISOWithoutMS(options.expiry),
    sp:   permissions,
    sig:  null
  };
  // Add optional parameters to query-string
  if (options.start) {
    assert(options.start instanceof Date,
           "if specified start must be a Date object");
    query.st = utils.dateToISOWithoutMS(options.start);
  }
  if (options.first) {
    assert(options.first.partitionKey && options.first.rowKey,
           "if options.first is specified, both partitionKey and rowKey must " +
           "be specified");
    query.spk = options.first.partitionKey;
    query.srk = options.first.rowKey;
  }
  if (options.last) {
    assert(options.last.partitionKey && options.last.rowKey,
           "if options.last is specified, both partitionKey and rowKey must " +
           "be specified");
    query.epk = options.last.partitionKey;
    query.erk = options.last.rowKey;
  }
  if (options.accessPolicy) {
    query.si = options.accessPolicy;
  }

  // Construct string to sign
  var stringToSign = [
    query.sp,
    query.st  || '',
    query.se,
    '/' + this.options.accountId.toLowerCase() + '/' + table.toLowerCase(),
    query.si  || '',
    query.sv,
    query.spk || '',
    query.srk || '',
    query.epk || '',
    query.erk || ''
  ].join('\n');

  // Compute signature
  query.sig = utils.hmacSha256(this._accessKey, stringToSign);

  // Return Shared-Access-Signature as query-string
  return querystring.stringify(query);
};

/**
 * Construct authorized request options by adding signature or
 * shared-access-signature, return promise for the request options.
 *
 * @protected
 * @method authorize
 * @param {string} method - HTTP verb in upper case, e.g. `GET`.
 * @param {string} path - Path on table resource for storage account.
 * @param {object} query - Query-string parameters.
 * @param {object} header - Mapping from header key in lowercase to value.
 * @returns {Promise} A promise for an options object compatible with
 * `https.request`.
 */
Table.prototype.authorize = function(method, path, query, headers) {
  throw new Error("authorize is not implemented, must be defined!");
};

/**
 * Make a signed request to `path` using `method` in upper-case and all `query`
 * parameters and `headers` keys in lower-case. The request will carry `json`
 * as payload and will be retried using the configured retry policy.
 *
 * @private
 * @method request
 * @param {string} method - HTTP verb in upper case, e.g. `GET`.
 * @param {string} path - Path on table resource for storage account.
 * @param {object} query - Query-string parameters.
 * @param {object} header - Mapping from header key in lowercase to value.
 * @param {object} json - Optional JSON object to send as payload.
 * @returns {Promise} A promise for the HTTP response object with a `payload`
 * property carrying the payload as string.
 */
Table.prototype.request = function request(method, path, query, headers, json) {
  // Set timeout, if not provided
  if (query.timeout === undefined) {
    query.timeout = this.timeoutInSeconds;
  }

  // Set date, version, dataServiceVersion and client-request-id headers
  headers['x-ms-date']              = new Date().toUTCString();
  headers['x-ms-version']           = this.options.version;
  headers['dataserviceversion']     = this.options.dataServiceVersion;
  headers['x-ms-client-request-id'] = this.options.clientId;

  // Serialize and set content-length/content-type, if json is given
  var data = undefined;
  if (json !== undefined) {
    data = JSON.stringify(json);
    headers['content-length'] = Buffer.byteLength(data, 'utf-8');
    headers['content-type'] = 'application/json';
  }

  // Set meta-data level for responses
  headers['accept'] = 'application/json;odata=' + this.options.metadata;

  // Construct authorized request options with shared key signature or
  // shared-access-signature.
  var self = this;
  return this.authorize(method, path, query, headers).then(function(options) {
    // Retry with retry policy
    return utils.retry(function(retry) {
      debug("Request: %s %s, retry: %s", method, path, retry);

      // Construct a promise chain first handling the request, and then parsing
      // any potential error message
      return utils.request(options, data, self.timeout).then(function(res) {
        // Accept the response if it's 2xx, otherwise we construct and
        // throw an error
        if (200 <= res.statusCode && res.statusCode < 300) {
          return res;
        }

        // Find message and code
        var message = null;
        var code    = 'InternalErrorWithoutCode';
        try {
          var details = JSON.parse(res.payload)['odata.error'];
          code = details.code;
          message = details.message.value || details.message;
        } catch (e) {
          // Ignore parse and extraction errors
        }

        // Set fallback message
        if (!message) {
          message = "No error message given, in payload '" + res.payload + "'"
        }

        var resMSHeaders = {};
        Object.keys(res.headers).forEach(h => {
          if (h.startsWith('x-ms-')) {
            resMSHeaders[h] = res.headers[h];
          }
        });

        // Construct error object, capturing only the first line in the
        // error message, as subsequent lines from Azure are usually just
        // a requestId and timestamp.
        var err = new Error(message.split('\n')[0]);
        err.name = code + 'Error';
        err.code = code;
        err.statusCode = res.statusCode;
        err.payload = res.payload;
        err.method = method;
        err.path = path;
        err.query = query;
        err.headers = headers;
        err.resMSHeaders = resMSHeaders;
        err.requestBody = json;
        err.message = message;

        debug("Error code: %s (%s) for %s %s on retry: %s",
              code, res.statusCode, method, path, retry);

        // Throw the constructed error
        throw err;
      });
    }, self.options);
  });
};

/**
 * Query for tables on the storage account.
 *
 * @method queryTables
 * @param {object} options - `options` on the following form:
 * ```js
 * {
 *   nextTableName:      '...'  // nextTableName, if paging
 * }
 * ```
 * @returns {Promise} A promise for an object on the form:
 * ```js
 * {
 *  tables:         ['<tableName>', ...],
 *  nextTableName:  '...',      // nextTableName if paging is necessary
 * }
 * ```
 */
Table.prototype.queryTables = function queryTables(options) {
  // Construct query
  var query = {};
  if (options && options.nextTableName) {
    query.NextTableName = options.nextTableName;
  }
  // Send request with retry logic
  return this.request('GET', '/Tables', query, {}).then(function(res) {
    if (res.statusCode !== 200) {
      throw new Error("queryTables: Unexpected statusCode: " + res.statusCode);
    }
    var payload = utils.parseJSON(res.payload);
    return {
      tables:   payload.value.map(function(table) {
        return table.TableName;
      }),
      nextTableName: res.headers['x-ms-continuation-nexttablename'] || null
    };
  });
};

/**
 * Create table with given `name`.
 *
 * @method createTable
 * @param {string} name - Name of table to create.
 * @return {Promise} A promise that the table was created.
 */
Table.prototype.createTable = function createTable(name) {
  // Construct json payload
  var json = {TableName: name};
  // Construct headers
  var headers = {
    // There is no reason to return content
    'prefer':   'return-no-content'
  };
  // Send request with retry logic
  return this.request('POST', '/Tables', {}, headers, json).then(function(res) {
    if (res.statusCode !== 204) {
      throw new Error("createTable: Unexpected statusCode: " + res.statusCode);
    }
  });
};

/**
 * Delete table with given `name`
 *
 * @method deleteTable
 * @param {string} name - Name of table to delete.
 * @return {Promise} A promise that the table was marked for deletion.
 */
Table.prototype.deleteTable = function deleteTable(name) {
  // Construct path
  var path = '/Tables(\'' + name + '\')';
  // Send request with retry logic
  return this.request('DELETE', path, {}, {}).then(function(res) {
    if (res.statusCode !== 204) {
      throw new Error("deleteTable: Unexpected statusCode: " + res.statusCode);
    }
  });
};

/**
 * Convert nested array structure to filter string.
 * See `Table.Operators` for details and examples.
 *
 * @method filter
 * @param {Array} expression - Array of arrays, keys, operators and formatted
 * constants that forms an expression, where arrays becomes parenthesis:
 * ```js
 * var op = azure.Table.Operators;
 * var filter = azure.Table.filter([
 *  ['key1', op.Equal, op.string('my-string')],
 *   op.And,
 *  ['key2', op.LessThan, op.date(new Date())]
 * ]) // "((key1 eq 'my-string') and (key2 le datetime'...'))"
 * ```
 * @returns {string} A filter string for use with `queryEntities`.
 */
Table.filter = function filter() {
  return Array.prototype.slice.call(arguments).map(function(entry) {
    if (entry instanceof Array) {
      return '(' + Table.filter.apply(Table, entry) + ')';
    }
    return entry;
  }).join(' ');
};

/**
 * Operators and helpers for constructing $filter strings using `Table.filter`.
 *
 * We have the following comparison operators:
 *  - `azure.Table.Operators.Equal`,
 *  - `azure.Table.Operators.GreaterThan`,
 *  - `azure.Table.Operators.GreaterThanOrEqual`,
 *  - `azure.Table.Operators.LessThan`,
 *  - `azure.Table.Operators.LessThanOrEqual`, and
 *  - `azure.Table.Operators.NotEqual`.
 *
 * They should be used in the middle of a triple as follows:
 * `['key1', op.Equal, op.string('my-string')]`.
 *
 * The boolean operators `And`, `Not` and `Or` should be used to connect
 * triples made with comparison operators. Note, that each set of array brackets
 * translates into a parentheses. Boolean operators:
 *  - `azure.Table.Operators.And`,
 *  - `azure.Table.Operators.Not`, and
 *  - `azure.Table.Operators.Or`.
 *
 * We also have formatting helpers, `string`, `number`, `bool`, `date` and
 * `guid` which takes constant values and encodes them correctly for use in
 * filter expression. It's strongly recommended that you employ these, as Azure
 * has some undocumented and semi obscure escaping rules. Constant formatters:
 *  - `azure.Table.Operators.string("...")`,
 *  - `azure.Table.Operators.number(42.2)`,
 *  - `azure.Table.Operators.bool(true)`,
 *  - `azure.Table.Operators.date(new Date())`, and
 *  - `azure.Table.Operators.guid('...')`.
 *
 * Complete example:
 * ```js
 * var op = azure.Table.Operators;
 * var filter = azure.Table.filter([
 *  ['key1', op.Equal, op.string('my-string')],
 *   op.And,
 *  ['key2', op.LessThan, op.date(new Date())]
 * ]) // "((key1 eq 'my-string') and (key2 le datetime'...'))"
 * ```
 *
 * @attribute Operators
 * @static
 * @final
 */
Table.Operators = {
  // Comparison operators
  Equal:              'eq',
  GreaterThan:        'gt',
  GreaterThanOrEqual: 'ge',
  LessThan:           'lt',
  LessThanOrEqual:    'le',
  NotEqual:           'ne',
  // Boolean operators
  And:                'and',
  Not:                'not',
  Or:                 'or',
  // Constant formatters
  string: function(c) { return "'" + c.replace(/'/g, "''") + "'"; },
  number: function(c) { return c.toString();                      },
  bool: function(c)   { return (c ? 'true' : 'false');            },
  date: function(c)   { return "datetime'" + c.toJSON() + "'";      },
  guid: function(c)   { return "guid'" + c + "'";                 }
};

/*
 * Auxiliary function to construct the entity path as used in many methods.
 * Format: `/<tabel>(PartitionKey='<partitionKey>',RowKey='<rowKey>')`.
 */
function buildEntityPath(table, partitionKey, rowKey) {
  // Escape partitionKey and rowKey
  var pk = encodeURIComponent(partitionKey.replace(/'/g, "''"));
  var rk = encodeURIComponent(rowKey.replace(/'/g, "''"));
  return '/' + table + '(PartitionKey=\'' + pk + '\',RowKey=\'' + rk + '\')';
}

/**
 * Get entity from `table` with given `partitionKey` and `rowKey`.
 *
 * @method getEntity
 * @param {string} table - Name of table to get entity from.
 * @param {string} partitionKey - Partition key of entity to get.
 * @param {string} rowKey - Row key of entity to get.
 * @param {object} options - Options on the following form:
 * ```js
 * {
 *   select:  ['key1', ...],  // List of keys to return (defaults to all)
 *   filter:  '...'           // Filter string for conditional load
 * }
 * ```
 * @return {Promise}
 * A promise for the entity, form of the object depends on the meta-data
 * level configured and if `select` as employed. See Azure documentation for
 * details.
 */
Table.prototype.getEntity = function getEntity(table, partitionKey, rowKey,
                                               options) {
  // Construct path
  var path = buildEntityPath(table, partitionKey, rowKey);

  // Construct query-string
  var query = {};
  if (options) {
    if (options.select) {
      query.$select = options.select.join(',');
    }
    if (options.filter) {
      query.$filter = options.filter;
    }
  }

  // Send request with retry logic
  return this.request('GET', path, query, {}).then(function(res) {
    if (res.statusCode !== 200) {
      throw new Error("getEntity: Unexpected statusCode: " + res.statusCode);
    }
    return utils.parseJSON(res.payload);
  });
};


/**
 * Query entities from `table`.
 *
 * @method queryEntitites
 * @param {string} table - Name of table to query entities for.
 * @param {object} options - Options on the following form:
 * ```js
 * {
 *   // Query options:
 *   select:            ['key1', ...],  // Keys to $select (defaults to all)
 *   filter:            'key1 eq true', // $filter string, see Table.filter
 *   top:               1000,           // Max number of entities to return
 *
 *   // Paging options:
 *   nextPartitionKey:  '...',          // nextPartitionKey from previous result
 *   nextRowKey:        '...'           // nextRowKey from previous result
 * }
 * ```
 * @return {Promise} A promise for an object on the form:
 * ```js
 * {
 *   entities: [
 *     {
 *       // Keys selected from entity and meta-data depending on meta-data level
 *     },
 *     ...
 *   ],
 *   nextPartitionKey: '...',  // Opaque token for paging
 *   nextRowKey:       '...'   // Opaque token for paging
 * }
 * ```
 */
Table.prototype.queryEntities = function queryEntities(table, options) {
  // Construct path
  var path = '/' + table + '()';

  // Construct query-string
  var query = {};
  if (options) {
    if (options.select) {
      query.$select = options.select.join(',');
    }
    if (options.filter) {
      query.$filter = options.filter;
    }
    if (options.top) {
      if (options.top > 1000) {
        throw new Error('queryEntities: Too Large Query: top of ' + options.top + ' > 1000');
      }
      query.$top = '' + options.top;
    }
    if (options.nextPartitionKey) {
      query.NextPartitionKey = options.nextPartitionKey;
    }
    if (options.nextRowKey) {
      query.NextRowKey = options.nextRowKey;
    }
  }

  // Send request with retry logic
  return this.request('GET', path, query, {}).then(function(res) {
    if (res.statusCode !== 200) {
      throw new Error("queryEntities: Unexpected statusCode: " +
                      res.statusCode);
    }

    // Read results from response
    var result            = utils.parseJSON(res.payload);
    var nextPartitionKey  = res.headers['x-ms-continuation-nextpartitionkey'];
    var nextRowKey        = res.headers['x-ms-continuation-nextrowkey'];

    // Return result in a nice format
    return {
      entities:           result.value || [],
      nextPartitionKey:   nextPartitionKey || null,
      nextRowKey:         nextRowKey || null
    };
  });
};


/**
 * Insert `entity` into `table`, the `entity` object must be on the format
 * accepted by azure table storage. See Azure Table Storage documentation for
 * details. Essentially, data-types will be inferred if `...@odata.type`
 * properties aren't specified. Also note that `PartitionKey` and `RowKey`
 * properties must be specified.
 *
 * @method insertEntity
 * @param {string} table - Name of table insert entity into.
 * @param {object} entity - Entity object, see Azure Table Storage
 * documentation for details on how to annotate types.
 * @return {Promise}
 * A promise for the `etag` of the inserted entity.
 */
Table.prototype.insertEntity = function insertEntity(table, entity) {
  // Construct path
  var path = '/' + table;
  // Construct headers
  var headers = {
    'prefer':       'return-no-content' // There is no reason to return content
  };
  // Send request with retry logic
  return this.request('POST', path, {}, headers, entity).then(function(res) {
    if (res.statusCode !== 204) {
      throw new Error("insertEntity: Unexpected statusCode: " + res.statusCode);
    }
    return res.headers['etag'];
  });
};


/**
 * Update entity from `table` identified by `entity.partitionKey` and
 * `entity.rowKey`.
 * Options are **required** for this method and takes form as follows:
 * ```js
 * {
 *   mode:  'replace' || 'merge'  // Replace entity or merge entity
 *   eTag:  '...' || '*' || null  // Update specific entity, any or allow insert
 * }
 * ```
 *
 * If `options.mode` is `'replace'` the remote entity will be completely
 * replaced by the structure given as `entity`. If `options.mode` is `'merge'`
 * properties from `entity` will overwrite existing properties on remote entity.
 *
 * If **`options.eTag` is not given** (or `null`) the remote entity will be
 * inserted if it does not exist, and otherwise replaced or merged depending
 * on `mode`.
 *
 * If **`options.eTag` is the string `'*'`** the remote entity will be replaced
 * or merged depending on `mode`, but it will not be inserted if it doesn't
 * exist.
 *
 * If **`options.eTag` is a string** (other than `'*'`) the remote entity will be
 * replaced or merged depending on `mode`, if the ETag of the remote entity
 * matches the string given in `options.eTag`.
 *
 * Combining `mode` and `eTag` options this method implements the following
 * operations:
 *  * Insert or replace (regardless of existence or ETag),
 *  * Replace if exists (regardless of ETag),
 *  * Replace if exists and has given ETag,
 *  * Insert or merge (regardless of existence or ETag),
 *  * Merge if exists (regardless of ETag), and
 *  * Merge if exists and has given ETag.
 *
 * @method updateEntity
 * @param {string} table - Name of table to update entity from.
 * @param {object} options - Options on the following form:
 * ```js
 * {
 *   mode:  'replace' || 'merge'  // Replace entity or merge entity
 *   eTag:  '...' || '*' || null  // Update specific entity, any or allow insert
 * }
 * ```
 * @return {Promise} A promise for `eTag` of the modified entity.
 */
Table.prototype.updateEntity = function updateEntity(table, entity, options) {
  assert(options, "Options is required for updateEntity");

  // Construct path
  var path = buildEntityPath(table, entity.PartitionKey, entity.RowKey);

  // Choose method
  var method;
  if (options.mode === 'replace') {
    method = 'PUT';
  } else if (options.mode === 'merge') {
    method = 'MERGE';
  } else {
    throw new Error("`options.mode` must be 'replace' or 'merge'");
  }

  // Construct headers
  var headers = {
    'prefer':       'return-no-content' // There is no reason to return content
  };
  if (options.eTag) {
    headers['if-match'] = options.eTag;  // Must be either '*' or an actual ETag
  }

  // Send request with retry logic
  return this.request(method, path, {}, headers, entity).then(function(res) {
    if (res.statusCode !== 204) {
      throw new Error("insertEntity: Unexpected statusCode: " + res.statusCode);
    }
    return res.headers['etag'];
  });
};


/**
 * Delete entity identified by `partitionKey` and `rowKey` from `table`.
 * Options are **required** for this method and takes form as follows:
 * ```js
 * {
 *   eTag:   '...' || '*'   // ETag to delete, or '*' to ignore ETag
 * }
 * ```
 *
 * Note, `options.eTag` is `'*'` will delete the entity regardless of its ETag.
 *
 * @method deleteEntity
 * @param {string} table - Name of table to delete entity from.
 * @param {string} partitionKey - Partition key of entity to delete.
 * @param {string} rowKey - Row key of entity to delete.
 * @param {object} options - Options on the following form:
 * ```js
 * {
 *   eTag:   '...' || '*'   // ETag to delete, or '*' to ignore ETag
 * }
 * ```
 * @returns {Promise} A promise that the entity was deleted.
 */
Table.prototype.deleteEntity = function deleteEntity(table, partitionKey,
                                                     rowKey, options) {
  // Construct path
  var path = buildEntityPath(table, partitionKey, rowKey);

  // Construct header
  assert(options && (options.eTag), "`options.eTag` must be given");
  var headers = {
    'if-match':    options.eTag
  };

  // Send request with retry logic
  return this.request('DELETE', path, {}, headers).then(function(res) {
    if (res.statusCode !== 204) {
      throw new Error("insertEntity: Unexpected statusCode: " + res.statusCode);
    }
  });
};