Skip to content

Commit 8c193b4

Browse files
authored
feat: added support for accessing context state name-value store (#66)
1 parent eeb3199 commit 8c193b4

File tree

8 files changed

+378
-17
lines changed

8 files changed

+378
-17
lines changed

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ The context stored by this module consists of the following data elements:
1010
* **authToken**: the access token used in calling the API
1111
* **refreshToken**: the refresh token used in generating a new access token when one expires
1212
* **config**: the current installed app instance configuration, i.e. selected devices, options, etc.
13+
* **state**: name-value storage for the installed app instance. This is useful for storing information
14+
between invocations of the SmartApp. It's not retried by the `get` method, but rather by `getItem`.
1315

1416
**_Note: Version 3.X.X is a breaking change to version 2.X.X as far as configuring the context store is
1517
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(
142144
}
143145
))
144146
```
147+
148+
## State Storage
149+
150+
The context store can also be used to store state information for the installed app instance. This is
151+
particularly useful for SmartApps that are not stateless, i.e. they need to remember information between
152+
invocations. The state storage functions are only available with version 5.X.X or later of the SDK.

jest-dynamodb-config.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
module.exports = {
22
tables: [
3+
{
4+
TableName: 'context-store-test-0',
5+
KeySchema: [{AttributeName: 'id', KeyType: 'HASH'}],
6+
AttributeDefinitions: [{AttributeName: 'id', AttributeType: 'S'}],
7+
ProvisionedThroughput: {ReadCapacityUnits: 1, WriteCapacityUnits: 1},
8+
},
39
{
410
TableName: 'context-store-test-1',
511
KeySchema: [{AttributeName: 'id', KeyType: 'HASH'}],

lib/dynamodb-context-store.d.ts

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,24 @@
11
import { DynamoDBClient, DynamoDBClientConfig, KeySchemaElement } from '@aws-sdk/client-dynamodb'
22

33
export interface ContextObject {
4-
installedAppId: string
4+
installedAppId: string;
55
locationId: string
6-
locale: string
7-
authToken: string
8-
refreshToken: string
9-
config: any
10-
state: any
6+
locale: string;
7+
authToken: string;
8+
refreshToken: string;
9+
config: any;
10+
state: any;
1111
}
1212

1313
export interface DynamoDBContextStore {
14-
get(installedAppId: string): Promise<ContextObject>
15-
put(installedAppId: string, context: ContextObject): Promise<void>
16-
update(installedAppId: string, context: Partial<ContextObject>): Promise<void>
17-
delete(installedAppId: string): Promise<void>
14+
get(installedAppId: string): Promise<ContextObject>;
15+
put(installedAppId: string, context: ContextObject): Promise<void>;
16+
update(installedAppId: string, context: Partial<ContextObject>): Promise<void>;
17+
delete(installedAppId: string): Promise<void>;
18+
getItem(installedAppId: string, key: string): Promise<any>;
19+
putItem(installedAppId: string, key: string, value: any): Promise<void>;
20+
removeItem(installedAppId: string, key: string): Promise<void>;
21+
removeAllItems(installedAppId: string): Promise<void>;
1822
}
1923

2024
export interface ExtendedKeySchemaElement extends KeySchemaElement {

lib/dynamodb-context-store.js

Lines changed: 113 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ const { DeleteCommand, GetCommand, PutCommand, UpdateCommand } = require('@aws-s
44

55
const primaryKey = Symbol('private')
66
const createTableIfNecessary = Symbol('private')
7+
const addStateRecord = Symbol('private')
78

89
module.exports = class DynamoDBContextStore {
910
/**
@@ -63,7 +64,8 @@ module.exports = class DynamoDBContextStore {
6364
Key: {
6465
[this.table.hashKey]: this[primaryKey](installedAppId)
6566
},
66-
ConsistentRead: true
67+
ConsistentRead: true,
68+
ProjectionExpression: 'installedAppId, locationId, locale, authToken, refreshToken, config'
6769
}
6870

6971
if (this.table.sortKey) {
@@ -170,10 +172,120 @@ module.exports = class DynamoDBContextStore {
170172
await this.documentClient.send(new DeleteCommand(params))
171173
}
172174

175+
/**
176+
* Get the value of the key from the context store state property
177+
* @param installedAppId the installed app identifier
178+
* @param key the name of the property to retrieve
179+
* @returns {Promise<*>}
180+
*/
181+
async getItem(installedAppId, key) {
182+
const params = {
183+
TableName: this.table.name,
184+
Key: {
185+
[this.table.hashKey]: this[primaryKey](installedAppId)
186+
},
187+
ExpressionAttributeNames: {'#key': key, '#state': 'state'},
188+
ProjectionExpression: '#state.#key'
189+
}
190+
191+
if (this.table.sortKey) {
192+
params.Key[this.table.sortKey.AttributeName] = this.table.sortKey.AttributeValue
193+
}
194+
195+
const response = await this.documentClient.send(new GetCommand(params))
196+
return response.Item && response.Item.state ? response.Item.state[key] : undefined
197+
}
198+
199+
/**
200+
* Set the value of the key in the context store state property
201+
* @param installedAppId the installed app identifier
202+
* @param key the name of the property to set
203+
* @param value the value to set
204+
* @returns {Promise<*>}
205+
*/
206+
async setItem(installedAppId, key, value) {
207+
try {
208+
const params = {
209+
TableName: this.table.name,
210+
Key: {
211+
[this.table.hashKey]: this[primaryKey](installedAppId)
212+
},
213+
UpdateExpression: 'SET #state.#key = :value',
214+
ExpressionAttributeNames: {'#key': key, '#state': 'state'},
215+
ExpressionAttributeValues: {':value': value},
216+
}
217+
218+
if (this.table.sortKey) {
219+
params.Key[this.table.sortKey.AttributeName] = this.table.sortKey.AttributeValue
220+
}
221+
222+
await this.documentClient.send(new UpdateCommand(params))
223+
} catch (error) {
224+
if (error.name === 'ValidationException' &&
225+
error.message === 'The document path provided in the update expression is invalid for update') {
226+
await this[addStateRecord](installedAppId, key, value)
227+
} else {
228+
throw error
229+
}
230+
}
231+
232+
return value
233+
}
234+
235+
/**
236+
* Remove the key from the context store state property
237+
* @param installedAppId the installed app identifier
238+
* @param key the name of the property to remove
239+
* @returns {Promise<void>}
240+
*/
241+
async removeItem(installedAppId, key) {
242+
const params = {
243+
TableName: this.table.name,
244+
Key: {
245+
[this.table.hashKey]: this[primaryKey](installedAppId)
246+
},
247+
UpdateExpression: 'REMOVE #state.#key',
248+
ExpressionAttributeNames: {'#key': key, '#state': 'state'},
249+
}
250+
251+
if (this.table.sortKey) {
252+
params.Key[this.table.sortKey.AttributeName] = this.table.sortKey.AttributeValue
253+
}
254+
255+
await this.documentClient.send(new UpdateCommand(params))
256+
}
257+
258+
/**
259+
* Clear the context store state property
260+
* @param installedAppId the installed app identifier
261+
* @returns {Promise<void>}
262+
*/
263+
async removeAllItems(installedAppId) {
264+
const params = {
265+
TableName: this.table.name,
266+
Key: {
267+
[this.table.hashKey]: this[primaryKey](installedAppId)
268+
},
269+
UpdateExpression: 'SET #state = :value',
270+
ExpressionAttributeNames: {'#state': 'state'},
271+
ExpressionAttributeValues: {':value': {}},
272+
}
273+
274+
if (this.table.sortKey) {
275+
params.Key[this.table.sortKey.AttributeName] = this.table.sortKey.AttributeValue
276+
}
277+
278+
await this.documentClient.send(new UpdateCommand(params))
279+
}
280+
173281
[primaryKey](installedAppId) {
174282
return `${this.table.prefix}${installedAppId}`
175283
}
176284

285+
async [addStateRecord](installedAppId, key, value) {
286+
return this.update(installedAppId, {state: { [key]: value }})
287+
}
288+
177289
async [createTableIfNecessary](options) {
178290
try {
179291
await this.client.send(new DescribeTableCommand({'TableName': this.table.name}))

test/integration/auto-create.js

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ describe('Automatic table creation', () => {
2424
authToken: 'authToken',
2525
refreshToken: 'refreshToken',
2626
config: {settings: 'something'},
27-
state: {isaState: 'some state'}
2827
})
2928

3029
const context = await contextStore.get(installedAppId)
@@ -34,7 +33,6 @@ describe('Automatic table creation', () => {
3433
expect(context.refreshToken).toEqual('refreshToken')
3534
expect(context.locale).toEqual('ko-KR')
3635
expect(context.config).toEqual({settings: 'something'})
37-
expect(context.state).toEqual({isaState: 'some state'})
3836

3937
await contextStore.delete(installedAppId)
4038
})
@@ -63,7 +61,6 @@ describe('Automatic table creation', () => {
6361
authToken: 'authToken',
6462
refreshToken: 'refreshToken',
6563
config: {settings: 'something'},
66-
state: {isaState: 'some state'}
6764
})
6865

6966
const context = await contextStore.get(installedAppId)
@@ -73,7 +70,6 @@ describe('Automatic table creation', () => {
7370
expect(context.refreshToken).toEqual('refreshToken')
7471
expect(context.locale).toEqual('en-US')
7572
expect(context.config).toEqual({settings: 'something'})
76-
expect(context.state).toEqual({isaState: 'some state'})
7773

7874
await contextStore.delete(installedAppId)
7975
})
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
const { v4: uuid } = require('uuid')
2+
const { createLocalClient } = require('../utilities/client-utils')
3+
const DynamoDBContextStore = require('../../lib/dynamodb-context-store')
4+
const { PutItemCommand } = require('@aws-sdk/client-dynamodb')
5+
6+
describe('Stateless record migration', () => {
7+
const tableName = 'context-store-test-0'
8+
const dynamoClient = createLocalClient()
9+
const contextStore = new DynamoDBContextStore({
10+
table: {
11+
name: tableName,
12+
},
13+
client: dynamoClient,
14+
autoCreate: false,
15+
})
16+
17+
test('set item creates state property if missing', async () => {
18+
const installedAppId = uuid()
19+
const params = {
20+
TableName: tableName,
21+
Item: {
22+
id: {S: `ctx:${installedAppId}`},
23+
installedAppId: {S: installedAppId},
24+
}
25+
}
26+
27+
await dynamoClient.send(new PutItemCommand(params))
28+
29+
await contextStore.setItem(installedAppId, 'count', 1)
30+
const count = await contextStore.getItem(installedAppId, 'count')
31+
expect(count).toEqual(1)
32+
33+
await contextStore.delete(installedAppId)
34+
})
35+
36+
test('get item return undefined if state property is missing', async () => {
37+
const installedAppId = uuid()
38+
const params = {
39+
TableName: tableName,
40+
Item: {
41+
id: {S: `ctx:${installedAppId}`},
42+
installedAppId: {S: installedAppId},
43+
}
44+
}
45+
46+
await dynamoClient.send(new PutItemCommand(params))
47+
48+
const partnerId = await contextStore.getItem(installedAppId, 'partnerId')
49+
expect(partnerId).toBeUndefined()
50+
51+
await contextStore.delete(installedAppId)
52+
})
53+
})

test/integration/with-sort-key.js

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,23 @@ describe('Context Store with sort key', () => {
1313
autoCreate: false,
1414
})
1515

16+
test('can set and get item', async () => {
17+
const installedAppId = uuid()
18+
const context = await contextStore.put({
19+
installedAppId,
20+
locationId: 'locationId',
21+
authToken: 'authToken',
22+
refreshToken: 'refreshToken',
23+
config: {settings: 'something'}
24+
})
25+
26+
await contextStore.setItem(context.installedAppId, 'count', 1)
27+
const count = await contextStore.getItem(context.installedAppId, 'count')
28+
expect(count).toEqual(1)
29+
30+
await contextStore.delete(installedAppId)
31+
})
32+
1633
test('can update', async () => {
1734
const installedAppId = uuid()
1835
await contextStore.put({
@@ -33,6 +50,53 @@ describe('Context Store with sort key', () => {
3350
await contextStore.delete(installedAppId)
3451
})
3552

53+
test('clear item', async () => {
54+
const installedAppId = uuid()
55+
const context = await contextStore.put({
56+
installedAppId,
57+
locationId: 'locationId',
58+
authToken: 'authToken',
59+
refreshToken: 'refreshToken',
60+
config: {settings: 'something'}
61+
})
62+
63+
await contextStore.setItem(context.installedAppId, 'count', 1)
64+
let count = await contextStore.getItem(context.installedAppId, 'count')
65+
expect(count).toEqual(1)
66+
67+
await contextStore.removeItem(context.installedAppId, 'count')
68+
count = await contextStore.getItem(context.installedAppId, 'count')
69+
expect(count).toBeUndefined()
70+
71+
await contextStore.delete(installedAppId)
72+
})
73+
74+
test('clear all items', async () => {
75+
const installedAppId = uuid()
76+
const context = await contextStore.put({
77+
installedAppId,
78+
locationId: 'locationId',
79+
authToken: 'authToken',
80+
refreshToken: 'refreshToken',
81+
config: {settings: 'something'}
82+
})
83+
84+
await contextStore.setItem(context.installedAppId, 'count', 1)
85+
await contextStore.setItem(context.installedAppId, 'name', 'Fred')
86+
let count = await contextStore.getItem(context.installedAppId, 'count')
87+
let name = await contextStore.getItem(context.installedAppId, 'name')
88+
expect(count).toEqual(1)
89+
expect(name).toEqual('Fred')
90+
91+
await contextStore.removeAllItems(context.installedAppId)
92+
count = await contextStore.getItem(context.installedAppId, 'count')
93+
name = await contextStore.getItem(context.installedAppId, 'name')
94+
expect(count).toBeUndefined()
95+
expect(name).toBeUndefined()
96+
97+
await contextStore.delete(installedAppId)
98+
})
99+
36100
afterAll(() => {
37101
dynamoClient.destroy()
38102
})

0 commit comments

Comments
 (0)