Skip to content

Commit

Permalink
feat: [FFM-12306]: Enhance caching key (#153)
Browse files Browse the repository at this point in the history
* apply security audit fix

* feat: [FFM-12306]: Add config to allow target attributes to be used when creating cache key
  • Loading branch information
knagurski authored Feb 7, 2025
1 parent 88971c5 commit 23d59df
Show file tree
Hide file tree
Showing 8 changed files with 100 additions and 22 deletions.
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,14 @@ interface CacheOptions {
// storage mechanism to use, conforming to the Web Storage API standard, can be either synchronous or asynchronous
// defaults to localStorage
storage?: AsyncStorage | SyncStorage
/**
* use target attributes when deriving the cache key
* when set to `false` or omitted, the key will be formed using only the target identifier and SDK key
* when set to `true`, all target attributes with be used in addition to the target identifier and SDK key
* can be set to an array of target attributes to use a subset in addition to the target identifier and SDK key
* defaults to false
*/
deriveKeyFromTargetAttributes?: boolean | string[]
}
```

Expand Down Expand Up @@ -355,7 +363,7 @@ If the request is aborted due to this timeout the SDK will fail to initialize an

The default value if not specified is `0` which means that no timeout will occur.

**This only applies to the authentiaction request. If you wish to set a read timeout on the remaining requests made by the SDK, you may register [API Middleware](#api-middleware)
**This only applies to the authentication request. If you wish to set a read timeout on the remaining requests made by the SDK, you may register [API Middleware](#api-middleware)

```typescript
const options = {
Expand Down
17 changes: 9 additions & 8 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@harnessio/ff-javascript-client-sdk",
"version": "1.29.0",
"version": "1.30.0",
"author": "Harness",
"license": "Apache-2.0",
"main": "dist/sdk.cjs.js",
Expand Down
39 changes: 37 additions & 2 deletions src/__tests__/cache.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { AsyncStorage, Evaluation, SyncStorage } from '../types'
import { getCache } from '../cache'
import type { AsyncStorage, Evaluation, SyncStorage, Target } from '../types'
import { createCacheIdSeed, getCache } from '../cache'

const sampleEvaluations: Evaluation[] = [
{ flag: 'flag1', value: 'false', kind: 'boolean', identifier: 'false' },
Expand Down Expand Up @@ -149,3 +149,38 @@ describe('getCache', () => {
})
})
})

describe('createCacheIdSeed', () => {
const apiKey = 'abc123'
const target: Target = {
name: 'Test Name',
identifier: 'test-identifier',
attributes: {
a: 'bcd',
b: 123,
c: ['x', 'y', 'z']
}
}

test('it should return the target id and api key when deriveKeyFromTargetAttributes is omitted', async () => {
expect(createCacheIdSeed(target, apiKey)).toEqual(target.identifier + apiKey)
})

test('it should return the target id and api key when deriveKeyFromTargetAttributes is false', async () => {
expect(createCacheIdSeed(target, apiKey, { deriveKeyFromTargetAttributes: false })).toEqual(
target.identifier + apiKey
)
})

test('it should return the target id and api key with all attributes when deriveKeyFromTargetAttributes is true', async () => {
expect(createCacheIdSeed(target, apiKey, { deriveKeyFromTargetAttributes: true })).toEqual(
'{"a":"bcd","b":123,"c":["x","y","z"]}test-identifierabc123'
)
})

test('it should return the target id and api key with a subset of attributes when deriveKeyFromTargetAttributes is an array', async () => {
expect(createCacheIdSeed(target, apiKey, { deriveKeyFromTargetAttributes: ['a', 'c'] })).toEqual(
'{"a":"bcd","c":["x","y","z"]}test-identifierabc123'
)
})
})
27 changes: 25 additions & 2 deletions src/cache.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { AsyncStorage, CacheOptions, Evaluation, SyncStorage } from './types'
import type { AsyncStorage, CacheOptions, Evaluation, SyncStorage, Target } from './types'
import { sortEvaluations } from './utils'

export interface GetCacheResponse {
loadFromCache: () => Promise<Evaluation[]>
Expand Down Expand Up @@ -48,7 +49,7 @@ async function clearCachedEvaluations(cacheId: string, storage: AsyncStorage): P
}

async function saveToCache(cacheId: string, storage: AsyncStorage, evaluations: Evaluation[]): Promise<void> {
await storage.setItem(cacheId, JSON.stringify(evaluations))
await storage.setItem(cacheId, JSON.stringify(sortEvaluations(evaluations)))
await storage.setItem(cacheId + '.ts', Date.now().toString())
}

Expand Down Expand Up @@ -76,6 +77,28 @@ async function removeCachedEvaluation(cacheId: string, storage: AsyncStorage, fl
}
}

export function createCacheIdSeed(target: Target, apiKey: string, config: CacheOptions = {}) {
if (!config.deriveKeyFromTargetAttributes) return target.identifier + apiKey

return (
JSON.stringify(
Object.keys(target.attributes || {})
.sort()
.filter(
attribute =>
!Array.isArray(config.deriveKeyFromTargetAttributes) ||
config.deriveKeyFromTargetAttributes.includes(attribute)
)
.reduce(
(filteredAttributes, attribute) => ({ ...filteredAttributes, [attribute]: target.attributes[attribute] }),
{}
)
) +
target.identifier +
apiKey
)
}

async function getCacheId(seed: string): Promise<string> {
let cacheId = seed

Expand Down
13 changes: 6 additions & 7 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,12 @@ import type {
DefaultVariationEventPayload
} from './types'
import { Event } from './types'
import { defer, encodeTarget, getConfiguration } from './utils'
import { defer, encodeTarget, getConfiguration, sortEvaluations } from './utils'
import { addMiddlewareToFetch } from './request'
import { Streamer } from './stream'
import { getVariation } from './variation'
import Poller from './poller'
import { getCache } from './cache'
import { createCacheIdSeed, getCache } from './cache'

const SDK_VERSION = '1.26.1'
const SDK_INFO = `Javascript ${SDK_VERSION} Client`
Expand Down Expand Up @@ -110,10 +110,9 @@ const initialize = (apiKey: string, target: Target, options?: Options): Result =
try {
let initialLoad = true

const cache = await getCache(
target.identifier + apiKey,
typeof configurations.cache === 'boolean' ? {} : configurations.cache
)
const cacheConfig = typeof configurations.cache === 'boolean' ? {} : configurations.cache

const cache = await getCache(createCacheIdSeed(target, apiKey, cacheConfig), cacheConfig)

const cachedEvaluations = await cache.loadFromCache()

Expand Down Expand Up @@ -441,7 +440,7 @@ const initialize = (apiKey: string, target: Target, options?: Options): Result =
)

if (res.ok) {
const data = await res.json()
const data = sortEvaluations(await res.json())
data.forEach(registerEvaluation)
eventBus.emit(Event.FLAGS_LOADED, data)
return { type: 'success', data: data }
Expand Down
8 changes: 8 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,14 @@ export interface CacheOptions {
* @default localStorage
*/
storage?: AsyncStorage | SyncStorage
/**
* Use target attributes when deriving the cache key
* When set to `false` or omitted, the key will be formed using only the target identifier and SDK key
* When set to `true`, all target attributes with be used in addition to the target identifier and SDK key
* Can be set to an array of target attributes to use a subset in addition to the target identifier and SDK key
* @default false
*/
deriveKeyFromTargetAttributes?: boolean | string[]
}

export interface Logger {
Expand Down
6 changes: 5 additions & 1 deletion src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Options, Target } from './types'
import type { Evaluation, Options, Target } from './types'

export const MIN_EVENTS_SYNC_INTERVAL = 60000
export const MIN_POLLING_INTERVAL = 60000
Expand Down Expand Up @@ -99,3 +99,7 @@ const utf8encode = (str: string): string =>
)
})
.join('')

export function sortEvaluations(evaluations: Evaluation[]): Evaluation[] {
return [...evaluations].sort(({ flag: flagA }, { flag: flagB }) => (flagA < flagB ? -1 : 1))
}

0 comments on commit 23d59df

Please sign in to comment.