Skip to content

Commit

Permalink
feat(breaking-change): ACNA-2584 - add Adobe App Builder State Store (#…
Browse files Browse the repository at this point in the history
…135)

* remove CosmosStateStore, added complete coverage for AdobeStateStore
* remove use of joi
* chore(deps): update @adobe/aio-lib-core-networking
  • Loading branch information
shazron authored Jan 30, 2024
1 parent ef5aa34 commit 1eee5df
Show file tree
Hide file tree
Showing 22 changed files with 1,132 additions and 1,321 deletions.
18 changes: 10 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<!--
Copyright 2019 Adobe. All rights reserved.
Copyright 2024 Adobe. All rights reserved.
This file is licensed to you under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. You may obtain a copy
of the License at http://www.apache.org/licenses/LICENSE-2.0
Expand All @@ -10,20 +10,18 @@ OF ANY KIND, either express or implied. See the License for the specific languag
governing permissions and limitations under the License.
-->

# Adobe I/O Lib State

[![Version](https://img.shields.io/npm/v/@adobe/aio-lib-state.svg)](https://npmjs.org/package/@adobe/aio-lib-state)
[![Downloads/week](https://img.shields.io/npm/dw/@adobe/aio-lib-state.svg)](https://npmjs.org/package/@adobe/aio-lib-state)
![Node.js CI](https://github.com/adobe/aio-lib-state/workflows/Node.js%20CI/badge.svg)
[![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
[![Codecov Coverage](https://img.shields.io/codecov/c/github/adobe/aio-lib-state/master.svg?style=flat-square)](https://codecov.io/gh/adobe/aio-lib-state/)

# Adobe I/O Lib State

A Node JavaScript abstraction on top of distributed/cloud DBs that exposes a simple state persistence API.

You can initialize the lib with your Adobe I/O Runtime (a.k.a OpenWhisk) credentials.

Alternatively, you can bring your own cloud db keys. As of now we only support Azure Cosmos.

Please note that currently you must be a customer of [Adobe Developer App Builder](https://www.adobe.io/apis/experienceplatform/project-firefly.html) to use this library. App Builder is a complete framework that enables enterprise developers to build and deploy custom web applications that extend Adobe Experience Cloud solutions and run on Adobe infrastructure.

## Install
Expand All @@ -39,19 +37,23 @@ npm install @adobe/aio-lib-state

// init when running in an Adobe I/O Runtime action (OpenWhisk) (uses env vars __OW_API_KEY and __OW_NAMESPACE automatically)
const state = await stateLib.init()
// or if you want to use your own cloud DB account (make sure your partition key path is /partitionKey)
const state = await stateLib.init({ cosmos: { endpoint, masterKey, databaseId, containerId, partitionKey } })

// get
const res = await state.get('key') // res = { value, expiration }
const value = res.value

// put
await state.put('key', 'value')
await state.put('key', { anObject: 'value' }, { ttl: -1 }) // -1 for no expiry, defaults to 86400 (24 hours)
await state.put('another key', 'another value', { ttl: -1 }) // -1 for no expiry, defaults to 86400 (24 hours)

// delete
await state.delete('key')

// delete all keys and values
await state.deleteAll()

// returns true if you have at least one key and value
await state.any()
```

## Explore
Expand Down
209 changes: 88 additions & 121 deletions doc/api.md

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions e2e/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
TEST_NAMESPACE_1=
TEST_AUTH_1=
TEST_NAMESPACE_2=
TEST_AUTH_2=
94 changes: 50 additions & 44 deletions e2e/e2e.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,24 +13,24 @@ governing permissions and limitations under the License.
/* ************* NOTE 1: these tests must be run sequentially, jest does it by default within a SINGLE file ************* */
/* ************* NOTE 2: requires env vars TEST_AUTH_1, TEST_NS_1 and TEST_AUTH_2, TEST_NS_2 for 2 different namespaces. ************* */

const path = require('node:path')

// load .env values in the e2e folder, if any
require('dotenv').config({ path: path.join(__dirname, '.env') })

const { MAX_TTL_SECONDS } = require('../lib/constants')
const stateLib = require('../index')
const { codes } = require('../lib/StateStoreError')

const testKey = 'e2e_test_state_key'

jest.setTimeout(30000) // thirty seconds per test

beforeEach(() => {
expect.hasAssertions()
})

const initStateEnv = async (n = 1) => {
delete process.env.__OW_API_KEY
delete process.env.__OW_NAMESPACE
process.env.__OW_API_KEY = process.env[`TEST_AUTH_${n}`]
process.env.__OW_NAMESPACE = process.env[`TEST_NAMESPACE_${n}`]
// 1. init will fetch credentials from the tvm using ow creds
const state = await stateLib.init() // { tvm: { cacheFile: false } } // keep cache for better perf?
const state = await stateLib.init()
// make sure we delete the testKey, note that delete might fail as it is an op under test
await state.delete(testKey)
return state
Expand All @@ -44,82 +44,83 @@ describe('e2e tests using OpenWhisk credentials (as env vars)', () => {
delete process.env.__OW_NAMESPACE
process.env.__OW_API_KEY = process.env.TEST_AUTH_1
process.env.__OW_NAMESPACE = process.env.TEST_NAMESPACE_1 + 'bad'
let expectedError

try {
await stateLib.init()
const store = await stateLib.init()
await store.get('something')
} catch (e) {
expect({ name: e.name, code: e.code, message: e.message, sdkDetails: e.sdkDetails }).toEqual(expect.objectContaining({
name: 'StateLibError',
expectedError = e
}

expect(expectedError).toBeDefined()
expect(expectedError instanceof Error).toBeTruthy()
expect({ name: expectedError.name, code: expectedError.code, message: expectedError.message, sdkDetails: expectedError.sdkDetails })
.toEqual(expect.objectContaining({
name: 'AdobeStateLibError',
code: 'ERROR_BAD_CREDENTIALS'
}))
}
})

test('key-value basic test on one key with string value: get, write, get, delete, get', async () => {
test('key-value basic test on one key with string value: put, get, delete, any, deleteAll', async () => {
const state = await initStateEnv()

const testValue = 'a string'

expect(await state.get(testKey)).toEqual(undefined)
expect(await state.put(testKey, testValue)).toEqual(testKey)
expect(await state.get(testKey)).toEqual(expect.objectContaining({ value: testValue }))
expect(await state.delete(testKey, testValue)).toEqual(testKey)
expect(await state.get(testKey)).toEqual(undefined)
})

test('key-value basic test on one key with object value: get, write, get, delete, get', async () => {
const state = await initStateEnv()

const testValue = { a: 'fake', object: { with: { multiple: 'layers' }, that: { dreams: { of: { being: 'real' } } } } }

expect(await state.delete(testKey)).toEqual(testKey)
expect(await state.get(testKey)).toEqual(undefined)
expect(await state.any()).toEqual(false)
expect(await state.put(testKey, testValue)).toEqual(testKey)
expect(await state.get(testKey)).toEqual(expect.objectContaining({ value: testValue }))
expect(await state.delete(testKey, testValue)).toEqual(testKey)
expect(await state.any()).toEqual(true)
expect(await state.deleteAll()).toEqual(true)
expect(await state.get(testKey)).toEqual(undefined)
expect(await state.any()).toEqual(false)
})

test('time-to-live tests: write w/o ttl, get default ttl, write with ttl, get, get after ttl', async () => {
const state = await initStateEnv()

const testValue = { an: 'object' }
const testValue = 'test value'
let res, resTime

// 1. test default ttl = 1 day
expect(await state.put(testKey, testValue)).toEqual(testKey)
let res = await state.get(testKey)
expect(new Date(res.expiration).getTime()).toBeLessThanOrEqual(new Date(Date.now() + 86400000).getTime()) // 86400000 ms = 1 day
expect(new Date(res.expiration).getTime()).toBeGreaterThanOrEqual(new Date(Date.now() + 86400000 - 10000).getTime()) // give more or less 10 seconds clock skew + request time
res = await state.get(testKey)
resTime = new Date(res.expiration).getTime()
expect(resTime).toBeLessThanOrEqual(new Date(Date.now() + 86400000).getTime()) // 86400000 ms = 1 day
expect(resTime).toBeGreaterThanOrEqual(new Date(Date.now() + 86400000 - 10000).getTime()) // give more or less 10 seconds clock skew + request time

// 2. test infinite ttl
// 2. test max ttl
const nowPlus365Days = new Date(MAX_TTL_SECONDS).getTime()
expect(await state.put(testKey, testValue, { ttl: -1 })).toEqual(testKey)
expect(await state.get(testKey)).toEqual(expect.objectContaining({ expiration: null }))
res = await state.get(testKey)
resTime = new Date(res.expiration).getTime()
expect(resTime).toBeGreaterThanOrEqual(nowPlus365Days)

// 3. test that after ttl object is deleted
expect(await state.put(testKey, testValue, { ttl: 2 })).toEqual(testKey)
res = await state.get(testKey)
expect(new Date(res.expiration).getTime()).toBeLessThanOrEqual(new Date(Date.now() + 2000).getTime())
await waitFor(3000) // give it one more sec - azure ttl is not so precise
await waitFor(3000) // give it one more sec - ttl is not so precise
expect(await state.get(testKey)).toEqual(undefined)
})

test('throw error when get/put with invalid keys', async () => {
const invalidChars = "The following characters are restricted and cannot be used in the Id property: '/', '\\', '?', '#' "
const invalidKey = 'invalid/key'
const invalidKey = 'some/invalid/key'
const state = await initStateEnv()
await expect(state.put(invalidKey, 'testValue')).rejects.toThrow(new codes.ERROR_BAD_REQUEST({
messageValues: [invalidChars]
}))
await expect(state.get(invalidKey)).rejects.toThrow(new codes.ERROR_BAD_REQUEST({
messageValues: [invalidChars]
}))
await expect(state.put(invalidKey, 'testValue')).rejects.toThrow('[AdobeStateLib:ERROR_BAD_ARGUMENT] invalid key and/or value')
await expect(state.get(invalidKey)).rejects.toThrow('[AdobeStateLib:ERROR_BAD_ARGUMENT] invalid key')
})

test('isolation tests: get, write, delete on same key for two namespaces do not interfere', async () => {
const state1 = await initStateEnv(1)
const state2 = await initStateEnv(2)

const testValue1 = { an: 'object' }
const testValue2 = { another: 'dummy' }
const testValue1 = 'one value'
const testValue2 = 'some other value'

// 1. test that ns2 cannot get state in ns1
await state1.put(testKey, testValue1)
Expand All @@ -139,16 +140,21 @@ describe('e2e tests using OpenWhisk credentials (as env vars)', () => {

test('error value bigger than 2MB test', async () => {
const state = await initStateEnv()

const bigValue = ('a').repeat(1024 * 1024 * 2 + 1)
let expectedError

try {
await state.put(testKey, bigValue)
} catch (e) {
expect({ name: e.name, code: e.code, message: e.message, sdkDetails: e.sdkDetails }).toEqual(expect.objectContaining({
name: 'StateLibError',
expectedError = e
}

expect(expectedError).toBeDefined()
expect(expectedError instanceof Error).toBeTruthy()
expect({ name: expectedError.name, code: expectedError.code, message: expectedError.message, sdkDetails: expectedError.sdkDetails })
.toEqual(expect.objectContaining({
name: 'AdobeStateLibError',
code: 'ERROR_PAYLOAD_TOO_LARGE'
}))
}
})
})
4 changes: 3 additions & 1 deletion e2e/e2e.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@
## Requirements

- To run the test you'll need two OpenWhisk namespaces. Please set the credentials for those in the following env
variables:
variables in an .env file:
- `TEST_NAMESPACE_1, TEST_AUTH_1, TEST_NAMESPACE_2, TEST_AUTH_2`

Copy the `.env.example` to your own `.env` in this folder.

## Run

`npm run e2e`
Expand Down
2 changes: 1 addition & 1 deletion index.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,5 @@ the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTA
OF ANY KIND, either express or implied. See the License for the specific language
governing permissions and limitations under the License.
*/
require('./lib/StateStore')
require('./lib/AdobeState')
module.exports = require('./lib/init')
10 changes: 9 additions & 1 deletion jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,13 @@ module.exports = {
collectCoverageFrom: [
'index.js',
'lib/**/*.js'
]
],
coverageThreshold: {
global: {
branches: 100,
lines: 100,
statements: 100,
functions: 100
}
}
}
Loading

0 comments on commit 1eee5df

Please sign in to comment.