Skip to content

Commit

Permalink
feat: ACNA-2585 - library configuration & networking (#138)
Browse files Browse the repository at this point in the history
* fix: use API_VERSION for the state endpoint (can be set as an env var, for testing)
* fix: add region support
* fix: add internal servers when in ADP Runtime
  • Loading branch information
shazron committed Feb 14, 2024
1 parent bbda114 commit 69417ca
Show file tree
Hide file tree
Showing 13 changed files with 417 additions and 72 deletions.
17 changes: 16 additions & 1 deletion .eslintrc
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,20 @@
"NodeJS": true
},
"plugins": ["jest"],
"extends": ["standard", "plugin:jsdoc/recommended", "plugin:jest/recommended"]
"extends": ["standard", "plugin:jsdoc/recommended", "plugin:jest/recommended"],
"settings": {
"jsdoc": {
"ignorePrivate": true
}
},
"rules": {
"jsdoc/tag-lines": [
// The Error level should be `error`, `warn`, or `off` (or 2, 1, or 0)
"error",
"never",
{
"startLines": null
}
]
}
}
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,10 @@ npm install @adobe/aio-lib-state
```js
const stateLib = require('@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)
// init when running in an Adobe I/O Runtime action (OpenWhisk) (uses env vars __OW_API_KEY and __OW_NAMESPACE automatically. default region is 'amer')
const state = await stateLib.init()
// set an explicit region
const state2 = await stateLib.init({ region: 'apac' })

// get
const res = await state.get('key') // res = { value, expiration }
Expand Down
25 changes: 13 additions & 12 deletions doc/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,11 +79,11 @@ Creates or updates a state key-value pair
**Kind**: instance method of [<code>AdobeState</code>](#AdobeState)
**Returns**: <code>Promise.&lt;string&gt;</code> - key

| Param | Type | Default | Description |
| --- | --- | --- | --- |
| key | <code>string</code> | | state key identifier |
| value | <code>string</code> | | state value |
| [options] | [<code>AdobeStatePutOptions</code>](#AdobeStatePutOptions) | <code>{}</code> | put options |
| Param | Type | Description |
| --- | --- | --- |
| key | <code>string</code> | state key identifier |
| value | <code>string</code> | state value |
| [options] | [<code>AdobeStatePutOptions</code>](#AdobeStatePutOptions) | put options |

<a name="AdobeState+delete"></a>

Expand Down Expand Up @@ -138,10 +138,10 @@ OpenWhisk credentials can also be read from environment variables `__OW_NAMESPAC
**Kind**: global function
**Returns**: [<code>Promise.&lt;AdobeState&gt;</code>](#AdobeState) - An AdobeState instance

| Param | Type | Default | Description |
| --- | --- | --- | --- |
| [config] | <code>object</code> | <code>{}</code> | used to init the sdk |
| [config.ow] | [<code>OpenWhiskCredentials</code>](#OpenWhiskCredentials) | | [OpenWhiskCredentials](#OpenWhiskCredentials). Set those if you want to use ootb credentials to access the state management service. OpenWhisk namespace and auth can also be passed through environment variables: `__OW_NAMESPACE` and `__OW_API_KEY` |
| Param | Type | Description |
| --- | --- | --- |
| [config] | <code>object</code> | used to init the sdk |
| [config.ow] | [<code>OpenWhiskCredentials</code>](#OpenWhiskCredentials) | [OpenWhiskCredentials](#OpenWhiskCredentials). Set those if you want to use ootb credentials to access the state management service. OpenWhisk namespace and auth can also be passed through environment variables: `__OW_NAMESPACE` and `__OW_API_KEY` |

<a name="AdobeStateCredentials"></a>

Expand All @@ -155,6 +155,7 @@ AdobeStateCredentials
| --- | --- | --- |
| namespace | <code>string</code> | the state store namespace |
| apikey | <code>string</code> | the state store api key |
| region | <code>&#x27;amer&#x27;</code> \| <code>&#x27;apac&#x27;</code> \| <code>&#x27;emea&#x27;</code> | the region for the Adobe State Store. defaults to 'amer' |

<a name="AdobeStatePutOptions"></a>

Expand All @@ -166,7 +167,7 @@ AdobeState put options

| Name | Type | Description |
| --- | --- | --- |
| ttl | <code>number</code> | time-to-live for key-value pair in seconds, defaults to 24 hours (86400s). Set to < 0 for no expiry. A value of 0 sets default. |
| ttl | <code>number</code> | time-to-live for key-value pair in seconds, defaults to 24 hours (86400s). Set to < 0 for max ttl of one year. A value of 0 sets default. |

<a name="AdobeStateGetReturnValue"></a>

Expand All @@ -178,8 +179,8 @@ AdobeState get return object

| Name | Type | Description |
| --- | --- | --- |
| expiration | <code>string</code> \| <code>null</code> | ISO date string of expiration time for the key-value pair, if the ttl is infinite expiration=null |
| value | <code>any</code> | the value set by put |
| expiration | <code>string</code> | the ISO-8601 date string of the expiration time for the key-value pair |
| value | <code>string</code> | the value set by put |

<a name="OpenWhiskCredentials"></a>

Expand Down
17 changes: 12 additions & 5 deletions e2e/e2e.js
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ describe('e2e tests using OpenWhisk credentials (as env vars)', () => {

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.get(testKey)).toEqual(expect.objectContaining({ value: testValue, expiration: expect.any(String) }))
expect(await state.delete(testKey)).toEqual(testKey)
expect(await state.get(testKey)).toEqual(undefined)
expect(await state.any()).toEqual(false)
Expand All @@ -93,14 +93,21 @@ describe('e2e tests using OpenWhisk credentials (as env vars)', () => {
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 max ttl
// 2. test ttl = 0 (should default to default ttl of 1 day)
expect(await state.put(testKey, testValue, { ttl: 0 })).toEqual(testKey)
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

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

// 3. test that after ttl object is deleted
// 4. 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())
Expand All @@ -111,8 +118,8 @@ describe('e2e tests using OpenWhisk credentials (as env vars)', () => {
test('throw error when get/put with invalid keys', async () => {
const invalidKey = 'some/invalid/key'
const state = await initStateEnv()
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')
await expect(state.put(invalidKey, 'testValue')).rejects.toThrow('[AdobeStateLib:ERROR_BAD_ARGUMENT] /key must match pattern "^[a-zA-Z0-9-_-]{1,1024}$"')
await expect(state.get(invalidKey)).rejects.toThrow('[AdobeStateLib:ERROR_BAD_ARGUMENT] /key must match pattern "^[a-zA-Z0-9-_-]{1,1024}$"')
})

test('isolation tests: get, write, delete on same key for two namespaces do not interfere', async () => {
Expand Down
49 changes: 34 additions & 15 deletions lib/AdobeState.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ const logger = require('@adobe/aio-lib-core-logging')('@adobe/aio-lib-state', {
const { HttpExponentialBackoff } = require('@adobe/aio-lib-core-networking')
const url = require('node:url')
const { getCliEnv } = require('@adobe/aio-lib-env')
const { ADOBE_STATE_STORE_ENDPOINT, REGEX_PATTERN_STORE_KEY } = require('./constants')
const { ADOBE_STATE_STORE_ENDPOINT, REGEX_PATTERN_STORE_KEY, API_VERSION, ADOBE_STATE_STORE_REGIONS, HEADER_KEY_EXPIRES } = require('./constants')
const Ajv = require('ajv')

/* *********************************** typedefs *********************************** */
Expand All @@ -28,14 +28,15 @@ const Ajv = require('ajv')
* @type {object}
* @property {string} namespace the state store namespace
* @property {string} apikey the state store api key
* @property {('amer'|'apac'|'emea')} region the region for the Adobe State Store. defaults to 'amer'
*/

/**
* AdobeState put options
*
* @typedef AdobeStatePutOptions
* @type {object}
* @property {number} ttl time-to-live for key-value pair in seconds, defaults to 24 hours (86400s). Set to < 0 for no expiry. A
* @property {number} ttl time-to-live for key-value pair in seconds, defaults to 24 hours (86400s). Set to < 0 for max ttl of one year. A
* value of 0 sets default.
*/

Expand All @@ -44,9 +45,8 @@ const Ajv = require('ajv')
*
* @typedef AdobeStateGetReturnValue
* @type {object}
* @property {string|null} expiration ISO date string of expiration time for the key-value pair, if the ttl is infinite
* expiration=null
* @property {any} value the value set by put
* @property {string} expiration the ISO-8601 date string of the expiration time for the key-value pair
* @property {string} value the value set by put
*/

/* *********************************** helpers *********************************** */
Expand Down Expand Up @@ -118,13 +118,16 @@ class AdobeState {
* @private
* @param {string} namespace the namespace for the Adobe State Store
* @param {string} apikey the apikey for the Adobe State Store
* @param {('amer'|'apac'|'emea')} [region] the region for the Adobe State Store. defaults to 'amer'
*/
constructor (namespace, apikey) {
constructor (namespace, apikey, region) {
/** @private */
this.namespace = namespace
/** @private */
this.apikey = apikey
/** @private */
this.region = region
/** @private */
this.endpoint = ADOBE_STATE_STORE_ENDPOINT[getCliEnv()]
/** @private */
this.fetchRetry = new HttpExponentialBackoff()
Expand All @@ -139,14 +142,19 @@ class AdobeState {
* @returns {string} the constructed request url
*/
createRequestUrl (key, queryObject = {}) {
let requestUrl
const isLocal = this.endpoint.startsWith('localhost') || this.endpoint.startsWith('127.0.0.1')
const protocol = isLocal ? 'http' : 'https'
const regionSubdomain = isLocal ? '' : `${this.region}.`
let urlString

if (key) {
requestUrl = new url.URL(`${this.endpoint}/v1/containers/${this.namespace}/data/${key}`)
urlString = `${protocol}://${regionSubdomain}${this.endpoint}/${API_VERSION}/containers/${this.namespace}/data/${key}`
} else {
requestUrl = new url.URL(`${this.endpoint}/v1/containers/${this.namespace}`)
urlString = `${protocol}://${regionSubdomain}${this.endpoint}/${API_VERSION}/containers/${this.namespace}`
}

logger.debug('requestUrl string', urlString)
const requestUrl = new url.URL(urlString)
// add the query params
requestUrl.search = (new url.URLSearchParams(queryObject)).toString()
return requestUrl.toString()
Expand Down Expand Up @@ -184,9 +192,17 @@ class AdobeState {
const cloned = utils.withHiddenFields(credentials, ['apikey'])
logger.debug(`init AdobeState with ${JSON.stringify(cloned, null, 2)}`)

if (!credentials.region) {
credentials.region = ADOBE_STATE_STORE_REGIONS.at(0) // first item is the default
}

const schema = {
type: 'object',
properties: {
region: {
type: 'string',
enum: ADOBE_STATE_STORE_REGIONS
},
apikey: { type: 'string' },
namespace: { type: 'string' }
},
Expand All @@ -196,12 +212,12 @@ class AdobeState {
const { valid, errors } = validate(schema, credentials)
if (!valid) {
logAndThrow(new codes.ERROR_BAD_ARGUMENT({
messageValues: ['apikey and/or namespace is missing', JSON.stringify(errors, null, 2)],
messageValues: utils.formatAjvErrors(errors),
sdkDetails: cloned
}))
}

return new AdobeState(credentials.namespace, credentials.apikey)
return new AdobeState(credentials.namespace, credentials.apikey, credentials.region)
}

/* **************************** ADOBE STATE STORE OPERATORS ***************************** */
Expand Down Expand Up @@ -230,7 +246,7 @@ class AdobeState {
const { valid, errors } = validate(schema, { key })
if (!valid) {
logAndThrow(new codes.ERROR_BAD_ARGUMENT({
messageValues: ['invalid key', JSON.stringify(errors, null, 2)],
messageValues: utils.formatAjvErrors(errors),
sdkDetails: { key, errors }
}))
}
Expand All @@ -246,7 +262,10 @@ class AdobeState {
const response = await _wrap(promise, { key })
if (response) {
// we only expect string values
return response.json()
const value = await response.text()
const expiration = new Date(Number(response.headers.get(HEADER_KEY_EXPIRES))).toISOString()

return { value, expiration }
}
}

Expand All @@ -255,7 +274,7 @@ class AdobeState {
*
* @param {string} key state key identifier
* @param {string} value state value
* @param {AdobeStatePutOptions} [options={}] put options
* @param {AdobeStatePutOptions} [options] put options
* @returns {Promise<string>} key
* @memberof AdobeState
*/
Expand All @@ -278,7 +297,7 @@ class AdobeState {
const { valid, errors } = validate(schema, { key, value })
if (!valid) {
logAndThrow(new codes.ERROR_BAD_ARGUMENT({
messageValues: ['invalid key and/or value', JSON.stringify(errors, null, 2)],
messageValues: utils.formatAjvErrors(errors),
sdkDetails: { key, value, options, errors }
}))
}
Expand Down
28 changes: 23 additions & 5 deletions lib/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,29 +11,47 @@ governing permissions and limitations under the License.
*/

const { PROD_ENV, STAGE_ENV } = require('@adobe/aio-lib-env')
const { isInternalToAdobeRuntime } = require('./utils')

// gets these values if the keys are set in the environment, if not it will use the defaults set
// omit the protocol (https)
const {
ADOBE_STATE_STORE_ENDPOINT_PROD = 'https://storage-state-amer.app-builder.adp.adobe.io',
ADOBE_STATE_STORE_ENDPOINT_STAGE = 'http://storage-state-amer.stg.app-builder.corp.adp.adobe.io'
ADOBE_STATE_STORE_ENDPOINT_PROD = 'storage-state-amer.app-builder.adp.adobe.io',
ADOBE_STATE_STORE_ENDPOINT_STAGE = 'storage-state-amer.stg.app-builder.corp.adp.adobe.io',
ADOBE_STATE_STORE_ENDPOINT_PROD_INTERNAL = 'storage-state-amer.app-builder.int.adp.adobe.io',
ADOBE_STATE_STORE_ENDPOINT_STAGE_INTERNAL = 'storage-state-amer.stg.app-builder.int.adp.adobe.io',
API_VERSION = 'v1beta1',
ADOBE_STATE_STORE_REGIONS = [ // first region is the default region
'amer',
'apac',
'emea'
]
} = process.env

const ADOBE_STATE_STORE_ENDPOINT = {
[PROD_ENV]: ADOBE_STATE_STORE_ENDPOINT_PROD,
[STAGE_ENV]: ADOBE_STATE_STORE_ENDPOINT_STAGE
[PROD_ENV]: isInternalToAdobeRuntime() ? ADOBE_STATE_STORE_ENDPOINT_PROD_INTERNAL : ADOBE_STATE_STORE_ENDPOINT_PROD,
[STAGE_ENV]: isInternalToAdobeRuntime() ? ADOBE_STATE_STORE_ENDPOINT_STAGE_INTERNAL : ADOBE_STATE_STORE_ENDPOINT_STAGE
}

const MAX_KEY_SIZE = 1024 * 1 // 1KB
const MAX_TTL_SECONDS = 60 * 60 * 24 * 365 // 365 days
const HEADER_KEY_EXPIRES = 'x-key-expires-ms'

const REGEX_PATTERN_STORE_NAMESPACE = '^(development-)?([0-9]{3,10})-([a-z0-9]{1,20})(-([a-z0-9]{1,20}))?$'
// The regex for keys, allowed chars are alphanumerical with _ and -
const REGEX_PATTERN_STORE_KEY = `^[a-zA-Z0-9-_-]{1,${MAX_KEY_SIZE}}$`

module.exports = {
ADOBE_STATE_STORE_REGIONS,
ADOBE_STATE_STORE_ENDPOINT_PROD,
ADOBE_STATE_STORE_ENDPOINT_STAGE,
ADOBE_STATE_STORE_ENDPOINT_PROD_INTERNAL,
ADOBE_STATE_STORE_ENDPOINT_STAGE_INTERNAL,
API_VERSION,
MAX_KEY_SIZE,
MAX_TTL_SECONDS,
REGEX_PATTERN_STORE_NAMESPACE,
REGEX_PATTERN_STORE_KEY,
ADOBE_STATE_STORE_ENDPOINT
ADOBE_STATE_STORE_ENDPOINT,
HEADER_KEY_EXPIRES
}
4 changes: 2 additions & 2 deletions lib/init.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ const { AdobeState } = require('./AdobeState')
*
* OpenWhisk credentials can also be read from environment variables `__OW_NAMESPACE` and `__OW_API_KEY`.
*
* @param {object} [config={}] used to init the sdk
* @param {object} [config] used to init the sdk
* @param {OpenWhiskCredentials} [config.ow]
* {@link OpenWhiskCredentials}. Set those if you want
* to use ootb credentials to access the state management service. OpenWhisk
Expand All @@ -50,7 +50,7 @@ async function init (config = {}) {
logger.debug(`init with config: ${JSON.stringify(logConfig, null, 2)}`)

const { auth: apikey, namespace } = (config.ow ?? {})
return AdobeState.init({ apikey, namespace })
return AdobeState.init({ apikey, namespace, region: config.region })
}

module.exports = { init }
Loading

0 comments on commit 69417ca

Please sign in to comment.