diff --git a/doc/api.md b/doc/api.md index 59a7596..5f43474 100644 --- a/doc/api.md +++ b/doc/api.md @@ -6,15 +6,17 @@ -## Functions +## Members
-
validate(schema, data)object
-

Validates json according to a schema.

-
-
handleResponse(response, params)void
-

Handle a network response.

+
MAX_TTL : number
+

Max supported TTL, 365 days in seconds

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

Initializes and returns the key-value-store SDK.

To use the SDK you must either provide your @@ -159,31 +161,12 @@ for await (const { keys } of state.list({ match: 'abc*' })) { console.log(keys) } ``` - - -## validate(schema, data) ⇒ object -Validates json according to a schema. - -**Kind**: global function -**Returns**: object - the result + -| Param | Type | Description | -| --- | --- | --- | -| schema | object | the AJV schema | -| data | object | the json data to test | - - - -## handleResponse(response, params) ⇒ void -Handle a network response. - -**Kind**: global function - -| Param | Type | Description | -| --- | --- | --- | -| response | Response | a fetch Response | -| params | object | the params to the network call | +## MAX\_TTL : number +Max supported TTL, 365 days in seconds +**Kind**: global variable ## init([config]) ⇒ [Promise.<AdobeState>](#AdobeState) @@ -227,7 +210,7 @@ AdobeState put options | Name | Type | Description | | --- | --- | --- | -| ttl | number | time-to-live for key-value pair in seconds, defaults to 24 hours (86400s). Set to < 0 for max ttl of one year. A value of 0 sets default. | +| ttl | number | Time-To-Live for key-value pair in seconds. When not defined or set to 0, defaults to 24 hours (86400s). Max TTL is one year (31536000s), `require('@adobe/aio-lib-state').MAX_TTL`. A TTL of 0 defaults to 24 hours. | diff --git a/e2e/e2e.js b/e2e/e2e.js index 06e674b..38a5608 100644 --- a/e2e/e2e.js +++ b/e2e/e2e.js @@ -113,10 +113,10 @@ describe('e2e tests using OpenWhisk credentials (as env vars)', () => { // 3. test max ttl const nowPlus365Days = new Date(MAX_TTL_SECONDS).getTime() - expect(await state.put(testKey, testValue, { ttl: -1 })).toEqual(testKey) + expect(await state.put(testKey, testValue, { ttl: MAX_TTL_SECONDS })).toEqual(testKey) res = await state.get(testKey) resTime = new Date(res.expiration).getTime() - expect(resTime).toBeGreaterThanOrEqual(nowPlus365Days) + expect(resTime).toBeGreaterThanOrEqual(nowPlus365Days - 10000) // 4. test that after ttl object is deleted expect(await state.put(testKey, testValue, { ttl: 2 })).toEqual(testKey) @@ -124,6 +124,9 @@ describe('e2e tests using OpenWhisk credentials (as env vars)', () => { expect(new Date(res.expiration).getTime()).toBeLessThanOrEqual(new Date(Date.now() + 2000).getTime()) await waitFor(3000) // give it one more sec - ttl is not so precise expect(await state.get(testKey)).toEqual(undefined) + + // 5. infinite ttl not supported + await expect(state.put(testKey, testValue, { ttl: -1 })).rejects.toThrow() }) test('listKeys test: few < 128 keys, many, and expired entries', async () => { diff --git a/index.js b/index.js index e8f638c..d7fc630 100644 --- a/index.js +++ b/index.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 @@ -9,5 +9,13 @@ 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 { MAX_TTL_SECONDS } = require('./lib/constants') require('./lib/AdobeState') + module.exports = require('./lib/init') + +/** + * Max supported TTL, 365 days in seconds + * @type {number} + */ +module.exports.MAX_TTL = MAX_TTL_SECONDS diff --git a/lib/AdobeState.js b/lib/AdobeState.js index edbd29f..3cb0ba7 100644 --- a/lib/AdobeState.js +++ b/lib/AdobeState.js @@ -28,7 +28,8 @@ const { MAX_LIST_COUNT_HINT, REQUEST_ID_HEADER, MIN_LIST_COUNT_HINT, - REGEX_PATTERN_LIST_KEY_MATCH + REGEX_PATTERN_LIST_KEY_MATCH, + MAX_TTL_SECONDS } = require('./constants') /* *********************************** typedefs *********************************** */ @@ -48,8 +49,10 @@ const { * * @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 max ttl of one year. A - * value of 0 sets default. + * @property {number} ttl Time-To-Live for key-value pair in seconds. When not + * defined or set to 0, defaults to 24 hours (86400s). Max TTL is one year + * (31536000s), `require('@adobe/aio-lib-state').MAX_TTL`. A TTL of 0 defaults + * to 24 hours. */ /** @@ -69,6 +72,7 @@ const { * @param {object} schema the AJV schema * @param {object} data the json data to test * @returns {object} the result + * @private */ function validate (schema, data) { const ajv = new Ajv({ allErrors: true }) @@ -78,7 +82,7 @@ function validate (schema, data) { return { valid, errors: validate.errors } } -// eslint-disable-next-line jsdoc/require-jsdoc +/** @private */ async function _wrap (promise, params) { let response try { @@ -97,6 +101,7 @@ async function _wrap (promise, params) { * @param {Response} response a fetch Response * @param {object} params the params to the network call * @returns {void} + * @private */ async function handleResponse (response, params) { if (response.ok) { @@ -317,20 +322,31 @@ class AdobeState { }, value: { type: 'string' + }, + ttl: { + type: 'integer' } } } - const { valid, errors } = validate(schema, { key, value }) + // validation + const { ttl } = options + const { valid, errors } = validate(schema, { key, value, ttl }) if (!valid) { logAndThrow(new codes.ERROR_BAD_ARGUMENT({ messageValues: utils.formatAjvErrors(errors), - sdkDetails: { key, value, options, errors } + sdkDetails: { key, valueLength: value.length, options, errors } + })) + } + if (ttl !== undefined && (ttl < 0 || ttl > MAX_TTL_SECONDS)) { + // error message is nicer like this than for + logAndThrow(new codes.ERROR_BAD_ARGUMENT({ + messageValues: 'ttl must be <= 365 days (31536000s). Infinite TTLs (< 0) are not supported.', + sdkDetails: { key, valueLength: value.length, options } })) } - const { ttl } = options - const queryParams = ttl ? { ttl } : {} + const queryParams = ttl !== undefined ? { ttl } : {} const requestOptions = { method: 'PUT', headers: { diff --git a/package.json b/package.json index ec47b41..b9b697f 100644 --- a/package.json +++ b/package.json @@ -15,10 +15,12 @@ "e2e": "jest -c jest.e2e.config.js", "jsdoc": "jsdoc2md -f index.js 'lib/**/*.js' > doc/api.md", "typings": "jsdoc -t node_modules/tsd-jsdoc/dist -r lib -d . && replace-in-file /declare/g export types.d.ts --isRegex", - "generate-docs": "npm run jsdoc && npm run typings" + "generate-docs": "npm run jsdoc && npm run typings", + "git-add-docs": "git add doc/ types.d.ts" }, "pre-commit": [ - "generate-docs" + "generate-docs", + "git-add-docs" ], "author": "Adobe Inc.", "license": "Apache-2.0", diff --git a/test/AdobeState.test.js b/test/AdobeState.test.js index 5265dbe..79687b1 100644 --- a/test/AdobeState.test.js +++ b/test/AdobeState.test.js @@ -16,7 +16,7 @@ const { HttpExponentialBackoff } = require('@adobe/aio-lib-core-networking') const { AdobeState } = require('../lib/AdobeState') const querystring = require('node:querystring') const { Buffer } = require('node:buffer') -const { ALLOWED_REGIONS, HEADER_KEY_EXPIRES } = require('../lib/constants') +const { ALLOWED_REGIONS, HEADER_KEY_EXPIRES, MAX_TTL_SECONDS } = require('../lib/constants') // constants ////////////////////////////////////////////////////////// @@ -146,12 +146,19 @@ describe('get', () => { expect(value).toEqual(fetchBody) expect(typeof expiration).toEqual('string') expect(expiration).toEqual(new Date(Number(expiryHeaderValue)).toISOString()) + + expect(mockExponentialBackoff) + .toHaveBeenCalledWith( + 'https://storage-state-amer.app-builder.adp.adobe.io/containers/some-namespace/data/valid-key', + expect.objectContaining({ method: 'GET' }) + ) }) test('invalid key', async () => { const key = 'bad/key' await expect(store.get(key)).rejects.toThrow('[AdobeStateLib:ERROR_BAD_ARGUMENT] /key must match pattern "^[a-zA-Z0-9-_.]{1,1024}$"') + expect(mockExponentialBackoff).not.toHaveBeenCalled() }) test('not found', async () => { @@ -180,6 +187,27 @@ describe('put', () => { const returnKey = await store.put(key, value) expect(returnKey).toEqual(key) + expect(mockExponentialBackoff) + .toHaveBeenCalledWith( + 'https://storage-state-amer.app-builder.adp.adobe.io/containers/some-namespace/data/valid-key', + expect.objectContaining({ method: 'PUT' }) + ) + }) + + test('success (string value) ttl = 0', async () => { + const key = 'valid-key' + const value = 'some-value' + const fetchResponseJson = {} + + mockExponentialBackoff.mockResolvedValue(wrapInFetchResponse(fetchResponseJson)) + + const returnKey = await store.put(key, value, { ttl: 0 }) + expect(returnKey).toEqual(key) + expect(mockExponentialBackoff) + .toHaveBeenCalledWith( + 'https://storage-state-amer.app-builder.adp.adobe.io/containers/some-namespace/data/valid-key?ttl=0', + expect.any(Object) + ) }) test('success (string value) with ttl', async () => { @@ -191,6 +219,11 @@ describe('put', () => { const returnKey = await store.put(key, value, { ttl: 999 }) expect(returnKey).toEqual(key) + expect(mockExponentialBackoff) + .toHaveBeenCalledWith( + 'https://storage-state-amer.app-builder.adp.adobe.io/containers/some-namespace/data/valid.for-those_chars?ttl=999', + expect.any(Object) + ) }) test('failure (invalid key)', async () => { @@ -198,13 +231,29 @@ describe('put', () => { const value = 'some-value' await expect(store.put(key, value)).rejects.toThrow('[AdobeStateLib:ERROR_BAD_ARGUMENT] /key must match pattern "^[a-zA-Z0-9-_.]{1,1024}$"') + expect(mockExponentialBackoff).not.toHaveBeenCalled() + }) + + test('failure (invalid ttl)', async () => { + const key = 'key' + const value = 'some-value' + + await expect(store.put(key, value, { ttl: 'string' })).rejects.toThrow('[AdobeStateLib:ERROR_BAD_ARGUMENT] /ttl must be integer') + await expect(store.put(key, value, { ttl: 1.1 })).rejects.toThrow('[AdobeStateLib:ERROR_BAD_ARGUMENT] /ttl must be integer') + + await expect(store.put(key, value, { ttl: MAX_TTL_SECONDS + 1 })).rejects.toThrow('ttl must be <= 365 days (31536000s). Infinite TTLs (< 0) are not supported.') + await expect(store.put(key, value, { ttl: -1 })).rejects.toThrow('ttl must be <= 365 days (31536000s). Infinite TTLs (< 0) are not supported.') + + expect(mockExponentialBackoff).not.toHaveBeenCalled() }) test('failure (binary value)', async () => { const key = 'valid-key' const value = Buffer.from([0x61, 0x72, 0x65, 0x26, 0x35, 0x55, 0xff]) - + // NOTE: the server supports binary values, so way want to revisit this eventually await expect(store.put(key, value)).rejects.toThrow('[AdobeStateLib:ERROR_BAD_ARGUMENT] /value must be string') + + expect(mockExponentialBackoff).not.toHaveBeenCalled() }) test('coverage: 401 error', async () => { @@ -303,6 +352,12 @@ describe('delete', () => { const returnKey = await store.delete(key) expect(returnKey).toEqual(key) + + expect(mockExponentialBackoff) + .toHaveBeenCalledWith( + 'https://storage-state-amer.app-builder.adp.adobe.io/containers/some-namespace/data/valid-key', + expect.objectContaining({ method: 'DELETE' }) + ) }) test('not found', async () => { @@ -328,6 +383,12 @@ describe('deleteAll', () => { const value = await store.deleteAll() expect(value).toEqual(true) + + expect(mockExponentialBackoff) + .toHaveBeenCalledWith( + 'https://storage-state-amer.app-builder.adp.adobe.io/containers/some-namespace', + expect.objectContaining({ method: 'DELETE' }) + ) }) test('not found', async () => { @@ -351,6 +412,12 @@ describe('stats()', () => { const value = await store.stats() expect(value).toEqual({}) + + expect(mockExponentialBackoff) + .toHaveBeenCalledWith( + 'https://storage-state-amer.app-builder.adp.adobe.io/containers/some-namespace', + expect.objectContaining({ method: 'GET' }) + ) }) test('not found', async () => { @@ -401,12 +468,27 @@ describe('list()', () => { expect(await it.next()).toEqual({ done: false, value: { keys: ['a', 'b', 'c'] } }) expect(await it.next()).toEqual({ done: true, value: undefined }) + expect(mockExponentialBackoff).toHaveBeenCalledTimes(1) + expect(mockExponentialBackoff) + .toHaveBeenCalledWith( + 'https://storage-state-amer.app-builder.adp.adobe.io/containers/some-namespace/data?cursor=0', + expect.objectContaining({ method: 'GET' }) + ) + + mockExponentialBackoff.mockClear() let iters = 0 for await (const { keys } of store.list()) { ++iters expect(keys).toStrictEqual(['a', 'b', 'c']) } expect(iters).toBe(1) + + expect(mockExponentialBackoff).toHaveBeenCalledTimes(1) + expect(mockExponentialBackoff) + .toHaveBeenCalledWith( + 'https://storage-state-amer.app-builder.adp.adobe.io/containers/some-namespace/data?cursor=0', + expect.objectContaining({ method: 'GET' }) + ) }) test('list 3 iterations', async () => { @@ -431,6 +513,18 @@ describe('list()', () => { allKeys.push(...keys) } expect(allKeys).toEqual(['a', 'b', 'c', 'd', 'e', 'f']) + + expect(mockExponentialBackoff).toHaveBeenCalledTimes(3) + expect(mockExponentialBackoff) + .toHaveBeenCalledWith( + 'https://storage-state-amer.app-builder.adp.adobe.io/containers/some-namespace/data?cursor=0', + expect.objectContaining({ method: 'GET' }) + ) + expect(mockExponentialBackoff) + .toHaveBeenCalledWith( + 'https://storage-state-amer.app-builder.adp.adobe.io/containers/some-namespace/data?cursor=2', + expect.objectContaining({ method: 'GET' }) + ) }) test('list 3 iterations with pattern and countHint', async () => { @@ -452,10 +546,22 @@ describe('list()', () => { const allKeys = [] // no pattern matching is happening on the client, we just check that the pattern is in a valid format - for await (const { keys } of store.list({ pattern: 'valid*', countHint: 1000 })) { + for await (const { keys } of store.list({ match: 'valid*', countHint: 1000 })) { allKeys.push(...keys) } expect(allKeys).toEqual(['a', 'b', 'c', 'd', 'e', 'f']) + + expect(mockExponentialBackoff).toHaveBeenCalledTimes(3) + expect(mockExponentialBackoff) + .toHaveBeenCalledWith( + 'https://storage-state-amer.app-builder.adp.adobe.io/containers/some-namespace/data?match=valid*&countHint=1000&cursor=0', + expect.objectContaining({ method: 'GET' }) + ) + expect(mockExponentialBackoff) + .toHaveBeenCalledWith( + 'https://storage-state-amer.app-builder.adp.adobe.io/containers/some-namespace/data?match=valid*&countHint=1000&cursor=2', + expect.objectContaining({ method: 'GET' }) + ) }) }) @@ -472,6 +578,12 @@ describe('any', () => { const value = await store.any() expect(value).toEqual(true) + + expect(mockExponentialBackoff) + .toHaveBeenCalledWith( + 'https://storage-state-amer.app-builder.adp.adobe.io/containers/some-namespace', + expect.objectContaining({ method: 'HEAD' }) + ) }) test('not found', async () => { diff --git a/types.d.ts b/types.d.ts index cb3f287..38f6a0e 100644 --- a/types.d.ts +++ b/types.d.ts @@ -12,8 +12,10 @@ export type AdobeStateCredentials = { /** * AdobeState put options - * @property ttl - time-to-live for key-value pair in seconds, defaults to 24 hours (86400s). Set to < 0 for max ttl of one year. A - * value of 0 sets default. + * @property ttl - Time-To-Live for key-value pair in seconds. When not + * defined or set to 0, defaults to 24 hours (86400s). Max TTL is one year + * (31536000s), `require('@adobe/aio-lib-state').MAX_TTL`. A TTL of 0 defaults + * to 24 hours. */ export type AdobeStatePutOptions = { ttl: number; @@ -29,21 +31,6 @@ export type AdobeStateGetReturnValue = { value: string; }; -/** - * 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; - -/** - * Handle a network response. - * @param response - a fetch Response - * @param params - the params to the network call - */ -export function handleResponse(response: Response, params: any): void; - /** * Cloud State Management */