diff --git a/README.md b/README.md index 2c74758..7e172e5 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Javascript DynamoDB Context Store +# Javascript DynamoDB Context Store Used by the [SmartApp SDK](https://github.com/SmartThingsCommunity/smartapp-sdk-nodejs) to store IDs and access tokens for an installed instance of a SmartApp and retrieves that information for use in asynchronous API calls. The use of a context store @@ -12,33 +12,114 @@ The context stored by this module consists of the following data elements: * **locationId**: the UUID of the location in which the app is installed * **authToken**: the access token used in calling the API * **refreshToken**: the refresh token used in generating a new access token when one expires -* **clientId**: the SmartApp's client ID, used in generating a new access token -* **clientSecret**: the SmartApp's client secret, used in generating a new access token -* **config**: the current installed app instance configuration, i.e. selected devices, options, etc.v +* **config**: the current installed app instance configuration, i.e. selected devices, options, etc. + +_Note: Version 2.X.X is a breaking change to version 1.X.X as far as configuring the context store is +concerned, but either one can be used with any version of the SmartThings SDK._ ## Installation: ``` -npm install @smartthings/dynamodb-context-store --save +npm install @smartthings/dynamodb-context-store ``` ## Usage -To use this module to add DynamoDB context storage to your SmartApp you should: -1. Create a DynamoDB table with `installedAppId` as its primary key +Create a `DynamoDBContextStore` object and pass it to the SmartApp connector to store the context in a table +named `"smartapp"` in the `us-east-1` AWS region. If the table does not exist it will be created. +```javascript +smartapp.contextStore(new DynamoDBContextStore()) +``` + +The more extensive set of options are shown in this example: +```javascript +smartapp.contextStore(new DynamoDBContextStore( + { + table: { + name: 'custom-table', // defaults to 'smartapp' + hashKey: 'key1', // defaults to 'id' + prefix: 'context', // defaults to 'ctx' + readCapacityUnits: 10, // defaults to 5, applies to automatic creation only + writeCapacityUnits: 10 // defaults to 5, applies to automatic creation only + }, + AWSRegion: 'us-east-2', // defaults to 'us-east-1' + autoCreate: true // defaults to true + } +)) +``` + +The **table** configuration options are: +* **name** -- The name of the DynamoDB table storing the context +* **hashKey** -- The name of the partition key of the table +* **prefix** -- A string pre-pended to the installed app ID and used as the partition key for the entry +* **readCapacityUnits** -- Number of consistent reads per second. Used only when table is created +* **writeCapacityUnits** -- Number of writes per second. Used only when table is created +* **sortKey** -- Optional sort key definition (see below for more details) -1. Give your Lambda permission to access that table +Other configuration options are: +* **AWSRegion** -- The AWS region containing the table +* **AWSConfigPath** -- The location of the AWS configuration JSON file +* **AWSConfigJSON** -- The AWS credentials and region +* **autoCreate** -- Controls whether table is created if it doesn't already exist -1. Create a context store instance with the table name and AWS region and pass it to the -smartapp SDK object. For example, the following code: +Note that only one of the AWS options should be specified or behavior will be inconsistent +### AWS Configuration Options + +By default, the AWS credentials are picked up from the environment. If you prefer you can read the credentials +from a file with this configuration: +```javascript +smartapp.contextStore(new DynamoDBContextStore( + { + AWSConfigPath: './path/to/file.json' + } +)) ``` -const smartapp = require('@smartthings/dynamodb-context-store'); -const DynamoDBContextStore = require('@smartthings/dynamodb-context-store'); -smartapp.contextStore(new DynamoDBContextStore('us-east-2', 'app-table-name')) - .configureI18n() - .page('mainPage', (page) => { - ... + +You can also explicitly set the credentials in this way: +```javascript +smartapp.contextStore(new DynamoDBContextStore( + { + AWSConfigJSON: { + accessKeyId: '', + secretAccessKey: '', + region: 'us-east-2' + } + } +)) +```` + +### Sort Key Configuration +In order to support single table schemas, the context store can be configured to use a table with a sort key. +The simplest way to do that is by specifying the sort key name: +```javascript +smartapp.contextStore(new DynamoDBContextStore( + { + table: { + name: 'my-application', + hashKey: 'pk', + sortKey: 'sk' + } + } +)) + ``` +More control over the sort key can be exercised using this form, which is configured with the default values +used when just the sort key name is specified: +```javascript +smartapp.contextStore(new DynamoDBContextStore( + { + table: { + name: 'my-application', + hashKey: 'pk', + sortKey: { + AttributeName: 'sk', + AttributeType: 'S', + AttributeValue: 'context', + KeyType: 'RANGE' + } + } + } +)) -will use a table named `app-table-name` in the `us-east-2` region. \ No newline at end of file +``` diff --git a/index.js b/index.js index 2400cf4..a2b4424 100644 --- a/index.js +++ b/index.js @@ -1,102 +1,3 @@ 'use strict'; -const AWS = require('aws-sdk'); -module.exports = class DynamoDBContextStore { - constructor(region, tableName) { - this.region = region; - this.tableName = tableName; - AWS.config.update({region: region}); - this.docClient = new AWS.DynamoDB.DocumentClient({apiVersion: '2012-08-10'}); - } - - get(installedAppId) { - let params = { - TableName: this.tableName, - Key: { - installedAppId: installedAppId - }, - ConsistentRead: true - }; - return new Promise((resolve, reject) => { - this.docClient.get(params, function(err, data) { - if (err) { - reject(err); - } else { - if (data.Item) { - let result = data.Item; - - // For backward compatibility with version 1.0.1 - if (typeof result.config === 'string') { - result.config = JSON.parse(result.config); - } - resolve(result); - } - else { - resolve({}); - } - } - }); - }); - } - - put(params) { - const data = { - TableName: this.tableName, - Item: { - installedAppId: params.installedAppId, - locationId: params.locationId, - authToken: params.authToken, - refreshToken: params.refreshToken, - config: params.config - } - }; - return new Promise((resolve, reject) => { - this.docClient.put(data, function(err, data) { - if (err) { - reject(err); - } else { - resolve(data); - } - }); - }); - } - - update(installedAppId, params) { - const data = { - TableName: this.tableName, - Key: {'installedAppId': installedAppId}, - UpdateExpression: 'SET authToken = :x, refreshToken = :y', - ExpressionAttributeValues: { - ':x': params.authToken, - ':y': params.refreshToken - } - }; - return new Promise((resolve, reject) => { - this.docClient.update(data, function(err, data) { - if (err) { - reject(err); - } else { - resolve(data); - } - }); - }); - } - - delete(installedAppId) { - let params = { - TableName: this.tableName, - Key: { - installedAppId: installedAppId - } - }; - return new Promise((resolve, reject) => { - this.docClient.delete(params, function(err, data) { - if (err) { - reject(err); - } else { - resolve(data); - } - }); - }); - } -}; \ No newline at end of file +module.exports = require('./lib/DynamoDBContextStore') diff --git a/lib/DynamoDBContextStore.js b/lib/DynamoDBContextStore.js new file mode 100644 index 0000000..de6d3a2 --- /dev/null +++ b/lib/DynamoDBContextStore.js @@ -0,0 +1,259 @@ +'use strict'; +const AWS = require('aws-sdk'); +const primaryKey = Symbol("private"); +const createTableIfNecessary = Symbol("private"); + +module.exports = class DynamoDBContextStore { + + /** + * @typedef {Object} TableOptions + * @prop {String=} name The name of the table + * @prop {String=} hashKey The name of the table + * @prop {String=} prefix String pre-pended to the app ID and used for the hashKey + * @prop {String|Object=} sortKey Optional sort key definition + * @prop {Number=} readCapacityUnits Number of consistent reads per second. Used only when table is created + * @prop {Number=} writeCapacityUnits Number of writes per second. Used only when table is created + */ + /** + * @typedef {Object} DynamoDBContextStoreOptions + * @prop {TableOptions=} table The table options + */ + /** + * Create a context store instance instance + * @param {DynamoDBContextStoreOptions} [options] Optionally, pass in a configuration object + */ + constructor(options = {}) { + this.table = { + name: "smartapp", + hashKey: 'id', + sortKey: null, + prefix: 'ctx:' + }; + + if (options.table) { + const table = options.table; + this.table.name = table.name || this.table.name; + this.table.hashKey = table.hashKey || this.table.hashKey; + this.table.prefix = table.prefix || this.table.prefix; + if (typeof options.table.sortKey === 'string') { + this.table.sortKey = { + AttributeName: options.table.sortKey, + AttributeType: 'S', + AttributeValue: 'context', + KeyType: 'RANGE' + } + } else { + this.table.sortKey = options.table.sortKey + } + } + + if (options.client) { + this.client = options.client; + } + else { + if (options.AWSConfigPath) { + AWS.config.loadFromPath(options.AWSConfigPath); + } + else if (options.AWSConfigJSON) { + AWS.config.update(options.AWSConfigJSON); + } + else { + this.AWSRegion = options.AWSRegion || 'us-east-1'; + AWS.config.update({region: this.AWSRegion}); + } + this.client = new AWS.DynamoDB(); + } + + if (options.autoCreate !== false) { + this[createTableIfNecessary](options); + } + } + + get(installedAppId) { + let params = { + TableName: this.table.name, + Key: { + [this.table.hashKey]: {S: this[primaryKey](installedAppId)} + }, + ConsistentRead: true + }; + + if (this.table.sortKey) { + params.Key[this.table.sortKey.AttributeName] = {[this.table.sortKey.AttributeType]: this.table.sortKey.AttributeValue} + } + + return new Promise((resolve, reject) => { + this.client.getItem(params, function(err, data) { + if (err) { + reject(err); + } else { + if (data && data.Item) { + const item = data.Item + resolve({ + installedAppId: AWS.DynamoDB.Converter.output(item.installedAppId), + locationId: AWS.DynamoDB.Converter.output(item.locationId), + authToken: AWS.DynamoDB.Converter.output(item.authToken), + refreshToken: AWS.DynamoDB.Converter.output(item.refreshToken), + config: AWS.DynamoDB.Converter.output(data.Item.config), + state: AWS.DynamoDB.Converter.output(data.Item.state) + }); + } + else { + resolve({}); + } + } + }); + }); + } + + put(params) { + const data = { + TableName: this.table.name, + Item: { + [this.table.hashKey]: {S: this[primaryKey](params.installedAppId)}, + installedAppId: {S: params.installedAppId}, + locationId: {S: params.locationId}, + authToken: {S: params.authToken}, + refreshToken: {S: params.refreshToken}, + config: AWS.DynamoDB.Converter.input(params.config), + state: AWS.DynamoDB.Converter.input(params.state) + } + }; + + if (this.table.sortKey) { + data.Item[this.table.sortKey.AttributeName] = {[this.table.sortKey.AttributeType]: this.table.sortKey.AttributeValue} + } + + return new Promise((resolve, reject) => { + this.client.putItem(data, function(err, data) { + if (err) { + reject(err); + } else { + resolve(data); + } + }); + }); + + } + + update(installedAppId, params) { + const names = {}; + const values = {}; + const expressions = []; + for (const name of Object.keys(params)) { + + const expressionNameKeys = []; + const nameSegs = name.split('.'); + for (const i in nameSegs) { + const nameKey = `#${nameSegs.slice(0,i+1).join('_')}`; + names[nameKey] = nameSegs[i]; + expressionNameKeys.push(nameKey) + } + const valueKey = `:${nameSegs.join('_')}`; + values[valueKey] = AWS.DynamoDB.Converter.input(params[name]); + expressions.push(`${expressionNameKeys.join('.')} = ${valueKey}`) + } + + const data = { + TableName: this.table.name, + Key: { + [this.table.hashKey]: {S: this[primaryKey](installedAppId)} + }, + UpdateExpression: 'SET ' + expressions.join(', '), + ExpressionAttributeNames: names, + ExpressionAttributeValues: values + }; + + if (this.table.sortKey) { + data.Key[this.table.sortKey.AttributeName] = {[this.table.sortKey.AttributeType]: this.table.sortKey.AttributeValue} + } + + console.log(JSON.stringify(data, null, 2)); + return new Promise((resolve, reject) => { + this.client.updateItem(data, function(err, data) { + if (err) { + reject(err); + } else { + resolve(data); + } + }); + }); + } + + delete(installedAppId) { + let params = { + TableName: this.table.name, + Key: { + [this.table.hashKey]: {S: this[primaryKey](installedAppId)} + } + }; + + if (this.table.sortKey) { + data.Key[this.table.sortKey.AttributeName] = {[this.table.sortKey.AttributeType]: this.table.sortKey.AttributeValue} + } + + return new Promise((resolve, reject) => { + this.client.deleteItem(params, function(err, data) { + if (err) { + reject(err); + } else { + resolve(data); + } + }); + }); + } + + [primaryKey](installedAppId) { + return `${this.table.prefix}${installedAppId}`; + } + + [createTableIfNecessary](options) { + return this.client.describeTable({"TableName": this.table.name}).promise() + .then(table => { + console.log(`DynamoDB context table ${this.table.name}, exists`) + }) + .catch(err => { + if (err.code === 'ResourceNotFoundException') { + console.log(`DynamoDB context table ${this.table.name}, creating`); + const params = { + "TableName": this.table.name, + "AttributeDefinitions": [ + { + "AttributeName": this.table.hashKey, + "AttributeType": "S" + } + ], + "KeySchema": [ + { + "KeyType": "HASH", + "AttributeName": this.table.hashKey + } + ], + "ProvisionedThroughput": { + "WriteCapacityUnits": options.table ? (options.table.writeCapacityUnits || 5) : 5, + "ReadCapacityUnits": options.table ? (options.table.readCapacityUnits || 5) : 5 + } + } + + if (this.table.sortKey) { + params.AttributeDefinitions.push({ + "AttributeName": this.table.sortKey.AttributeName, + "AttributeType": this.table.sortKey.AttributeType + }); + params.KeySchema.push({ + "KeyType": this.table.sortKey.KeyType, + "AttributeName": this.table.sortKey.AttributeName, + }) + } + + return this.client.createTable(params).promise() + } + else { + console.error('Error creating DynamoDB table %j', err); + return new Promise((resolve, reject) => { + reject(err) + }) + } + }) + } +}; diff --git a/package-lock.json b/package-lock.json index b211741..4cb9c55 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@smartthings/dynamodb-context-store", - "version": "1.0.0", + "version": "2.0.0", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index f123278..c812363 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@smartthings/dynamodb-context-store", - "version": "1.0.2", + "version": "2.0.0", "description": "Stores SmartApp configuration and auth tokens for use in app-initiated calls", "main": "index.js", "scripts": {