From 77799961a65bff81341508eb33491899b05e64fb Mon Sep 17 00:00:00 2001 From: Shazron Abdullah Date: Tue, 19 Dec 2023 13:47:01 +0800 Subject: [PATCH 01/17] stub --- lib/StateStoreError.js | 2 +- lib/impl/AdobeStateStore.js | 220 ++++++++++++++++++++++++++++++++++++ lib/init.js | 11 +- package.json | 2 + 4 files changed, 230 insertions(+), 5 deletions(-) create mode 100644 lib/impl/AdobeStateStore.js diff --git a/lib/StateStoreError.js b/lib/StateStoreError.js index 6483ec8..fa6b927 100644 --- a/lib/StateStoreError.js +++ b/lib/StateStoreError.js @@ -56,11 +56,11 @@ const E = ErrorWrapper( E('ERROR_INTERNAL', '%s') E('ERROR_BAD_REQUEST', '%s') E('ERROR_BAD_ARGUMENT', '%s') +E('ERROR_UNKNOWN_PROVIDER', '%s') E('ERROR_NOT_IMPLEMENTED', 'method `%s` not implemented') E('ERROR_BAD_CREDENTIALS', 'cannot access %s, make sure your credentials are valid') E('ERROR_PAYLOAD_TOO_LARGE', 'key, value or request payload is too large') E('ERROR_REQUEST_RATE_TOO_HIGH', 'Request rate too high. Please retry after sometime.') -// this error is specific to Adobe's owned database E('ERROR_FIREWALL', 'cannot access %s because your IP is blocked by a firewall, please make sure to run in an Adobe I/O Runtime action') // eslint-disable-next-line jsdoc/require-jsdoc diff --git a/lib/impl/AdobeStateStore.js b/lib/impl/AdobeStateStore.js new file mode 100644 index 0000000..a0aa46f --- /dev/null +++ b/lib/impl/AdobeStateStore.js @@ -0,0 +1,220 @@ +/* +Copyright 2023 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 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ +const { codes, logAndThrow } = require('./StateStoreError') +const joi = require('joi') +const utils = require('../utils') +const cloneDeep = require('lodash.clonedeep') +const logger = require('@adobe/aio-lib-core-logging')('@adobe/aio-lib-state', { provider: 'debug' }) +const { StateStore } = require('../StateStore') +// const { HttpExponentialBackoff } = require('@adobe/aio-lib-core-networking') +// const fetchRetry = new HttpExponentialBackoff() +const { getCliEnv, PROD_ENV, STAGE_ENV } = require('@adobe/aio-lib-env') + +/* *********************************** typedefs *********************************** */ + +const ADOBE_STATE_STORE_ENDPOINT = { + [PROD_ENV]: '???', + [STAGE_ENV]: '???-stage' +} + +/* *********************************** typedefs *********************************** */ + +/** + * AdobeStateStore put options + * + * @typedef AdobeStateStorePutOptions + * @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 + * value of 0 sets default. + */ + +/** + * AdobeStateStore get return object + * + * @typedef AdobeStateStoreGetReturnValue + * @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 + */ + +/* *********************************** helpers *********************************** */ + +// eslint-disable-next-line jsdoc/require-jsdoc +function throwNotImplemented (methodName) { + logAndThrow(new codes.ERROR_NOT_IMPLEMENTED({ messageValues: [methodName] })) +} + +// eslint-disable-next-line jsdoc/require-jsdoc +function validateInput (input, schema, details) { + const validation = schema.validate(input) + if (validation.error) { + logAndThrow(new codes.ERROR_BAD_ARGUMENT({ + messageValues: [validation.error.message], + sdkDetails: cloneDeep(details) + })) + } +} + +// eslint-disable-next-line jsdoc/require-jsdoc +function validateKey (key, details, label = 'key') { + validateInput(key, joi.string().label(label).required().regex(/[?#/\\]/, { invert: true }).messages({ + 'string.pattern.invert.base': 'Key cannot contain ?, #, /, or \\' + }), details) +} + +// eslint-disable-next-line jsdoc/require-jsdoc +function validateValue (value, details, label = 'value') { + validateInput(value, joi.any().label(label), details) // make it .required() ? +} + +/** + * @abstract + * @class AdobeStateStore + * @classdesc Cloud State Management + * @hideconstructor + */ +class AdobeStateStore extends StateStore { + /* **************************** CONSTRUCTOR/INIT TO IMPLEMENT ***************************** */ + + /** + * Creates an instance of AdobeStateStore. + * + * @memberof AdobeStateStore + * @private + * @param {string} apikey the apikey for the Adobe State Store + */ + constructor (apikey) { + super() + /** @private */ + this.apikey = apikey + /** @private */ + this.endpoint = ADOBE_STATE_STORE_ENDPOINT[getCliEnv()] + } + + /** + * Instantiates and returns a new AdobeStateStore object + * + * @static + * @param {object} credentials abstract credential object + * @returns {Promise} a new AdobeStateStore instance + * @memberof AdobeStateStore + * @override + * @private + */ + static async init (credentials = {}) { + // include ow environment vars to credentials + if (!credentials.apikey) { + credentials.apikey = process.env.__OW_API_KEY + } + + const cloned = utils.withHiddenFields(credentials, ['apikey']) + logger.debug(`init AdobeStateStore with ${JSON.stringify(cloned, null, 2)}`) + + const validation = joi.object().label('adobe').keys({ + apikey: joi.string() + }).required() + .validate(credentials) + if (validation.error) { + logAndThrow(new codes.ERROR_BAD_ARGUMENT({ + messageValues: [validation.error.message], + sdkDetails: cloned + })) + } + + return new AdobeStateStore(credentials.apikey) + } + + /* **************************** ADOBE STATE STORE OPERATORS ***************************** */ + + /** + * Retrieves the state value for given key. + * If the key doesn't exist returns undefined. + * + * @param {string} key state key identifier + * @returns {Promise} get response holding value and additional info + * @memberof AdobeStateStore + */ + async get (key) { + validateKey(key, { key }) + logger.debug(`get '${key}'`) + return this._get(key) + } + + /** + * Creates or updates a state key-value pair + * + * @param {string} key state key identifier + * @param {any} value state value + * @param {AdobeStateStorePutOptions} [options={}] put options + * @returns {Promise} key + * @memberof AdobeStateStore + */ + async put (key, value, options = {}) { + const details = { key, value, options } + validateKey(key, details) + validateValue(value, details) + validateInput(options, joi.object().label('options').keys({ ttl: joi.number() }).options({ convert: false }), details) + + const ttl = options.ttl || AdobeStateStore.DefaultTTL // => undefined, null, 0 sets to default + logger.debug(`put '${key}' with ttl ${ttl}`) + return this._put(key, value, { ttl }) + } + + /** + * Deletes a state key-value pair + * + * @param {string} key state key identifier + * @returns {Promise} key of deleted state or `null` if state does not exists + * @memberof AdobeStateStore + */ + async delete (key) { + validateKey(key, { key }) + logger.debug(`delete '${key}'`) + return this._delete(key) + } + + /* **************************** PRIVATE METHODS TO IMPLEMENT ***************************** */ + + /** + * @param {string} key state key identifier + * @returns {Promise} get response holding value and additional info + * @protected + */ + async _get (key) { + throwNotImplemented('_get') + } + + /** + * @param {string} key state key identifier + * @param {any} value state value + * @param {object} options state put options + * @returns {Promise} key + * @protected + */ + async _put (key, value, options) { + throwNotImplemented('_put') + } + + /** + * @param {string} key state key identifier + * @returns {Promise} key of deleted state or `null` if state does not exists + * @protected + */ + async _delete (key) { + throwNotImplemented('_delete') + } +} + +AdobeStateStore.DefaultTTL = 86400 // 24hours + +module.exports = { AdobeStateStore } diff --git a/lib/init.js b/lib/init.js index 5e70b2a..b0edc14 100644 --- a/lib/init.js +++ b/lib/init.js @@ -15,6 +15,7 @@ const logger = require('@adobe/aio-lib-core-logging')('@adobe/aio-lib-state', { const utils = require('./utils') const { CosmosStateStore } = require('./impl/CosmosStateStore') +const { AdobeStateStore } = require('./impl/AdobeStateStore') const { StateStore } = require('./StateStore') const { codes, logAndThrow } = require('./StateStoreError') @@ -100,8 +101,8 @@ async function init (config = {}) { logger.debug(`init with config: ${JSON.stringify(logConfig, null, 2)}`) - // 1. set provider - const provider = 'cosmos' // only cosmos is supported for now + // 1. get provider (default 'adobe') + const { provider = 'adobe' } = config // 2. instantiate tvm if ow credentials let tvm @@ -122,8 +123,10 @@ async function init (config = {}) { } logger.debug('init with openwhisk credentials') return CosmosStateStore.init(await wrapTVMRequest(tvm.getAzureCosmosCredentials())) - // default: - // throw new StateStoreError(`provider '${provider}' is not supported.`, StateStoreError.codes.BadArgument) + case 'adobe': + return AdobeStateStore.init({ apikey: config.adobe?.apikey }) + default: + throw new codes.ERROR_UNKNOWN_PROVIDER(`provider '${provider}' is not supported.`) } } diff --git a/package.json b/package.json index 17150ba..132ca36 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,9 @@ "dependencies": { "@adobe/aio-lib-core-errors": "^3.1.0", "@adobe/aio-lib-core-logging": "^2.0.0", + "@adobe/aio-lib-core-networking": "^4.1.0", "@adobe/aio-lib-core-tvm": "^3.0.0", + "@adobe/aio-lib-env": "^2.0.0", "@azure/cosmos": "^3.17.1", "joi": "^17.4.2", "lodash.clonedeep": "^4.5.0" From 9eb435713b1db54b254bcf7f1104a72c58e5df87 Mon Sep 17 00:00:00 2001 From: Shazron Abdullah Date: Tue, 19 Dec 2023 16:16:18 +0800 Subject: [PATCH 02/17] initial implementation --- lib/StateStore.js | 32 +++++++ lib/StateStoreError.js | 1 + lib/impl/AdobeStateStore.js | 183 +++++++++++++++++++++++++++++++++--- 3 files changed, 203 insertions(+), 13 deletions(-) diff --git a/lib/StateStore.js b/lib/StateStore.js index 36399d4..e9ab596 100644 --- a/lib/StateStore.js +++ b/lib/StateStore.js @@ -144,6 +144,28 @@ class StateStore { return this._delete(key) } + /** + * Deletes all key-values + * + * @returns {Promise} true if deleted, false if not + * @memberof StateStore + */ + async deleteAll () { + logger.debug('deleteAll') + return this._deleteAll() + } + + /** + * There exists key-values. + * + * @returns {Promise} true if exists, false if not + * @memberof StateStore + */ + async any () { + logger.debug('any') + return this._any() + } + /* **************************** PRIVATE METHODS TO IMPLEMENT ***************************** */ /** * @param {string} key state key identifier @@ -165,6 +187,16 @@ class StateStore { * @protected */ async _delete (key) { throwNotImplemented('_delete') } + /** + * @returns {Promise} true if deleted, false if not + * @protected + */ + async _deleteAll () { throwNotImplemented('_deleteAll') } + /** + * @returns {Promise} true if exists, false if not + * @protected + */ + async _any () { throwNotImplemented('_any') } } StateStore.DefaultTTL = 86400 // 24hours diff --git a/lib/StateStoreError.js b/lib/StateStoreError.js index fa6b927..eacbe37 100644 --- a/lib/StateStoreError.js +++ b/lib/StateStoreError.js @@ -58,6 +58,7 @@ E('ERROR_BAD_REQUEST', '%s') E('ERROR_BAD_ARGUMENT', '%s') E('ERROR_UNKNOWN_PROVIDER', '%s') E('ERROR_NOT_IMPLEMENTED', 'method `%s` not implemented') +E('ERROR_UNAUTHORIZED', 'you are not authorized to access %s') E('ERROR_BAD_CREDENTIALS', 'cannot access %s, make sure your credentials are valid') E('ERROR_PAYLOAD_TOO_LARGE', 'key, value or request payload is too large') E('ERROR_REQUEST_RATE_TOO_HIGH', 'Request rate too high. Please retry after sometime.') diff --git a/lib/impl/AdobeStateStore.js b/lib/impl/AdobeStateStore.js index a0aa46f..70298c2 100644 --- a/lib/impl/AdobeStateStore.js +++ b/lib/impl/AdobeStateStore.js @@ -15,11 +15,13 @@ const utils = require('../utils') const cloneDeep = require('lodash.clonedeep') const logger = require('@adobe/aio-lib-core-logging')('@adobe/aio-lib-state', { provider: 'debug' }) const { StateStore } = require('../StateStore') -// const { HttpExponentialBackoff } = require('@adobe/aio-lib-core-networking') -// const fetchRetry = new HttpExponentialBackoff() +const { HttpExponentialBackoff } = require('@adobe/aio-lib-core-networking') +const fetchRetry = new HttpExponentialBackoff() const { getCliEnv, PROD_ENV, STAGE_ENV } = require('@adobe/aio-lib-env') +const url = require('node:url') +const fs = require('node:fs') -/* *********************************** typedefs *********************************** */ +/* *********************************** constants *********************************** */ const ADOBE_STATE_STORE_ENDPOINT = { [PROD_ENV]: '???', @@ -28,6 +30,15 @@ const ADOBE_STATE_STORE_ENDPOINT = { /* *********************************** typedefs *********************************** */ +/** + * AdobeStateStoreCredentials + * + * @typedef AdobeStateStoreCredentials + * @type {object} + * @property {string} namespace the state store namespace + * @property {string} apikey the state store api key + */ + /** * AdobeStateStore put options * @@ -50,8 +61,38 @@ const ADOBE_STATE_STORE_ENDPOINT = { /* *********************************** helpers *********************************** */ // eslint-disable-next-line jsdoc/require-jsdoc -function throwNotImplemented (methodName) { - logAndThrow(new codes.ERROR_NOT_IMPLEMENTED({ messageValues: [methodName] })) +async function _wrap (promise, params) { + let response + try { + response = await promise + // reuse code in exception handler, for any other network exceptions + if (!response.ok) { + // no exception on 404 + if (response.statusCode === 404) { + return null + } else { + const e = new Error(response.statusText) + e.statusCode = response.statusCode + e.internal = response + throw e + } + } + } catch (e) { + const statusCode = e.statusCode || e.code + const copyParams = cloneDeep(params) + logger.debug(`got internal error with status ${statusCode}: ${e.message} `) + switch (statusCode) { + case 401: + return logAndThrow(new codes.ERROR_UNAUTHORIZED({ messageValues: ['underlying DB provider'], sdkDetails: copyParams })) + case 403: + return logAndThrow(new codes.ERROR_BAD_CREDENTIALS({ messageValues: ['underlying DB provider'], sdkDetails: copyParams })) + case 429: + return logAndThrow(new codes.ERROR_REQUEST_RATE_TOO_HIGH({ sdkDetails: copyParams })) + default: + return logAndThrow(new codes.ERROR_INTERNAL({ messageValues: [`unexpected response from provider with status: ${statusCode}`], sdkDetails: { ...cloneDeep(params), _internal: e.internal } })) + } + } + return response } // eslint-disable-next-line jsdoc/require-jsdoc @@ -91,21 +132,57 @@ class AdobeStateStore extends StateStore { * * @memberof AdobeStateStore * @private + * @param {string} namespace the namespace for the Adobe State Store * @param {string} apikey the apikey for the Adobe State Store */ - constructor (apikey) { + constructor (namespace, apikey) { super() /** @private */ + this.namespace = namespace + /** @private */ this.apikey = apikey /** @private */ this.endpoint = ADOBE_STATE_STORE_ENDPOINT[getCliEnv()] } + /** + * Creates a request url. + * + * @private + * @param {string} key the key of the state store + * @param {object} queryObject the query variables to send + * @returns {string} the constructed request url + */ + createRequestUrl (key, queryObject = {}) { + let requestUrl + + if (key) { + requestUrl = new url.URL(`${this.endpoint}/v1/containers/${this.namespace}/${key}`) + } else { + requestUrl = new url.URL(`${this.endpoint}/v1/containers/${this.namespace}`) + } + + requestUrl.search = (new url.URLSearchParams(queryObject)).toString() + return requestUrl.toString() + } + + /** + * Get Authorization headers. + * + * @private + * @returns {string} the authorization headers + */ + getAuthorizationHeaders () { + return { + Authorization: `Basic ${this.apikey}` + } + } + /** * Instantiates and returns a new AdobeStateStore object * * @static - * @param {object} credentials abstract credential object + * @param {AdobeStateStoreCredentials} credentials the credential object * @returns {Promise} a new AdobeStateStore instance * @memberof AdobeStateStore * @override @@ -113,7 +190,8 @@ class AdobeStateStore extends StateStore { */ static async init (credentials = {}) { // include ow environment vars to credentials - if (!credentials.apikey) { + if (!credentials.namespace && !credentials.apikey) { + credentials.namespace = process.env.__OW_NAMESPACE credentials.apikey = process.env.__OW_API_KEY } @@ -121,7 +199,8 @@ class AdobeStateStore extends StateStore { logger.debug(`init AdobeStateStore with ${JSON.stringify(cloned, null, 2)}`) const validation = joi.object().label('adobe').keys({ - apikey: joi.string() + apikey: joi.string(), + namespace: joi.string() }).required() .validate(credentials) if (validation.error) { @@ -183,6 +262,28 @@ class AdobeStateStore extends StateStore { return this._delete(key) } + /** + * Deletes all key-values + * + * @returns {Promise} true if deleted, false if not + * @memberof StateStore + */ + async deleteAll () { + logger.debug('deleteAll') + return this._deleteAll() + } + + /** + * There exists key-values. + * + * @returns {Promise} true if exists, false if not + * @memberof StateStore + */ + async any () { + logger.debug('any') + return this._any() + } + /* **************************** PRIVATE METHODS TO IMPLEMENT ***************************** */ /** @@ -191,7 +292,16 @@ class AdobeStateStore extends StateStore { * @protected */ async _get (key) { - throwNotImplemented('_get') + const promise = fetchRetry.exponentialBackoff(this.createRequestUrl(key), { + method: 'GET', + headers: { + ...this.getAuthorizationHeaders() + } + }) + const response = await _wrap(promise, { key }) + if (response) { + return response.json() + } } /** @@ -202,7 +312,17 @@ class AdobeStateStore extends StateStore { * @protected */ async _put (key, value, options) { - throwNotImplemented('_put') + const ttl = options.ttl < 0 ? -1 : options.ttl + const promise = fetchRetry.exponentialBackoff(this.createRequestUrl(key, { ttl }), { + method: 'PUT', + headers: { + ...this.getAuthorizationHeaders(), + 'Content-Type': 'application/octet-stream' + }, + body: fs.createReadStream(value) + }) + await _wrap(promise, { key, value, ...options }) + return key } /** @@ -211,10 +331,47 @@ class AdobeStateStore extends StateStore { * @protected */ async _delete (key) { - throwNotImplemented('_delete') + const promise = fetchRetry.exponentialBackoff(this.createRequestUrl(key), { + method: 'DELETE', + headers: { + ...this.getAuthorizationHeaders() + } + }) + const ret = await _wrap(promise, { key }) + return ret && key + } + + /** + * @returns {Promise} true if deleted, false if not + * @protected + */ + async _deleteAll () { + const promise = fetchRetry.exponentialBackoff(this.createRequestUrl(), { + method: 'DELETE', + headers: { + ...this.getAuthorizationHeaders() + } + }) + const response = await _wrap(promise, {}) + return response !== null + } + + /** + * @returns {Promise} true if exists, false if not + * @protected + */ + async _any () { + const promise = fetchRetry.exponentialBackoff(this.createRequestUrl(), { + method: 'HEAD', + headers: { + ...this.getAuthorizationHeaders() + } + }) + const response = await _wrap(promise, {}) + return response !== null } } -AdobeStateStore.DefaultTTL = 86400 // 24hours +AdobeStateStore.DefaultTTL = 86400 // 24 hours (in seconds) module.exports = { AdobeStateStore } From c9e4be01cfc71ac11ecb093e5642ca0251772eb7 Mon Sep 17 00:00:00 2001 From: Shazron Abdullah Date: Tue, 19 Dec 2023 16:17:35 +0800 Subject: [PATCH 03/17] add temp endpoints --- lib/impl/AdobeStateStore.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/impl/AdobeStateStore.js b/lib/impl/AdobeStateStore.js index 70298c2..4d6e408 100644 --- a/lib/impl/AdobeStateStore.js +++ b/lib/impl/AdobeStateStore.js @@ -24,8 +24,8 @@ const fs = require('node:fs') /* *********************************** constants *********************************** */ const ADOBE_STATE_STORE_ENDPOINT = { - [PROD_ENV]: '???', - [STAGE_ENV]: '???-stage' + [PROD_ENV]: 'http://localhost:8080', // TODO: + [STAGE_ENV]: 'http://localhost:8080' // TODO: } /* *********************************** typedefs *********************************** */ From e6b54ac7c2fd1bfa44ca325c9aab706a996155a2 Mon Sep 17 00:00:00 2001 From: Shazron Abdullah Date: Tue, 19 Dec 2023 16:33:50 +0800 Subject: [PATCH 04/17] fix import error --- lib/impl/AdobeStateStore.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/impl/AdobeStateStore.js b/lib/impl/AdobeStateStore.js index 4d6e408..58d0575 100644 --- a/lib/impl/AdobeStateStore.js +++ b/lib/impl/AdobeStateStore.js @@ -9,12 +9,12 @@ 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. */ -const { codes, logAndThrow } = require('./StateStoreError') +const { codes, logAndThrow } = require('../StateStoreError') +const { StateStore } = require('../StateStore') const joi = require('joi') const utils = require('../utils') const cloneDeep = require('lodash.clonedeep') const logger = require('@adobe/aio-lib-core-logging')('@adobe/aio-lib-state', { provider: 'debug' }) -const { StateStore } = require('../StateStore') const { HttpExponentialBackoff } = require('@adobe/aio-lib-core-networking') const fetchRetry = new HttpExponentialBackoff() const { getCliEnv, PROD_ENV, STAGE_ENV } = require('@adobe/aio-lib-env') From 13172e2a8de71adb94387294f97b6d0b9628fa14 Mon Sep 17 00:00:00 2001 From: Shazron Abdullah Date: Tue, 19 Dec 2023 16:44:11 +0800 Subject: [PATCH 05/17] make existing tests pass --- package.json | 2 +- test/impl/CosmosStateStore.test.js | 4 ++-- test/init.test.js | 12 ++++++------ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/package.json b/package.json index 132ca36..e7e7226 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ "eslint-plugin-node": "^11.0.0", "eslint-plugin-promise": "^4.2.1", "eslint-plugin-standard": "^4.0.1", - "jest": "^24.9.0", + "jest": "^29.7.0", "jsdoc-to-markdown": "^5.0.2", "replace-in-file": "^6.1.0", "tsd-jsdoc": "^2.4.0" diff --git a/test/impl/CosmosStateStore.test.js b/test/impl/CosmosStateStore.test.js index 9b754d4..a871c6c 100644 --- a/test/impl/CosmosStateStore.test.js +++ b/test/impl/CosmosStateStore.test.js @@ -188,9 +188,9 @@ describe('init', () => { expect(cosmos.CosmosClient).toHaveBeenCalledTimes(0) }) test('No reuse for BYO creds', async () => { - await stateLib.init({ cosmos: fakeCosmosMasterCredentials }) + await stateLib.init({ cosmos: fakeCosmosMasterCredentials, provider: 'cosmos' }) expect(cosmos.CosmosClient).toHaveBeenCalledTimes(1) - await stateLib.init({ cosmos: fakeCosmosMasterCredentials }) + await stateLib.init({ cosmos: fakeCosmosMasterCredentials, provider: 'cosmos' }) // New CosmosClient instance generated again expect(cosmos.CosmosClient).toHaveBeenCalledTimes(2) }) diff --git a/test/init.test.js b/test/init.test.js index de830ee..73df484 100644 --- a/test/init.test.js +++ b/test/init.test.js @@ -33,7 +33,7 @@ describe('init', () => { resourceToken: 'fakeToken' } test('with cosmos config', async () => { - await stateLib.init({ cosmos: fakeCosmosConfig }) + await stateLib.init({ cosmos: fakeCosmosConfig, provider: 'cosmos' }) expect(CosmosStateStore.init).toHaveBeenCalledTimes(1) expect(CosmosStateStore.init).toHaveBeenCalledWith(fakeCosmosConfig) expect(TvmClient.init).toHaveBeenCalledTimes(0) @@ -65,7 +65,7 @@ describe('init', () => { }) test('when tvm options', async () => { cosmosTVMMock.mockResolvedValue(fakeTVMResponse) - await stateLib.init({ ow: fakeOWCreds, tvm: fakeTVMOptions }) + await stateLib.init({ ow: fakeOWCreds, tvm: fakeTVMOptions, provider: 'cosmos' }) expect(TvmClient.init).toHaveBeenCalledTimes(1) expect(TvmClient.init).toHaveBeenCalledWith({ ow: fakeOWCreds, ...fakeTVMOptions }) expect(CosmosStateStore.init).toHaveBeenCalledTimes(1) @@ -75,7 +75,7 @@ describe('init', () => { }) test('when empty config to be able to pass OW creds as env variables', async () => { cosmosTVMMock.mockResolvedValue(fakeTVMResponse) - await stateLib.init() + await stateLib.init({ provider: 'cosmos' }) expect(TvmClient.init).toHaveBeenCalledTimes(1) expect(TvmClient.init).toHaveBeenCalledWith({ ow: undefined }) expect(CosmosStateStore.init).toHaveBeenCalledTimes(1) @@ -87,20 +87,20 @@ describe('init', () => { const e = new Error('tvm error') e.sdkDetails = { fake: 'details', status: 401 } cosmosTVMMock.mockRejectedValue(e) - await global.expectToThrowForbidden(stateLib.init.bind(stateLib, { ow: fakeOWCreds }), e.sdkDetails) + await global.expectToThrowForbidden(stateLib.init.bind(stateLib, { ow: fakeOWCreds, provider: 'cosmos' }), e.sdkDetails) }) // eslint-disable-next-line jest/expect-expect test('when tvm rejects with a 403 (throws wrapped error)', async () => { const e = new Error('tvm error') e.sdkDetails = { fake: 'details', status: 403 } cosmosTVMMock.mockRejectedValue(e) - await global.expectToThrowForbidden(stateLib.init.bind(stateLib, { ow: fakeOWCreds }), e.sdkDetails) + await global.expectToThrowForbidden(stateLib.init.bind(stateLib, { ow: fakeOWCreds, provider: 'cosmos' }), e.sdkDetails) }) test('when tvm rejects with another status code (throws tvm error)', async () => { const tvmError = new Error('tvm error') tvmError.sdkDetails = { fake: 'details', status: 500 } cosmosTVMMock.mockRejectedValue(tvmError) - return expect(stateLib.init({ ow: fakeOWCreds })).rejects.toThrow(tvmError) + return expect(stateLib.init({ ow: fakeOWCreds, provider: 'cosmos' })).rejects.toThrow(tvmError) }) }) }) From 618db41418e3f84504f328dadf66203386c12ca7 Mon Sep 17 00:00:00 2001 From: Shazron Abdullah Date: Thu, 21 Dec 2023 16:59:58 +0800 Subject: [PATCH 06/17] remove CosmosStateStore, added complete coverage for AdobeStateStore --- index.js | 2 +- jest.config.js | 10 +- lib/{impl => }/AdobeStateStore.js | 92 ++--- ...eStoreError.js => AdobeStateStoreError.js} | 29 +- lib/StateStore.js | 204 ---------- lib/constants.js | 22 ++ lib/impl/CosmosStateStore.js | 185 --------- lib/init.js | 81 +--- lib/utils.js | 28 +- package.json | 2 - test/AdobeStateStore.test.js | 351 +++++++++++++++++ test/StateStore.test.js | 153 -------- test/impl/CosmosStateStore.test.js | 361 ------------------ test/init.test.js | 117 ++---- test/utils.test.js | 43 +++ 15 files changed, 518 insertions(+), 1162 deletions(-) rename lib/{impl => }/AdobeStateStore.js (75%) rename lib/{StateStoreError.js => AdobeStateStoreError.js} (62%) delete mode 100644 lib/StateStore.js create mode 100644 lib/constants.js delete mode 100644 lib/impl/CosmosStateStore.js create mode 100644 test/AdobeStateStore.test.js delete mode 100644 test/StateStore.test.js delete mode 100644 test/impl/CosmosStateStore.test.js create mode 100644 test/utils.test.js diff --git a/index.js b/index.js index b62f727..11dea9c 100644 --- a/index.js +++ b/index.js @@ -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/AdobeStateStore') module.exports = require('./lib/init') diff --git a/jest.config.js b/jest.config.js index 6dabe5d..28273b4 100644 --- a/jest.config.js +++ b/jest.config.js @@ -18,5 +18,13 @@ module.exports = { collectCoverageFrom: [ 'index.js', 'lib/**/*.js' - ] + ], + coverageThreshold: { + global: { + branches: 100, + lines: 100, + statements: 100, + functions: 100 + } + } } diff --git a/lib/impl/AdobeStateStore.js b/lib/AdobeStateStore.js similarity index 75% rename from lib/impl/AdobeStateStore.js rename to lib/AdobeStateStore.js index 58d0575..2caa690 100644 --- a/lib/impl/AdobeStateStore.js +++ b/lib/AdobeStateStore.js @@ -9,24 +9,16 @@ 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. */ -const { codes, logAndThrow } = require('../StateStoreError') -const { StateStore } = require('../StateStore') +const { codes, logAndThrow } = require('./AdobeStateStoreError') const joi = require('joi') -const utils = require('../utils') +const utils = require('./utils') const cloneDeep = require('lodash.clonedeep') const logger = require('@adobe/aio-lib-core-logging')('@adobe/aio-lib-state', { provider: 'debug' }) const { HttpExponentialBackoff } = require('@adobe/aio-lib-core-networking') -const fetchRetry = new HttpExponentialBackoff() -const { getCliEnv, PROD_ENV, STAGE_ENV } = require('@adobe/aio-lib-env') const url = require('node:url') -const fs = require('node:fs') - -/* *********************************** constants *********************************** */ - -const ADOBE_STATE_STORE_ENDPOINT = { - [PROD_ENV]: 'http://localhost:8080', // TODO: - [STAGE_ENV]: 'http://localhost:8080' // TODO: -} +const { Readable } = require('node:stream') +const { getCliEnv } = require('@adobe/aio-lib-env') +const { ADOBE_STATE_STORE_ENDPOINT } = require('./constants') /* *********************************** typedefs *********************************** */ @@ -68,20 +60,20 @@ async function _wrap (promise, params) { // reuse code in exception handler, for any other network exceptions if (!response.ok) { // no exception on 404 - if (response.statusCode === 404) { + if (response.status === 404) { return null } else { const e = new Error(response.statusText) - e.statusCode = response.statusCode + e.status = response.status e.internal = response throw e } } } catch (e) { - const statusCode = e.statusCode || e.code + const status = e.status || e.code const copyParams = cloneDeep(params) - logger.debug(`got internal error with status ${statusCode}: ${e.message} `) - switch (statusCode) { + logger.debug(`got internal error with status ${status}: ${e.message} `) + switch (status) { case 401: return logAndThrow(new codes.ERROR_UNAUTHORIZED({ messageValues: ['underlying DB provider'], sdkDetails: copyParams })) case 403: @@ -89,44 +81,19 @@ async function _wrap (promise, params) { case 429: return logAndThrow(new codes.ERROR_REQUEST_RATE_TOO_HIGH({ sdkDetails: copyParams })) default: - return logAndThrow(new codes.ERROR_INTERNAL({ messageValues: [`unexpected response from provider with status: ${statusCode}`], sdkDetails: { ...cloneDeep(params), _internal: e.internal } })) + return logAndThrow(new codes.ERROR_INTERNAL({ messageValues: [`unexpected response from provider with status: ${status}`], sdkDetails: { ...cloneDeep(params), _internal: e.internal } })) } } return response } -// eslint-disable-next-line jsdoc/require-jsdoc -function validateInput (input, schema, details) { - const validation = schema.validate(input) - if (validation.error) { - logAndThrow(new codes.ERROR_BAD_ARGUMENT({ - messageValues: [validation.error.message], - sdkDetails: cloneDeep(details) - })) - } -} - -// eslint-disable-next-line jsdoc/require-jsdoc -function validateKey (key, details, label = 'key') { - validateInput(key, joi.string().label(label).required().regex(/[?#/\\]/, { invert: true }).messages({ - 'string.pattern.invert.base': 'Key cannot contain ?, #, /, or \\' - }), details) -} - -// eslint-disable-next-line jsdoc/require-jsdoc -function validateValue (value, details, label = 'value') { - validateInput(value, joi.any().label(label), details) // make it .required() ? -} - /** * @abstract * @class AdobeStateStore * @classdesc Cloud State Management * @hideconstructor */ -class AdobeStateStore extends StateStore { - /* **************************** CONSTRUCTOR/INIT TO IMPLEMENT ***************************** */ - +class AdobeStateStore { /** * Creates an instance of AdobeStateStore. * @@ -136,13 +103,14 @@ class AdobeStateStore extends StateStore { * @param {string} apikey the apikey for the Adobe State Store */ constructor (namespace, apikey) { - super() /** @private */ this.namespace = namespace /** @private */ this.apikey = apikey /** @private */ this.endpoint = ADOBE_STATE_STORE_ENDPOINT[getCliEnv()] + /** @private */ + this.fetchRetry = new HttpExponentialBackoff() } /** @@ -162,6 +130,7 @@ class AdobeStateStore extends StateStore { requestUrl = new url.URL(`${this.endpoint}/v1/containers/${this.namespace}`) } + // add the query params requestUrl.search = (new url.URLSearchParams(queryObject)).toString() return requestUrl.toString() } @@ -199,8 +168,8 @@ class AdobeStateStore extends StateStore { logger.debug(`init AdobeStateStore with ${JSON.stringify(cloned, null, 2)}`) const validation = joi.object().label('adobe').keys({ - apikey: joi.string(), - namespace: joi.string() + apikey: joi.string().required(), + namespace: joi.string().required() }).required() .validate(credentials) if (validation.error) { @@ -210,7 +179,7 @@ class AdobeStateStore extends StateStore { })) } - return new AdobeStateStore(credentials.apikey) + return new AdobeStateStore(credentials.namespace, credentials.apikey) } /* **************************** ADOBE STATE STORE OPERATORS ***************************** */ @@ -224,7 +193,6 @@ class AdobeStateStore extends StateStore { * @memberof AdobeStateStore */ async get (key) { - validateKey(key, { key }) logger.debug(`get '${key}'`) return this._get(key) } @@ -239,12 +207,7 @@ class AdobeStateStore extends StateStore { * @memberof AdobeStateStore */ async put (key, value, options = {}) { - const details = { key, value, options } - validateKey(key, details) - validateValue(value, details) - validateInput(options, joi.object().label('options').keys({ ttl: joi.number() }).options({ convert: false }), details) - - const ttl = options.ttl || AdobeStateStore.DefaultTTL // => undefined, null, 0 sets to default + const { ttl } = options logger.debug(`put '${key}' with ttl ${ttl}`) return this._put(key, value, { ttl }) } @@ -257,7 +220,6 @@ class AdobeStateStore extends StateStore { * @memberof AdobeStateStore */ async delete (key) { - validateKey(key, { key }) logger.debug(`delete '${key}'`) return this._delete(key) } @@ -292,7 +254,7 @@ class AdobeStateStore extends StateStore { * @protected */ async _get (key) { - const promise = fetchRetry.exponentialBackoff(this.createRequestUrl(key), { + const promise = this.fetchRetry.exponentialBackoff(this.createRequestUrl(key), { method: 'GET', headers: { ...this.getAuthorizationHeaders() @@ -312,14 +274,14 @@ class AdobeStateStore extends StateStore { * @protected */ async _put (key, value, options) { - const ttl = options.ttl < 0 ? -1 : options.ttl - const promise = fetchRetry.exponentialBackoff(this.createRequestUrl(key, { ttl }), { + const { ttl } = options + const promise = this.fetchRetry.exponentialBackoff(this.createRequestUrl(key, { ttl }), { method: 'PUT', headers: { ...this.getAuthorizationHeaders(), 'Content-Type': 'application/octet-stream' }, - body: fs.createReadStream(value) + body: Readable.from(value) }) await _wrap(promise, { key, value, ...options }) return key @@ -331,7 +293,7 @@ class AdobeStateStore extends StateStore { * @protected */ async _delete (key) { - const promise = fetchRetry.exponentialBackoff(this.createRequestUrl(key), { + const promise = this.fetchRetry.exponentialBackoff(this.createRequestUrl(key), { method: 'DELETE', headers: { ...this.getAuthorizationHeaders() @@ -346,7 +308,7 @@ class AdobeStateStore extends StateStore { * @protected */ async _deleteAll () { - const promise = fetchRetry.exponentialBackoff(this.createRequestUrl(), { + const promise = this.fetchRetry.exponentialBackoff(this.createRequestUrl(), { method: 'DELETE', headers: { ...this.getAuthorizationHeaders() @@ -361,7 +323,7 @@ class AdobeStateStore extends StateStore { * @protected */ async _any () { - const promise = fetchRetry.exponentialBackoff(this.createRequestUrl(), { + const promise = this.fetchRetry.exponentialBackoff(this.createRequestUrl(), { method: 'HEAD', headers: { ...this.getAuthorizationHeaders() @@ -372,6 +334,4 @@ class AdobeStateStore extends StateStore { } } -AdobeStateStore.DefaultTTL = 86400 // 24 hours (in seconds) - module.exports = { AdobeStateStore } diff --git a/lib/StateStoreError.js b/lib/AdobeStateStoreError.js similarity index 62% rename from lib/StateStoreError.js rename to lib/AdobeStateStoreError.js index eacbe37..717ca1e 100644 --- a/lib/StateStoreError.js +++ b/lib/AdobeStateStoreError.js @@ -14,7 +14,7 @@ const { ErrorWrapper, createUpdater } = require('@adobe/aio-lib-core-errors').Ai const logger = require('@adobe/aio-lib-core-logging')('@adobe/aio-lib-state', { provider: 'debug' }) /** - * @typedef {object} StateLibError + * @typedef {object} AdobeStateLibError * @property {string} message The message for the Error * @property {string} code The code for the Error * @property {string} sdk The SDK associated with the Error @@ -22,21 +22,22 @@ const logger = require('@adobe/aio-lib-core-logging')('@adobe/aio-lib-state', { */ /** - * State lib custom errors. + * Adobe State lib custom errors. * `e.sdkDetails` provides additional context for each error (e.g. function parameter) * * @typedef StateLibErrors * @type {object} - * @property {StateLibError} ERROR_BAD_ARGUMENT this error is thrown when an argument is missing, has invalid type, or includes invalid characters. - * @property {StateLibError} ERROR_BAD_REQUEST this error is thrown when an argument has an illegal value. - * @property {StateLibError} ERROR_NOT_IMPLEMENTED this error is thrown when a method is not implemented or when calling + * @property {AdobeStateLibError} ERROR_BAD_ARGUMENT this error is thrown when an argument is missing, has invalid type, or includes invalid characters. + * @property {AdobeStateLibError} ERROR_BAD_REQUEST this error is thrown when an argument has an illegal value. + * @property {AdobeStateLibError} ERROR_NOT_IMPLEMENTED this error is thrown when a method is not implemented or when calling * methods directly on the abstract class (StateStore). - * @property {StateLibError} ERROR_PAYLOAD_TOO_LARGE this error is thrown when the state key, state value or underlying request payload size + * @property {AdobeStateLibError} ERROR_PAYLOAD_TOO_LARGE this error is thrown when the state key, state value or underlying request payload size * exceeds the specified limitations. - * @property {StateLibError} ERROR_BAD_CREDENTIALS this error is thrown when the supplied init credentials are invalid. - * @property {StateLibError} ERROR_INTERNAL this error is thrown when an unknown error is thrown by the underlying + * @property {AdobeStateLibError} ERROR_BAD_CREDENTIALS this error is thrown when the supplied init credentials are invalid. + * @property {AdobeStateLibError} ERROR_UNAUTHORIZED this error is thrown when the credentials are unauthorized to access the resource + * @property {AdobeStateLibError} ERROR_INTERNAL this error is thrown when an unknown error is thrown by the underlying * DB provider or TVM server for credential exchange. More details can be found in `e.sdkDetails._internal`. - * @property {StateLibError} ERROR_REQUEST_RATE_TOO_HIGH this error is thrown when the request rate for accessing state is too high. + * @property {AdobeStateLibError} ERROR_REQUEST_RATE_TOO_HIGH this error is thrown when the request rate for accessing state is too high. */ const codes = {} @@ -48,8 +49,8 @@ const Updater = createUpdater( ) const E = ErrorWrapper( - 'StateLibError', - 'StateLib', + 'AdobeStateLibError', + 'AdobeStateLib', Updater ) @@ -66,12 +67,6 @@ E('ERROR_FIREWALL', 'cannot access %s because your IP is blocked by a firewall, // eslint-disable-next-line jsdoc/require-jsdoc function logAndThrow (e) { - const internalError = e.sdkDetails._internal - // by default stringifying an Error returns '{}' because toJSON is not defined, so here we make sure that we properly - // stringify the _internal error objects - if (internalError instanceof Error && !internalError.toJSON) { - internalError.toJSON = () => Object.getOwnPropertyNames(internalError).reduce((obj, prop) => { obj[prop] = internalError[prop]; return obj }, {}) - } logger.error(JSON.stringify(e, null, 2)) throw e } diff --git a/lib/StateStore.js b/lib/StateStore.js deleted file mode 100644 index e9ab596..0000000 --- a/lib/StateStore.js +++ /dev/null @@ -1,204 +0,0 @@ -/* -Copyright 2019 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 - -Unless required by applicable law or agreed to in writing, software distributed under -the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS -OF ANY KIND, either express or implied. See the License for the specific language -governing permissions and limitations under the License. -*/ -const { codes, logAndThrow } = require('./StateStoreError') -const joi = require('joi') -const cloneDeep = require('lodash.clonedeep') -const logger = require('@adobe/aio-lib-core-logging')('@adobe/aio-lib-state', { provider: 'debug' }) - -/* *********************************** typedefs *********************************** */ - -/** - * StateStore put options - * - * @typedef StateStorePutOptions - * @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 - * value of 0 sets default. - */ - -/** - * StateStore get return object - * - * @typedef StateStoreGetReturnValue - * @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 - */ - -/* *********************************** helpers *********************************** */ - -// eslint-disable-next-line jsdoc/require-jsdoc -function throwNotImplemented (methodName) { - logAndThrow(new codes.ERROR_NOT_IMPLEMENTED({ messageValues: [methodName] })) -} - -// eslint-disable-next-line jsdoc/require-jsdoc -function validateInput (input, schema, details) { - const validation = schema.validate(input) - if (validation.error) { - logAndThrow(new codes.ERROR_BAD_ARGUMENT({ - messageValues: [validation.error.message], - sdkDetails: cloneDeep(details) - })) - } -} - -// eslint-disable-next-line jsdoc/require-jsdoc -function validateKey (key, details, label = 'key') { - validateInput(key, joi.string().label(label).required().regex(/[?#/\\]/, { invert: true }).messages({ - 'string.pattern.invert.base': 'Key cannot contain ?, #, /, or \\' - }), details) -} - -// eslint-disable-next-line jsdoc/require-jsdoc -function validateValue (value, details, label = 'value') { - validateInput(value, joi.any().label(label), details) // make it .required() ? -} - -/** - * @abstract - * @class StateStore - * @classdesc Cloud State Management - * @hideconstructor - */ -class StateStore { - /* **************************** CONSTRUCTOR/INIT TO IMPLEMENT ***************************** */ - - /** - * Creates an instance of StateStore. - * - * @param {boolean} _isTest set this to true to allow construction - * @memberof StateStore - * @private - * @abstract - */ - constructor (_isTest) { if (new.target === StateStore && !_isTest) throwNotImplemented('StateStore') } - // marked as private to hide from jsdoc, wrapped by index.js init - /** - * Instantiates and returns a new StateStore object - * - * @static - * @param {object} credentials abstract credential object - * @returns {Promise} a new StateStore instance - * @memberof StateStore - * @private - */ - static async init (credentials) { throwNotImplemented('init') } - - /* **************************** STATE STORE OPERATORS ***************************** */ - - /** - * Retrieves the state value for given key. - * If the key doesn't exist returns undefined. - * - * @param {string} key state key identifier - * @returns {Promise} get response holding value and additional info - * @memberof StateStore - */ - async get (key) { - validateKey(key, { key }) - logger.debug(`get '${key}'`) - return this._get(key) - } - - /** - * Creates or updates a state key-value pair - * - * @param {string} key state key identifier - * @param {any} value state value - * @param {StateStorePutOptions} [options={}] put options - * @returns {Promise} key - * @memberof StateStore - */ - async put (key, value, options = {}) { - const details = { key, value, options } - validateKey(key, details) - validateValue(value, details) - validateInput(options, joi.object().label('options').keys({ ttl: joi.number() }).options({ convert: false }), details) - - const ttl = options.ttl || StateStore.DefaultTTL // => undefined, null, 0 sets to default - logger.debug(`put '${key}' with ttl ${ttl}`) - return this._put(key, value, { ttl }) - } - - /** - * Deletes a state key-value pair - * - * @param {string} key state key identifier - * @returns {Promise} key of deleted state or `null` if state does not exists - * @memberof StateStore - */ - async delete (key) { - validateKey(key, { key }) - logger.debug(`delete '${key}'`) - return this._delete(key) - } - - /** - * Deletes all key-values - * - * @returns {Promise} true if deleted, false if not - * @memberof StateStore - */ - async deleteAll () { - logger.debug('deleteAll') - return this._deleteAll() - } - - /** - * There exists key-values. - * - * @returns {Promise} true if exists, false if not - * @memberof StateStore - */ - async any () { - logger.debug('any') - return this._any() - } - - /* **************************** PRIVATE METHODS TO IMPLEMENT ***************************** */ - /** - * @param {string} key state key identifier - * @returns {Promise} get response holding value and additional info - * @protected - */ - async _get (key) { throwNotImplemented('_get') } - /** - * @param {string} key state key identifier - * @param {any} value state value - * @param {object} options state put options - * @returns {Promise} key - * @protected - */ - async _put (key, value, options) { throwNotImplemented('_put') } - /** - * @param {string} key state key identifier - * @returns {Promise} key of deleted state or `null` if state does not exists - * @protected - */ - async _delete (key) { throwNotImplemented('_delete') } - /** - * @returns {Promise} true if deleted, false if not - * @protected - */ - async _deleteAll () { throwNotImplemented('_deleteAll') } - /** - * @returns {Promise} true if exists, false if not - * @protected - */ - async _any () { throwNotImplemented('_any') } -} - -StateStore.DefaultTTL = 86400 // 24hours - -module.exports = { StateStore } diff --git a/lib/constants.js b/lib/constants.js new file mode 100644 index 0000000..f0d7291 --- /dev/null +++ b/lib/constants.js @@ -0,0 +1,22 @@ +/* +Copyright 2023 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 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +const { PROD_ENV, STAGE_ENV } = require('@adobe/aio-lib-env') + +const ADOBE_STATE_STORE_ENDPOINT = { + [PROD_ENV]: 'http://localhost:8080', // TODO: + [STAGE_ENV]: 'http://localhost-stage:8080' // TODO: +} + +module.exports = { + ADOBE_STATE_STORE_ENDPOINT +} diff --git a/lib/impl/CosmosStateStore.js b/lib/impl/CosmosStateStore.js deleted file mode 100644 index a340330..0000000 --- a/lib/impl/CosmosStateStore.js +++ /dev/null @@ -1,185 +0,0 @@ -/* -Copyright 2019 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 - -Unless required by applicable law or agreed to in writing, software distributed under -the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS -OF ANY KIND, either express or implied. See the License for the specific language -governing permissions and limitations under the License. -*/ - -const joi = require('joi') -const cosmos = require('@azure/cosmos') -const cloneDeep = require('lodash.clonedeep') -const logger = require('@adobe/aio-lib-core-logging')('@adobe/aio-lib-state', { provider: 'debug' }) - -const utils = require('../utils') -const { codes, logAndThrow } = require('../StateStoreError') -const { StateStore } = require('../StateStore') - -// eslint-disable-next-line jsdoc/require-jsdoc -async function _wrap (promise, params) { - let response - try { - response = await promise - } catch (e) { - const copyParams = cloneDeep(params) - // error handling - const status = e.statusCode || e.code - if (status === 404) { - return null - } - logger.debug(`got internal error with status ${status}: ${e.message} `) - if (status === 403) { - if (e.message.includes('blocked by your Cosmos DB account firewall settings')) { - logAndThrow(new codes.ERROR_FIREWALL({ messageValues: ['underlying DB provider'], sdkDetails: copyParams })) - } - logAndThrow(new codes.ERROR_BAD_CREDENTIALS({ messageValues: ['underlying DB provider'], sdkDetails: copyParams })) - } - if (status === 413) { - logAndThrow(new codes.ERROR_PAYLOAD_TOO_LARGE({ sdkDetails: copyParams })) - } - if (e.message.toLowerCase().includes('illegal')) { - // e.message is is not as descriptive or consistent. - const invalidChars = "The following characters are restricted and cannot be used in the Id property: '/', '\\', '?', '#' " - logAndThrow(new codes.ERROR_BAD_REQUEST({ messageValues: [invalidChars], sdkDetails: copyParams })) - } - if (status === 429) { - logAndThrow(new codes.ERROR_REQUEST_RATE_TOO_HIGH({ sdkDetails: copyParams })) - } - logAndThrow(new codes.ERROR_INTERNAL({ messageValues: [`unknown error response from provider with status: ${status || 'unknown'}`], sdkDetails: { ...copyParams, _internal: e } })) - } - // 404 does not throw in cosmos SDK which is fine as we treat 404 as a non-error, - // here we just make sure there are no other cases of bad status codes that don't throw - const status = response.statusCode - if (status && status >= 300 && status !== 404) { - logAndThrow(new codes.ERROR_INTERNAL({ messageValues: [`unexpected response from provider with status: ${status}`], sdkDetails: { ...cloneDeep(params), _internal: response } })) - } - return response -} - -/** - * @class CosmosStateStore - * @classdesc Azure Cosmos state store implementation - * @augments StateStore - * @hideconstructor - * @private - */ -class CosmosStateStore extends StateStore { - /** - * @memberof CosmosStateStore - * @override - * @private - */ - constructor (container, partitionKey, /* istanbul ignore next */ options = { expiration: null }) { - super() - /** @private */ - this._cosmos = {} - this._cosmos.container = container - this._cosmos.partitionKey = partitionKey - this.expiration = options.expiration - } - - /** - * @param {object} credentials azure cosmos credentials - * @memberof CosmosStateStore - * @override - * @private - */ - static async init (credentials) { - const cloned = utils.withHiddenFields(credentials, ['masterKey', 'resourceToken']) - logger.debug(`init CosmosStateStore with ${JSON.stringify(cloned, null, 2)}`) - - const validation = joi.object().label('cosmos').keys({ - // either - resourceToken: joi.string(), - // or - masterKey: joi.string(), - // for both - endpoint: joi.string().required(), - databaseId: joi.string().required(), - containerId: joi.string().required(), - partitionKey: joi.string().required(), - - expiration: joi.string() // allowed for tvm response, in ISO format - }).xor('masterKey', 'resourceToken').required() - .validate(credentials) - if (validation.error) { - logAndThrow(new codes.ERROR_BAD_ARGUMENT({ - messageValues: [validation.error.message], - sdkDetails: cloned - })) - } - - const inMemoryInstance = CosmosStateStore.inMemoryInstance[credentials.partitionKey] - if (inMemoryInstance && inMemoryInstance.expiration !== credentials.expiration) { - // the TVM credentials have changed, aio-lib-core-tvm has generated new one likely because of expiration. - delete CosmosStateStore.inMemoryInstance[credentials.partitionKey] - } - - if (!CosmosStateStore.inMemoryInstance[credentials.partitionKey]) { - let cosmosClient - if (credentials.resourceToken) { - // Note: resourceToken doesn't necessarily mean that the TVM provided the credentials, it can have been provided by a user. - logger.debug('using azure cosmos resource token') - cosmosClient = new cosmos.CosmosClient({ endpoint: credentials.endpoint, consistencyLevel: 'Session', tokenProvider: /* istanbul ignore next */ async () => credentials.resourceToken }) - } else { - logger.debug('using azure cosmos master key') - cosmosClient = new cosmos.CosmosClient({ endpoint: credentials.endpoint, consistencyLevel: 'Session', key: credentials.masterKey }) - // create if not exist creates 2 additional round trips on init -> should be enabled as an option - // const { database } = await cosmosClient.databases.createIfNotExists({ id: credentials.databaseId }) - // container = (await database.containers.createIfNotExists({ id: credentials.containerId })).container - } - const container = cosmosClient.database(credentials.databaseId).container(credentials.containerId) - CosmosStateStore.inMemoryInstance[credentials.partitionKey] = new CosmosStateStore(container, credentials.partitionKey, { expiration: credentials.expiration }) - } else { - logger.debug('reusing exising in-memory CosmosClient initialization') - } - return CosmosStateStore.inMemoryInstance[credentials.partitionKey] - } - - /** - * @memberof CosmosStateStore - * @override - * @private - */ - async _get (key) { - const response = await _wrap(this._cosmos.container.item(key, this._cosmos.partitionKey).read(), { key }) - // if 404 response.resource = undefined - if (!response.resource) return undefined - if (response.resource.ttl < 0) { - return { value: response.resource.value, expiration: null } - } - - // azure ts and ttl in seconds, date takes ms - const expiration = new Date(response.resource._ts * 1000 + response.resource.ttl * 1000).toISOString() - return response.resource && { value: response.resource.value, expiration } - } - - /** - * @memberof CosmosStateStore - * @override - * @private - */ - async _put (key, value, options) { - const ttl = options.ttl < 0 ? -1 : options.ttl - await _wrap(this._cosmos.container.items.upsert({ id: key, partitionKey: this._cosmos.partitionKey, ttl, value }), { key, value, options }) - return key - } - - /** - * @memberof CosmosStateStore - * @override - * @private - */ - async _delete (key) { - // if throws 404 wrap returns null - const ret = await _wrap(this._cosmos.container.item(key, this._cosmos.partitionKey).delete(), { key }) - return ret && key - } -} - -CosmosStateStore.inMemoryInstance = {} -module.exports = { CosmosStateStore } diff --git a/lib/init.js b/lib/init.js index c756fd5..0c439b8 100644 --- a/lib/init.js +++ b/lib/init.js @@ -1,5 +1,5 @@ /* -Copyright 2019 Adobe. All rights reserved. +Copyright 2023 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 @@ -10,14 +10,10 @@ OF ANY KIND, either express or implied. See the License for the specific languag governing permissions and limitations under the License. */ -const TvmClient = require('@adobe/aio-lib-core-tvm') const logger = require('@adobe/aio-lib-core-logging')('@adobe/aio-lib-state', { provider: 'debug' }) const utils = require('./utils') -const { CosmosStateStore } = require('./impl/CosmosStateStore') -const { AdobeStateStore } = require('./impl/AdobeStateStore') -const { StateStore } = require('./StateStore') -const { codes, logAndThrow } = require('./StateStoreError') +const { AdobeStateStore } = require('./AdobeStateStore') /* *********************************** typedefs *********************************** */ /** @@ -29,41 +25,8 @@ const { codes, logAndThrow } = require('./StateStoreError') * @property {string} auth auth key */ -/** - * An object holding the Azure Cosmos resource credentials with permissions on a single partition and container - * - * @typedef AzureCosmosPartitionResourceCredentials - * @type {object} - * @property {string} endpoint cosmosdb resource endpoint - * @property {string} resourceToken cosmosdb resource token restricted to the partitionKey - * @property {string} databaseId id for cosmosdb database - * @property {string} containerId id for cosmosdb container within database - * @property {string} partitionKey key for cosmosdb partition within container authorized by resource token - */ - -/** - * An object holding the Azure Cosmos account master key - * - * @typedef AzureCosmosMasterCredentials - * @type {object} - * @property {string} endpoint cosmosdb resource endpoint - * @property {string} masterKey cosmosdb account masterKey - * @property {string} databaseId id for cosmosdb database - * @property {string} containerId id for cosmosdb container within database - * @property {string} partitionKey key for cosmosdb partition where data will be stored - */ /* *********************************** helpers & init() *********************************** */ -// eslint-disable-next-line jsdoc/require-jsdoc -async function wrapTVMRequest (promise, params) { - return promise - .catch(e => { - if (e.sdkDetails.status === 401 || e.sdkDetails.status === 403) { - logAndThrow(new codes.ERROR_BAD_CREDENTIALS({ messageValues: ['TVM'], sdkDetails: e.sdkDetails })) - } - throw e // throw raw tvm error - }) -} /** * Initializes and returns the key-value-store SDK. * @@ -80,47 +43,15 @@ async function wrapTVMRequest (promise, params) { * 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 {AzureCosmosMasterCredentials|AzureCosmosPartitionResourceCredentials} [config.cosmos] - * [Azure Cosmos resource credentials]{@link AzureCosmosPartitionResourceCredentials} or - * [Azure Cosmos account credentials]{@link AzureCosmosMasterCredentials} - * @param {object} [config.tvm] tvm configuration, applies only when passing OpenWhisk credentials - * @param {string} [config.tvm.apiUrl] alternative tvm api url. - * @param {string} [config.tvm.cacheFile] alternative tvm cache file, set to `false` to disable caching of temporary credentials. - * @returns {Promise} A StateStore instance + * @returns {Promise} An AdobeStateStore instance */ async function init (config = {}) { - // 0. log - const logConfig = utils.withHiddenFields(config, ['ow.auth', 'cosmos.resourceToken', 'cosmos.masterKey']) + const logConfig = utils.withHiddenFields(config, ['ow.auth']) logger.debug(`init with config: ${JSON.stringify(logConfig, null, 2)}`) - // 1. get provider (default 'adobe') - const { provider = 'adobe' } = config - - // 2. instantiate tvm if ow credentials - let tvm - if (provider === 'cosmos' && !config.cosmos) { - // remember config.ow can be empty if env vars are set - const tvmArgs = { ow: config.ow, ...config.tvm } - tvm = await TvmClient.init(tvmArgs) - } - - // 3. return state store based on provider - switch (provider) { - case 'cosmos': - if (config.cosmos) { - logger.debug('init with user provided cosmosDB credentials') - // Do not reuse cosmos client instances for BringYourOwn creds - CosmosStateStore.inMemoryInstance = {} - return CosmosStateStore.init(config.cosmos) - } - logger.debug('init with openwhisk credentials') - return CosmosStateStore.init(await wrapTVMRequest(tvm.getAzureCosmosCredentials())) - case 'adobe': - return AdobeStateStore.init({ apikey: config.adobe?.apikey }) - default: - throw new codes.ERROR_UNKNOWN_PROVIDER(`provider '${provider}' is not supported.`) - } + const { auth: apikey, namespace } = (config.ow ?? {}) + return AdobeStateStore.init({ apikey, namespace }) } module.exports = { init } diff --git a/lib/utils.js b/lib/utils.js index 60ababc..408472a 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -1,16 +1,30 @@ const cloneDeep = require('lodash.clonedeep') -// eslint-disable-next-line jsdoc/require-jsdoc -function withHiddenFields (toCopy, fields) { - if (!toCopy) return toCopy - const copyConfig = cloneDeep(toCopy) - fields.forEach(f => { +/** + * Replaces any hidden field values with the string '' + * + * @param {object} sourceObj the object to needs fields hidden + * @param {Array} fieldsToHide the fields that need the value hidden + * @returns {object} the source object but with the specified fields hidden + */ +function withHiddenFields (sourceObj, fieldsToHide) { + if (!sourceObj || !Array.isArray(fieldsToHide)) { + return sourceObj + } + + const copyConfig = cloneDeep(sourceObj) + fieldsToHide.forEach(f => { const keys = f.split('.') const lastKey = keys.slice(-1)[0] + // keep last key - const traverse = keys.slice(0, -1).reduce((obj, k) => obj && obj[k], copyConfig) + const traverse = keys + .slice(0, -1) + .reduce((obj, k) => obj && obj[k], copyConfig) - if (traverse && traverse[lastKey]) traverse[lastKey] = '' + if (traverse && traverse[lastKey]) { + traverse[lastKey] = '' + } }) return copyConfig } diff --git a/package.json b/package.json index 503f143..27cc2dd 100644 --- a/package.json +++ b/package.json @@ -54,9 +54,7 @@ "@adobe/aio-lib-core-errors": "^3.1.0", "@adobe/aio-lib-core-logging": "^2.0.0", "@adobe/aio-lib-core-networking": "^4.1.0", - "@adobe/aio-lib-core-tvm": "^3.0.0", "@adobe/aio-lib-env": "^2.0.0", - "@azure/cosmos": "^3.17.1", "joi": "^17.4.2", "lodash.clonedeep": "^4.5.0" } diff --git a/test/AdobeStateStore.test.js b/test/AdobeStateStore.test.js new file mode 100644 index 0000000..3f0fc5c --- /dev/null +++ b/test/AdobeStateStore.test.js @@ -0,0 +1,351 @@ +/* +Copyright 2019 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 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +// @ts-nocheck +const { getCliEnv, DEFAULT_ENV, PROD_ENV, STAGE_ENV } = require('@adobe/aio-lib-env') +const { HttpExponentialBackoff } = require('@adobe/aio-lib-core-networking') +const { AdobeStateStore } = require('../lib/AdobeStateStore') +const querystring = require('node:querystring') +const { Buffer } = require('node:buffer') + +// constants ////////////////////////////////////////////////////////// + +const mockExponentialBackoff = jest.fn() +HttpExponentialBackoff.mockImplementation(() => { + return { + exponentialBackoff: mockExponentialBackoff + } +}) + +const fakeCredentials = { + apikey: 'some-api-key', + namespace: 'some-namespace' +} + +const myConstants = { + ADOBE_STATE_STORE_ENDPOINT: { + prod: 'https://prod', + stage: 'https://stage' + } +} + +// helpers ////////////////////////////////////////////////////////// + +const wrapInFetchResponse = (body) => { + return { + ok: true, + headers: { + get: () => 'fake req id' + }, + json: async () => body + } +} + +const wrapInFetchError = (status) => { + return { + ok: false, + headers: { + get: () => 'fake req id' + }, + json: async () => 'error', + text: async () => 'error', + status + } +} + +// mocks ////////////////////////////////////////////////////////// + +jest.mock('@adobe/aio-lib-core-networking') + +jest.mock('../lib/constants', () => { + return myConstants +}) + +jest.mock('@adobe/aio-lib-env', () => { + return { + ...jest.requireActual('@adobe/aio-lib-env'), + getCliEnv: jest.fn() + } +}) + +// jest globals ////////////////////////////////////////////////////////// + +beforeEach(() => { + getCliEnv.mockReturnValue(DEFAULT_ENV) + mockExponentialBackoff.mockReset() +}) + +// ////////////////////////////////////////////////////////// + +describe('init and constructor', () => { + test('good credentials', async () => { + const credentials = { + apikey: 'some-api-key', + namespace: 'some-namespace' + } + + const store = await AdobeStateStore.init(credentials) + expect(store.apikey).toEqual(credentials.apikey) + expect(store.namespace).toEqual(credentials.namespace) + expect(store.endpoint).toBeDefined() + }) + + test('bad credentials (no apikey and no namespace)', async () => { + await expect(AdobeStateStore.init()).rejects + .toThrow('[AdobeStateLib:ERROR_BAD_ARGUMENT] "apikey" is required') + }) + + test('bad credentials (no apikey)', async () => { + const credentials = { + namespace: 'some-namespace' + } + + await expect(AdobeStateStore.init(credentials)).rejects + .toThrow('[AdobeStateLib:ERROR_BAD_ARGUMENT] "apikey" is required') + }) + + test('bad credentials (no namespace)', async () => { + const credentials = { + apikey: 'some-apikey' + } + + await expect(AdobeStateStore.init(credentials)).rejects + .toThrow('[AdobeStateLib:ERROR_BAD_ARGUMENT] "namespace" is required') + }) +}) + +describe('get', () => { + let store + + beforeEach(async () => { + store = await AdobeStateStore.init(fakeCredentials) + }) + + test('success', async () => { + const key = 'valid-key' + const fetchResponseJson = { + expiration: 999, + value: 'foo' + } + + mockExponentialBackoff.mockResolvedValue(wrapInFetchResponse(fetchResponseJson)) + + const value = await store.get(key) + expect(value).toEqual(fetchResponseJson) + }) + + test('not found', async () => { + const key = 'not-found-key' + + mockExponentialBackoff.mockResolvedValue(wrapInFetchError(404)) + + const value = await store.get(key) + expect(value).toEqual(undefined) + }) +}) + +describe('put', () => { + let store + + beforeEach(async () => { + store = await AdobeStateStore.init(fakeCredentials) + }) + + test('success (string value)', async () => { + const key = 'valid-key' + const value = 'some-value' + const fetchResponseJson = {} + + mockExponentialBackoff.mockResolvedValue(wrapInFetchResponse(fetchResponseJson)) + + const returnKey = await store.put(key, value) + expect(returnKey).toEqual(key) + }) + + test('success (binary value)', async () => { + const key = 'valid-key' + const value = Buffer.from([0x61, 0x72, 0x65, 0x26, 0x35, 0x55, 0xff]) + const fetchResponseJson = {} + + mockExponentialBackoff.mockResolvedValue(wrapInFetchResponse(fetchResponseJson)) + + const returnKey = await store.put(key, value) + expect(returnKey).toEqual(key) + }) + + test('coverage: 401 error', async () => { + const key = 'some-key' + const value = 'some-value' + + mockExponentialBackoff.mockResolvedValue(wrapInFetchError(401)) + await expect(store.put(key, value)).rejects.toThrow('[AdobeStateLib:ERROR_UNAUTHORIZED] you are not authorized to access underlying DB provider') + }) + + test('coverage: 403 error', async () => { + const key = 'some-key' + const value = 'some-value' + + mockExponentialBackoff.mockResolvedValue(wrapInFetchError(403)) + await expect(store.put(key, value)).rejects.toThrow('[AdobeStateLib:ERROR_BAD_CREDENTIALS] cannot access underlying DB provider, make sure your credentials are valid') + }) + + test('coverage: 429 error', async () => { + const key = 'some-key' + const value = 'some-value' + + mockExponentialBackoff.mockResolvedValue(wrapInFetchError(429)) + await expect(store.put(key, value)).rejects.toThrow('[AdobeStateLib:ERROR_REQUEST_RATE_TOO_HIGH] Request rate too high. Please retry after sometime.') + }) + + test('coverage: unknown server error', async () => { + const key = 'some-key' + const value = 'some-value' + + mockExponentialBackoff.mockResolvedValue(wrapInFetchError(500)) + await expect(store.put(key, value)).rejects.toThrow('[AdobeStateLib:ERROR_INTERNAL] unexpected response from provider with status: 500') + }) + + test('coverage: unknown error (fetch network failure)', async () => { + const key = 'some-key' + const value = 'some-value' + + const error = new Error('some network error') + error.code = 502 + mockExponentialBackoff.mockRejectedValue(error) + await expect(store.put(key, value)).rejects.toThrow('[AdobeStateLib:ERROR_INTERNAL] unexpected response from provider with status: 502') + }) +}) + +describe('delete', () => { + let store + + beforeEach(async () => { + store = await AdobeStateStore.init(fakeCredentials) + }) + + test('success', async () => { + const key = 'valid-key' + const fetchResponseJson = {} + + mockExponentialBackoff.mockResolvedValue(wrapInFetchResponse(fetchResponseJson)) + + const returnKey = await store.delete(key) + expect(returnKey).toEqual(key) + }) + + test('not found', async () => { + const key = 'not-found-key' + + mockExponentialBackoff.mockResolvedValue(wrapInFetchError(404)) + + const value = await store.delete(key) + expect(value).toEqual(null) + }) +}) + +describe('deleteAll', () => { + let store + + beforeEach(async () => { + store = await AdobeStateStore.init(fakeCredentials) + }) + + test('success', async () => { + const fetchResponseJson = {} + mockExponentialBackoff.mockResolvedValue(wrapInFetchResponse(fetchResponseJson)) + + const value = await store.deleteAll() + expect(value).toEqual(true) + }) + + test('not found', async () => { + mockExponentialBackoff.mockResolvedValue(wrapInFetchError(404)) + + const value = await store.deleteAll() + expect(value).toEqual(false) + }) +}) + +describe('any', () => { + let store + + beforeEach(async () => { + store = await AdobeStateStore.init(fakeCredentials) + }) + + test('success', async () => { + const fetchResponseJson = {} + mockExponentialBackoff.mockResolvedValue(wrapInFetchResponse(fetchResponseJson)) + + const value = await store.any() + expect(value).toEqual(true) + }) + + test('not found', async () => { + mockExponentialBackoff.mockResolvedValue(wrapInFetchError(404)) + + const value = await store.any() + expect(value).toEqual(false) + }) +}) + +describe('private methods', () => { + test('getAuthorizationHeaders (private)', async () => { + const expectedHeaders = { + Authorization: `Basic ${fakeCredentials.apikey}` + } + const store = await AdobeStateStore.init(fakeCredentials) + + expect(store.getAuthorizationHeaders()).toEqual(expectedHeaders) + }) + + describe('createRequestUrl (private)', () => { + test('no params', async () => { + const env = PROD_ENV + getCliEnv.mockReturnValue(env) + + // need to instantiate a new store, when env changes + const store = await AdobeStateStore.init(fakeCredentials) + + const url = store.createRequestUrl() + expect(url).toEqual(`${myConstants.ADOBE_STATE_STORE_ENDPOINT[env]}/v1/containers/${fakeCredentials.namespace}`) + }) + + test('key set, no query params', async () => { + const key = 'some-key' + const env = STAGE_ENV + getCliEnv.mockReturnValue(env) + + // need to instantiate a new store, when env changes + const store = await AdobeStateStore.init(fakeCredentials) + + const url = store.createRequestUrl(key) + expect(url).toEqual(`${myConstants.ADOBE_STATE_STORE_ENDPOINT[env]}/v1/containers/${fakeCredentials.namespace}/${key}`) + }) + + test('key set, some query params', async () => { + const queryParams = { + foo: 'bar', + cat: 'bat' + } + const key = 'some-key' + const env = STAGE_ENV + getCliEnv.mockReturnValue(env) + + // need to instantiate a new store, when env changes + const store = await AdobeStateStore.init(fakeCredentials) + + const url = store.createRequestUrl(key, queryParams) + expect(url).toEqual(`${myConstants.ADOBE_STATE_STORE_ENDPOINT[env]}/v1/containers/${fakeCredentials.namespace}/${key}?${querystring.stringify(queryParams)}`) + }) + }) +}) diff --git a/test/StateStore.test.js b/test/StateStore.test.js deleted file mode 100644 index 08fce17..0000000 --- a/test/StateStore.test.js +++ /dev/null @@ -1,153 +0,0 @@ -/* -Copyright 2019 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 - -Unless required by applicable law or agreed to in writing, software distributed under -the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS -OF ANY KIND, either express or implied. See the License for the specific language -governing permissions and limitations under the License. -*/ - -// @ts-nocheck -const { StateStore } = require('../lib/StateStore') - -describe('init', () => { - // eslint-disable-next-line jest/expect-expect - test('missing implementation', async () => { - expect.hasAssertions() - await global.expectToThrowNotImplemented(StateStore.init.bind(StateStore), 'init') - }) -}) - -describe('constructor', () => { - // eslint-disable-next-line jest/expect-expect - test('missing implementation', async () => { - expect.hasAssertions() - await global.expectToThrowNotImplemented(() => new StateStore(false), 'StateStore') - }) -}) - -describe('get', () => { - // eslint-disable-next-line jest/expect-expect - test('missing implementation', async () => { - expect.hasAssertions() - const state = new StateStore(true) - await global.expectToThrowNotImplemented(state.get.bind(state, 'key'), '_get') - }) - // eslint-disable-next-line jest/expect-expect - test('bad key type', async () => { - expect.hasAssertions() - const state = new StateStore(true) - await global.expectToThrowBadArg(state.get.bind(state, 123), ['string', 'key'], { key: 123 }) - }) - // eslint-disable-next-line jest/expect-expect - test('bad key characters', async () => { - expect.hasAssertions() - const state = new StateStore(true) - await global.expectToThrowBadArg(state.put.bind(state, '?test', 'value', {}), ['Key', 'cannot', 'contain'], { key: '?test', value: 'value', options: {} }) - await global.expectToThrowBadArg(state.put.bind(state, 't#est', 'value', {}), ['Key', 'cannot', 'contain'], { key: 't#est', value: 'value', options: {} }) - await global.expectToThrowBadArg(state.put.bind(state, 't\\est', 'value', {}), ['Key', 'cannot', 'contain'], { key: 't\\est', value: 'value', options: {} }) - await global.expectToThrowBadArg(state.put.bind(state, 'test/', 'value', {}), ['Key', 'cannot', 'contain'], { key: 'test/', value: 'value', options: {} }) - }) - test('calls _get (part of interface)', async () => { - expect.hasAssertions() - const state = new StateStore(true) - state._get = jest.fn() - await state.get('key') - expect(state._get).toHaveBeenCalledTimes(1) - expect(state._get).toHaveBeenCalledWith('key') - expect(global.mockLogDebug).toHaveBeenCalledWith('get \'key\'') - }) -}) - -describe('put', () => { - // eslint-disable-next-line jest/expect-expect - test('missing implementation', async () => { - expect.hasAssertions() - const state = new StateStore(true) - await global.expectToThrowNotImplemented(state.put.bind(state, 'key', 'value'), '_put') - }) - // eslint-disable-next-line jest/expect-expect - test('bad key type', async () => { - expect.hasAssertions() - const state = new StateStore(true) - await global.expectToThrowBadArg(state.put.bind(state, 123, 'value', {}), ['string', 'key'], { key: 123, value: 'value', options: {} }) - }) - // eslint-disable-next-line jest/expect-expect - test('bad key characters', async () => { - expect.hasAssertions() - const state = new StateStore(true) - await global.expectToThrowBadArg(state.put.bind(state, '?test', 'value', {}), ['Key', 'cannot', 'contain'], { key: '?test', value: 'value', options: {} }) - await global.expectToThrowBadArg(state.put.bind(state, 't#est', 'value', {}), ['Key', 'cannot', 'contain'], { key: 't#est', value: 'value', options: {} }) - await global.expectToThrowBadArg(state.put.bind(state, 't\\est', 'value', {}), ['Key', 'cannot', 'contain'], { key: 't\\est', value: 'value', options: {} }) - await global.expectToThrowBadArg(state.put.bind(state, 'test/', 'value', {}), ['Key', 'cannot', 'contain'], { key: 'test/', value: 'value', options: {} }) - }) - // eslint-disable-next-line jest/expect-expect - test('bad options', async () => { - expect.hasAssertions() - const state = new StateStore(true) - const expectedDetails = { key: 'key', value: 'value' } - await global.expectToThrowBadArg(state.put.bind(state, 'key', 'value', 'options'), ['object', 'options'], { ...expectedDetails, options: 'options' }) - await global.expectToThrowBadArg(state.put.bind(state, 'key', 'value', { nonexiting__option: 'value' }), ['nonexiting__option', 'not allowed'], { ...expectedDetails, options: { nonexiting__option: 'value' } }) - await global.expectToThrowBadArg(state.put.bind(state, 'key', 'value', { ttl: 'value' }), ['ttl', 'number'], { ...expectedDetails, options: { ttl: 'value' } }) - await global.expectToThrowBadArg(state.put.bind(state, 'key', 'value', { ttl: '1' }), ['ttl', 'number'], { ...expectedDetails, options: { ttl: '1' } }) - }) - test('calls _put with default ttl when options is undefined or options.ttl is = 0', async () => { - expect.hasAssertions() - const state = new StateStore(true) - state._put = jest.fn() - await state.put('key', 'value') - expect(state._put).toHaveBeenCalledTimes(1) - expect(state._put).toHaveBeenCalledWith('key', 'value', { ttl: StateStore.DefaultTTL }) - - state._put.mockReset() - await state.put('key', 'value', { ttl: 0 }) - expect(state._put).toHaveBeenCalledTimes(1) - expect(state._put).toHaveBeenCalledWith('key', 'value', { ttl: StateStore.DefaultTTL }) - expect(global.mockLogDebug).toHaveBeenCalledWith(`put 'key' with ttl ${StateStore.DefaultTTL}`) - }) - test('calls _put with custom ttl when options.ttl is set', async () => { - expect.hasAssertions() - const state = new StateStore(true) - state._put = jest.fn() - await state.put('key', 'value', { ttl: 99 }) - expect(state._put).toHaveBeenCalledTimes(1) - expect(state._put).toHaveBeenCalledWith('key', 'value', { ttl: 99 }) - expect(global.mockLogDebug).toHaveBeenCalledWith('put \'key\' with ttl 99') - }) -}) - -describe('delete', () => { - // eslint-disable-next-line jest/expect-expect - test('missing implementation', async () => { - expect.hasAssertions() - const state = new StateStore(true) - await global.expectToThrowNotImplemented(state.delete.bind(state, 'key'), '_delete') - }) - // eslint-disable-next-line jest/expect-expect - test('bad key type', async () => { - expect.hasAssertions() - const state = new StateStore(true) - await global.expectToThrowBadArg(state.delete.bind(state, 123), ['string', 'key'], { key: 123 }) - }) - // eslint-disable-next-line jest/expect-expect - test('bad key characters', async () => { - expect.hasAssertions() - const state = new StateStore(true) - await global.expectToThrowBadArg(state.put.bind(state, '?test', 'value', {}), ['Key', 'cannot', 'contain'], { key: '?test', value: 'value', options: {} }) - await global.expectToThrowBadArg(state.put.bind(state, 't#est', 'value', {}), ['Key', 'cannot', 'contain'], { key: 't#est', value: 'value', options: {} }) - await global.expectToThrowBadArg(state.put.bind(state, 't\\est', 'value', {}), ['Key', 'cannot', 'contain'], { key: 't\\est', value: 'value', options: {} }) - await global.expectToThrowBadArg(state.put.bind(state, 'test/', 'value', {}), ['Key', 'cannot', 'contain'], { key: 'test/', value: 'value', options: {} }) - }) - test('calls _delete (part of interface)', async () => { - expect.hasAssertions() - const state = new StateStore(true) - state._delete = jest.fn() - await state.delete('key', 'value') - expect(state._delete).toHaveBeenCalledTimes(1) - expect(state._delete).toHaveBeenCalledWith('key') - expect(global.mockLogDebug).toHaveBeenCalledWith('delete \'key\'') - }) -}) diff --git a/test/impl/CosmosStateStore.test.js b/test/impl/CosmosStateStore.test.js deleted file mode 100644 index 83ab56d..0000000 --- a/test/impl/CosmosStateStore.test.js +++ /dev/null @@ -1,361 +0,0 @@ -/* -Copyright 2019 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 - -Unless required by applicable law or agreed to in writing, software distributed under -the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS -OF ANY KIND, either express or implied. See the License for the specific language -governing permissions and limitations under the License. -*/ - -const { CosmosStateStore } = require('../../lib/impl/CosmosStateStore') -const { StateStore } = require('../../lib/StateStore') -const cloneDeep = require('lodash.clonedeep') - -const cosmos = require('@azure/cosmos') -jest.mock('@azure/cosmos') - -const stateLib = require('../../index') - -const fakeCosmosResourceCredentials = { - endpoint: 'https://fake.com', - resourceToken: 'fakeToken', - databaseId: 'fakedb', - containerId: 'fakeContainer', - partitionKey: 'fakePK' -} - -const fakeCosmosMasterCredentials = { - endpoint: 'https://fake.com', - masterKey: 'fakeKey', - databaseId: 'fakedb', - containerId: 'fakeContainer', - partitionKey: 'fakePK' -} - -const fakeCosmosTVMResponse = { - expiration: new Date(8640000000000000).toISOString(), - ...fakeCosmosResourceCredentials -} - -const cosmosDatabaseMock = jest.fn() -const cosmosContainerMock = jest.fn() -beforeEach(async () => { - CosmosStateStore.inMemoryInstance = {} - cosmos.CosmosClient.mockReset() - cosmosContainerMock.mockReset() - cosmosDatabaseMock.mockReset() - - cosmos.CosmosClient.mockImplementation(() => { - return { - database: cosmosDatabaseMock.mockReturnValue({ - container: cosmosContainerMock - }) - } - }) -}) - -// eslint-disable-next-line jsdoc/require-jsdoc -async function testProviderErrorHandling (func, mock, fparams) { - const illegalId = "The following characters are restricted and cannot be used in the Id property: '/', '\\', '?', '#' " - // eslint-disable-next-line jsdoc/require-jsdoc - async function testOne (status, errorMessage, expectCheck, isInternal, ...addArgs) { - const providerError = new Error(errorMessage) - if (status) { - providerError.code = status - } - - const expectedErrorDetails = { ...fparams } - if (isInternal) { expectedErrorDetails._internal = providerError } - mock.mockReset() - mock.mockRejectedValue(providerError) - await global[expectCheck](func, ...addArgs, expectedErrorDetails) - } - - await testOne(403, 'This is blocked by your Cosmos DB account firewall settings.', 'expectToThrowFirewall') - await testOne(403, 'fakeError', 'expectToThrowForbidden') - await testOne(413, 'fakeError', 'expectToThrowTooLarge') - await testOne(500, 'fakeError', 'expectToThrowInternalWithStatus', true, 500) - await testOne(undefined, 'fakeError', 'expectToThrowInternal', true) - await testOne(undefined, 'contains illegal chars', 'expectToThrowBadRequest', false, [illegalId]) - await testOne(429, 'fakeError', 'expectToThrowRequestRateTooHigh') - // when provider resolves with bad status which is not 404 - const providerResponse = { - statusCode: 400 - } - mock.mockReset() - mock.mockResolvedValue(providerResponse) - await global.expectToThrowInternalWithStatus(func, 400, { ...fparams, _internal: providerResponse }) -} - -describe('init', () => { - // eslint-disable-next-line jsdoc/require-jsdoc - async function testInitBadArg (object, missing, expectedWords) { - if (typeof missing === 'string') missing = [missing] - if (typeof expectedWords === 'string') expectedWords = [expectedWords] - - if (!expectedWords) expectedWords = missing - - const args = cloneDeep(object) - - let expectedErrorDetails = {} - if (args) { - missing.forEach(m => delete args[m]) - expectedErrorDetails = cloneDeep(args) - if (expectedErrorDetails.masterKey) expectedErrorDetails.masterKey = '' - if (expectedErrorDetails.resourceToken) expectedErrorDetails.resourceToken = '' - } - - await global.expectToThrowBadArg(CosmosStateStore.init.bind(CosmosStateStore, args), expectedWords, expectedErrorDetails) - } - const checkInitDebugLogNoSecrets = (str) => expect(global.mockLogDebug).not.toHaveBeenCalledWith(expect.stringContaining(str)) - - describe('with bad args', () => { - // eslint-disable-next-line jest/expect-expect - test('with undefined credentials', async () => { - await testInitBadArg(undefined, [], ['cosmos']) - }) - // eslint-disable-next-line jest/expect-expect - test('with resourceToken and missing endpoint, databaseId, containerId, partitionKey', async () => { - const array = ['endpoint', 'databaseId', 'containerId', 'partitionKey'] - for (let i = 0; i < array.length; i++) { - await testInitBadArg(fakeCosmosResourceCredentials, array[i], 'required') - } - }) - // eslint-disable-next-line jest/expect-expect - test('with masterKey and missing endpoint, databaseId, containerId, partitionKey', async () => { - const array = ['endpoint', 'databaseId', 'containerId', 'partitionKey'] - for (let i = 0; i < array.length; i++) { - await testInitBadArg(fakeCosmosMasterCredentials, array[i], 'required') - } - }) - // eslint-disable-next-line jest/expect-expect - test('with missing masterKey and resourceToken', async () => { - await testInitBadArg(fakeCosmosMasterCredentials, ['resourceToken', 'masterKey']) - }) - // eslint-disable-next-line jest/expect-expect - test('with both masterKey and resourceToken', async () => { - const args = { ...fakeCosmosResourceCredentials, masterKey: 'fakeKey' } - await testInitBadArg(args, [], ['resourceToken', 'masterKey']) - }) - // eslint-disable-next-line jest/expect-expect - test('with unknown option', async () => { - const args = { ...fakeCosmosMasterCredentials, someFake__unknown: 'hello' } - await testInitBadArg(args, [], ['someFake__unknown', 'not', 'allowed']) - }) - describe('with correct args', () => { - const testInitOK = async (credentials) => { - const state = await CosmosStateStore.init(credentials) - expect(state).toBeInstanceOf(CosmosStateStore) - expect(state).toBeInstanceOf(StateStore) - expect(cosmos.CosmosClient).toHaveBeenCalledTimes(1) - expect(cosmos.CosmosClient).toHaveBeenCalledWith(expect.objectContaining({ endpoint: credentials.endpoint })) - expect(cosmosDatabaseMock).toHaveBeenCalledTimes(1) - expect(cosmosDatabaseMock).toHaveBeenCalledWith(credentials.databaseId) - expect(cosmosContainerMock).toHaveBeenCalledTimes(1) - expect(cosmosContainerMock).toHaveBeenCalledWith(credentials.containerId) - } - - // eslint-disable-next-line jest/expect-expect - test('with resourceToken', async () => { - await testInitOK(fakeCosmosResourceCredentials) - checkInitDebugLogNoSecrets(fakeCosmosResourceCredentials.resourceToken) - }) - // eslint-disable-next-line jest/expect-expect - test('with resourceToken and expiration (tvm response format)', async () => { - await testInitOK(fakeCosmosTVMResponse) - checkInitDebugLogNoSecrets(fakeCosmosTVMResponse.resourceToken) - }) - // eslint-disable-next-line jest/expect-expect - test('with masterKey', async () => { - await testInitOK(fakeCosmosMasterCredentials) - checkInitDebugLogNoSecrets(fakeCosmosMasterCredentials.masterKey) - }) - test('successive calls should reuse the CosmosStateStore instance - with resourceToken', async () => { - await testInitOK(fakeCosmosTVMResponse) - expect(cosmos.CosmosClient).toHaveBeenCalledTimes(1) - cosmos.CosmosClient.mockReset() - await CosmosStateStore.init(fakeCosmosTVMResponse) - expect(cosmos.CosmosClient).toHaveBeenCalledTimes(0) - }) - test('successive calls should reuse the CosmosStateStore instance - with masterkey', async () => { - // note this test may be confusing as no reuse is made with BYO credentials, but the cache is actually deleted by the top level init file. See test below. - await testInitOK(fakeCosmosMasterCredentials) - expect(cosmos.CosmosClient).toHaveBeenCalledTimes(1) - cosmos.CosmosClient.mockReset() - await CosmosStateStore.init(fakeCosmosMasterCredentials) - expect(cosmos.CosmosClient).toHaveBeenCalledTimes(0) - }) - test('No reuse for BYO creds', async () => { - await stateLib.init({ cosmos: fakeCosmosMasterCredentials, provider: 'cosmos' }) - expect(cosmos.CosmosClient).toHaveBeenCalledTimes(1) - await stateLib.init({ cosmos: fakeCosmosMasterCredentials, provider: 'cosmos' }) - // New CosmosClient instance generated again - expect(cosmos.CosmosClient).toHaveBeenCalledTimes(2) - }) - test('No reuse if TVM credential expiration changed', async () => { - await testInitOK(fakeCosmosTVMResponse) - expect(cosmos.CosmosClient).toHaveBeenCalledTimes(1) - await CosmosStateStore.init({ ...fakeCosmosTVMResponse, expiration: new Date(0).toISOString() }) - expect(cosmos.CosmosClient).toHaveBeenCalledTimes(2) - await CosmosStateStore.init({ ...fakeCosmosTVMResponse, expiration: new Date(1).toISOString() }) - expect(cosmos.CosmosClient).toHaveBeenCalledTimes(3) - // double check, same credentials no additional call - await CosmosStateStore.init({ ...fakeCosmosTVMResponse, expiration: new Date(1).toISOString() }) - expect(cosmos.CosmosClient).toHaveBeenCalledTimes(3) - }) - }) - }) -}) - -describe('_get', () => { - const cosmosItemMock = jest.fn() - const cosmosItemReadMock = jest.fn() - beforeEach(async () => { - cosmosItemMock.mockReset() - cosmosItemReadMock.mockReset() - cosmosContainerMock.mockReturnValue({ - item: cosmosItemMock.mockReturnValue({ - read: cosmosItemReadMock - }) - }) - }) - - test('with existing key value and no ttl', async () => { - cosmosItemReadMock.mockResolvedValue({ - resource: { - value: 'fakeValue', - _ts: 123456789, - ttl: -1 - } - }) - const state = await CosmosStateStore.init(fakeCosmosResourceCredentials) - const res = await state._get('fakeKey') - expect(res.value).toEqual('fakeValue') - expect(res.expiration).toEqual(null) - expect(cosmosItemReadMock).toHaveBeenCalledTimes(1) - expect(cosmosItemMock).toHaveBeenCalledWith('fakeKey', state._cosmos.partitionKey) - }) - test('with existing key and value=undefined', async () => { - cosmosItemReadMock.mockResolvedValue({ - resource: { - value: undefined, - _ts: 123456789, - ttl: -1 - } - }) - const state = await CosmosStateStore.init(fakeCosmosResourceCredentials) - const res = await state._get('fakeKey') - expect(res.value).toEqual(undefined) - expect(res.expiration).toEqual(null) - }) - test('with non existing key value', async () => { - cosmosItemReadMock.mockResolvedValue({ - resource: undefined, - statusCode: 404 - }) - const state = await CosmosStateStore.init(fakeCosmosResourceCredentials) - const res = await state._get('fakeKey') - expect(res).toEqual(undefined) - }) - test('with key value that has a non negative ttl', async () => { - cosmosItemReadMock.mockResolvedValue({ - resource: { - value: { a: { fake: 'value' } }, - _ts: 123456789, - ttl: 10 - } - }) - const state = await CosmosStateStore.init(fakeCosmosResourceCredentials) - const res = await state._get('fakeKey') - expect(res.value).toEqual({ a: { fake: 'value' } }) - expect(res.expiration).toEqual(new Date(123456789 * 1000 + 10 * 1000).toISOString()) - }) - // eslint-disable-next-line jest/expect-expect - test('with error response from provider', async () => { - const state = await CosmosStateStore.init(fakeCosmosResourceCredentials) - await testProviderErrorHandling(state._get.bind(state, 'key'), cosmosItemReadMock, { key: 'key' }) - }) -}) - -describe('_delete', () => { - const cosmosItemMock = jest.fn() - const cosmosItemDeleteMock = jest.fn() - beforeEach(async () => { - cosmosItemMock.mockReset() - cosmosItemDeleteMock.mockReset() - cosmosContainerMock.mockReturnValue({ - item: cosmosItemMock.mockReturnValue({ - delete: cosmosItemDeleteMock.mockResolvedValue({}) - }) - }) - }) - - test('with no errors', async () => { - const state = await CosmosStateStore.init(fakeCosmosResourceCredentials) - const ret = await state._delete('fakeKey') - expect(ret).toEqual('fakeKey') - expect(cosmosItemDeleteMock).toHaveBeenCalledTimes(1) - expect(cosmosItemMock).toHaveBeenCalledWith('fakeKey', state._cosmos.partitionKey) - }) - test('when cosmos return with a 404 (should return null)', async () => { - cosmosItemDeleteMock.mockRejectedValue({ code: 404 }) - const state = await CosmosStateStore.init(fakeCosmosResourceCredentials) - const ret = await state._delete('fakeKey') - expect(ret).toEqual(null) - expect(cosmosItemDeleteMock).toHaveBeenCalledTimes(1) - expect(cosmosItemMock).toHaveBeenCalledWith('fakeKey', state._cosmos.partitionKey) - }) - // eslint-disable-next-line jest/expect-expect - test('with error response from provider', async () => { - const state = await CosmosStateStore.init(fakeCosmosResourceCredentials) - await testProviderErrorHandling(state._delete.bind(state, 'key'), cosmosItemDeleteMock, { key: 'key' }) - }) -}) - -describe('_put', () => { - const cosmosUpsertMock = jest.fn() - beforeEach(async () => { - cosmosUpsertMock.mockReset() - cosmosContainerMock.mockReturnValue({ - items: { - upsert: cosmosUpsertMock.mockResolvedValue({}) - } - }) - }) - - test('with default ttl (ttl is always set)', async () => { - const key = 'fakeKey' - const value = 'fakeValue' - const state = await CosmosStateStore.init(fakeCosmosResourceCredentials) - const ret = await state._put(key, 'fakeValue', { ttl: StateStore.DefaultTTL }) - expect(ret).toEqual(key) - expect(cosmosUpsertMock).toHaveBeenCalledTimes(1) - expect(cosmosUpsertMock).toHaveBeenCalledWith({ id: key, partitionKey: state._cosmos.partitionKey, ttl: StateStore.DefaultTTL, value }) - }) - test('with positive ttl', async () => { - const key = 'fakeKey' - const value = 'fakeValue' - const state = await CosmosStateStore.init(fakeCosmosResourceCredentials) - const ret = await state._put(key, 'fakeValue', { ttl: 99 }) - expect(ret).toEqual(key) - expect(cosmosUpsertMock).toHaveBeenCalledTimes(1) - expect(cosmosUpsertMock).toHaveBeenCalledWith({ id: key, partitionKey: state._cosmos.partitionKey, ttl: 99, value }) - }) - test('with negative ttl (converts to -1 always)', async () => { - const key = 'fakeKey' - const value = 'fakeValue' - const state = await CosmosStateStore.init(fakeCosmosResourceCredentials) - const ret = await state._put(key, 'fakeValue', { ttl: -99 }) - expect(ret).toEqual(key) - expect(cosmosUpsertMock).toHaveBeenCalledTimes(1) - expect(cosmosUpsertMock).toHaveBeenCalledWith({ id: key, partitionKey: state._cosmos.partitionKey, ttl: -1, value }) - }) - // eslint-disable-next-line jest/expect-expect - test('with error response from provider', async () => { - const state = await CosmosStateStore.init(fakeCosmosResourceCredentials) - await testProviderErrorHandling(state._put.bind(state, 'key', 'value', {}), cosmosUpsertMock, { key: 'key', value: 'value', options: {} }) - }) -}) diff --git a/test/init.test.js b/test/init.test.js index c4e50bf..2071413 100644 --- a/test/init.test.js +++ b/test/init.test.js @@ -1,5 +1,5 @@ /* -Copyright 2019 Adobe. All rights reserved. +Copyright 2023 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 @@ -11,102 +11,39 @@ governing permissions and limitations under the License. */ const stateLib = require('../index') -const { CosmosStateStore } = require('../lib/impl/CosmosStateStore') -jest.mock('../lib/impl/CosmosStateStore.js') +describe('init', () => { + const env = process.env -const TvmClient = require('@adobe/aio-lib-core-tvm') -jest.mock('@adobe/aio-lib-core-tvm') + beforeEach(() => { + jest.resetModules() + process.env = { ...env } + }) -describe('init', () => { - /* Common setup for init tests */ - beforeEach(async () => { - CosmosStateStore.mockRestore() - CosmosStateStore.init = jest.fn() + afterEach(() => { + process.env = env }) - const checkInitDebugLogNoSecrets = (str) => expect(global.mockLogDebug).not.toHaveBeenCalledWith(expect.stringContaining(str)) + const fakeOWCreds = { + auth: 'fakeAuth', + namespace: 'fakeNS' + } - describe('when user db credentials', () => { - const fakeCosmosConfig = { + test('pass OW creds', async () => { + expect.hasAssertions() + const store = await stateLib.init({ ow: fakeOWCreds }) - masterKey: 'fakeKey', - resourceToken: 'fakeToken' - } - test('with cosmos config', async () => { - expect.hasAssertions() - await stateLib.init({ cosmos: fakeCosmosConfig, provider: 'cosmos' }) - expect(CosmosStateStore.init).toHaveBeenCalledTimes(1) - expect(CosmosStateStore.init).toHaveBeenCalledWith(fakeCosmosConfig) - expect(TvmClient.init).toHaveBeenCalledTimes(0) - expect(global.mockLogDebug).toHaveBeenCalledWith(expect.stringContaining('cosmos')) - checkInitDebugLogNoSecrets(fakeCosmosConfig.masterKey) - checkInitDebugLogNoSecrets(fakeCosmosConfig.resourceToken) - }) + expect(store.namespace).toEqual(fakeOWCreds.namespace) + expect(store.apikey).toEqual(fakeOWCreds.auth) }) - describe('with openwhisk credentials', () => { - const fakeTVMResponse = { - fakeTVMResponse: 'response' - } - const fakeOWCreds = { - auth: 'fakeAuth', - namespace: 'fakeNS' - } - const fakeTVMOptions = { - some: 'options' - } - const cosmosTVMMock = jest.fn() - beforeEach(async () => { - TvmClient.mockReset() - TvmClient.init.mockReset() - cosmosTVMMock.mockReset() - TvmClient.init.mockResolvedValue({ - getAzureCosmosCredentials: cosmosTVMMock - }) - }) - test('when tvm options', async () => { - expect.hasAssertions() - cosmosTVMMock.mockResolvedValue(fakeTVMResponse) - await stateLib.init({ ow: fakeOWCreds, tvm: fakeTVMOptions, provider: 'cosmos' }) - expect(TvmClient.init).toHaveBeenCalledTimes(1) - expect(TvmClient.init).toHaveBeenCalledWith({ ow: fakeOWCreds, ...fakeTVMOptions }) - expect(CosmosStateStore.init).toHaveBeenCalledTimes(1) - expect(CosmosStateStore.init).toHaveBeenCalledWith(fakeTVMResponse) - expect(global.mockLogDebug).toHaveBeenCalledWith(expect.stringContaining('openwhisk')) - checkInitDebugLogNoSecrets(fakeOWCreds.auth) - }) - test('when empty config to be able to pass OW creds as env variables', async () => { - expect.hasAssertions() - cosmosTVMMock.mockResolvedValue(fakeTVMResponse) - await stateLib.init({ provider: 'cosmos' }) - expect(TvmClient.init).toHaveBeenCalledTimes(1) - expect(TvmClient.init).toHaveBeenCalledWith({ ow: undefined }) - expect(CosmosStateStore.init).toHaveBeenCalledTimes(1) - expect(CosmosStateStore.init).toHaveBeenCalledWith(fakeTVMResponse) - expect(global.mockLogDebug).toHaveBeenCalledWith(expect.stringContaining('openwhisk')) - }) - // eslint-disable-next-line jest/expect-expect - test('when tvm rejects with a 401 (throws wrapped error)', async () => { - expect.hasAssertions() - const e = new Error('tvm error') - e.sdkDetails = { fake: 'details', status: 401 } - cosmosTVMMock.mockRejectedValue(e) - await global.expectToThrowForbidden(stateLib.init.bind(stateLib, { ow: fakeOWCreds, provider: 'cosmos' }), e.sdkDetails) - }) - // eslint-disable-next-line jest/expect-expect - test('when tvm rejects with a 403 (throws wrapped error)', async () => { - expect.hasAssertions() - const e = new Error('tvm error') - e.sdkDetails = { fake: 'details', status: 403 } - cosmosTVMMock.mockRejectedValue(e) - await global.expectToThrowForbidden(stateLib.init.bind(stateLib, { ow: fakeOWCreds, provider: 'cosmos' }), e.sdkDetails) - }) - test('when tvm rejects with another status code (throws tvm error)', async () => { - expect.hasAssertions() - const tvmError = new Error('tvm error') - tvmError.sdkDetails = { fake: 'details', status: 500 } - cosmosTVMMock.mockRejectedValue(tvmError) - return expect(stateLib.init({ ow: fakeOWCreds, provider: 'cosmos' })).rejects.toThrow(tvmError) - }) + test('when empty config to be able to pass OW creds as env variables', async () => { + process.env.__OW_NAMESPACE = 'some-namespace' + process.env.__OW_API_KEY = 'some-api-key' + + expect.hasAssertions() + const store = await stateLib.init() + + expect(store.namespace).toEqual(process.env.__OW_NAMESPACE) + expect(store.apikey).toEqual(process.env.__OW_API_KEY) }) }) diff --git a/test/utils.test.js b/test/utils.test.js new file mode 100644 index 0000000..9045d78 --- /dev/null +++ b/test/utils.test.js @@ -0,0 +1,43 @@ +/* +Copyright 2023 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 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ +const { withHiddenFields } = require('../lib/utils') + +describe('withHiddenFields', () => { + test('no params', () => { + expect(withHiddenFields()).toEqual(undefined) + }) + + test('object with undefined hidden fields', () => { + expect(withHiddenFields({})).toEqual({}) + }) + + test('object with non-array hidden fields', () => { + expect(withHiddenFields({}, 123)).toEqual({}) + }) + + test('object with no hidden fields', () => { + expect(withHiddenFields({}, [])).toEqual({}) + }) + + test('object with hidden fields', () => { + const src = { + foo: 'bar', + cat: 'bat' + } + const target = { + ...src, + cat: '' + } + + expect(withHiddenFields(src, ['cat'])).toEqual(target) + }) +}) From 7ec5ad1a515996a0c3305ffe5e5da279a5b0cd5a Mon Sep 17 00:00:00 2001 From: Shazron Abdullah Date: Thu, 21 Dec 2023 18:04:06 +0800 Subject: [PATCH 07/17] update e2e --- e2e/e2e.js | 10 ++++++---- e2e/e2e.md | 4 +++- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/e2e/e2e.js b/e2e/e2e.js index 480bd18..76c3f15 100644 --- a/e2e/e2e.js +++ b/e2e/e2e.js @@ -14,7 +14,10 @@ governing permissions and limitations under the License. /* ************* NOTE 2: requires env vars TEST_AUTH_1, TEST_NS_1 and TEST_AUTH_2, TEST_NS_2 for 2 different namespaces. ************* */ const stateLib = require('../index') -const { codes } = require('../lib/StateStoreError') +const path = require('node:path') +const { codes } = require('../lib/AdobeStateStoreError') +// load .env values in the e2e folder, if any +require('dotenv').config({ path: path.join(__dirname, '.env') }) const testKey = 'e2e_test_state_key' @@ -29,8 +32,7 @@ const initStateEnv = async (n = 1) => { 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 @@ -49,7 +51,7 @@ describe('e2e tests using OpenWhisk credentials (as env vars)', () => { await stateLib.init() } catch (e) { expect({ name: e.name, code: e.code, message: e.message, sdkDetails: e.sdkDetails }).toEqual(expect.objectContaining({ - name: 'StateLibError', + name: 'AdobeStateLibError', code: 'ERROR_BAD_CREDENTIALS' })) } diff --git a/e2e/e2e.md b/e2e/e2e.md index 70f7ff4..fb5f4bf 100644 --- a/e2e/e2e.md +++ b/e2e/e2e.md @@ -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` From 091725646e68d5c134370a3d88529e1d0d216b9e Mon Sep 17 00:00:00 2001 From: Shazron Abdullah Date: Thu, 21 Dec 2023 18:05:56 +0800 Subject: [PATCH 08/17] update impl --- e2e/.env.example | 4 ++++ lib/AdobeStateStore.js | 33 ++++++++++++++++++++++----------- package.json | 1 + test/AdobeStateStore.test.js | 17 ++++++++++++++--- 4 files changed, 41 insertions(+), 14 deletions(-) create mode 100644 e2e/.env.example diff --git a/e2e/.env.example b/e2e/.env.example new file mode 100644 index 0000000..1f72db1 --- /dev/null +++ b/e2e/.env.example @@ -0,0 +1,4 @@ +TEST_NAMESPACE_1= +TEST_AUTH_1= +TEST_NAMESPACE_2= +TEST_AUTH_2= diff --git a/lib/AdobeStateStore.js b/lib/AdobeStateStore.js index 2caa690..c1afca4 100644 --- a/lib/AdobeStateStore.js +++ b/lib/AdobeStateStore.js @@ -125,7 +125,7 @@ class AdobeStateStore { let requestUrl if (key) { - requestUrl = new url.URL(`${this.endpoint}/v1/containers/${this.namespace}/${key}`) + requestUrl = new url.URL(`${this.endpoint}/v1/containers/${this.namespace}/data/${key}`) } else { requestUrl = new url.URL(`${this.endpoint}/v1/containers/${this.namespace}`) } @@ -254,12 +254,14 @@ class AdobeStateStore { * @protected */ async _get (key) { - const promise = this.fetchRetry.exponentialBackoff(this.createRequestUrl(key), { + const requestOptions = { method: 'GET', headers: { ...this.getAuthorizationHeaders() } - }) + } + logger.debug('_get', requestOptions) + const promise = this.fetchRetry.exponentialBackoff(this.createRequestUrl(key), requestOptions) const response = await _wrap(promise, { key }) if (response) { return response.json() @@ -275,14 +277,17 @@ class AdobeStateStore { */ async _put (key, value, options) { const { ttl } = options - const promise = this.fetchRetry.exponentialBackoff(this.createRequestUrl(key, { ttl }), { + const queryParams = ttl ? { ttl } : {} + const requestOptions = { method: 'PUT', headers: { ...this.getAuthorizationHeaders(), 'Content-Type': 'application/octet-stream' }, body: Readable.from(value) - }) + } + logger.debug('_put', requestOptions) + const promise = this.fetchRetry.exponentialBackoff(this.createRequestUrl(key, queryParams), requestOptions) await _wrap(promise, { key, value, ...options }) return key } @@ -293,12 +298,14 @@ class AdobeStateStore { * @protected */ async _delete (key) { - const promise = this.fetchRetry.exponentialBackoff(this.createRequestUrl(key), { + const requestOptions = { method: 'DELETE', headers: { ...this.getAuthorizationHeaders() } - }) + } + logger.debug('_delete', requestOptions) + const promise = this.fetchRetry.exponentialBackoff(this.createRequestUrl(key), requestOptions) const ret = await _wrap(promise, { key }) return ret && key } @@ -308,12 +315,14 @@ class AdobeStateStore { * @protected */ async _deleteAll () { - const promise = this.fetchRetry.exponentialBackoff(this.createRequestUrl(), { + const requestOptions = { method: 'DELETE', headers: { ...this.getAuthorizationHeaders() } - }) + } + logger.debug('_deleteAll', requestOptions) + const promise = this.fetchRetry.exponentialBackoff(this.createRequestUrl(), requestOptions) const response = await _wrap(promise, {}) return response !== null } @@ -323,12 +332,14 @@ class AdobeStateStore { * @protected */ async _any () { - const promise = this.fetchRetry.exponentialBackoff(this.createRequestUrl(), { + const requestOptions = { method: 'HEAD', headers: { ...this.getAuthorizationHeaders() } - }) + } + logger.debug('_any', requestOptions) + const promise = this.fetchRetry.exponentialBackoff(this.createRequestUrl(), requestOptions) const response = await _wrap(promise, {}) return response !== null } diff --git a/package.json b/package.json index 27cc2dd..15cf666 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "@types/hapi__joi": "^17.1.9", "@types/jest": "^29.5.0", "codecov": "^3.6.1", + "dotenv": "^16.3.1", "eslint": "^8", "eslint-config-standard": "^17", "eslint-plugin-import": "^2", diff --git a/test/AdobeStateStore.test.js b/test/AdobeStateStore.test.js index 3f0fc5c..80aa281 100644 --- a/test/AdobeStateStore.test.js +++ b/test/AdobeStateStore.test.js @@ -160,7 +160,7 @@ describe('put', () => { store = await AdobeStateStore.init(fakeCredentials) }) - test('success (string value)', async () => { + test('success (string value) no ttl', async () => { const key = 'valid-key' const value = 'some-value' const fetchResponseJson = {} @@ -171,6 +171,17 @@ describe('put', () => { expect(returnKey).toEqual(key) }) + test('success (string value) with ttl', async () => { + const key = 'valid-key' + const value = 'some-value' + const fetchResponseJson = {} + + mockExponentialBackoff.mockResolvedValue(wrapInFetchResponse(fetchResponseJson)) + + const returnKey = await store.put(key, value, { ttl: 999 }) + expect(returnKey).toEqual(key) + }) + test('success (binary value)', async () => { const key = 'valid-key' const value = Buffer.from([0x61, 0x72, 0x65, 0x26, 0x35, 0x55, 0xff]) @@ -329,7 +340,7 @@ describe('private methods', () => { const store = await AdobeStateStore.init(fakeCredentials) const url = store.createRequestUrl(key) - expect(url).toEqual(`${myConstants.ADOBE_STATE_STORE_ENDPOINT[env]}/v1/containers/${fakeCredentials.namespace}/${key}`) + expect(url).toEqual(`${myConstants.ADOBE_STATE_STORE_ENDPOINT[env]}/v1/containers/${fakeCredentials.namespace}/data/${key}`) }) test('key set, some query params', async () => { @@ -345,7 +356,7 @@ describe('private methods', () => { const store = await AdobeStateStore.init(fakeCredentials) const url = store.createRequestUrl(key, queryParams) - expect(url).toEqual(`${myConstants.ADOBE_STATE_STORE_ENDPOINT[env]}/v1/containers/${fakeCredentials.namespace}/${key}?${querystring.stringify(queryParams)}`) + expect(url).toEqual(`${myConstants.ADOBE_STATE_STORE_ENDPOINT[env]}/v1/containers/${fakeCredentials.namespace}/data/${key}?${querystring.stringify(queryParams)}`) }) }) }) From 93ae06bd12aca4ba3c6db58cd0652e28bb7a9757 Mon Sep 17 00:00:00 2001 From: Shazron Abdullah Date: Thu, 21 Dec 2023 22:09:30 +0800 Subject: [PATCH 09/17] fixed bugs, completed e2e and unit tests --- e2e/e2e.js | 48 +++++++++++------------- lib/AdobeStateStore.js | 72 ++++++++++++++++++++++++++++++++---- lib/constants.js | 11 ++++++ package.json | 1 + test/AdobeStateStore.test.js | 37 ++++++++++++++---- 5 files changed, 128 insertions(+), 41 deletions(-) diff --git a/e2e/e2e.js b/e2e/e2e.js index 76c3f15..80c7912 100644 --- a/e2e/e2e.js +++ b/e2e/e2e.js @@ -16,6 +16,8 @@ governing permissions and limitations under the License. const stateLib = require('../index') const path = require('node:path') const { codes } = require('../lib/AdobeStateStoreError') +const { MAX_TTL_SECONDS } = require('../lib/constants') + // load .env values in the e2e folder, if any require('dotenv').config({ path: path.join(__dirname, '.env') }) @@ -48,7 +50,8 @@ describe('e2e tests using OpenWhisk credentials (as env vars)', () => { process.env.__OW_NAMESPACE = process.env.TEST_NAMESPACE_1 + 'bad' 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: 'AdobeStateLibError', @@ -69,49 +72,42 @@ describe('e2e tests using OpenWhisk credentials (as env vars)', () => { 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.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('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 invalidChars = 'invalid key or value [{\"instancePath\":\"/key\",\"schemaPath\":\"#/properties/key/pattern\",\"keyword\":\"pattern\",\"params\":{\"pattern\":\"^[a-zA-Z0-9-_-]{1,1024}$\"},\"message\":\"must match pattern \\\"^[a-zA-Z0-9-_-]{1,1024}$\\\"\"}]' const invalidKey = 'invalid/key' const state = await initStateEnv() - await expect(state.put(invalidKey, 'testValue')).rejects.toThrow(new codes.ERROR_BAD_REQUEST({ + await expect(state.put(invalidKey, 'testValue')).rejects.toThrow(new codes.ERROR_BAD_ARGUMENT({ messageValues: [invalidChars] })) - await expect(state.get(invalidKey)).rejects.toThrow(new codes.ERROR_BAD_REQUEST({ + await expect(state.get(invalidKey)).rejects.toThrow(new codes.ERROR_BAD_ARGUMENT({ messageValues: [invalidChars] })) }) @@ -120,8 +116,8 @@ describe('e2e tests using OpenWhisk credentials (as env vars)', () => { 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) @@ -148,7 +144,7 @@ describe('e2e tests using OpenWhisk credentials (as env vars)', () => { 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', + name: 'AdobeStateLibError', code: 'ERROR_PAYLOAD_TOO_LARGE' })) } diff --git a/lib/AdobeStateStore.js b/lib/AdobeStateStore.js index c1afca4..f6e55bf 100644 --- a/lib/AdobeStateStore.js +++ b/lib/AdobeStateStore.js @@ -16,9 +16,9 @@ const cloneDeep = require('lodash.clonedeep') const logger = require('@adobe/aio-lib-core-logging')('@adobe/aio-lib-state', { provider: 'debug' }) const { HttpExponentialBackoff } = require('@adobe/aio-lib-core-networking') const url = require('node:url') -const { Readable } = require('node:stream') const { getCliEnv } = require('@adobe/aio-lib-env') -const { ADOBE_STATE_STORE_ENDPOINT } = require('./constants') +const { ADOBE_STATE_STORE_ENDPOINT, REGEX_PATTERN_STORE_KEY } = require('./constants') +const Ajv = require('ajv') /* *********************************** typedefs *********************************** */ @@ -52,11 +52,27 @@ const { ADOBE_STATE_STORE_ENDPOINT } = require('./constants') /* *********************************** helpers *********************************** */ +/** + * Validates json according to a schema. + * + * @param {object} schema the AJV schema + * @param {object} data the json data to test + * @returns {object} the result + */ +function validate (schema, data) { + const ajv = new Ajv({ allErrors: true }) + const validate = ajv.compile(schema) + const valid = validate(data) + + return { valid, errors: validate.errors } +} + // eslint-disable-next-line jsdoc/require-jsdoc async function _wrap (promise, params) { let response try { response = await promise + logger.debug('response', response) // reuse code in exception handler, for any other network exceptions if (!response.ok) { // no exception on 404 @@ -78,6 +94,8 @@ async function _wrap (promise, params) { return logAndThrow(new codes.ERROR_UNAUTHORIZED({ messageValues: ['underlying DB provider'], sdkDetails: copyParams })) case 403: return logAndThrow(new codes.ERROR_BAD_CREDENTIALS({ messageValues: ['underlying DB provider'], sdkDetails: copyParams })) + case 413: + return logAndThrow(new codes.ERROR_PAYLOAD_TOO_LARGE({ messageValues: ['underlying DB provider'], sdkDetails: copyParams })) case 429: return logAndThrow(new codes.ERROR_REQUEST_RATE_TOO_HIGH({ sdkDetails: copyParams })) default: @@ -193,6 +211,24 @@ class AdobeStateStore { * @memberof AdobeStateStore */ async get (key) { + const schema = { + type: 'object', + properties: { + key: { + type: 'string', + pattern: REGEX_PATTERN_STORE_KEY + } + } + } + + const { valid, errors } = validate(schema, { key }) + if (!valid) { + logAndThrow(new codes.ERROR_BAD_ARGUMENT({ + messageValues: ['invalid key or value', JSON.stringify(errors)], + sdkDetails: { key, errors } + })) + } + logger.debug(`get '${key}'`) return this._get(key) } @@ -201,15 +237,34 @@ class AdobeStateStore { * Creates or updates a state key-value pair * * @param {string} key state key identifier - * @param {any} value state value + * @param {string} value state value * @param {AdobeStateStorePutOptions} [options={}] put options * @returns {Promise} key * @memberof AdobeStateStore */ async put (key, value, options = {}) { - const { ttl } = options - logger.debug(`put '${key}' with ttl ${ttl}`) - return this._put(key, value, { ttl }) + const schema = { + type: 'object', + properties: { + key: { + type: 'string', + pattern: REGEX_PATTERN_STORE_KEY + }, + value: { + type: 'string' + } + } + } + + const { valid, errors } = validate(schema, { key, value }) + if (!valid) { + logAndThrow(new codes.ERROR_BAD_ARGUMENT({ + messageValues: ['invalid key or value', JSON.stringify(errors)], + sdkDetails: { key, value, options, errors } + })) + } + logger.debug(`put '${key}' with options ${JSON.stringify(options)}`) + return this._put(key, value, options) } /** @@ -254,7 +309,7 @@ class AdobeStateStore { * @protected */ async _get (key) { - const requestOptions = { + const requestOptions = { method: 'GET', headers: { ...this.getAuthorizationHeaders() @@ -264,6 +319,7 @@ class AdobeStateStore { const promise = this.fetchRetry.exponentialBackoff(this.createRequestUrl(key), requestOptions) const response = await _wrap(promise, { key }) if (response) { + // we only expect string values return response.json() } } @@ -284,7 +340,7 @@ class AdobeStateStore { ...this.getAuthorizationHeaders(), 'Content-Type': 'application/octet-stream' }, - body: Readable.from(value) + body: value } logger.debug('_put', requestOptions) const promise = this.fetchRetry.exponentialBackoff(this.createRequestUrl(key, queryParams), requestOptions) diff --git a/lib/constants.js b/lib/constants.js index f0d7291..e1baa9d 100644 --- a/lib/constants.js +++ b/lib/constants.js @@ -17,6 +17,17 @@ const ADOBE_STATE_STORE_ENDPOINT = { [STAGE_ENV]: 'http://localhost-stage:8080' // TODO: } +const MAX_KEY_SIZE = 1024 * 1 // 1KB +const MAX_TTL_SECONDS = 60 * 60 * 24 * 365 // 365 days + +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 = { + MAX_KEY_SIZE, + MAX_TTL_SECONDS, + REGEX_PATTERN_STORE_NAMESPACE, + REGEX_PATTERN_STORE_KEY, ADOBE_STATE_STORE_ENDPOINT } diff --git a/package.json b/package.json index 15cf666..72aec4c 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "@adobe/aio-lib-core-logging": "^2.0.0", "@adobe/aio-lib-core-networking": "^4.1.0", "@adobe/aio-lib-env": "^2.0.0", + "ajv": "^8.12.0", "joi": "^17.4.2", "lodash.clonedeep": "^4.5.0" } diff --git a/test/AdobeStateStore.test.js b/test/AdobeStateStore.test.js index 80aa281..fa8725a 100644 --- a/test/AdobeStateStore.test.js +++ b/test/AdobeStateStore.test.js @@ -67,7 +67,10 @@ const wrapInFetchError = (status) => { jest.mock('@adobe/aio-lib-core-networking') jest.mock('../lib/constants', () => { - return myConstants + return { + ...jest.requireActual('../lib/constants'), + ...myConstants + } }) jest.mock('@adobe/aio-lib-env', () => { @@ -143,6 +146,13 @@ describe('get', () => { expect(value).toEqual(fetchResponseJson) }) + test('invalid key', async () => { + const key = 'bad/key' + + // TODO: to improve + await expect(store.get(key)).rejects.toThrow('[AdobeStateLib:ERROR_BAD_ARGUMENT] invalid key or value [{"instancePath":"/key","schemaPath":"#/properties/key/pattern","keyword":"pattern","params":{"pattern":"^[a-zA-Z0-9-_-]{1,1024}$"},"message":"must match pattern \\"^[a-zA-Z0-9-_-]{1,1024}$\\""}]') + }) + test('not found', async () => { const key = 'not-found-key' @@ -182,15 +192,20 @@ describe('put', () => { expect(returnKey).toEqual(key) }) - test('success (binary value)', async () => { + test('failure (invalid key)', async () => { + const key = 'invalid/key' + const value = 'some-value' + + // TODO: to improve + await expect(store.put(key, value)).rejects.toThrow('[AdobeStateLib:ERROR_BAD_ARGUMENT] invalid key or value [{"instancePath":"/key","schemaPath":"#/properties/key/pattern","keyword":"pattern","params":{"pattern":"^[a-zA-Z0-9-_-]{1,1024}$"},"message":"must match pattern \\"^[a-zA-Z0-9-_-]{1,1024}$\\""}]') + }) + + test('failure (binary value)', async () => { const key = 'valid-key' const value = Buffer.from([0x61, 0x72, 0x65, 0x26, 0x35, 0x55, 0xff]) - const fetchResponseJson = {} - mockExponentialBackoff.mockResolvedValue(wrapInFetchResponse(fetchResponseJson)) - - const returnKey = await store.put(key, value) - expect(returnKey).toEqual(key) + // TODO: to improve + await expect(store.put(key, value)).rejects.toThrow('[AdobeStateLib:ERROR_BAD_ARGUMENT] invalid key or value [{"instancePath":"/value","schemaPath":"#/properties/value/type","keyword":"type","params":{"type":"string"},"message":"must be string"}]') }) test('coverage: 401 error', async () => { @@ -209,6 +224,14 @@ describe('put', () => { await expect(store.put(key, value)).rejects.toThrow('[AdobeStateLib:ERROR_BAD_CREDENTIALS] cannot access underlying DB provider, make sure your credentials are valid') }) + test('coverage: 413 error', async () => { + const key = 'some-key' + const value = 'some-value' + + mockExponentialBackoff.mockResolvedValue(wrapInFetchError(413)) + await expect(store.put(key, value)).rejects.toThrow('[AdobeStateLib:ERROR_PAYLOAD_TOO_LARGE] key, value or request payload is too large underlying DB provider') + }) + test('coverage: 429 error', async () => { const key = 'some-key' const value = 'some-value' From db02e365882f30a0792d757617bbb4b9bdfef7c8 Mon Sep 17 00:00:00 2001 From: Shazron Abdullah Date: Thu, 21 Dec 2023 23:17:19 +0800 Subject: [PATCH 10/17] improved ajv validation tests --- e2e/e2e.js | 11 +++-------- lib/AdobeStateStore.js | 4 ++-- test/AdobeStateStore.test.js | 9 +++------ 3 files changed, 8 insertions(+), 16 deletions(-) diff --git a/e2e/e2e.js b/e2e/e2e.js index 80c7912..2a72441 100644 --- a/e2e/e2e.js +++ b/e2e/e2e.js @@ -101,15 +101,10 @@ describe('e2e tests using OpenWhisk credentials (as env vars)', () => { }) test('throw error when get/put with invalid keys', async () => { - const invalidChars = 'invalid key or value [{\"instancePath\":\"/key\",\"schemaPath\":\"#/properties/key/pattern\",\"keyword\":\"pattern\",\"params\":{\"pattern\":\"^[a-zA-Z0-9-_-]{1,1024}$\"},\"message\":\"must match pattern \\\"^[a-zA-Z0-9-_-]{1,1024}$\\\"\"}]' - 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_ARGUMENT({ - messageValues: [invalidChars] - })) - await expect(state.get(invalidKey)).rejects.toThrow(new codes.ERROR_BAD_ARGUMENT({ - 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 () => { diff --git a/lib/AdobeStateStore.js b/lib/AdobeStateStore.js index f6e55bf..83e0010 100644 --- a/lib/AdobeStateStore.js +++ b/lib/AdobeStateStore.js @@ -224,7 +224,7 @@ class AdobeStateStore { const { valid, errors } = validate(schema, { key }) if (!valid) { logAndThrow(new codes.ERROR_BAD_ARGUMENT({ - messageValues: ['invalid key or value', JSON.stringify(errors)], + messageValues: ['invalid key', JSON.stringify(errors, null, 2)], sdkDetails: { key, errors } })) } @@ -259,7 +259,7 @@ class AdobeStateStore { const { valid, errors } = validate(schema, { key, value }) if (!valid) { logAndThrow(new codes.ERROR_BAD_ARGUMENT({ - messageValues: ['invalid key or value', JSON.stringify(errors)], + messageValues: ['invalid key and/or value', JSON.stringify(errors, null, 2)], sdkDetails: { key, value, options, errors } })) } diff --git a/test/AdobeStateStore.test.js b/test/AdobeStateStore.test.js index fa8725a..bdb8239 100644 --- a/test/AdobeStateStore.test.js +++ b/test/AdobeStateStore.test.js @@ -149,8 +149,7 @@ describe('get', () => { test('invalid key', async () => { const key = 'bad/key' - // TODO: to improve - await expect(store.get(key)).rejects.toThrow('[AdobeStateLib:ERROR_BAD_ARGUMENT] invalid key or value [{"instancePath":"/key","schemaPath":"#/properties/key/pattern","keyword":"pattern","params":{"pattern":"^[a-zA-Z0-9-_-]{1,1024}$"},"message":"must match pattern \\"^[a-zA-Z0-9-_-]{1,1024}$\\""}]') + await expect(store.get(key)).rejects.toThrow('[AdobeStateLib:ERROR_BAD_ARGUMENT] invalid key') }) test('not found', async () => { @@ -196,16 +195,14 @@ describe('put', () => { const key = 'invalid/key' const value = 'some-value' - // TODO: to improve - await expect(store.put(key, value)).rejects.toThrow('[AdobeStateLib:ERROR_BAD_ARGUMENT] invalid key or value [{"instancePath":"/key","schemaPath":"#/properties/key/pattern","keyword":"pattern","params":{"pattern":"^[a-zA-Z0-9-_-]{1,1024}$"},"message":"must match pattern \\"^[a-zA-Z0-9-_-]{1,1024}$\\""}]') + await expect(store.put(key, value)).rejects.toThrow('[AdobeStateLib:ERROR_BAD_ARGUMENT] invalid key and/or value') }) test('failure (binary value)', async () => { const key = 'valid-key' const value = Buffer.from([0x61, 0x72, 0x65, 0x26, 0x35, 0x55, 0xff]) - // TODO: to improve - await expect(store.put(key, value)).rejects.toThrow('[AdobeStateLib:ERROR_BAD_ARGUMENT] invalid key or value [{"instancePath":"/value","schemaPath":"#/properties/value/type","keyword":"type","params":{"type":"string"},"message":"must be string"}]') + await expect(store.put(key, value)).rejects.toThrow('[AdobeStateLib:ERROR_BAD_ARGUMENT] invalid key and/or value') }) test('coverage: 401 error', async () => { From 060169c04bc4a44a76701a127b3bc9b5454ac3a4 Mon Sep 17 00:00:00 2001 From: Shazron Abdullah Date: Thu, 21 Dec 2023 23:23:06 +0800 Subject: [PATCH 11/17] remove use of joi --- lib/AdobeStateStore.js | 20 ++++++++++++-------- package.json | 2 -- test/AdobeStateStore.test.js | 6 +++--- 3 files changed, 15 insertions(+), 13 deletions(-) diff --git a/lib/AdobeStateStore.js b/lib/AdobeStateStore.js index 83e0010..74c3e3a 100644 --- a/lib/AdobeStateStore.js +++ b/lib/AdobeStateStore.js @@ -10,7 +10,6 @@ OF ANY KIND, either express or implied. See the License for the specific languag governing permissions and limitations under the License. */ const { codes, logAndThrow } = require('./AdobeStateStoreError') -const joi = require('joi') const utils = require('./utils') const cloneDeep = require('lodash.clonedeep') const logger = require('@adobe/aio-lib-core-logging')('@adobe/aio-lib-state', { provider: 'debug' }) @@ -185,14 +184,19 @@ class AdobeStateStore { const cloned = utils.withHiddenFields(credentials, ['apikey']) logger.debug(`init AdobeStateStore with ${JSON.stringify(cloned, null, 2)}`) - const validation = joi.object().label('adobe').keys({ - apikey: joi.string().required(), - namespace: joi.string().required() - }).required() - .validate(credentials) - if (validation.error) { + const schema = { + type: 'object', + properties: { + apikey: { type: 'string' }, + namespace: { type: 'string' } + }, + required: ['apikey', 'namespace'] + } + + const { valid, errors } = validate(schema, credentials) + if (!valid) { logAndThrow(new codes.ERROR_BAD_ARGUMENT({ - messageValues: [validation.error.message], + messageValues: ['apikey and/or namespace is missing', JSON.stringify(errors, null, 2)], sdkDetails: cloned })) } diff --git a/package.json b/package.json index 72aec4c..e456070 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,6 @@ "key-value" ], "devDependencies": { - "@types/hapi__joi": "^17.1.9", "@types/jest": "^29.5.0", "codecov": "^3.6.1", "dotenv": "^16.3.1", @@ -57,7 +56,6 @@ "@adobe/aio-lib-core-networking": "^4.1.0", "@adobe/aio-lib-env": "^2.0.0", "ajv": "^8.12.0", - "joi": "^17.4.2", "lodash.clonedeep": "^4.5.0" } } diff --git a/test/AdobeStateStore.test.js b/test/AdobeStateStore.test.js index bdb8239..ddcf423 100644 --- a/test/AdobeStateStore.test.js +++ b/test/AdobeStateStore.test.js @@ -104,7 +104,7 @@ describe('init and constructor', () => { test('bad credentials (no apikey and no namespace)', async () => { await expect(AdobeStateStore.init()).rejects - .toThrow('[AdobeStateLib:ERROR_BAD_ARGUMENT] "apikey" is required') + .toThrow('[AdobeStateLib:ERROR_BAD_ARGUMENT] apikey and/or namespace is missing') }) test('bad credentials (no apikey)', async () => { @@ -113,7 +113,7 @@ describe('init and constructor', () => { } await expect(AdobeStateStore.init(credentials)).rejects - .toThrow('[AdobeStateLib:ERROR_BAD_ARGUMENT] "apikey" is required') + .toThrow('[AdobeStateLib:ERROR_BAD_ARGUMENT] apikey and/or namespace is missing') }) test('bad credentials (no namespace)', async () => { @@ -122,7 +122,7 @@ describe('init and constructor', () => { } await expect(AdobeStateStore.init(credentials)).rejects - .toThrow('[AdobeStateLib:ERROR_BAD_ARGUMENT] "namespace" is required') + .toThrow('[AdobeStateLib:ERROR_BAD_ARGUMENT] apikey and/or namespace is missing') }) }) From 8037fb8a68a0e914bf0c479564f17436481af9a2 Mon Sep 17 00:00:00 2001 From: Shazron Abdullah Date: Sat, 23 Dec 2023 01:23:39 +0800 Subject: [PATCH 12/17] update e2e --- e2e/e2e.js | 36 ++++++++++++++++++++++++------------ 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/e2e/e2e.js b/e2e/e2e.js index 2a72441..eca2f4f 100644 --- a/e2e/e2e.js +++ b/e2e/e2e.js @@ -15,7 +15,6 @@ governing permissions and limitations under the License. const stateLib = require('../index') const path = require('node:path') -const { codes } = require('../lib/AdobeStateStoreError') const { MAX_TTL_SECONDS } = require('../lib/constants') // load .env values in the e2e folder, if any @@ -25,10 +24,6 @@ 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 @@ -48,19 +43,25 @@ 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 { 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({ + 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' @@ -68,8 +69,14 @@ 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.delete(testKey, testValue)).toEqual(testKey) + 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.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 () => { @@ -132,16 +139,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({ + 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' })) - } }) }) From 7484b61266e5b843fab18def5c80f936a00279ef Mon Sep 17 00:00:00 2001 From: Shazron Abdullah Date: Sat, 6 Jan 2024 00:19:30 +0800 Subject: [PATCH 13/17] fix: resolved comments --- index.js | 2 +- lib/{AdobeStateStore.js => AdobeState.js} | 169 +++++++----------- ...{AdobeStateStoreError.js => StateError.js} | 2 +- lib/init.js | 8 +- lib/utils.js | 12 ++ ...eStateStore.test.js => AdobeState.test.js} | 30 ++-- test/init.test.js | 2 +- test/utils.test.js | 2 +- 8 files changed, 101 insertions(+), 126 deletions(-) rename lib/{AdobeStateStore.js => AdobeState.js} (79%) rename lib/{AdobeStateStoreError.js => StateError.js} (98%) rename test/{AdobeStateStore.test.js => AdobeState.test.js} (92%) diff --git a/index.js b/index.js index 11dea9c..e8f638c 100644 --- a/index.js +++ b/index.js @@ -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/AdobeStateStore') +require('./lib/AdobeState') module.exports = require('./lib/init') diff --git a/lib/AdobeStateStore.js b/lib/AdobeState.js similarity index 79% rename from lib/AdobeStateStore.js rename to lib/AdobeState.js index 74c3e3a..2b0081c 100644 --- a/lib/AdobeStateStore.js +++ b/lib/AdobeState.js @@ -1,5 +1,5 @@ /* -Copyright 2023 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 @@ -9,7 +9,7 @@ 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. */ -const { codes, logAndThrow } = require('./AdobeStateStoreError') +const { codes, logAndThrow } = require('./StateError') const utils = require('./utils') const cloneDeep = require('lodash.clonedeep') const logger = require('@adobe/aio-lib-core-logging')('@adobe/aio-lib-state', { provider: 'debug' }) @@ -22,27 +22,27 @@ const Ajv = require('ajv') /* *********************************** typedefs *********************************** */ /** - * AdobeStateStoreCredentials + * AdobeStateCredentials * - * @typedef AdobeStateStoreCredentials + * @typedef AdobeStateCredentials * @type {object} * @property {string} namespace the state store namespace * @property {string} apikey the state store api key */ /** - * AdobeStateStore put options + * AdobeState put options * - * @typedef AdobeStateStorePutOptions + * @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 * value of 0 sets default. */ /** - * AdobeStateStore get return object + * AdobeState get return object * - * @typedef AdobeStateStoreGetReturnValue + * @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 @@ -106,15 +106,15 @@ async function _wrap (promise, params) { /** * @abstract - * @class AdobeStateStore + * @class AdobeState * @classdesc Cloud State Management * @hideconstructor */ -class AdobeStateStore { +class AdobeState { /** - * Creates an instance of AdobeStateStore. + * Creates an instance of AdobeState. * - * @memberof AdobeStateStore + * @memberof AdobeState * @private * @param {string} namespace the namespace for the Adobe State Store * @param {string} apikey the apikey for the Adobe State Store @@ -165,12 +165,12 @@ class AdobeStateStore { } /** - * Instantiates and returns a new AdobeStateStore object + * Instantiates and returns a new AdobeState object * * @static - * @param {AdobeStateStoreCredentials} credentials the credential object - * @returns {Promise} a new AdobeStateStore instance - * @memberof AdobeStateStore + * @param {AdobeStateCredentials} credentials the credential object + * @returns {Promise} a new AdobeState instance + * @memberof AdobeState * @override * @private */ @@ -182,7 +182,7 @@ class AdobeStateStore { } const cloned = utils.withHiddenFields(credentials, ['apikey']) - logger.debug(`init AdobeStateStore with ${JSON.stringify(cloned, null, 2)}`) + logger.debug(`init AdobeState with ${JSON.stringify(cloned, null, 2)}`) const schema = { type: 'object', @@ -201,7 +201,7 @@ class AdobeStateStore { })) } - return new AdobeStateStore(credentials.namespace, credentials.apikey) + return new AdobeState(credentials.namespace, credentials.apikey) } /* **************************** ADOBE STATE STORE OPERATORS ***************************** */ @@ -211,10 +211,12 @@ class AdobeStateStore { * If the key doesn't exist returns undefined. * * @param {string} key state key identifier - * @returns {Promise} get response holding value and additional info - * @memberof AdobeStateStore + * @returns {Promise} get response holding value and additional info + * @memberof AdobeState */ async get (key) { + logger.debug(`get '${key}'`) + const schema = { type: 'object', properties: { @@ -233,8 +235,19 @@ class AdobeStateStore { })) } - logger.debug(`get '${key}'`) - return this._get(key) + const requestOptions = { + method: 'GET', + headers: { + ...this.getAuthorizationHeaders() + } + } + logger.debug('get', requestOptions) + const promise = this.fetchRetry.exponentialBackoff(this.createRequestUrl(key), requestOptions) + const response = await _wrap(promise, { key }) + if (response) { + // we only expect string values + return response.json() + } } /** @@ -242,11 +255,13 @@ class AdobeStateStore { * * @param {string} key state key identifier * @param {string} value state value - * @param {AdobeStateStorePutOptions} [options={}] put options + * @param {AdobeStatePutOptions} [options={}] put options * @returns {Promise} key - * @memberof AdobeStateStore + * @memberof AdobeState */ async put (key, value, options = {}) { + logger.debug(`put '${key}' with options ${JSON.stringify(options)}`) + const schema = { type: 'object', properties: { @@ -267,75 +282,7 @@ class AdobeStateStore { sdkDetails: { key, value, options, errors } })) } - logger.debug(`put '${key}' with options ${JSON.stringify(options)}`) - return this._put(key, value, options) - } - - /** - * Deletes a state key-value pair - * - * @param {string} key state key identifier - * @returns {Promise} key of deleted state or `null` if state does not exists - * @memberof AdobeStateStore - */ - async delete (key) { - logger.debug(`delete '${key}'`) - return this._delete(key) - } - - /** - * Deletes all key-values - * - * @returns {Promise} true if deleted, false if not - * @memberof StateStore - */ - async deleteAll () { - logger.debug('deleteAll') - return this._deleteAll() - } - - /** - * There exists key-values. - * - * @returns {Promise} true if exists, false if not - * @memberof StateStore - */ - async any () { - logger.debug('any') - return this._any() - } - - /* **************************** PRIVATE METHODS TO IMPLEMENT ***************************** */ - - /** - * @param {string} key state key identifier - * @returns {Promise} get response holding value and additional info - * @protected - */ - async _get (key) { - const requestOptions = { - method: 'GET', - headers: { - ...this.getAuthorizationHeaders() - } - } - logger.debug('_get', requestOptions) - const promise = this.fetchRetry.exponentialBackoff(this.createRequestUrl(key), requestOptions) - const response = await _wrap(promise, { key }) - if (response) { - // we only expect string values - return response.json() - } - } - /** - * @param {string} key state key identifier - * @param {any} value state value - * @param {object} options state put options - * @returns {Promise} key - * @protected - */ - async _put (key, value, options) { const { ttl } = options const queryParams = ttl ? { ttl } : {} const requestOptions = { @@ -346,63 +293,79 @@ class AdobeStateStore { }, body: value } - logger.debug('_put', requestOptions) + + logger.debug('put', requestOptions) + const promise = this.fetchRetry.exponentialBackoff(this.createRequestUrl(key, queryParams), requestOptions) await _wrap(promise, { key, value, ...options }) return key } /** + * Deletes a state key-value pair + * * @param {string} key state key identifier * @returns {Promise} key of deleted state or `null` if state does not exists - * @protected + * @memberof AdobeState */ - async _delete (key) { + async delete (key) { + logger.debug(`delete '${key}'`) + const requestOptions = { method: 'DELETE', headers: { ...this.getAuthorizationHeaders() } } - logger.debug('_delete', requestOptions) + + logger.debug('delete', requestOptions) + const promise = this.fetchRetry.exponentialBackoff(this.createRequestUrl(key), requestOptions) const ret = await _wrap(promise, { key }) return ret && key } /** + * Deletes all key-values + * * @returns {Promise} true if deleted, false if not - * @protected + * @memberof StateStore */ - async _deleteAll () { + async deleteAll () { const requestOptions = { method: 'DELETE', headers: { ...this.getAuthorizationHeaders() } } - logger.debug('_deleteAll', requestOptions) + + logger.debug('deleteAll', requestOptions) + const promise = this.fetchRetry.exponentialBackoff(this.createRequestUrl(), requestOptions) const response = await _wrap(promise, {}) return response !== null } /** + * There exists key-values. + * * @returns {Promise} true if exists, false if not - * @protected + * @memberof StateStore */ - async _any () { + async any () { const requestOptions = { method: 'HEAD', headers: { ...this.getAuthorizationHeaders() } } - logger.debug('_any', requestOptions) + + logger.debug('any', requestOptions) + const promise = this.fetchRetry.exponentialBackoff(this.createRequestUrl(), requestOptions) const response = await _wrap(promise, {}) return response !== null } } -module.exports = { AdobeStateStore } +module.exports = { AdobeState } diff --git a/lib/AdobeStateStoreError.js b/lib/StateError.js similarity index 98% rename from lib/AdobeStateStoreError.js rename to lib/StateError.js index 717ca1e..3d98632 100644 --- a/lib/AdobeStateStoreError.js +++ b/lib/StateError.js @@ -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 diff --git a/lib/init.js b/lib/init.js index 0c439b8..47afef0 100644 --- a/lib/init.js +++ b/lib/init.js @@ -1,5 +1,5 @@ /* -Copyright 2023 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 @@ -13,7 +13,7 @@ governing permissions and limitations under the License. const logger = require('@adobe/aio-lib-core-logging')('@adobe/aio-lib-state', { provider: 'debug' }) const utils = require('./utils') -const { AdobeStateStore } = require('./AdobeStateStore') +const { AdobeState } = require('./AdobeState') /* *********************************** typedefs *********************************** */ /** @@ -43,7 +43,7 @@ const { AdobeStateStore } = require('./AdobeStateStore') * 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` - * @returns {Promise} An AdobeStateStore instance + * @returns {Promise} An AdobeStateStore instance */ async function init (config = {}) { const logConfig = utils.withHiddenFields(config, ['ow.auth']) @@ -51,7 +51,7 @@ async function init (config = {}) { logger.debug(`init with config: ${JSON.stringify(logConfig, null, 2)}`) const { auth: apikey, namespace } = (config.ow ?? {}) - return AdobeStateStore.init({ apikey, namespace }) + return AdobeState.init({ apikey, namespace }) } module.exports = { init } diff --git a/lib/utils.js b/lib/utils.js index 408472a..aeabd8d 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -1,3 +1,15 @@ +/* +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 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + const cloneDeep = require('lodash.clonedeep') /** diff --git a/test/AdobeStateStore.test.js b/test/AdobeState.test.js similarity index 92% rename from test/AdobeStateStore.test.js rename to test/AdobeState.test.js index ddcf423..6fd7c4d 100644 --- a/test/AdobeStateStore.test.js +++ b/test/AdobeState.test.js @@ -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 @@ -13,7 +13,7 @@ governing permissions and limitations under the License. // @ts-nocheck const { getCliEnv, DEFAULT_ENV, PROD_ENV, STAGE_ENV } = require('@adobe/aio-lib-env') const { HttpExponentialBackoff } = require('@adobe/aio-lib-core-networking') -const { AdobeStateStore } = require('../lib/AdobeStateStore') +const { AdobeState } = require('../lib/AdobeState') const querystring = require('node:querystring') const { Buffer } = require('node:buffer') @@ -96,14 +96,14 @@ describe('init and constructor', () => { namespace: 'some-namespace' } - const store = await AdobeStateStore.init(credentials) + const store = await AdobeState.init(credentials) expect(store.apikey).toEqual(credentials.apikey) expect(store.namespace).toEqual(credentials.namespace) expect(store.endpoint).toBeDefined() }) test('bad credentials (no apikey and no namespace)', async () => { - await expect(AdobeStateStore.init()).rejects + await expect(AdobeState.init()).rejects .toThrow('[AdobeStateLib:ERROR_BAD_ARGUMENT] apikey and/or namespace is missing') }) @@ -112,7 +112,7 @@ describe('init and constructor', () => { namespace: 'some-namespace' } - await expect(AdobeStateStore.init(credentials)).rejects + await expect(AdobeState.init(credentials)).rejects .toThrow('[AdobeStateLib:ERROR_BAD_ARGUMENT] apikey and/or namespace is missing') }) @@ -121,7 +121,7 @@ describe('init and constructor', () => { apikey: 'some-apikey' } - await expect(AdobeStateStore.init(credentials)).rejects + await expect(AdobeState.init(credentials)).rejects .toThrow('[AdobeStateLib:ERROR_BAD_ARGUMENT] apikey and/or namespace is missing') }) }) @@ -130,7 +130,7 @@ describe('get', () => { let store beforeEach(async () => { - store = await AdobeStateStore.init(fakeCredentials) + store = await AdobeState.init(fakeCredentials) }) test('success', async () => { @@ -166,7 +166,7 @@ describe('put', () => { let store beforeEach(async () => { - store = await AdobeStateStore.init(fakeCredentials) + store = await AdobeState.init(fakeCredentials) }) test('success (string value) no ttl', async () => { @@ -260,7 +260,7 @@ describe('delete', () => { let store beforeEach(async () => { - store = await AdobeStateStore.init(fakeCredentials) + store = await AdobeState.init(fakeCredentials) }) test('success', async () => { @@ -287,7 +287,7 @@ describe('deleteAll', () => { let store beforeEach(async () => { - store = await AdobeStateStore.init(fakeCredentials) + store = await AdobeState.init(fakeCredentials) }) test('success', async () => { @@ -310,7 +310,7 @@ describe('any', () => { let store beforeEach(async () => { - store = await AdobeStateStore.init(fakeCredentials) + store = await AdobeState.init(fakeCredentials) }) test('success', async () => { @@ -334,7 +334,7 @@ describe('private methods', () => { const expectedHeaders = { Authorization: `Basic ${fakeCredentials.apikey}` } - const store = await AdobeStateStore.init(fakeCredentials) + const store = await AdobeState.init(fakeCredentials) expect(store.getAuthorizationHeaders()).toEqual(expectedHeaders) }) @@ -345,7 +345,7 @@ describe('private methods', () => { getCliEnv.mockReturnValue(env) // need to instantiate a new store, when env changes - const store = await AdobeStateStore.init(fakeCredentials) + const store = await AdobeState.init(fakeCredentials) const url = store.createRequestUrl() expect(url).toEqual(`${myConstants.ADOBE_STATE_STORE_ENDPOINT[env]}/v1/containers/${fakeCredentials.namespace}`) @@ -357,7 +357,7 @@ describe('private methods', () => { getCliEnv.mockReturnValue(env) // need to instantiate a new store, when env changes - const store = await AdobeStateStore.init(fakeCredentials) + const store = await AdobeState.init(fakeCredentials) const url = store.createRequestUrl(key) expect(url).toEqual(`${myConstants.ADOBE_STATE_STORE_ENDPOINT[env]}/v1/containers/${fakeCredentials.namespace}/data/${key}`) @@ -373,7 +373,7 @@ describe('private methods', () => { getCliEnv.mockReturnValue(env) // need to instantiate a new store, when env changes - const store = await AdobeStateStore.init(fakeCredentials) + const store = await AdobeState.init(fakeCredentials) const url = store.createRequestUrl(key, queryParams) expect(url).toEqual(`${myConstants.ADOBE_STATE_STORE_ENDPOINT[env]}/v1/containers/${fakeCredentials.namespace}/data/${key}?${querystring.stringify(queryParams)}`) diff --git a/test/init.test.js b/test/init.test.js index 2071413..5e53b7b 100644 --- a/test/init.test.js +++ b/test/init.test.js @@ -1,5 +1,5 @@ /* -Copyright 2023 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 diff --git a/test/utils.test.js b/test/utils.test.js index 9045d78..20f86db 100644 --- a/test/utils.test.js +++ b/test/utils.test.js @@ -1,5 +1,5 @@ /* -Copyright 2023 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 From 3253b42c08623986d9502847a9a79b968a74d380 Mon Sep 17 00:00:00 2001 From: Shazron Abdullah Date: Sat, 6 Jan 2024 00:19:40 +0800 Subject: [PATCH 14/17] added prod and stage endpoints --- lib/constants.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/constants.js b/lib/constants.js index e1baa9d..a9831ca 100644 --- a/lib/constants.js +++ b/lib/constants.js @@ -1,5 +1,5 @@ /* -Copyright 2023 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 @@ -13,8 +13,8 @@ governing permissions and limitations under the License. const { PROD_ENV, STAGE_ENV } = require('@adobe/aio-lib-env') const ADOBE_STATE_STORE_ENDPOINT = { - [PROD_ENV]: 'http://localhost:8080', // TODO: - [STAGE_ENV]: 'http://localhost-stage:8080' // TODO: + [PROD_ENV]: 'https://storage-state-amer.app-builder.adp.adobe.io', + [STAGE_ENV]: 'http://storage-state-amer.stg.app-builder.corp.adp.adobe.io' } const MAX_KEY_SIZE = 1024 * 1 // 1KB From 2f12dbdec0b0fb76989acbec5fbad849189a050b Mon Sep 17 00:00:00 2001 From: Shazron Abdullah Date: Mon, 8 Jan 2024 12:12:08 +0800 Subject: [PATCH 15/17] add support to override prod and stage endpoints (for local dev testing purposes) --- e2e/e2e.js | 5 +++-- lib/constants.js | 10 ++++++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/e2e/e2e.js b/e2e/e2e.js index eca2f4f..90e674b 100644 --- a/e2e/e2e.js +++ b/e2e/e2e.js @@ -13,13 +13,14 @@ 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 stateLib = require('../index') const path = require('node:path') -const { MAX_TTL_SECONDS } = require('../lib/constants') // 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 testKey = 'e2e_test_state_key' jest.setTimeout(30000) // thirty seconds per test diff --git a/lib/constants.js b/lib/constants.js index a9831ca..1d10da3 100644 --- a/lib/constants.js +++ b/lib/constants.js @@ -12,9 +12,15 @@ governing permissions and limitations under the License. const { PROD_ENV, STAGE_ENV } = require('@adobe/aio-lib-env') +// gets these values if the keys are set in the environment, if not it will use the defaults set +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' +} = process.env + const ADOBE_STATE_STORE_ENDPOINT = { - [PROD_ENV]: 'https://storage-state-amer.app-builder.adp.adobe.io', - [STAGE_ENV]: 'http://storage-state-amer.stg.app-builder.corp.adp.adobe.io' + [PROD_ENV]: ADOBE_STATE_STORE_ENDPOINT_PROD, + [STAGE_ENV]: ADOBE_STATE_STORE_ENDPOINT_STAGE } const MAX_KEY_SIZE = 1024 * 1 // 1KB From 7da53fcbb63d5acc559c05ab18114bfda501a29b Mon Sep 17 00:00:00 2001 From: Shazron Abdullah Date: Mon, 8 Jan 2024 22:38:27 +0800 Subject: [PATCH 16/17] fix: update docs for v3 --- README.md | 18 ++-- doc/api.md | 209 +++++++++++++++++++-------------------------- lib/AdobeState.js | 4 +- lib/StateError.js | 5 +- lib/init.js | 3 +- lib/utils.js | 1 + test/jest.setup.js | 1 - types.d.ts | 123 ++++++++++---------------- 8 files changed, 149 insertions(+), 215 deletions(-) diff --git a/README.md b/README.md index b83ad00..c2b7068 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ +# 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 @@ -39,8 +37,6 @@ 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 } @@ -48,10 +44,16 @@ npm install @adobe/aio-lib-state // 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 diff --git a/doc/api.md b/doc/api.md index 2a1b248..5e629ae 100644 --- a/doc/api.md +++ b/doc/api.md @@ -1,7 +1,7 @@ ## Classes
-
StateStore
+
AdobeState

Cloud State Management

@@ -9,12 +9,14 @@ ## Functions
-
init([config])Promise.<StateStore>
+
validate(schema, data)object
+

Validates json according to a schema.

+
+
init([config])Promise.<AdobeState>

Initializes and returns the key-value-store SDK.

To use the SDK you must either provide your OpenWhisk credentials in -config.ow or your own -Azure Cosmos credentials in config.cosmos.

+config.ow or your own

OpenWhisk credentials can also be read from environment variables __OW_NAMESPACE and __OW_API_KEY.

@@ -22,215 +24,179 @@ ## Typedefs
-
OpenWhiskCredentials : object
-

An object holding the OpenWhisk credentials

+
AdobeStateCredentials : object
+

AdobeStateCredentials

-
AzureCosmosPartitionResourceCredentials : object
-

An object holding the Azure Cosmos resource credentials with permissions on a single partition and container

+
AdobeStatePutOptions : object
+

AdobeState put options

-
AzureCosmosMasterCredentials : object
-

An object holding the Azure Cosmos account master key

+
AdobeStateGetReturnValue : object
+

AdobeState get return object

-
StateStorePutOptions : object
-

StateStore put options

-
-
StateStoreGetReturnValue : object
-

StateStore get return object

+
OpenWhiskCredentials : object
+

An object holding the OpenWhisk credentials

-
StateLibError : Object
+
AdobeStateLibError : object
-
StateLibErrors : object
-

State lib custom errors. +

AdobeStateLibErrors : object
+

Adobe State lib custom errors. e.sdkDetails provides additional context for each error (e.g. function parameter)

- + -## *StateStore* +## *AdobeState* Cloud State Management **Kind**: global abstract class -* *[StateStore](#StateStore)* - * *[.get(key)](#StateStore+get) ⇒ [Promise.<StateStoreGetReturnValue>](#StateStoreGetReturnValue)* - * *[.put(key, value, [options])](#StateStore+put) ⇒ Promise.<string>* - * *[.delete(key)](#StateStore+delete) ⇒ Promise.<string>* - * *[._get(key)](#StateStore+_get) ⇒ [Promise.<StateStoreGetReturnValue>](#StateStoreGetReturnValue)* - * *[._put(key, value, options)](#StateStore+_put) ⇒ Promise.<string>* - * *[._delete(key)](#StateStore+_delete) ⇒ Promise.<string>* +* *[AdobeState](#AdobeState)* + * *[.get(key)](#AdobeState+get) ⇒ [Promise.<AdobeStateGetReturnValue>](#AdobeStateGetReturnValue)* + * *[.put(key, value, [options])](#AdobeState+put) ⇒ Promise.<string>* + * *[.delete(key)](#AdobeState+delete) ⇒ Promise.<string>* + * *[.deleteAll()](#AdobeState+deleteAll) ⇒ Promise.<boolean>* + * *[.any()](#AdobeState+any) ⇒ Promise.<boolean>* - + -### *stateStore.get(key) ⇒ [Promise.<StateStoreGetReturnValue>](#StateStoreGetReturnValue)* +### *adobeState.get(key) ⇒ [Promise.<AdobeStateGetReturnValue>](#AdobeStateGetReturnValue)* Retrieves the state value for given key. If the key doesn't exist returns undefined. -**Kind**: instance method of [StateStore](#StateStore) -**Returns**: [Promise.<StateStoreGetReturnValue>](#StateStoreGetReturnValue) - get response holding value and additional info +**Kind**: instance method of [AdobeState](#AdobeState) +**Returns**: [Promise.<AdobeStateGetReturnValue>](#AdobeStateGetReturnValue) - get response holding value and additional info | Param | Type | Description | | --- | --- | --- | | key | string | state key identifier | - + -### *stateStore.put(key, value, [options]) ⇒ Promise.<string>* +### *adobeState.put(key, value, [options]) ⇒ Promise.<string>* Creates or updates a state key-value pair -**Kind**: instance method of [StateStore](#StateStore) +**Kind**: instance method of [AdobeState](#AdobeState) **Returns**: Promise.<string> - key | Param | Type | Default | Description | | --- | --- | --- | --- | | key | string | | state key identifier | -| value | any | | state value | -| [options] | [StateStorePutOptions](#StateStorePutOptions) | {} | put options | +| value | string | | state value | +| [options] | [AdobeStatePutOptions](#AdobeStatePutOptions) | {} | put options | - + -### *stateStore.delete(key) ⇒ Promise.<string>* +### *adobeState.delete(key) ⇒ Promise.<string>* Deletes a state key-value pair -**Kind**: instance method of [StateStore](#StateStore) +**Kind**: instance method of [AdobeState](#AdobeState) **Returns**: Promise.<string> - key of deleted state or `null` if state does not exists | Param | Type | Description | | --- | --- | --- | | key | string | state key identifier | - + -### *stateStore.\_get(key) ⇒ [Promise.<StateStoreGetReturnValue>](#StateStoreGetReturnValue)* -**Kind**: instance method of [StateStore](#StateStore) -**Returns**: [Promise.<StateStoreGetReturnValue>](#StateStoreGetReturnValue) - get response holding value and additional info -**Access**: protected +### *adobeState.deleteAll() ⇒ Promise.<boolean>* +Deletes all key-values -| Param | Type | Description | -| --- | --- | --- | -| key | string | state key identifier | +**Kind**: instance method of [AdobeState](#AdobeState) +**Returns**: Promise.<boolean> - true if deleted, false if not + - +### *adobeState.any() ⇒ Promise.<boolean>* +There exists key-values. -### *stateStore.\_put(key, value, options) ⇒ Promise.<string>* -**Kind**: instance method of [StateStore](#StateStore) -**Returns**: Promise.<string> - key -**Access**: protected +**Kind**: instance method of [AdobeState](#AdobeState) +**Returns**: Promise.<boolean> - true if exists, false if not + -| Param | Type | Description | -| --- | --- | --- | -| key | string | state key identifier | -| value | any | state value | -| options | object | state put options | - - +## validate(schema, data) ⇒ object +Validates json according to a schema. -### *stateStore.\_delete(key) ⇒ Promise.<string>* -**Kind**: instance method of [StateStore](#StateStore) -**Returns**: Promise.<string> - key of deleted state or `null` if state does not exists -**Access**: protected +**Kind**: global function +**Returns**: object - the result | Param | Type | Description | | --- | --- | --- | -| key | string | state key identifier | +| schema | object | the AJV schema | +| data | object | the json data to test | -## init([config]) ⇒ [Promise.<StateStore>](#StateStore) +## init([config]) ⇒ [Promise.<AdobeState>](#AdobeState) Initializes and returns the key-value-store SDK. To use the SDK you must either provide your [OpenWhisk credentials](#OpenWhiskCredentials) in `config.ow` or your own -[Azure Cosmos credentials](#AzureCosmosMasterCredentials) in `config.cosmos`. OpenWhisk credentials can also be read from environment variables `__OW_NAMESPACE` and `__OW_API_KEY`. **Kind**: global function -**Returns**: [Promise.<StateStore>](#StateStore) - A StateStore instance +**Returns**: [Promise.<AdobeState>](#AdobeState) - An AdobeState instance | Param | Type | Default | Description | | --- | --- | --- | --- | | [config] | object | {} | used to init the sdk | | [config.ow] | [OpenWhiskCredentials](#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` | -| [config.cosmos] | [AzureCosmosMasterCredentials](#AzureCosmosMasterCredentials) \| [AzureCosmosPartitionResourceCredentials](#AzureCosmosPartitionResourceCredentials) | | [Azure Cosmos resource credentials](#AzureCosmosPartitionResourceCredentials) or [Azure Cosmos account credentials](#AzureCosmosMasterCredentials) | -| [config.tvm] | object | | tvm configuration, applies only when passing OpenWhisk credentials | -| [config.tvm.apiUrl] | string | | alternative tvm api url. | -| [config.tvm.cacheFile] | string | | alternative tvm cache file, set to `false` to disable caching of temporary credentials. | - + -## OpenWhiskCredentials : object -An object holding the OpenWhisk credentials +## AdobeStateCredentials : object +AdobeStateCredentials **Kind**: global typedef **Properties** | Name | Type | Description | | --- | --- | --- | -| namespace | string | user namespace | -| auth | string | auth key | - - - -## AzureCosmosPartitionResourceCredentials : object -An object holding the Azure Cosmos resource credentials with permissions on a single partition and container - -**Kind**: global typedef -**Properties** +| namespace | string | the state store namespace | +| apikey | string | the state store api key | -| Name | Type | Description | -| --- | --- | --- | -| endpoint | string | cosmosdb resource endpoint | -| resourceToken | string | cosmosdb resource token restricted to the partitionKey | -| databaseId | string | id for cosmosdb database | -| containerId | string | id for cosmosdb container within database | -| partitionKey | string | key for cosmosdb partition within container authorized by resource token | + - - -## AzureCosmosMasterCredentials : object -An object holding the Azure Cosmos account master key +## AdobeStatePutOptions : object +AdobeState put options **Kind**: global typedef **Properties** | Name | Type | Description | | --- | --- | --- | -| endpoint | string | cosmosdb resource endpoint | -| masterKey | string | cosmosdb account masterKey | -| databaseId | string | id for cosmosdb database | -| containerId | string | id for cosmosdb container within database | -| partitionKey | string | key for cosmosdb partition where data will be stored | +| ttl | number | 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. | - + -## StateStorePutOptions : object -StateStore put options +## AdobeStateGetReturnValue : object +AdobeState get return object **Kind**: global typedef **Properties** | Name | Type | Description | | --- | --- | --- | -| ttl | number | 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. | +| expiration | string \| null | ISO date string of expiration time for the key-value pair, if the ttl is infinite expiration=null | +| value | any | the value set by put | - + -## StateStoreGetReturnValue : object -StateStore get return object +## OpenWhiskCredentials : object +An object holding the OpenWhisk credentials **Kind**: global typedef **Properties** | Name | Type | Description | | --- | --- | --- | -| expiration | string \| null | ISO date string of expiration time for the key-value pair, if the ttl is infinite expiration=null | -| value | any | the value set by put | +| namespace | string | user namespace | +| auth | string | auth key | - + -## StateLibError : Object +## AdobeStateLibError : object **Kind**: global typedef **Properties** @@ -239,12 +205,12 @@ StateStore get return object | message | string | The message for the Error | | code | string | The code for the Error | | sdk | string | The SDK associated with the Error | -| sdkDetails | Object | The SDK details associated with the Error | +| sdkDetails | object | The SDK details associated with the Error | - + -## StateLibErrors : object -State lib custom errors. +## AdobeStateLibErrors : object +Adobe State lib custom errors. `e.sdkDetails` provides additional context for each error (e.g. function parameter) **Kind**: global typedef @@ -252,10 +218,11 @@ State lib custom errors. | Name | Type | Description | | --- | --- | --- | -| ERROR_BAD_ARGUMENT | [StateLibError](#StateLibError) | this error is thrown when an argument is missing or has invalid type | -| ERROR_BAD_REQUEST | [StateLibError](#StateLibError) | this error is thrown when an argument has an illegal value. | -| ERROR_NOT_IMPLEMENTED | [StateLibError](#StateLibError) | this error is thrown when a method is not implemented or when calling methods directly on the abstract class (StateStore). | -| ERROR_PAYLOAD_TOO_LARGE | [StateLibError](#StateLibError) | this error is thrown when the state key, state value or underlying request payload size exceeds the specified limitations. | -| ERROR_BAD_CREDENTIALS | [StateLibError](#StateLibError) | this error is thrown when the supplied init credentials are invalid. | -| ERROR_INTERNAL | [StateLibError](#StateLibError) | this error is thrown when an unknown error is thrown by the underlying DB provider or TVM server for credential exchange. More details can be found in `e.sdkDetails._internal`. | +| ERROR_BAD_ARGUMENT | [AdobeStateLibError](#AdobeStateLibError) | this error is thrown when an argument is missing, has invalid type, or includes invalid characters. | +| ERROR_BAD_REQUEST | [AdobeStateLibError](#AdobeStateLibError) | this error is thrown when an argument has an illegal value. | +| ERROR_PAYLOAD_TOO_LARGE | [AdobeStateLibError](#AdobeStateLibError) | this error is thrown when the state key, state value or underlying request payload size exceeds the specified limitations. | +| ERROR_BAD_CREDENTIALS | [AdobeStateLibError](#AdobeStateLibError) | this error is thrown when the supplied init credentials are invalid. | +| ERROR_UNAUTHORIZED | [AdobeStateLibError](#AdobeStateLibError) | this error is thrown when the credentials are unauthorized to access the resource | +| ERROR_INTERNAL | [AdobeStateLibError](#AdobeStateLibError) | this error is thrown when an unknown error is thrown by the underlying DB provider or TVM server for credential exchange. More details can be found in `e.sdkDetails._internal`. | +| ERROR_REQUEST_RATE_TOO_HIGH | [AdobeStateLibError](#AdobeStateLibError) | this error is thrown when the request rate for accessing state is too high. | diff --git a/lib/AdobeState.js b/lib/AdobeState.js index 2b0081c..562c077 100644 --- a/lib/AdobeState.js +++ b/lib/AdobeState.js @@ -329,7 +329,7 @@ class AdobeState { * Deletes all key-values * * @returns {Promise} true if deleted, false if not - * @memberof StateStore + * @memberof AdobeState */ async deleteAll () { const requestOptions = { @@ -350,7 +350,7 @@ class AdobeState { * There exists key-values. * * @returns {Promise} true if exists, false if not - * @memberof StateStore + * @memberof AdobeState */ async any () { const requestOptions = { diff --git a/lib/StateError.js b/lib/StateError.js index 3d98632..e5a4c4a 100644 --- a/lib/StateError.js +++ b/lib/StateError.js @@ -25,12 +25,10 @@ const logger = require('@adobe/aio-lib-core-logging')('@adobe/aio-lib-state', { * Adobe State lib custom errors. * `e.sdkDetails` provides additional context for each error (e.g. function parameter) * - * @typedef StateLibErrors + * @typedef AdobeStateLibErrors * @type {object} * @property {AdobeStateLibError} ERROR_BAD_ARGUMENT this error is thrown when an argument is missing, has invalid type, or includes invalid characters. * @property {AdobeStateLibError} ERROR_BAD_REQUEST this error is thrown when an argument has an illegal value. - * @property {AdobeStateLibError} ERROR_NOT_IMPLEMENTED this error is thrown when a method is not implemented or when calling - * methods directly on the abstract class (StateStore). * @property {AdobeStateLibError} ERROR_PAYLOAD_TOO_LARGE this error is thrown when the state key, state value or underlying request payload size * exceeds the specified limitations. * @property {AdobeStateLibError} ERROR_BAD_CREDENTIALS this error is thrown when the supplied init credentials are invalid. @@ -58,7 +56,6 @@ E('ERROR_INTERNAL', '%s') E('ERROR_BAD_REQUEST', '%s') E('ERROR_BAD_ARGUMENT', '%s') E('ERROR_UNKNOWN_PROVIDER', '%s') -E('ERROR_NOT_IMPLEMENTED', 'method `%s` not implemented') E('ERROR_UNAUTHORIZED', 'you are not authorized to access %s') E('ERROR_BAD_CREDENTIALS', 'cannot access %s, make sure your credentials are valid') E('ERROR_PAYLOAD_TOO_LARGE', 'key, value or request payload is too large') diff --git a/lib/init.js b/lib/init.js index 47afef0..d03b5b8 100644 --- a/lib/init.js +++ b/lib/init.js @@ -33,7 +33,6 @@ const { AdobeState } = require('./AdobeState') * To use the SDK you must either provide your * [OpenWhisk credentials]{@link OpenWhiskCredentials} in * `config.ow` or your own - * [Azure Cosmos credentials]{@link AzureCosmosMasterCredentials} in `config.cosmos`. * * OpenWhisk credentials can also be read from environment variables `__OW_NAMESPACE` and `__OW_API_KEY`. * @@ -43,7 +42,7 @@ const { AdobeState } = require('./AdobeState') * 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` - * @returns {Promise} An AdobeStateStore instance + * @returns {Promise} An AdobeState instance */ async function init (config = {}) { const logConfig = utils.withHiddenFields(config, ['ow.auth']) diff --git a/lib/utils.js b/lib/utils.js index aeabd8d..187b50d 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -15,6 +15,7 @@ const cloneDeep = require('lodash.clonedeep') /** * Replaces any hidden field values with the string '' * + * @private * @param {object} sourceObj the object to needs fields hidden * @param {Array} fieldsToHide the fields that need the value hidden * @returns {object} the source object but with the specified fields hidden diff --git a/test/jest.setup.js b/test/jest.setup.js index 7bbe70d..e476554 100644 --- a/test/jest.setup.js +++ b/test/jest.setup.js @@ -66,6 +66,5 @@ global.expectToThrowForbidden = async (received, expectedErrorDetails) => global global.expectToThrowFirewall = async (received, expectedErrorDetails) => global.expectToThrowCustomError(received, 'ERROR_FIREWALL', ['your', 'IP', 'blocked', 'firewall'], expectedErrorDetails) global.expectToThrowInternalWithStatus = async (received, status, expectedErrorDetails) => global.expectToThrowCustomError(received, 'ERROR_INTERNAL', ['' + status], expectedErrorDetails) global.expectToThrowInternal = async (received, expectedErrorDetails) => global.expectToThrowCustomError(received, 'ERROR_INTERNAL', ['unknown'], expectedErrorDetails) -global.expectToThrowNotImplemented = async (received, methodName) => global.expectToThrowCustomError(received, 'ERROR_NOT_IMPLEMENTED', ['not', 'implemented', methodName], {}) global.expectToThrowTooLarge = async (received, expectedErrorDetails) => global.expectToThrowCustomError(received, 'ERROR_PAYLOAD_TOO_LARGE', ['payload', 'is', 'too', 'large'], expectedErrorDetails) global.expectToThrowRequestRateTooHigh = async (received, expectedErrorDetails) => global.expectToThrowCustomError(received, 'ERROR_REQUEST_RATE_TOO_HIGH', ['Request', 'rate', 'too', 'high'], expectedErrorDetails) diff --git a/types.d.ts b/types.d.ts index 7fc3c5d..f3736fb 100644 --- a/types.d.ts +++ b/types.d.ts @@ -1,34 +1,52 @@ /** - * StateStore put options + * AdobeStateCredentials + * @property namespace - the state store namespace + * @property apikey - the state store api key + */ +export type AdobeStateCredentials = { + namespace: string; + apikey: string; +}; + +/** + * AdobeState put options * @property ttl - 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. */ -export type StateStorePutOptions = { +export type AdobeStatePutOptions = { ttl: number; }; /** - * StateStore get return object + * AdobeState get return object * @property expiration - ISO date string of expiration time for the key-value pair, if the ttl is infinite * expiration=null * @property value - the value set by put */ -export type StateStoreGetReturnValue = { +export type AdobeStateGetReturnValue = { expiration: string | null; value: any; }; +/** + * Validates json according to a schema. + * @param schema - the AJV schema + * @param data - the json data to test + * @returns the result + */ +export function validate(schema: any, data: any): any; + /** * Cloud State Management */ -export class StateStore { +export class AdobeState { /** * Retrieves the state value for given key. * If the key doesn't exist returns undefined. * @param key - state key identifier * @returns get response holding value and additional info */ - get(key: string): Promise; + get(key: string): Promise; /** * Creates or updates a state key-value pair * @param key - state key identifier @@ -36,7 +54,7 @@ export class StateStore { * @param [options = {}] - put options * @returns key */ - put(key: string, value: any, options?: StateStorePutOptions): Promise; + put(key: string, value: string, options?: AdobeStatePutOptions): Promise; /** * Deletes a state key-value pair * @param key - state key identifier @@ -44,22 +62,15 @@ export class StateStore { */ delete(key: string): Promise; /** - * @param key - state key identifier - * @returns get response holding value and additional info + * Deletes all key-values + * @returns true if deleted, false if not */ - protected _get(key: string): Promise; + deleteAll(): Promise; /** - * @param key - state key identifier - * @param value - state value - * @param options - state put options - * @returns key + * There exists key-values. + * @returns true if exists, false if not */ - protected _put(key: string, value: any, options: any): Promise; - /** - * @param key - state key identifier - * @returns key of deleted state or `null` if state does not exists - */ - protected _delete(key: string): Promise; + any(): Promise; } /** @@ -68,7 +79,7 @@ export class StateStore { * @property sdk - The SDK associated with the Error * @property sdkDetails - The SDK details associated with the Error */ -export type StateLibError = { +export type AdobeStateLibError = { message: string; code: string; sdk: string; @@ -76,25 +87,26 @@ export type StateLibError = { }; /** - * State lib custom errors. + * Adobe State lib custom errors. * `e.sdkDetails` provides additional context for each error (e.g. function parameter) - * @property ERROR_BAD_ARGUMENT - this error is thrown when an argument is missing or has invalid type + * @property ERROR_BAD_ARGUMENT - this error is thrown when an argument is missing, has invalid type, or includes invalid characters. * @property ERROR_BAD_REQUEST - this error is thrown when an argument has an illegal value. - * @property ERROR_NOT_IMPLEMENTED - this error is thrown when a method is not implemented or when calling - * methods directly on the abstract class (StateStore). * @property ERROR_PAYLOAD_TOO_LARGE - this error is thrown when the state key, state value or underlying request payload size * exceeds the specified limitations. * @property ERROR_BAD_CREDENTIALS - this error is thrown when the supplied init credentials are invalid. + * @property ERROR_UNAUTHORIZED - this error is thrown when the credentials are unauthorized to access the resource * @property ERROR_INTERNAL - this error is thrown when an unknown error is thrown by the underlying * DB provider or TVM server for credential exchange. More details can be found in `e.sdkDetails._internal`. + * @property ERROR_REQUEST_RATE_TOO_HIGH - this error is thrown when the request rate for accessing state is too high. */ -export type StateLibErrors = { - ERROR_BAD_ARGUMENT: StateLibError; - ERROR_BAD_REQUEST: StateLibError; - ERROR_NOT_IMPLEMENTED: StateLibError; - ERROR_PAYLOAD_TOO_LARGE: StateLibError; - ERROR_BAD_CREDENTIALS: StateLibError; - ERROR_INTERNAL: StateLibError; +export type AdobeStateLibErrors = { + ERROR_BAD_ARGUMENT: AdobeStateLibError; + ERROR_BAD_REQUEST: AdobeStateLibError; + ERROR_PAYLOAD_TOO_LARGE: AdobeStateLibError; + ERROR_BAD_CREDENTIALS: AdobeStateLibError; + ERROR_UNAUTHORIZED: AdobeStateLibError; + ERROR_INTERNAL: AdobeStateLibError; + ERROR_REQUEST_RATE_TOO_HIGH: AdobeStateLibError; }; /** @@ -107,45 +119,12 @@ export type OpenWhiskCredentials = { auth: string; }; -/** - * An object holding the Azure Cosmos resource credentials with permissions on a single partition and container - * @property endpoint - cosmosdb resource endpoint - * @property resourceToken - cosmosdb resource token restricted to the partitionKey - * @property databaseId - id for cosmosdb database - * @property containerId - id for cosmosdb container within database - * @property partitionKey - key for cosmosdb partition within container authorized by resource token - */ -export type AzureCosmosPartitionResourceCredentials = { - endpoint: string; - resourceToken: string; - databaseId: string; - containerId: string; - partitionKey: string; -}; - -/** - * An object holding the Azure Cosmos account master key - * @property endpoint - cosmosdb resource endpoint - * @property masterKey - cosmosdb account masterKey - * @property databaseId - id for cosmosdb database - * @property containerId - id for cosmosdb container within database - * @property partitionKey - key for cosmosdb partition where data will be stored - */ -export type AzureCosmosMasterCredentials = { - endpoint: string; - masterKey: string; - databaseId: string; - containerId: string; - partitionKey: string; -}; - /** * Initializes and returns the key-value-store SDK. * * To use the SDK you must either provide your * [OpenWhisk credentials]{@link OpenWhiskCredentials} in * `config.ow` or your own - * [Azure Cosmos credentials]{@link AzureCosmosMasterCredentials} in `config.cosmos`. * * OpenWhisk credentials can also be read from environment variables `__OW_NAMESPACE` and `__OW_API_KEY`. * @param [config = {}] - used to init the sdk @@ -153,19 +132,9 @@ export type AzureCosmosMasterCredentials = { * 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 [config.cosmos] - [Azure Cosmos resource credentials]{@link AzureCosmosPartitionResourceCredentials} or - * [Azure Cosmos account credentials]{@link AzureCosmosMasterCredentials} - * @param [config.tvm] - tvm configuration, applies only when passing OpenWhisk credentials - * @param [config.tvm.apiUrl] - alternative tvm api url. - * @param [config.tvm.cacheFile] - alternative tvm cache file, set to `false` to disable caching of temporary credentials. - * @returns A StateStore instance + * @returns An AdobeState instance */ export function init(config?: { ow?: OpenWhiskCredentials; - cosmos?: AzureCosmosMasterCredentials | AzureCosmosPartitionResourceCredentials; - tvm?: { - apiUrl?: string; - cacheFile?: string; - }; -}): Promise; +}): Promise; From 0f46e9dd39b409dbcd9959311d66661b3aa945fb Mon Sep 17 00:00:00 2001 From: Shazron Abdullah <36107+shazron@users.noreply.github.com> Date: Tue, 30 Jan 2024 10:21:43 +0800 Subject: [PATCH 17/17] chore(deps): update @adobe/aio-lib-core-networking --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 085b6e0..a3a56f9 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,7 @@ "dependencies": { "@adobe/aio-lib-core-errors": "^4", "@adobe/aio-lib-core-logging": "^3", - "@adobe/aio-lib-core-networking": "^4.1.0", + "@adobe/aio-lib-core-networking": "^5", "@adobe/aio-lib-env": "^3", "ajv": "^8.12.0", "lodash.clonedeep": "^4.5.0"