diff --git a/README.md b/README.md index 89da82e..5f3224c 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ The context stored by this module consists of the following data elements: * **authToken**: the access token used in calling the API * **refreshToken**: the refresh token used in generating a new access token when one expires * **config**: the current installed app instance configuration, i.e. selected devices, options, etc. +* **state**: name-value storage for the installed app instance. This is useful for storing information + between invocations of the SmartApp. It's not retried by the `get` method, but rather by `getItem`. **_Note: Version 3.X.X is a breaking change to version 2.X.X as far as configuring the context store is concerned, but either one can be used with any version of the SmartThings SDK. The new state storage @@ -142,3 +144,9 @@ smartapp.contextStore(new DynamoDBContextStore( } )) ``` + +## State Storage + +The context store can also be used to store state information for the installed app instance. This is +particularly useful for SmartApps that are not stateless, i.e. they need to remember information between +invocations. The state storage functions are only available with version 5.X.X or later of the SDK. diff --git a/jest-dynamodb-config.js b/jest-dynamodb-config.js index 1de7964..088727f 100644 --- a/jest-dynamodb-config.js +++ b/jest-dynamodb-config.js @@ -1,5 +1,11 @@ module.exports = { tables: [ + { + TableName: 'context-store-test-0', + KeySchema: [{AttributeName: 'id', KeyType: 'HASH'}], + AttributeDefinitions: [{AttributeName: 'id', AttributeType: 'S'}], + ProvisionedThroughput: {ReadCapacityUnits: 1, WriteCapacityUnits: 1}, + }, { TableName: 'context-store-test-1', KeySchema: [{AttributeName: 'id', KeyType: 'HASH'}], diff --git a/lib/dynamodb-context-store.d.ts b/lib/dynamodb-context-store.d.ts index 587c95c..4a0faf6 100644 --- a/lib/dynamodb-context-store.d.ts +++ b/lib/dynamodb-context-store.d.ts @@ -1,20 +1,24 @@ import { DynamoDBClient, DynamoDBClientConfig, KeySchemaElement } from '@aws-sdk/client-dynamodb' export interface ContextObject { - installedAppId: string + installedAppId: string; locationId: string - locale: string - authToken: string - refreshToken: string - config: any - state: any + locale: string; + authToken: string; + refreshToken: string; + config: any; + state: any; } export interface DynamoDBContextStore { - get(installedAppId: string): Promise - put(installedAppId: string, context: ContextObject): Promise - update(installedAppId: string, context: Partial): Promise - delete(installedAppId: string): Promise + get(installedAppId: string): Promise; + put(installedAppId: string, context: ContextObject): Promise; + update(installedAppId: string, context: Partial): Promise; + delete(installedAppId: string): Promise; + getItem(installedAppId: string, key: string): Promise; + putItem(installedAppId: string, key: string, value: any): Promise; + removeItem(installedAppId: string, key: string): Promise; + removeAllItems(installedAppId: string): Promise; } export interface ExtendedKeySchemaElement extends KeySchemaElement { diff --git a/lib/dynamodb-context-store.js b/lib/dynamodb-context-store.js index cb6257c..fdbd273 100644 --- a/lib/dynamodb-context-store.js +++ b/lib/dynamodb-context-store.js @@ -4,6 +4,7 @@ const { DeleteCommand, GetCommand, PutCommand, UpdateCommand } = require('@aws-s const primaryKey = Symbol('private') const createTableIfNecessary = Symbol('private') +const addStateRecord = Symbol('private') module.exports = class DynamoDBContextStore { /** @@ -63,7 +64,8 @@ module.exports = class DynamoDBContextStore { Key: { [this.table.hashKey]: this[primaryKey](installedAppId) }, - ConsistentRead: true + ConsistentRead: true, + ProjectionExpression: 'installedAppId, locationId, locale, authToken, refreshToken, config' } if (this.table.sortKey) { @@ -170,10 +172,120 @@ module.exports = class DynamoDBContextStore { await this.documentClient.send(new DeleteCommand(params)) } + /** + * Get the value of the key from the context store state property + * @param installedAppId the installed app identifier + * @param key the name of the property to retrieve + * @returns {Promise<*>} + */ + async getItem(installedAppId, key) { + const params = { + TableName: this.table.name, + Key: { + [this.table.hashKey]: this[primaryKey](installedAppId) + }, + ExpressionAttributeNames: {'#key': key, '#state': 'state'}, + ProjectionExpression: '#state.#key' + } + + if (this.table.sortKey) { + params.Key[this.table.sortKey.AttributeName] = this.table.sortKey.AttributeValue + } + + const response = await this.documentClient.send(new GetCommand(params)) + return response.Item && response.Item.state ? response.Item.state[key] : undefined + } + + /** + * Set the value of the key in the context store state property + * @param installedAppId the installed app identifier + * @param key the name of the property to set + * @param value the value to set + * @returns {Promise<*>} + */ + async setItem(installedAppId, key, value) { + try { + const params = { + TableName: this.table.name, + Key: { + [this.table.hashKey]: this[primaryKey](installedAppId) + }, + UpdateExpression: 'SET #state.#key = :value', + ExpressionAttributeNames: {'#key': key, '#state': 'state'}, + ExpressionAttributeValues: {':value': value}, + } + + if (this.table.sortKey) { + params.Key[this.table.sortKey.AttributeName] = this.table.sortKey.AttributeValue + } + + await this.documentClient.send(new UpdateCommand(params)) + } catch (error) { + if (error.name === 'ValidationException' && + error.message === 'The document path provided in the update expression is invalid for update') { + await this[addStateRecord](installedAppId, key, value) + } else { + throw error + } + } + + return value + } + + /** + * Remove the key from the context store state property + * @param installedAppId the installed app identifier + * @param key the name of the property to remove + * @returns {Promise} + */ + async removeItem(installedAppId, key) { + const params = { + TableName: this.table.name, + Key: { + [this.table.hashKey]: this[primaryKey](installedAppId) + }, + UpdateExpression: 'REMOVE #state.#key', + ExpressionAttributeNames: {'#key': key, '#state': 'state'}, + } + + if (this.table.sortKey) { + params.Key[this.table.sortKey.AttributeName] = this.table.sortKey.AttributeValue + } + + await this.documentClient.send(new UpdateCommand(params)) + } + + /** + * Clear the context store state property + * @param installedAppId the installed app identifier + * @returns {Promise} + */ + async removeAllItems(installedAppId) { + const params = { + TableName: this.table.name, + Key: { + [this.table.hashKey]: this[primaryKey](installedAppId) + }, + UpdateExpression: 'SET #state = :value', + ExpressionAttributeNames: {'#state': 'state'}, + ExpressionAttributeValues: {':value': {}}, + } + + if (this.table.sortKey) { + params.Key[this.table.sortKey.AttributeName] = this.table.sortKey.AttributeValue + } + + await this.documentClient.send(new UpdateCommand(params)) + } + [primaryKey](installedAppId) { return `${this.table.prefix}${installedAppId}` } + async [addStateRecord](installedAppId, key, value) { + return this.update(installedAppId, {state: { [key]: value }}) + } + async [createTableIfNecessary](options) { try { await this.client.send(new DescribeTableCommand({'TableName': this.table.name})) diff --git a/test/integration/auto-create.js b/test/integration/auto-create.js index 918bdc9..7bbbe83 100644 --- a/test/integration/auto-create.js +++ b/test/integration/auto-create.js @@ -24,7 +24,6 @@ describe('Automatic table creation', () => { authToken: 'authToken', refreshToken: 'refreshToken', config: {settings: 'something'}, - state: {isaState: 'some state'} }) const context = await contextStore.get(installedAppId) @@ -34,7 +33,6 @@ describe('Automatic table creation', () => { expect(context.refreshToken).toEqual('refreshToken') expect(context.locale).toEqual('ko-KR') expect(context.config).toEqual({settings: 'something'}) - expect(context.state).toEqual({isaState: 'some state'}) await contextStore.delete(installedAppId) }) @@ -63,7 +61,6 @@ describe('Automatic table creation', () => { authToken: 'authToken', refreshToken: 'refreshToken', config: {settings: 'something'}, - state: {isaState: 'some state'} }) const context = await contextStore.get(installedAppId) @@ -73,7 +70,6 @@ describe('Automatic table creation', () => { expect(context.refreshToken).toEqual('refreshToken') expect(context.locale).toEqual('en-US') expect(context.config).toEqual({settings: 'something'}) - expect(context.state).toEqual({isaState: 'some state'}) await contextStore.delete(installedAppId) }) diff --git a/test/integration/stateless-record-migration.js b/test/integration/stateless-record-migration.js new file mode 100644 index 0000000..4dd0126 --- /dev/null +++ b/test/integration/stateless-record-migration.js @@ -0,0 +1,53 @@ +const { v4: uuid } = require('uuid') +const { createLocalClient } = require('../utilities/client-utils') +const DynamoDBContextStore = require('../../lib/dynamodb-context-store') +const { PutItemCommand } = require('@aws-sdk/client-dynamodb') + +describe('Stateless record migration', () => { + const tableName = 'context-store-test-0' + const dynamoClient = createLocalClient() + const contextStore = new DynamoDBContextStore({ + table: { + name: tableName, + }, + client: dynamoClient, + autoCreate: false, + }) + + test('set item creates state property if missing', async () => { + const installedAppId = uuid() + const params = { + TableName: tableName, + Item: { + id: {S: `ctx:${installedAppId}`}, + installedAppId: {S: installedAppId}, + } + } + + await dynamoClient.send(new PutItemCommand(params)) + + await contextStore.setItem(installedAppId, 'count', 1) + const count = await contextStore.getItem(installedAppId, 'count') + expect(count).toEqual(1) + + await contextStore.delete(installedAppId) + }) + + test('get item return undefined if state property is missing', async () => { + const installedAppId = uuid() + const params = { + TableName: tableName, + Item: { + id: {S: `ctx:${installedAppId}`}, + installedAppId: {S: installedAppId}, + } + } + + await dynamoClient.send(new PutItemCommand(params)) + + const partnerId = await contextStore.getItem(installedAppId, 'partnerId') + expect(partnerId).toBeUndefined() + + await contextStore.delete(installedAppId) + }) +}) diff --git a/test/integration/with-sort-key.js b/test/integration/with-sort-key.js index 9e29d0e..d85892a 100644 --- a/test/integration/with-sort-key.js +++ b/test/integration/with-sort-key.js @@ -13,6 +13,23 @@ describe('Context Store with sort key', () => { autoCreate: false, }) + test('can set and get item', async () => { + const installedAppId = uuid() + const context = await contextStore.put({ + installedAppId, + locationId: 'locationId', + authToken: 'authToken', + refreshToken: 'refreshToken', + config: {settings: 'something'} + }) + + await contextStore.setItem(context.installedAppId, 'count', 1) + const count = await contextStore.getItem(context.installedAppId, 'count') + expect(count).toEqual(1) + + await contextStore.delete(installedAppId) + }) + test('can update', async () => { const installedAppId = uuid() await contextStore.put({ @@ -33,6 +50,53 @@ describe('Context Store with sort key', () => { await contextStore.delete(installedAppId) }) + test('clear item', async () => { + const installedAppId = uuid() + const context = await contextStore.put({ + installedAppId, + locationId: 'locationId', + authToken: 'authToken', + refreshToken: 'refreshToken', + config: {settings: 'something'} + }) + + await contextStore.setItem(context.installedAppId, 'count', 1) + let count = await contextStore.getItem(context.installedAppId, 'count') + expect(count).toEqual(1) + + await contextStore.removeItem(context.installedAppId, 'count') + count = await contextStore.getItem(context.installedAppId, 'count') + expect(count).toBeUndefined() + + await contextStore.delete(installedAppId) + }) + + test('clear all items', async () => { + const installedAppId = uuid() + const context = await contextStore.put({ + installedAppId, + locationId: 'locationId', + authToken: 'authToken', + refreshToken: 'refreshToken', + config: {settings: 'something'} + }) + + await contextStore.setItem(context.installedAppId, 'count', 1) + await contextStore.setItem(context.installedAppId, 'name', 'Fred') + let count = await contextStore.getItem(context.installedAppId, 'count') + let name = await contextStore.getItem(context.installedAppId, 'name') + expect(count).toEqual(1) + expect(name).toEqual('Fred') + + await contextStore.removeAllItems(context.installedAppId) + count = await contextStore.getItem(context.installedAppId, 'count') + name = await contextStore.getItem(context.installedAppId, 'name') + expect(count).toBeUndefined() + expect(name).toBeUndefined() + + await contextStore.delete(installedAppId) + }) + afterAll(() => { dynamoClient.destroy() }) diff --git a/test/integration/without-sort-key.js b/test/integration/without-sort-key.js index 57bd10b..4c918b5 100644 --- a/test/integration/without-sort-key.js +++ b/test/integration/without-sort-key.js @@ -20,7 +20,6 @@ describe('Context Store without sort key', () => { authToken: 'authToken', refreshToken: 'refreshToken', config: {settings: 'something'}, - state: {isaState: 'some state'} }) const context = await contextStore.get(installedAppId) @@ -30,7 +29,6 @@ describe('Context Store without sort key', () => { expect(context.refreshToken).toEqual('refreshToken') expect(context.locale).toEqual('ko-KR') expect(context.config).toEqual({settings: 'something'}) - expect(context.state).toEqual({isaState: 'some state'}) await contextStore.delete(installedAppId) }) @@ -55,6 +53,126 @@ describe('Context Store without sort key', () => { await contextStore.delete(installedAppId) }) + test('can set and get integer item', async () => { + const installedAppId = uuid() + const context = await contextStore.put({ + installedAppId, + locationId: 'locationId', + authToken: 'authToken', + refreshToken: 'refreshToken', + config: {settings: 'something'} + }) + + await contextStore.setItem(context.installedAppId, 'count', 1) + const count = await contextStore.getItem(context.installedAppId, 'count') + expect(count).toEqual(1) + + await contextStore.delete('installedAppId3') + }) + + test('can set and get string item', async () => { + const installedAppId = uuid() + await contextStore.put({ + installedAppId, + locationId: 'locationId', + authToken: 'authToken', + refreshToken: 'refreshToken', + config: {settings: 'something'} + }) + + const context = await contextStore.get(installedAppId) + + await contextStore.setItem(context.installedAppId, 'name', 'Bill') + const name = await contextStore.getItem(context.installedAppId, 'name') + expect(name).toEqual('Bill') + + await contextStore.delete(installedAppId,) + }) + + test('can set and get object item', async () => { + const installedAppId = uuid() + const context = await contextStore.put({ + installedAppId, + locationId: 'locationId', + authToken: 'authToken', + refreshToken: 'refreshToken', + config: {settings: 'something'} + }) + + await contextStore.setItem(context.installedAppId, 'point', {x: 1, y: 2, z: 3}) + const point = await contextStore.getItem(context.installedAppId, 'point') + expect(point).toEqual({x: 1, y: 2, z: 3}) + + await contextStore.delete(installedAppId) + }) + + test('multiple setItem calls leave previous state undisturbed', async () => { + const installedAppId = uuid() + const context = await contextStore.put({ + installedAppId, + locationId: 'locationId', + authToken: 'authToken', + refreshToken: 'refreshToken', + config: {settings: 'something'} + }) + + await contextStore.setItem(context.installedAppId, 'count', 1) + await contextStore.setItem(context.installedAppId, 'name', 'Fred') + const count = await contextStore.getItem(context.installedAppId, 'count') + const name = await contextStore.getItem(context.installedAppId, 'name') + expect(count).toEqual(1) + expect(name).toEqual('Fred') + + await contextStore.delete(installedAppId) + }) + + test('clear item', async () => { + const installedAppId = uuid() + const context = await contextStore.put({ + installedAppId, + locationId: 'locationId', + authToken: 'authToken', + refreshToken: 'refreshToken', + config: {settings: 'something'} + }) + + await contextStore.setItem(context.installedAppId, 'count', 1) + let count = await contextStore.getItem(context.installedAppId, 'count') + expect(count).toEqual(1) + + await contextStore.removeItem(context.installedAppId, 'count') + count = await contextStore.getItem(context.installedAppId, 'count') + expect(count).toBeUndefined() + + await contextStore.delete(installedAppId) + }) + + test('clear all items', async () => { + const installedAppId = uuid() + const context = await contextStore.put({ + installedAppId, + locationId: 'locationId', + authToken: 'authToken', + refreshToken: 'refreshToken', + config: {settings: 'something'} + }) + + await contextStore.setItem(context.installedAppId, 'count', 1) + await contextStore.setItem(context.installedAppId, 'name', 'Fred') + let count = await contextStore.getItem(context.installedAppId, 'count') + let name = await contextStore.getItem(context.installedAppId, 'name') + expect(count).toEqual(1) + expect(name).toEqual('Fred') + + await contextStore.removeAllItems(context.installedAppId) + count = await contextStore.getItem(context.installedAppId, 'count') + name = await contextStore.getItem(context.installedAppId, 'name') + expect(count).toBeUndefined() + expect(name).toBeUndefined() + + await contextStore.delete(installedAppId) + }) + afterAll(() => { dynamoClient.destroy() })