Skip to content
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

feat(breaking-change): ACNA-2584 - add Adobe App Builder State Store #135

Merged
merged 21 commits into from
Jan 30, 2024
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
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
Loading