dojox
Version:
Dojo eXtensions, a rollup of many useful sub-projects and varying states of maturity – from very stable and robust, to alpha and experimental. See individual projects contain README files for details.
785 lines (670 loc) • 24.9 kB
JavaScript
define([
'dojo/_base/declare',
'dojo/Stateful',
'dojo/request',
'dojo/store/util/QueryResults',
'dojo/store/util/SimpleQueryEngine',
'dojo/_base/lang',
'dojo/_base/array',
'dojo/errors/RequestError',
'dojo/Deferred',
'../encoding/digests/SHA256',
'../encoding/digests/_base',
'dojo/has!host-node?dojo/node!url'
], function (
declare,
Stateful,
request,
createQueryResults,
SimpleQueryEngine,
lang,
arrayUtil,
RequestError,
Deferred,
hash,
digests,
nodeUrl
) {
// summary:
// This module provides a `dojo/store` interface to an Amazon DynamoDB table. It must be configured for a
// specific AWS region, and the user must provide permanent or temporary authorization credentials to allow
// access to the table.
//
// A typical store creation using temporary access credentials would look like:
// | var store new DynamoDB({
// | tableName: 'myData',
// | idProperty: 'id',
// | region: 'us-east-1',
// | credentials: {
// | AccessKeyId: 'abc123',
// | SecretAccessKey: 'def456',
// | SessionToken: 'ghi6789'
// | }
// | }));
//
// Note that this module supports version 4 AWS signatures, which is the version supported by the AWS SDKs.
// See http://docs.aws.amazon.com/general/latest/gr/signing_aws_api_requests.html for additional information
// about AWS request signing.
function hmac(key, data, outputType) {
// summary:
// Convenience function for computing the HMAC. Output is returned as a binary string by default to allow
// for successive calls.
// returns: string
outputType = outputType || digests.outputTypes.String;
return hash._hmac(data, key, outputType);
}
function getSigningKey(shortDate, secretKey, region) {
// summary:
// Get an AWS signing key for the current date.
// returns: string
this._signingDate = shortDate;
var kDate = hmac('AWS4' + secretKey, shortDate);
var kRegion = hmac(kDate, region);
var kService = hmac(kRegion, 'dynamodb');
return hmac(kService, 'aws4_request');
}
function toUTCString(date) {
// summary:
// Convert a given date to a UTC datetime string.
// returns: string
function twoDigit(value) {
value = String(value);
return value.length === 1 ? '0' + value : value;
}
var year = String(date.getUTCFullYear());
var month = twoDigit(date.getUTCMonth() + 1);
var day = twoDigit(date.getUTCDate());
var hour = twoDigit(date.getUTCHours());
var minute = twoDigit(date.getUTCMinutes());
var second = twoDigit(date.getUTCSeconds());
return year + month + day + 'T' + hour + minute + second + 'Z';
}
function getHostName(url) {
// summary:
// Extract the hostname from a URL.
// returns: string
if (typeof document !== 'undefined') {
var a = document.createElement('a');
a.href = url;
return a.hostname;
}
else {
url = nodeUrl.parse(url);
return url.hostname;
}
}
function getDynamoType(value) {
// summary:
// Return the corresponding DynamoDB type for the given value
// returns: string
if (value == null) {
// return NULL for null or undefined values
return 'NULL';
}
var type = typeof value;
if (type === 'string') {
return 'S';
}
if (type === 'boolean') {
return 'BOOL';
}
if (type === 'number') {
return 'N';
}
if (value instanceof Array) {
// DynamoDB array equivalent is 'List'
return 'L';
}
// default to 'Map', the DynamoDB equivalent to a JavaScript object
return 'M';
}
function getDynamoValue(value) {
// summary:
// Convert a JavaScript value into a DynamoDB typed object.
// returns: Object
var type = getDynamoType(value);
var dynamoValue = {};
var returnValue;
switch (type) {
case 'BOOL':
case 'S':
returnValue = value;
break;
case 'N':
returnValue = String(value);
break;
case 'NULL':
returnValue = true;
break;
case 'M':
returnValue = {};
for (var key in value) {
returnValue[key] = getDynamoValue(value[key]);
}
break;
case 'L':
returnValue = [];
for (var i = 0; i < value.length; i++) {
returnValue[i] = getDynamoValue(value[i]);
}
break;
default:
throw new Error('Unknown Dynamo type: ' + type);
}
dynamoValue[type] = returnValue;
return dynamoValue;
}
function getNativeValue(dynamoValue) {
// summary:
// Convert a DynamoDB value into a JavaScript value.
// returns: any
var type;
var value;
// fill in the type and value variables from the dynamoValue, which is a { type: value } object
for (type in dynamoValue) {
value = dynamoValue[type];
}
function parse(value) {
if (type === 'N') {
return Number(value);
}
if (type === 'NULL') {
return null;
}
if (type === 'L') {
return arrayUtil.map(value, function (value) {
return getNativeValue(value);
});
}
if (type === 'M') {
var returnValue = {};
for (var key in value) {
returnValue[key] = getNativeValue(value[key]);
}
return returnValue;
}
// strings, booleans, and binary data are returned as-is
return value;
}
if (type.charAt(1) === 'S') {
// type is a StringSet, NumberSet, or BinarySet -- set type to the contained type, parse each contained
// element, and return in an array
type = type.charAt(0);
return arrayUtil.map(value, parse);
}
return parse(value);
}
function toDynamoObject(object) {
// summary:
// Convert a JavaScript object to a format consumable by DynamoDB.
// returns: Object
var dynamoObject = {};
for (var k in object) {
dynamoObject[k] = getDynamoValue(object[k]);
}
return dynamoObject;
}
function fromDynamoObject(dynamoObject) {
// summary:
// Converts a DynamoDB hash map to one that conforms to normal JavaScript object conventions.
// returns: Object|null
if (!dynamoObject) {
return null;
}
var object = {};
for (var k in dynamoObject) {
object[k] = getNativeValue(dynamoObject[k]);
}
return object;
}
return declare(Stateful, {
// summary:
// DynamoDB provides a `dojo/store` interface to an Amazon DynamoDB table.
// tableName: string
// The name of the DynamoDB table being accessed by this store.
tableName: '',
// attributesToGet: Array?
// An optional array of attribute key names that should be fetched when retrieving an object from the
// table. If `null`, all attributes will be retrieved.
attributesToGet: null,
// consistentRead: boolean
// Whether or not to enforce consistent reads on the table.
consistentRead: false,
// maxRetries: number
// The maximum number of times a request to the server should be retried before treating it as a failure.
maxRetries: 5,
// idProperty: string|string[]
// The names of the property or properties that are used as the primary key within the DynamoDB. If using
// a hash table, this should be the hash key name; if using a hash+range table, this should be an array
// of both the hash and range key names.
idProperty: 'id',
// queryEngine: SimpleQueryEngine
// Provides basic support for using DynamoDB with Observable. Not necessarily reliable since it does
// not account for record changes on the server or any special server sort ordering.
queryEngine: SimpleQueryEngine,
// region: string
// The AWS region that the target DynamoDB instance is running in.
region: null,
// endpointUrl: string?
// An optional URL to the DynamoDB endpoint. If this URL not specified, it will be generated using the
// region property.
endpointUrl: null,
// credentials: object?
// An optional object containing AWS authorization credentials. Currently three properties are
// used:
// AccessKeyId: string
// An AWS access key, temporary or permanent.
// SecretAccessKey: string
// An AWS secret key, temporary or permanent.
// SessionToken: string?
// An optional temporary session token.
//
// The credentials object uses AWS standard property names, so the Credentials property of a
// credentials object returned by the Security Token Service can be used directly with the DynamoDB
// store. More information about temporary credentials and security tokens is available at
// http://docs.aws.amazon.com/STS/latest/UsingSTS/Welcome.html
credentials: null,
_signRequest: function (/*object*/ request, /*Date?*/ date) {
// summary:
// Sign an object containing request data. The result of signing is that three additional headers
// will be added to the request. See http://docs.aws.amazon.com/general/latest/gr/sigv4_signing.html
// for more information about the signing process.
//
// request: Object
// Object with keys:
// body: request body
// host: DynamoDB host
// headers: Request headers -- these will be updated
// method: HTTP method
// date: Date
// Optional time of the request. If not specified, the current time is used.
// returns:
// None
date = toUTCString(date || new Date());
var shortDate = date.slice(0, 8);
var secretKey = this.credentials.SecretAccessKey;
var accessKey = this.credentials.AccessKeyId;
var sessionToken = this.credentials.SessionToken;
var tokenHeader = '';
var tokenTag = '';
request.headers['x-amz-date'] = date;
if (sessionToken) {
tokenHeader = 'x-amz-security-token:' + sessionToken + '\n';
tokenTag = ';x-amz-security-token';
request.headers['x-amz-security-token'] = sessionToken;
}
var canonicalRequest = request.method + '\n/\n\n' +
'host:' + request.host + '\n' +
'x-amz-date:' + date + '\n' +
tokenHeader +
'x-amz-target:' + request.headers['x-amz-target'] + '\n' +
'\n' +
'host;x-amz-date' + tokenTag +
';x-amz-target\n' + hash(request.body, digests.outputTypes.Hex);
var key = getSigningKey(shortDate, secretKey, this.region);
var stringToSign = 'AWS4-HMAC-SHA256\n' +
date + '\n' +
shortDate + '/' + this.region + '/dynamodb/aws4_request\n' +
hash(canonicalRequest, digests.outputTypes.Hex);
var signature = hmac(key, stringToSign, digests.outputTypes.Hex);
request.headers.authorization = 'AWS4-HMAC-SHA256 Credential=' + accessKey + '/' + shortDate + '/' +
this.region + '/dynamodb/aws4_request,SignedHeaders=host;x-amz-date;x-amz-target' + tokenTag +
',Signature=' + signature;
},
_rpc: function (/**string*/ action, /**Object*/ data) {
// summary:
// A convenience method for sending requests to DynamoDB with improved error reporting and error
// retries
// (<http://docs.aws.amazon.com/amazondynamodb/latest/developerguide/ErrorHandling.html#APIRetries>).
// action:
// The name of the action to perform. See
// <http://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_Operations.html> for a list.
// data:
// The request payload.
// returns: dojo/promise/Promise -> Object
var timeoutId;
var requestPromise;
var dfd = new Deferred(function () {
requestPromise && requestPromise.cancel.apply(request, arguments);
clearTimeout(timeoutId);
});
var headers = {
'Content-Type': 'application/x-amz-json-1.0',
'x-amz-target': 'DynamoDB_20120810.' + action
};
data.TableName = this.tableName;
data = JSON.stringify(data);
// if no endpoint URL has been specified, create one from the AWS region
if (!this.endpointUrl) {
this.endpointUrl = 'https://dynamodb.' + this.region + '.amazonaws.com';
}
// only sign the request if credentials were provided
if (this.credentials) {
this._signRequest({
body: data,
host: getHostName(this.endpointUrl),
method: 'POST',
headers: headers
});
}
var endpointUrl = this.endpointUrl;
var maxRetries = this.maxRetries;
var currentRetry = 0;
(function sendRequest() {
requestPromise = request.post(endpointUrl, {
data: data,
headers: headers,
handleAs: 'json'
}).then(lang.hitch(dfd, 'resolve'), function (error) {
var response = error.response;
if (++currentRetry === maxRetries) {
dfd.reject(error);
return;
}
if (response.status >= 500 || response.status < 400 ||
(response.status === 400 && response.data && response.data.__type &&
(response.data.__type.indexOf('#ProvisionedThroughputExceededException') > -1 ||
response.data.__type.indexOf('#ThrottlingException') > -1))
) {
timeoutId = setTimeout(sendRequest, Math.pow(2, currentRetry) * 50);
}
else {
dfd.reject(error);
}
});
})();
return dfd.promise.then(function (response) {
return response;
}, function (error) {
var errorInfo = error.response && error.response.data;
if (errorInfo) {
throw new RequestError(errorInfo.__type + ': ' + errorInfo.message, error.response);
}
throw error;
});
},
_getKeyFromId: function (/**string|Array*/ id) {
// summary:
// Generates a DynamoDB attribute map from a DynamoDB scalar identity value.
// id:
// An opaque record identifier created by `DynamoDB#getIdentity`, or an array of primary key
// values.
// returns: Object
var key = {};
if (this.idProperty instanceof Array) {
if (typeof id === 'string') {
id = arrayUtil.map(id.split('/'), function (value) {
var type = value.charAt(0);
value = value.slice(1);
if (type === 'N') {
value = +value;
}
return value;
});
}
arrayUtil.forEach(id, function (value, index) {
key[this.idProperty[index]] = getDynamoValue(value);
}, this);
}
else {
key[this.idProperty] = getDynamoValue(id);
}
return key;
},
get: function (/**string|number|Array*/ id) {
// summary:
// Retrieves a single record from the table.
// id:
// The identifier for the record. If using a hash table, a scalar value corresponding to the hash key
// of the table. If using a hash+range table, an array of values corresponding to the keys specified
// in the `DynamoDB#idProperty` array, or a serialized ID in the format of `type + value + "/" +
// type + value` (e.g. `N1234/Sfoo`).
// returns: dojo/promise/Promise -> Object
// The object from the server, or `null` if it does not exist.
var data = {
Key: this._getKeyFromId(id),
ConsistentRead: this.consistentRead
};
if (this.attributesToGet) {
data.AttributesToGet = this.attributesToGet;
}
return this._rpc('GetItem', data).then(function (object) {
return fromDynamoObject(object.Item);
});
},
getIdentity: function (/**Object*/ object) {
// summary:
// Generates and returns an opaque scalar identifier for a given object.
// object:
// A data object from this store.
// returns: string
var id;
if (this.idProperty instanceof Array) {
id = arrayUtil.map(this.idProperty, function (property) {
return getDynamoType(object[property]) + object[property];
}).join('/');
}
else {
id = object[this.idProperty];
}
return id;
},
query: function (/**Object*/ query, /**Object?*/ options) {
// summary:
// Retrieves multiple records from the table. Additional restrictions exist for DynamoDB that
// do not exist with a standard SQL database.
// query:
// A hash map of values to query for. If the value is an array, DynamoDB will search for any of the
// specified values for that array. The result sets are combined using an AND operator, so all
// specified attributes must match for a record to be returned.
//
// For a query on a table, you can only have conditions on the table primary key attributes. You can
// optionally specify a second condition, referring to the range key attribute.
//
// For a query on a secondary index, you can only have conditions on the index key attributes. You
// can optionally specify a second condition, referring to the index key range attribute.
// options:
// A set of options. The following options are supported by DynamoDB, with limitations described
// below:
// * `start` (number|Object): The record to start querying from. Because DynamoDB does not support
// starting a set of query results from an arbitrary numeric index, if a number is provided, all
// results up to `start + count` will be retrieved on each request.
// * `count` (number): The number of records to retrieve. If `start` is a number, this will be
// combined with `start` and used as the Limit for the request.
// * `indexName` (string): The name of the secondary index to query against. If this property
// is not explicitly specified but a `sort` option is specified, the name of the sort attribute
// will be used as the name of the index to use.
// * `sort` (Array): An array containing a single `{ attribute, descending }` object. Only a single
// sort dimension is supported, and the attribute must match the range key of the index being
// queried.
// * `fetchTotal` (boolean): Whether or not to retrieve the total number of available records. This
// requires multiple requests to DynamoDB. Defaults to `true`.
// * `filter`: (Object): An object defining a DynamoDB filter expression. This object must contain
// the following properties:
// * `FilterExpression`: (string) A filter expression string. This property is required.
// * `ExpressionAttributeValues`: (Object) An optional mapping of value placeholder strings to
// actual values. Note that value placeholders must start with ':'.
// * `ExpressionAttributeNames`: (Object) An optional mapping of name placeholder strings to full
// name strings. This is typically used when a filter expression uses a property name that is a
// DynamoDB reserved word, such as 'name'. Note that name placeholders must start with '#'.
// Below is an example filter that will cause the result set to contain only items whose `name`
// property contains tthe word "The".
// | filter: {
// | FilterExpression: 'contains(#n, :word)',
// | ExpressionAttributeNames: { '#n': 'name' },
// | ExpressionAttributeValues: { ':word': 'The' }
// | }
// For more information about DynamoDB query syntax, see:
// http://docs.aws.amazon.com/amazondynamodb/latest/developerguide/QueryAndScan.html.
// returns: dojo/store/util/QueryResults
// A list of objects matching the query.
// jshint maxcomplexity:17
function copyFilterMap(map) {
var values = {};
for (var key in map) {
values[key] = getDynamoValue(map[key]);
}
return values;
}
options = options || {};
var data = {
KeyConditions: {},
ConsistentRead: this.consistentRead
};
if (this.attributesToGet) {
data.AttributesToGet = this.attributesToGet;
}
if (options.indexName) {
data.IndexName = options.indexName;
}
if (options.sort && options.sort.length) {
if (options.sort.length > 1) {
throw new Error('Cannot sort by more than one dimension');
}
if (!data.IndexName) {
data.IndexName = options.sort[0].attribute;
}
data.ScanIndexForward = !options.sort[0].descending;
}
for (var k in query) {
var value = query[k];
data.KeyConditions[k] = {
AttributeValueList: value instanceof Array ? arrayUtil.map(value, getDynamoValue) :
[ getDynamoValue(value) ],
ComparisonOperator: 'EQ'
};
}
if (options.filter) {
var filter = options.filter;
data.FilterExpression = filter.FilterExpression;
if (filter.ExpressionAttributeValues) {
data.ExpressionAttributeValues = copyFilterMap(filter.ExpressionAttributeValues);
}
if (filter.ExpressionAttributeNames) {
data.ExpressionAttributeNames = filter.ExpressionAttributeNames;
}
}
var dfd = new Deferred(function () {
request && request.cancel.apply(request, arguments);
});
var response = createQueryResults(dfd.promise);
if (options.fetchTotal !== false) {
response.total = this._rpc('Query', lang.mixin({}, data, {
Select: 'COUNT'
})).then(function (response) {
return response.Count;
});
}
var self = this;
var result = [];
var skipRecords = typeof options.start === 'number' ? options.start : 0;
var recordsToRetrieve = (skipRecords + (options.count || 0)) || Infinity;
var request;
(function nextQuery(nextStartKey) {
data.Limit = recordsToRetrieve < Infinity ? recordsToRetrieve : undefined;
data.ExclusiveStartKey = nextStartKey;
request = self._rpc('Query', data).then(function (response) {
if (response.Items.length) {
var newData = arrayUtil.map(response.Items.slice(skipRecords), fromDynamoObject);
if (skipRecords > 0) {
skipRecords = Math.max(skipRecords - newData.length, 0);
}
recordsToRetrieve -= newData.length;
result = result.concat(newData);
}
// DynamoDB has a 1MB limit per request; if we have not retrieved all the requested records when the
// limit is reached, the response will contain a LastEvaluatedKey value that can be used to continue
// the query in a subsequent operation.
if (recordsToRetrieve > 0 && response.LastEvaluatedKey) {
nextQuery(response.LastEvaluatedKey);
}
else {
dfd.resolve(result);
}
}, lang.hitch(dfd, 'reject'));
})(typeof options.start === 'object' ? toDynamoObject(options.start) : undefined);
return response;
},
remove: function (/**string|number|Array*/ id, /**Object*/ options) {
// summary:
// Removes a record from the table.
// id:
// See `DynamoDB#get` for information on the structure of the identifier. Note that you must use the
// serialized ID format if using a hash+range table and wrapping DynamoDB with Observable.
// options:
// Additional options for the remove operation:
// * `expected` (Object): The object expected to exist on the server for the given identifier. If the
// expected object does not match the object on the server, the remove operation will fail.
// returns: dojo/promise/Promise -> Object
// The old object that was removed from the server, or `null` if there was no old object.
options = options || {};
var data = {
Key: this._getKeyFromId(id),
ReturnValues: 'ALL_OLD'
};
if (options.expected) {
data.Expected = {};
for (var k in options.expected) {
data.Expected[k] = {
Value: getDynamoValue(options.expected[k])
};
}
}
return this._rpc('DeleteItem', data).then(function (object) {
return fromDynamoObject(object.Attributes);
});
},
put: function (/**Object*/ object, /**Object?*/ options) {
// summary:
// Puts an object into the table.
// object:
// The object to put into the table.
// options:
// Additional options for the put operation:
// * `overwrite` (boolean): If set to `false`, and an object with the same identifier as the one
// being put into the table already exists, the put will fail.
// * `id` (string|number|Array): An identifier that will be assigned to the object before it is put
// to the store, overriding any identifier that already exists on the object. Note that you must
// use the serialized ID format if using a hash+range table and wrapping DynamoDB with Observable.
// * `expected` (Object): The object expected to exist on the server for the given identifier. If the
// expected object does not match the object on the server, the remove operation will fail.
// returns: dojo/promise/Promise
options = options || {};
var data = {
Item: toDynamoObject(object)
};
if (options.id) {
lang.mixin(data.Item, this._getKeyFromId(options.id));
}
if (options.overwrite === false) {
data.Expected = {};
var idProperties = this.idProperty instanceof Array ? this.idProperty : [ this.idProperty ];
arrayUtil.forEach(idProperties, function (property) {
data.Expected[property] = { Exists: false };
});
}
else if (options.expected) {
data.Expected = {};
for (var k in options.expected) {
data.Expected[k] = {
Value: getDynamoValue(options.expected[k])
};
}
}
return this._rpc('PutItem', data).then(function () {
// Ensure that no data is returned from the rpc promise chain since a returned object would override the
// object passed to `put` by Observable
});
},
add: function (/**Object*/ object, /**Object?*/ options) {
// summary:
// Puts an object to the table only if it does not already exist. See `DynamoDB#put` for more
// information.
// returns: dojo/promise/Promise
// See `DynamoDB#put` for more information.
options = options || {};
options.overwrite = false;
return this.put(object, options);
}
});
});