Skip to content

feat: added support for accessing context state name-value store #66

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Oct 4, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
6 changes: 6 additions & 0 deletions jest-dynamodb-config.js
Original file line number Diff line number Diff line change
@@ -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'}],
Expand Down
24 changes: 14 additions & 10 deletions lib/dynamodb-context-store.d.ts
Original file line number Diff line number Diff line change
@@ -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<ContextObject>
put(installedAppId: string, context: ContextObject): Promise<void>
update(installedAppId: string, context: Partial<ContextObject>): Promise<void>
delete(installedAppId: string): Promise<void>
get(installedAppId: string): Promise<ContextObject>;
put(installedAppId: string, context: ContextObject): Promise<void>;
update(installedAppId: string, context: Partial<ContextObject>): Promise<void>;
delete(installedAppId: string): Promise<void>;
getItem(installedAppId: string, key: string): Promise<any>;
putItem(installedAppId: string, key: string, value: any): Promise<void>;
removeItem(installedAppId: string, key: string): Promise<void>;
removeAllItems(installedAppId: string): Promise<void>;
}

export interface ExtendedKeySchemaElement extends KeySchemaElement {
Expand Down
114 changes: 113 additions & 1 deletion lib/dynamodb-context-store.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
/**
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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<void>}
*/
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<void>}
*/
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}))
Expand Down
4 changes: 0 additions & 4 deletions test/integration/auto-create.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
})
Expand Down Expand Up @@ -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)
Expand All @@ -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)
})
Expand Down
53 changes: 53 additions & 0 deletions test/integration/stateless-record-migration.js
Original file line number Diff line number Diff line change
@@ -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)
})
})
64 changes: 64 additions & 0 deletions test/integration/with-sort-key.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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()
})
Expand Down
Loading