diff --git a/README.md b/README.md index a5eb56d..5d84292 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,48 @@ FronteggContext.init({ }); ``` +### Redis cache +Some parts of SDK can facilitate the Redis cache for the sake of performance. To set up the cache, pass additional options +to `FronteggContext.init(..)` call. +If no cache is configured, then data is cached locally, in NodeJS process memory. + +#### Redis cache with `ioredis` library +```javascript +const { FronteggContext } = require('@frontegg/client'); + +FronteggContext.init({ + FRONTEGG_CLIENT_ID: '', + FRONTEGG_API_KEY: '', +}, { + cache: { + type: 'ioredis', + options: { + host: 'localhost', + port: 6379, + password: '', + db: 10, + } + } +}); +``` + +#### Redis cache with `redis` library +```javascript +const { FronteggContext } = require('@frontegg/client'); + +FronteggContext.init({ + FRONTEGG_CLIENT_ID: '', + FRONTEGG_API_KEY: '', +}, { + cache: { + type: 'redis', + options: { + url: 'redis[s]://[[username][:password]@][host][:port][/db-number]', + } + } +}); +``` + ### Middleware Use Frontegg's "withAuthentication" auth guard to protect your routes. @@ -81,65 +123,12 @@ Head over to the Doc ### Access tokens When using M2M authentication, access tokens will be cached by the SDK. -By default access tokens will be cached locally, however you can use two other kinds of cache: +By default, access tokens will be cached locally, however you can use two other kinds of cache: - ioredis - redis -#### Use ioredis as your cache -When initializing your context, pass an access tokens options object with your ioredis parameters - -```javascript -const { FronteggContext } = require('@frontegg/client'); - -const accessTokensOptions = { - cache: { - type: 'ioredis', - options: { - host: 'localhost', - port: 6379, - password: '', - db: 10, - }, - }, -}; - -FronteggContext.init( - { - FRONTEGG_CLIENT_ID: '', - FRONTEGG_API_KEY: '', - }, - { - accessTokensOptions, - }, -); -``` - -#### Use redis as your cache -When initializing your context, pass an access tokens options object with your redis parameters - -```javascript -const { FronteggContext } = require('@frontegg/client'); - -const accessTokensOptions = { - cache: { - type: 'redis', - options: { - url: 'redis[s]://[[username][:password]@][host][:port][/db-number]', - }, - }, -}; - -FronteggContext.init( - { - FRONTEGG_CLIENT_ID: '', - FRONTEGG_API_KEY: '', - }, - { - accessTokensOptions, - }, -); -``` +For details on cache configuration, refer to Redis cache section. ### Clients @@ -154,7 +143,7 @@ const { AuditsClient } = require('@frontegg/client'); const audits = new AuditsClient(); // initialize the module -await audits.init('MY-CLIENT-ID', 'MY-AUDITS-KEY'); +await audits.init('', ''); ``` #### Sending audits @@ -295,7 +284,7 @@ const { IdentityClient } = require('@frontegg/client'); Then, initialize the client ```javascript -const identityClient = new IdentityClient({ FRONTEGG_CLIENT_ID: 'your-client-id', FRONTEGG_API_KEY: 'your-api-key' }); +const identityClient = new IdentityClient({ FRONTEGG_CLIENT_ID: '', FRONTEGG_API_KEY: '' }); ``` And use this client to validate diff --git a/ci/docker-compose.yml b/ci/docker-compose.yml new file mode 100644 index 0000000..e050b2a --- /dev/null +++ b/ci/docker-compose.yml @@ -0,0 +1,7 @@ +version: "3" +services: + redis: + image: redis + restart: always + ports: + - "36279:6379" \ No newline at end of file diff --git a/ci/run-test-suite.sh b/ci/run-test-suite.sh new file mode 100755 index 0000000..7bc7803 --- /dev/null +++ b/ci/run-test-suite.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash + +set -e + +docker compose -p nodejs-sdk-tests up -d --wait + +npm run --prefix ../ test:jest --coverage +RESULT=$@ + +docker compose -p nodejs-sdk-tests down + +exit $RESULT diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 464f018..2b53481 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,3 +1,25 @@ +# [6.0.0-alpha.1](https://github.com/frontegg/nodejs-sdk/compare/5.1.1-alpha.1...6.0.0-alpha.1) (2023-07-31) + + +### Code Refactoring + +* **sdk:** removed irrelevant accessTokenOptions; refactored cache manager implementations ([3bbe939](https://github.com/frontegg/nodejs-sdk/commit/3bbe93926e52eda261db11bb6fbdd65671074e4e)) + + +### Bug Fixes + +* **cache:** Bringing back the ICacheManager generic to the class level ([7d04440](https://github.com/frontegg/nodejs-sdk/commit/7d04440ab94e66d0155032597d42ec8b17c4b1da)) + + +### Features + +* **cache:** decoupled cache managers from AccessTokens ([85db523](https://github.com/frontegg/nodejs-sdk/commit/85db5230d7530e2e61dcea8e79174148e9cb1f6f)) + + +### BREAKING CHANGES + +* **sdk:** removed accessTokenOptions from FronteggContext configuration + ## [5.1.1-alpha.1](https://github.com/frontegg/nodejs-sdk/compare/5.1.0...5.1.1-alpha.1) (2023-07-30) diff --git a/jest.config.js b/jest.config.js index dd030d7..1332537 100644 --- a/jest.config.js +++ b/jest.config.js @@ -13,6 +13,7 @@ module.exports = { lines: 18, }, }, + setupFilesAfterEnv: ['jest-extended/all'], reporters: [ 'default', [ diff --git a/package-lock.json b/package-lock.json index 20b109f..7aed92c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -498,6 +498,38 @@ "integrity": "sha512-Ag+9YM4ocKQx9AarydN0KY2j0ErMHNIocPDrVo8zAE44xLTjEtz81OdR68/cydGtk6m6jDb5Za3r2useMzYmSw==", "dev": true }, + "@fast-check/jest": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@fast-check/jest/-/jest-1.7.3.tgz", + "integrity": "sha512-6NcpYIIUnLwEdEfPhijYT5mnFPiQNP/isC+os+P+rV8qHRzUxRNx8WyPTOx+oVkBMm1+XSn00ZqfD3ANfciTZQ==", + "dev": true, + "requires": { + "fast-check": "^3.0.0" + } + }, + "@frontegg/base-domain-events": { + "version": "2.0.209", + "resolved": "https://registry.npmjs.org/@frontegg/base-domain-events/-/base-domain-events-2.0.209.tgz", + "integrity": "sha512-omfE7T6ZUgMgTWhOXv98uqFZ4qaqCalp4nS9XZ9CMLtqgwCAwDFsSmFOefkRGAjnKt3OxlrW4sJa3gNg7H24GA==", + "dev": true + }, + "@frontegg/entitlements-javascript-commons": { + "version": "1.0.0-alpha.12", + "resolved": "https://registry.npmjs.org/@frontegg/entitlements-javascript-commons/-/entitlements-javascript-commons-1.0.0-alpha.12.tgz", + "integrity": "sha512-zZmlLgknEtBFWxQ4u1kZM1G+jExm0CyFE8gk6Lz0g1VIeM3itE3/oo0pG3IjWeH5Szdk7IxuPTIFJU0yOQD5uQ==", + "requires": { + "flat": "^5.0.2" + } + }, + "@frontegg/entitlements-service-types": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/@frontegg/entitlements-service-types/-/entitlements-service-types-0.29.0.tgz", + "integrity": "sha512-evfet/4Kesy8xc4FkzJvC18S8cNLbj9QsYo2LiF6c33nwfSn+vR51mpWYJbLTOHiMSlY9+mvKaUEcEhBnCOP2g==", + "dev": true, + "requires": { + "@frontegg/base-domain-events": "^2.0.209" + } + }, "@humanwhocodes/config-array": { "version": "0.11.10", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.10.tgz", @@ -3489,6 +3521,15 @@ } } }, + "fast-check": { + "version": "3.13.1", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.13.1.tgz", + "integrity": "sha512-Xp00tFuWd83i8rbG/4wU54qU+yINjQha7bXH2N4ARNTkyOimzHtUBJ5+htpdXk7RMaCOD/j2jxSjEt9u9ZPNeQ==", + "dev": true, + "requires": { + "pure-rand": "^6.0.0" + } + }, "fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3658,6 +3699,11 @@ "semver-regex": "^4.0.5" } }, + "flat": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", + "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==" + }, "flat-cache": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz", @@ -4644,6 +4690,16 @@ } } }, + "jest-extended": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/jest-extended/-/jest-extended-4.0.2.tgz", + "integrity": "sha512-FH7aaPgtGYHc9mRjriS0ZEHYM5/W69tLrFTIdzm+yJgeoCmmrSB/luSfMSqWP9O29QWHPEmJ4qmU6EwsZideog==", + "dev": true, + "requires": { + "jest-diff": "^29.0.0", + "jest-get-type": "^29.0.0" + } + }, "jest-get-type": { "version": "29.4.3", "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.4.3.tgz", @@ -8738,6 +8794,12 @@ "integrity": "sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==", "dev": true }, + "pure-rand": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.0.4.tgz", + "integrity": "sha512-LA0Y9kxMYv47GIPJy6MI84fqTd2HmYZI83W/kM/SkKfDlajnZYfmXFTxkbY+xSBPkLJxltMa9hIkmdc29eguMA==", + "dev": true + }, "qs": { "version": "6.11.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", diff --git a/package.json b/package.json index 5a1f9f7..710728d 100644 --- a/package.json +++ b/package.json @@ -9,8 +9,9 @@ "build:watch": "rm -rf dist && tsc --watch", "lint": "eslint --ignore-path .eslintignore --ext .js,.ts .", "format": "prettier --write \"**/*.+(js|ts|json)\"", - "test": "npm run build && jest", - "test:coverage": "npm test -- --coverage", + "test:jest": "npm run build && jest --runInBand", + "test": "(cd ci; ./run-test-suite.sh)", + "test:coverage": "npm test", "test:watch": "npm run build && jest --watch", "dev": "tsc --watch" }, @@ -22,6 +23,7 @@ "license": "ISC", "homepage": "https://github.com/frontegg/nodejs-sdk", "dependencies": { + "@frontegg/entitlements-javascript-commons": "^1.0.0-alpha.12", "@slack/web-api": "^6.7.2", "axios": "^0.27.2", "jsonwebtoken": "^9.0.0", @@ -41,6 +43,8 @@ } }, "devDependencies": { + "@fast-check/jest": "^1.7.3", + "@frontegg/entitlements-service-types": "^0.29.0", "@semantic-release/changelog": "^6.0.1", "@semantic-release/git": "^10.0.1", "@types/axios-mock-adapter": "^1.10.0", @@ -58,6 +62,7 @@ "ioredis": "^5.2.5", "ioredis-mock": "^8.2.2", "jest": "^28.1.3", + "jest-extended": "^4.0.2", "jest-junit": "^14.0.1", "jest-mock-extended": "^3.0.4", "prettier": "^2.7.1", diff --git a/src/cache/cache.manager.interface.ts b/src/cache/cache.manager.interface.ts deleted file mode 100644 index 60221ed..0000000 --- a/src/cache/cache.manager.interface.ts +++ /dev/null @@ -1,9 +0,0 @@ -export interface SetOptions { - expiresInSeconds: number; -} - -export interface ICacheManager { - set(key: string, data: T, options?: SetOptions): Promise; - get(key: string): Promise; - del(key: string[]): Promise; -} diff --git a/src/cache/index.ts b/src/cache/index.ts deleted file mode 100644 index f38d27f..0000000 --- a/src/cache/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from './cache.manager.interface'; -export * from './local-cache.manager'; -export * from './ioredis-cache.manager'; -export * from './redis-cache.manager'; diff --git a/src/cache/ioredis-cache.manager.spec.ts b/src/cache/ioredis-cache.manager.spec.ts deleted file mode 100644 index 926c40c..0000000 --- a/src/cache/ioredis-cache.manager.spec.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { IORedisCacheManager } from './ioredis-cache.manager'; - -jest.mock('../utils/package-loader', () => ({ - PackageUtils: { - loadPackage: (name: string) => { - switch (name) { - case 'ioredis': - return require('ioredis-mock'); - } - }, - }, -})); - -describe('IORedis cache manager', () => { - //@ts-ignore - const redisCacheManager = new IORedisCacheManager<{ data: string }>(); - const cacheKey = 'key'; - const cacheValue = { data: 'value' }; - - it('should set, get and delete from redis cache manager', async () => { - await redisCacheManager.set(cacheKey, cacheValue); - const res = await redisCacheManager.get(cacheKey); - expect(res).toEqual(cacheValue); - await redisCacheManager.del([cacheKey]); - const resAfterDel = await redisCacheManager.get(cacheKey); - expect(resAfterDel).toEqual(null); - }); - - it('should get null after expiration time', async () => { - await redisCacheManager.set(cacheKey, cacheValue, { expiresInSeconds: 1 }); - await new Promise((r) => setTimeout(r, 500)); - const res = await redisCacheManager.get(cacheKey); - expect(res).toEqual(cacheValue); - - await new Promise((r) => setTimeout(r, 600)); - - const resAfterDel = await redisCacheManager.get(cacheKey); - expect(resAfterDel).toEqual(null); - }); -}); diff --git a/src/cache/ioredis-cache.manager.ts b/src/cache/ioredis-cache.manager.ts deleted file mode 100644 index f830fdd..0000000 --- a/src/cache/ioredis-cache.manager.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { ICacheManager, SetOptions } from './cache.manager.interface'; -import { PackageUtils } from '../utils/package-loader'; -import { IIORedisCacheOptions } from './types'; - -export class IORedisCacheManager implements ICacheManager { - private redisManager: any; - - constructor(options: IIORedisCacheOptions) { - const RedisInstance = PackageUtils.loadPackage('ioredis') as any; - this.redisManager = new RedisInstance(options); - } - - public async set(key: string, data: T, options?: SetOptions): Promise { - if (options?.expiresInSeconds) { - this.redisManager.set(key, JSON.stringify(data), 'EX', options.expiresInSeconds); - } else { - this.redisManager.set(key, JSON.stringify(data)); - } - } - - public async get(key: string): Promise { - const stringifiedData = await this.redisManager.get(key); - return stringifiedData ? JSON.parse(stringifiedData) : null; - } - - public async del(key: string[]): Promise { - if (key.length) { - await this.redisManager.del(key); - } - } -} diff --git a/src/cache/local-cache.manager.spec.ts b/src/cache/local-cache.manager.spec.ts deleted file mode 100644 index ec61587..0000000 --- a/src/cache/local-cache.manager.spec.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { LocalCacheManager } from './local-cache.manager'; - -describe('Local cache manager', () => { - const localCacheManager = new LocalCacheManager<{ data: string }>(); - const cacheKey = 'key'; - const cacheValue = { data: 'value' }; - - it('should set, get and delete from local cache manager', async () => { - await localCacheManager.set(cacheKey, cacheValue); - const res = await localCacheManager.get(cacheKey); - expect(res).toEqual(cacheValue); - await localCacheManager.del([cacheKey]); - const resAfterDel = await localCacheManager.get(cacheKey); - expect(resAfterDel).toEqual(null); - }); - - it('should get null after expiration time', async () => { - await localCacheManager.set(cacheKey, cacheValue, { expiresInSeconds: 1 }); - await new Promise((r) => setTimeout(r, 500)); - const res = await localCacheManager.get(cacheKey); - expect(res).toEqual(cacheValue); - - await new Promise((r) => setTimeout(r, 600)); - - const resAfterDel = await localCacheManager.get(cacheKey); - expect(resAfterDel).toEqual(null); - }); -}); diff --git a/src/cache/local-cache.manager.ts b/src/cache/local-cache.manager.ts deleted file mode 100644 index 7a05871..0000000 --- a/src/cache/local-cache.manager.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { ICacheManager, SetOptions } from './cache.manager.interface'; -import * as NodeCache from 'node-cache'; - -export class LocalCacheManager implements ICacheManager { - private nodeCache: NodeCache = new NodeCache(); - - public async set(key: string, data: T, options?: SetOptions): Promise { - if (options?.expiresInSeconds) { - this.nodeCache.set(key, data, options.expiresInSeconds); - } else { - this.nodeCache.set(key, data); - } - } - - public async get(key: string): Promise { - return this.nodeCache.get(key) || null; - } - - public async del(key: string[]): Promise { - if (key.length) { - this.nodeCache.del(key); - } - } -} diff --git a/src/cache/redis-cache.manager.ts b/src/cache/redis-cache.manager.ts deleted file mode 100644 index b799ad4..0000000 --- a/src/cache/redis-cache.manager.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { ICacheManager, SetOptions } from './cache.manager.interface'; -import { PackageUtils } from '../utils/package-loader'; -import { IRedisCacheOptions } from './types'; -import Logger from '../components/logger'; - -export class RedisCacheManager implements ICacheManager { - private redisManager: any; - - constructor(options: IRedisCacheOptions) { - const { createClient } = PackageUtils.loadPackage('redis') as any; - this.redisManager = createClient(options); - this.redisManager.connect().catch((e) => Logger.error('Failed to connect to redis', e)); - } - - public async set(key: string, data: T, options?: SetOptions): Promise { - if (options?.expiresInSeconds) { - this.redisManager.set(key, JSON.stringify(data), { EX: options.expiresInSeconds }); - } else { - this.redisManager.set(key, JSON.stringify(data)); - } - } - - public async get(key: string): Promise { - const stringifiedData = await this.redisManager.get(key); - return stringifiedData ? JSON.parse(stringifiedData) : null; - } - - public async del(key: string[]): Promise { - if (key.length) { - await this.redisManager.del(key); - } - } -} diff --git a/src/cache/types.ts b/src/cache/types.ts index ae4741d..e69de29 100644 --- a/src/cache/types.ts +++ b/src/cache/types.ts @@ -1,10 +0,0 @@ -export interface IIORedisCacheOptions { - host: string; - password?: string; - port: number; - db?: number; -} - -export interface IRedisCacheOptions { - url: string; -} diff --git a/src/clients/entitlements/entitlements-client.spec.ts b/src/clients/entitlements/entitlements-client.spec.ts index 12f86f8..55e3bbe 100644 --- a/src/clients/entitlements/entitlements-client.spec.ts +++ b/src/clients/entitlements/entitlements-client.spec.ts @@ -3,13 +3,14 @@ import { FronteggContext } from '../../components/frontegg-context'; import { FronteggAuthenticator } from '../../authenticator'; import { HttpClient } from '../http'; import { mock, mockClear } from 'jest-mock-extended'; -import { VendorEntitlementsDto, VendorEntitlementsSnapshotOffsetDto } from './types'; +import { VendorEntitlementsSnapshotOffsetDto } from './types'; import { AxiosResponse } from 'axios'; import * as Sinon from 'sinon'; import { useFakeTimers } from 'sinon'; import { IUserAccessTokenWithRoles, tokenTypes } from '../identity/types'; import { EntitlementsUserScoped } from './entitlements.user-scoped'; import { InMemoryEntitlementsCache } from './storage/in-memory/in-memory.cache'; +import type { DTO } from '@frontegg/entitlements-service-types'; const { EntitlementsUserScoped: EntitlementsUserScopedActual } = jest.requireActual('./entitlements.user-scoped'); @@ -38,10 +39,11 @@ describe(EntitlementsClient.name, () => { entitlements: [], features: [], featureBundles: [], + featureFlags: [], }, snapshotOffset: 1234, }, - } as unknown as AxiosResponse); + } as unknown as AxiosResponse); httpMock.get.calledWith('/api/v1/vendor-entitlements-snapshot-offset').mockResolvedValue({ data: { snapshotOffset: 1234, @@ -123,10 +125,11 @@ describe(EntitlementsClient.name, () => { entitlements: [], features: [], featureBundles: [], + featureFlags: [], }, snapshotOffset: 2345, }, - } as unknown as AxiosResponse); + } as unknown as AxiosResponse); mockClear(httpMock); }); diff --git a/src/clients/entitlements/entitlements-client.ts b/src/clients/entitlements/entitlements-client.ts index cb221c8..d71c45c 100644 --- a/src/clients/entitlements/entitlements-client.ts +++ b/src/clients/entitlements/entitlements-client.ts @@ -1,7 +1,7 @@ import { IFronteggContext } from '../../components/frontegg-context/types'; import { FronteggContext } from '../../components/frontegg-context'; import { FronteggAuthenticator } from '../../authenticator'; -import { EntitlementsClientOptions, VendorEntitlementsDto, VendorEntitlementsSnapshotOffsetDto } from './types'; +import { EntitlementsClientOptions, VendorEntitlementsSnapshotOffsetDto } from './types'; import { config } from '../../config'; import { HttpClient } from '../http'; import Logger from '../../components/logger'; @@ -12,6 +12,9 @@ import { EntitlementsCache } from './storage/types'; import { InMemoryEntitlementsCache } from './storage/in-memory/in-memory.cache'; import { TEntity } from '../identity/types'; import { EntitlementsUserScoped } from './entitlements.user-scoped'; +import { DTO } from '@frontegg/entitlements-service-types'; +import { IdentityClient } from '../identity'; +import { JwtAttributes, prepareAttributes } from '@frontegg/entitlements-javascript-commons'; export class EntitlementsClient extends events.EventEmitter { // periodical refresh handler @@ -64,8 +67,24 @@ export class EntitlementsClient extends events.EventEmitter { return new EntitlementsUserScoped(entity, this.cache); } + async forFronteggToken(token: string): Promise { + if (!this.cache) { + throw new Error('EntitlementsClient is not initialized yet.'); + } + + const tokenData = await IdentityClient.getInstance().validateToken(token); + + return new EntitlementsUserScoped( + tokenData, + this.cache, + prepareAttributes({ + jwt: tokenData, + }), + ); + } + private async loadVendorEntitlements(): Promise { - const entitlementsData = await this.httpClient.get('/api/v1/vendor-entitlements'); + const entitlementsData = await this.httpClient.get('/api/v1/vendor-entitlements'); const vendorEntitlementsDto = entitlementsData.data; const newOffset = entitlementsData.data.snapshotOffset; diff --git a/src/clients/entitlements/entitlements.user-scoped.spec.ts b/src/clients/entitlements/entitlements.user-scoped.spec.ts index 6418d4f..e08284c 100644 --- a/src/clients/entitlements/entitlements.user-scoped.spec.ts +++ b/src/clients/entitlements/entitlements.user-scoped.spec.ts @@ -7,7 +7,11 @@ import { IUser, IUserAccessToken, IUserApiToken, TEntityWithRoles, tokenTypes } import { mock, mockReset } from 'jest-mock-extended'; import { EntitlementsCache, NO_EXPIRE } from './storage/types'; import { EntitlementJustifications } from './types'; +import { evaluateFeatureFlag, TreatmentEnum } from '@frontegg/entitlements-javascript-commons'; import SpyInstance = jest.SpyInstance; +import { FeatureFlag } from '@frontegg/entitlements-javascript-commons/dist/feature-flags/types'; + +jest.mock('@frontegg/entitlements-javascript-commons'); const userApiTokenBase: Pick< IUserApiToken, @@ -26,16 +30,16 @@ const userApiTokenBase: Pick< const userAccessTokenBase: Pick = { type: tokenTypes.UserAccessToken, id: 'irrelevant', - sub: 'irrelevant' -} + sub: 'irrelevant', +}; const userTokenBase: Pick = { type: tokenTypes.UserToken, id: 'irrelevant', userId: 'irrelevant', roles: ['irrelevant'], - metadata: {} -} + metadata: {}, +}; describe(EntitlementsUserScoped.name, () => { const cacheMock = mock(); @@ -43,16 +47,18 @@ describe(EntitlementsUserScoped.name, () => { afterEach(() => { mockReset(cacheMock); + jest.mocked(evaluateFeatureFlag).mockReset(); }); describe.each([ - { tokenType: tokenTypes.UserApiToken, + { + tokenType: tokenTypes.UserApiToken, entity: { ...userApiTokenBase, permissions: ['foo'], userId: 'the-user-id', tenantId: 'the-tenant-id', - } as IUserApiToken + } as IUserApiToken, }, { tokenType: tokenTypes.UserAccessToken, @@ -61,18 +67,18 @@ describe(EntitlementsUserScoped.name, () => { userId: 'the-user-id', tenantId: 'the-tenant-id', roles: [], - permissions: ['foo'] - } as TEntityWithRoles + permissions: ['foo'], + } as TEntityWithRoles, }, { tokenType: tokenTypes.UserToken, entity: { ...userTokenBase, - permissions: [ 'foo' ], + permissions: ['foo'], sub: 'the-user-id', - tenantId: 'the-tenant-id' - } as IUser - } + tenantId: 'the-tenant-id', + } as IUser, + }, ])('given the authenticated user using $tokenType with permission "foo" granted', ({ entity }) => { beforeEach(() => { cut = new EntitlementsUserScoped(entity, cacheMock); @@ -116,6 +122,9 @@ describe(EntitlementsUserScoped.name, () => { cacheMock.getEntitlementExpirationTime .calledWith('bar', 'the-tenant-id', undefined) .mockResolvedValue(undefined); + cacheMock.getFeatureFlags.calledWith('bar').mockResolvedValue([ + // given: no feature flags + ]); }); afterEach(() => { @@ -148,6 +157,52 @@ describe(EntitlementsUserScoped.name, () => { }); }); }); + + describe('and no entitlement to "bar" has ever been granted to user', () => { + const dummyFF: FeatureFlag = { + on: true, + offTreatment: TreatmentEnum.False, + defaultTreatment: TreatmentEnum.True, + }; + + beforeEach(() => { + cacheMock.getFeatureFlags.calledWith('bar').mockResolvedValue([dummyFF]); + cacheMock.getEntitlementExpirationTime + .calledWith('bar', entity.tenantId, entity.userId) + .mockResolvedValue(undefined); + }); + + describe('and feature flag is enabled for the user', () => { + beforeEach(() => { + jest.mocked(evaluateFeatureFlag).mockReturnValue({ treatment: TreatmentEnum.True }); + }); + + it('when .isEntitledTo({ permissionKey: "foo" }) is executed, then it resolves to TRUE treatment.', async () => { + await expect(cut.isEntitledTo({ permissionKey: 'foo' })).resolves.toEqual({ + result: true, + }); + + // and: feature flag has been evaluated + expect(evaluateFeatureFlag).toHaveBeenCalledWith(dummyFF, expect.anything()); + }); + }); + + describe('and feature flag is disabled for the user', () => { + beforeEach(() => { + jest.mocked(evaluateFeatureFlag).mockReturnValue({ treatment: TreatmentEnum.False }); + }); + + it('when .isEntitledTo({ permissionKey: "foo" }) is executed, then the user is not entitled with "missing feature" justification.', async () => { + await expect(cut.isEntitledTo({ permissionKey: 'foo' })).resolves.toEqual({ + result: false, + justification: EntitlementJustifications.MISSING_FEATURE, + }); + + // and: feature flag has been evaluated + expect(evaluateFeatureFlag).toHaveBeenCalledWith(dummyFF, expect.anything()); + }); + }); + }); }); describe('and no feature is linked to permissions "foo" and "bar"', () => { @@ -199,7 +254,7 @@ describe(EntitlementsUserScoped.name, () => { await cut.isEntitledTo({ permissionKey: 'foo' }); // then - expect(isEntitledToPermissionSpy).toHaveBeenCalledWith('foo'); + expect(isEntitledToPermissionSpy).toHaveBeenCalledWith('foo', {}); expect(isEntitledToFeatureSpy).not.toHaveBeenCalled(); }); @@ -209,9 +264,33 @@ describe(EntitlementsUserScoped.name, () => { // then expect(isEntitledToPermissionSpy).not.toHaveBeenCalled(); - expect(isEntitledToFeatureSpy).toHaveBeenCalledWith('foo'); + expect(isEntitledToFeatureSpy).toHaveBeenCalledWith('foo', {}); }); + it.each([ + { + key: 'featureKey' as const, + method: 'isEntitledToFeature', + run: (attrs) => cut.isEntitledTo({ featureKey: 'foo' }, attrs), + getSpy: () => isEntitledToFeatureSpy, + }, + { + key: 'permissionKey' as const, + method: 'isEntitledToPermission', + run: (attrs) => cut.isEntitledTo({ permissionKey: 'foo' }, attrs), + getSpy: () => isEntitledToPermissionSpy, + }, + ])( + 'with $key and additional attributes, then they are passed down to $method method.', + async ({ key, run, getSpy }) => { + // when + await run({ bar: 'baz' }); + + // then + expect(getSpy()).toHaveBeenCalledWith('foo', { bar: 'baz' }); + }, + ); + it('with both featureKey and permissionKey, then the Error is thrown.', async () => { // when & then await expect( diff --git a/src/clients/entitlements/entitlements.user-scoped.ts b/src/clients/entitlements/entitlements.user-scoped.ts index 5eaaf57..e20d1c0 100644 --- a/src/clients/entitlements/entitlements.user-scoped.ts +++ b/src/clients/entitlements/entitlements.user-scoped.ts @@ -2,6 +2,7 @@ import { EntitlementJustifications, IsEntitledResult } from './types'; import { IEntityWithRoles, Permission, TEntity, tokenTypes, TUserEntity } from '../identity/types'; import { EntitlementsCache, NO_EXPIRE } from './storage/types'; import { pickExpTimestamp } from './storage/exp-time.utils'; +import { evaluateFeatureFlag, TreatmentEnum } from '@frontegg/entitlements-javascript-commons'; export type IsEntitledToPermissionInput = { permissionKey: string }; export type IsEntitledToFeatureInput = { featureKey: string }; @@ -11,7 +12,11 @@ export class EntitlementsUserScoped { private readonly userId?: string; private readonly permissions: Permission[]; - constructor(private readonly entity: T, private readonly cache: EntitlementsCache) { + constructor( + private readonly entity: T, + private readonly cache: EntitlementsCache, + private readonly predefinedAttributes: Record = {}, + ) { this.tenantId = entity.tenantId; const entityWithUserId = entity as TUserEntity; @@ -31,11 +36,26 @@ export class EntitlementsUserScoped { return entity.sub; case tokenTypes.UserApiToken: case tokenTypes.UserAccessToken: - return entity.userId; + return entity.userId; } } - async isEntitledToFeature(featureKey: string): Promise { + async isEntitledToFeature(featureKey: string, attributes: Record = {}): Promise { + const isEntitledResult = await this.getEntitlementResult(featureKey); + + if (!isEntitledResult.result) { + const ffResult = await this.getFeatureFlagResult(featureKey, { ...this.predefinedAttributes, ...attributes }); + + if (ffResult.result) { + return ffResult; + } + // else: just return result & justification of entitlements + } + + return isEntitledResult; + } + + private async getEntitlementResult(featureKey: string): Promise { const tenantEntitlementExpTime = await this.cache.getEntitlementExpirationTime(featureKey, this.tenantId); const userEntitlementExpTime = this.userId ? await this.cache.getEntitlementExpirationTime(featureKey, this.tenantId, this.userId) @@ -62,7 +82,23 @@ export class EntitlementsUserScoped { } } - async isEntitledToPermission(permissionKey: string): Promise { + private async getFeatureFlagResult(featureKey: string, attributes: Record): Promise { + const featureFlags = await this.cache.getFeatureFlags(featureKey); + + for (const flag of featureFlags) { + const ffResult = evaluateFeatureFlag(flag, attributes); + if (ffResult?.treatment === TreatmentEnum.True) { + return { result: true }; + } + } + + return { + result: false, + justification: EntitlementJustifications.MISSING_FEATURE, + }; + } + + async isEntitledToPermission(permissionKey: string, attributes: Record = {}): Promise { if (this.permissions === undefined || this.permissions.indexOf(permissionKey) < 0) { return { result: false, @@ -78,7 +114,7 @@ export class EntitlementsUserScoped { let hasExpired = false; for (const feature of features) { - const isEntitledToFeatureResult = await this.isEntitledToFeature(feature); + const isEntitledToFeatureResult = await this.isEntitledToFeature(feature, attributes); if (isEntitledToFeatureResult.result === true) { return { @@ -95,21 +131,30 @@ export class EntitlementsUserScoped { }; } - isEntitledTo(featureOrPermission: IsEntitledToPermissionInput): Promise; - isEntitledTo(featureOrPermission: IsEntitledToFeatureInput): Promise; - async isEntitledTo({ - featureKey, - permissionKey, - }: { - permissionKey?: string; - featureKey?: string; - }): Promise { + isEntitledTo( + featureOrPermission: IsEntitledToPermissionInput, + attributes?: Record, + ): Promise; + isEntitledTo( + featureOrPermission: IsEntitledToFeatureInput, + attributes?: Record, + ): Promise; + async isEntitledTo( + { + featureKey, + permissionKey, + }: { + permissionKey?: string; + featureKey?: string; + }, + attributes: Record = {}, + ): Promise { if (featureKey && permissionKey) { throw new Error('Cannot check both feature and permission entitlement at the same time.'); } else if (featureKey !== undefined) { - return this.isEntitledToFeature(featureKey!); + return this.isEntitledToFeature(featureKey!, attributes); } else if (permissionKey !== undefined) { - return this.isEntitledToPermission(permissionKey!); + return this.isEntitledToPermission(permissionKey!, attributes); } else { throw new Error('Neither feature, nor permission key is provided.'); } diff --git a/src/clients/entitlements/feature-flags.types.ts b/src/clients/entitlements/feature-flags.types.ts new file mode 100644 index 0000000..ef5d487 --- /dev/null +++ b/src/clients/entitlements/feature-flags.types.ts @@ -0,0 +1,3 @@ +import { DTO } from '@frontegg/entitlements-service-types'; + +export type FeatureFlagTuple = DTO.VendorEntitlementsV1.FeatureFlags.Tuple; diff --git a/src/clients/entitlements/storage/in-memory/in-memory.cache-key.utils.ts b/src/clients/entitlements/storage/in-memory/in-memory.cache-key.utils.ts index 3cfa6b2..10de646 100644 --- a/src/clients/entitlements/storage/in-memory/in-memory.cache-key.utils.ts +++ b/src/clients/entitlements/storage/in-memory/in-memory.cache-key.utils.ts @@ -2,7 +2,9 @@ import { FeatureKey } from '../../types'; export const ENTITLEMENTS_MAP_KEY = 'entitlements'; export const PERMISSIONS_MAP_KEY = 'permissions'; +export const FEAT_TO_FLAG_MAP_KEY = 'feats_to_flags'; export const SRC_BUNDLES_KEY = 'src_bundles'; +export const SRC_FEATURE_FLAGS = 'src_feature_flags'; export function getFeatureEntitlementKey(featKey: FeatureKey, tenantId: string, userId = ''): string { return `${tenantId}:${userId}:${featKey}`; diff --git a/src/clients/entitlements/storage/in-memory/in-memory.cache.spec.ts b/src/clients/entitlements/storage/in-memory/in-memory.cache.spec.ts index 45ce576..d635285 100644 --- a/src/clients/entitlements/storage/in-memory/in-memory.cache.spec.ts +++ b/src/clients/entitlements/storage/in-memory/in-memory.cache.spec.ts @@ -12,6 +12,7 @@ describe(InMemoryEntitlementsCache.name, () => { entitlements: [], features: [['f-1', 'foo', []]], featureBundles: [['b-1', ['f-1']]], + featureFlags: [], }, }); }); @@ -29,7 +30,8 @@ describe(InMemoryEntitlementsCache.name, () => { data: { features: [['f-1', 'foo', []]], featureBundles: [['b-1', ['f-1']]], - entitlements: [['b-1', 't-1', 'u-1']], + entitlements: [['b-1', 't-1', 'u-1', undefined]], + featureFlags: [], }, }); }); @@ -47,7 +49,8 @@ describe(InMemoryEntitlementsCache.name, () => { data: { features: [['f-1', 'foo', []]], featureBundles: [['b-1', ['f-1']]], - entitlements: [['b-1', 't-1']], + entitlements: [['b-1', 't-1', undefined, undefined]], + featureFlags: [], }, }); }); @@ -76,6 +79,7 @@ describe(InMemoryEntitlementsCache.name, () => { ['b-1', 't-2', undefined, '2022-02-01T12:00:00+00:00'], // TS: 1643716800000 ['b-1', 't-2', undefined, '2022-03-01T12:00:00+00:00'], // TS: 1646136000000 ], + featureFlags: [], }, }); }); @@ -100,10 +104,11 @@ describe(InMemoryEntitlementsCache.name, () => { featureBundles: [['b-1', ['f-1']]], entitlements: [ ['b-1', 't-1', 'u-1', '2022-06-01T12:00:00+00:00'], // TS: 1654084800000 - ['b-1', 't-1', 'u-1'], + ['b-1', 't-1', 'u-1', undefined], ['b-1', 't-2', undefined, '2022-02-01T12:00:00+00:00'], // TS: 1643716800000 - ['b-1', 't-2'], + ['b-1', 't-2', undefined, undefined], ], + featureFlags: [], }, }); }); @@ -127,6 +132,7 @@ describe(InMemoryEntitlementsCache.name, () => { features: [['f-1', 'foo', ['bar.baz']]], featureBundles: [], entitlements: [], + featureFlags: [], }, }); }); diff --git a/src/clients/entitlements/storage/in-memory/in-memory.cache.ts b/src/clients/entitlements/storage/in-memory/in-memory.cache.ts index b27353e..6b339a9 100644 --- a/src/clients/entitlements/storage/in-memory/in-memory.cache.ts +++ b/src/clients/entitlements/storage/in-memory/in-memory.cache.ts @@ -1,24 +1,20 @@ -import { EntitlementsCache, ExpirationTime, NO_EXPIRE } from '../types'; -import { - EntitlementTuple, - FeatureBundleTuple, - FeatureTuple, - FeatureKey, - TenantId, - UserId, - VendorEntitlementsDto, - FeatureId, -} from '../../types'; +import { EntitlementsCache, ExpirationTime } from '../types'; +import { FeatureKey, TenantId, UserId } from '../../types'; import { ENTITLEMENTS_MAP_KEY, PERMISSIONS_MAP_KEY, SRC_BUNDLES_KEY, + FEAT_TO_FLAG_MAP_KEY, getFeatureEntitlementKey, } from './in-memory.cache-key.utils'; import NodeCache = require('node-cache'); import { pickExpTimestamp } from '../exp-time.utils'; -import { BundlesSource, EntitlementsMap, FeatureSource, PermissionsMap, UNBUNDLED_SRC_ID } from './types'; +import { BundlesSource, EntitlementsMap, FeatureFlagsSource, PermissionsMap } from './types'; import { Permission } from '../../../identity/types'; +import { DTO } from '@frontegg/entitlements-service-types'; +import { FeatureFlag } from '@frontegg/entitlements-javascript-commons/dist/feature-flags/types'; +import { SourcesMapper } from './mappers/sources.mapper'; +import { ensureSetInMap } from './mappers/helper'; export class InMemoryEntitlementsCache implements EntitlementsCache { private nodeCache: NodeCache; @@ -56,110 +52,26 @@ export class InMemoryEntitlementsCache implements EntitlementsCache { return mapping || new Set(); } - static initialize(data: VendorEntitlementsDto, revPrefix?: string): InMemoryEntitlementsCache { - const cache = new InMemoryEntitlementsCache(revPrefix ?? data.snapshotOffset.toString()); + async getFeatureFlags(featureKey: string): Promise { + return this.nodeCache.get(FEAT_TO_FLAG_MAP_KEY)?.get(featureKey) || []; + } - const { - data: { features, entitlements, featureBundles }, - } = data; + static initialize(data: DTO.VendorEntitlementsV1.GetDTO, revPrefix?: string): InMemoryEntitlementsCache { + const cache = new InMemoryEntitlementsCache(revPrefix ?? data.snapshotOffset.toString()); // build source structure - const sourceData = cache.buildSource(featureBundles, features, entitlements); - cache.nodeCache.set(SRC_BUNDLES_KEY, sourceData); + const { entitlements: e10sSourceData, featureFlags: ffSourceData } = new SourcesMapper(data.data).buildSources(); + + cache.nodeCache.set(SRC_BUNDLES_KEY, e10sSourceData); // setup data for SDK to work - cache.setupEntitlementsReadModel(sourceData); - cache.setupPermissionsReadModel(sourceData); + cache.setupEntitlementsReadModel(e10sSourceData); + cache.setupPermissionsReadModel(e10sSourceData); + cache.setupFeatureFlagsReadModel(ffSourceData); return cache; } - private buildSource( - bundles: FeatureBundleTuple[], - features: FeatureTuple[], - entitlements: EntitlementTuple[], - ): BundlesSource { - const bundlesMap: BundlesSource = new Map(); - const unbundledFeaturesIds: Set = new Set(); - - // helper features maps - const featuresMap: Map = new Map(); - features.forEach((feat) => { - const [id, key, permissions] = feat; - featuresMap.set(id, { - id, - key, - permissions: new Set(permissions || []), - }); - unbundledFeaturesIds.add(id); - }); - - // initialize bundles map - bundles.forEach((bundle) => { - const [id, featureIds] = bundle; - bundlesMap.set(id, { - id, - user_entitlements: new Map(), - tenant_entitlements: new Map(), - features: new Map( - featureIds.reduce>((prev, fId) => { - const featSource = featuresMap.get(fId); - - if (!featSource) { - // TODO: issue warning here! - } else { - prev.push([featSource.key, featSource]); - - // mark feature as bundled - unbundledFeaturesIds.delete(fId); - } - - return prev; - }, []), - ), - }); - }); - - // fill bundles with entitlements - entitlements.forEach((entitlement) => { - const [featureBundleId, tenantId, userId, expirationDate] = entitlement; - const bundle = bundlesMap.get(featureBundleId); - - if (bundle) { - if (userId) { - // that's user-targeted entitlement - const tenantUserEntitlements = this.ensureMapInMap(bundle.user_entitlements, tenantId); - const usersEntitlements = this.ensureArrayInMap(tenantUserEntitlements, userId); - - usersEntitlements.push(this.parseExpirationTime(expirationDate)); - } else { - // that's tenant-targeted entitlement - const tenantEntitlements = this.ensureArrayInMap(bundle.tenant_entitlements, tenantId); - - tenantEntitlements.push(this.parseExpirationTime(expirationDate)); - } - } else { - // TODO: issue warning here! - } - }); - - // make "dummy" bundle for unbundled features - bundlesMap.set(UNBUNDLED_SRC_ID, { - id: UNBUNDLED_SRC_ID, - user_entitlements: new Map(), - tenant_entitlements: new Map(), - features: new Map( - [...unbundledFeaturesIds.values()].map((fId) => { - const featSource = featuresMap.get(fId)!; - - return [featSource.key, featSource]; - }), - ), - }); - - return bundlesMap; - } - private setupEntitlementsReadModel(src: BundlesSource): void { const entitlementsReadModel: EntitlementsMap = new Map(); @@ -196,7 +108,7 @@ export class InMemoryEntitlementsCache implements EntitlementsCache { src.forEach((singleBundle) => { singleBundle.features.forEach((feature) => { feature.permissions.forEach((permission) => { - this.ensureSetInMap(permissionsReadModel, permission).add(feature.key); + ensureSetInMap(permissionsReadModel, permission).add(feature.key); }); }); }); @@ -204,36 +116,8 @@ export class InMemoryEntitlementsCache implements EntitlementsCache { this.nodeCache.set(PERMISSIONS_MAP_KEY, permissionsReadModel); } - private ensureSetInMap(map: Map>, mapKey: K): Set { - if (!map.has(mapKey)) { - map.set(mapKey, new Set()); - } - - return map.get(mapKey)!; - } - - private ensureMapInMap>(map: Map, mapKey: K): T { - if (!map.has(mapKey)) { - map.set(mapKey, new Map() as T); - } - - return map.get(mapKey)!; - } - - private ensureArrayInMap(map: Map, mapKey: K): T[] { - if (!map.has(mapKey)) { - map.set(mapKey, []); - } - - return map.get(mapKey)!; - } - - private parseExpirationTime(time?: string | null): ExpirationTime { - if (time !== undefined && time !== null) { - return new Date(time).getTime(); - } - - return NO_EXPIRE; + private setupFeatureFlagsReadModel(src: FeatureFlagsSource): void { + this.nodeCache.set(FEAT_TO_FLAG_MAP_KEY, src); } async clear(): Promise { diff --git a/src/clients/entitlements/storage/in-memory/mappers/feature-flag-tuple.mapper.spec.ts b/src/clients/entitlements/storage/in-memory/mappers/feature-flag-tuple.mapper.spec.ts new file mode 100644 index 0000000..64bc4b9 --- /dev/null +++ b/src/clients/entitlements/storage/in-memory/mappers/feature-flag-tuple.mapper.spec.ts @@ -0,0 +1,91 @@ +import { mapFromTuple } from './feature-flag-tuple.mapper'; +import { fc, it } from '@fast-check/jest'; +import { FeatureFlagTuple } from '../../../types'; +import { + AttributeSourceEnum, + IRule, + OperationEnum, + TreatmentEnum, + TreatmentTypeEnum, + ConditionLogicEnum, +} from '@frontegg/entitlements-service-types'; +import { FeatureFlag } from '@frontegg/entitlements-javascript-commons'; + +const TreatmentEnumValues = Object.values(TreatmentEnum); + +describe(mapFromTuple.name, () => { + it.prop( + [ + fc.string({ size: 'small' }), + fc.boolean(), + fc.constantFrom(...TreatmentEnumValues), + fc.constantFrom(...TreatmentEnumValues), + fc.constantFrom(...TreatmentEnumValues), + fc.string({ size: 'small' }), + fc.string(), + fc.boolean(), + fc.constantFrom(...Object.values(OperationEnum)), + ], + { verbose: true }, + )( + 'maps tuple to FeatureFlag structure', + ( + featKey: string, + isOn: boolean, + def: TreatmentEnum, + whenOff: TreatmentEnum, + ruleTreatment: TreatmentEnum, + attribute: string, + attrValue: string, + negate: boolean, + op: OperationEnum, + ) => { + // given + const conditionValue = { string: attrValue }; + + expect( + mapFromTuple([ + featKey, + isOn, + TreatmentTypeEnum.Boolean, + def, + whenOff, + [ + { + conditionLogic: ConditionLogicEnum.And, + conditions: [ + { + attribute, + negate, + op, + attributeType: AttributeSourceEnum.Custom, + value: conditionValue, + }, + ], + description: 'Irrelevant', + treatment: ruleTreatment, + } as IRule, + ], + ] as FeatureFlagTuple), + ).toMatchObject({ + on: isOn, + offTreatment: whenOff, + defaultTreatment: def, + rules: [ + { + conditionLogic: ConditionLogicEnum.And, + conditions: [ + { + negate, + attribute, + op, + value: conditionValue, + }, + ], + treatment: ruleTreatment, + }, + ], + } as FeatureFlag); + }, + ); +}); diff --git a/src/clients/entitlements/storage/in-memory/mappers/feature-flag-tuple.mapper.ts b/src/clients/entitlements/storage/in-memory/mappers/feature-flag-tuple.mapper.ts new file mode 100644 index 0000000..e1f2be2 --- /dev/null +++ b/src/clients/entitlements/storage/in-memory/mappers/feature-flag-tuple.mapper.ts @@ -0,0 +1,30 @@ +import { FeatureFlagTuple } from '../../../types'; +import { FeatureFlag, Rule } from '@frontegg/entitlements-javascript-commons'; +import { IRule } from '@frontegg/entitlements-service-types'; +import { RawConditionValue } from '@frontegg/entitlements-javascript-commons/dist/operations/types'; + +export function mapFromTuple(tuple: FeatureFlagTuple): FeatureFlag { + const [, /* featureKey */ on /* type */, , defaultTreatment, offTreatment, rules] = tuple; + + return { + on, + defaultTreatment, + offTreatment, + rules: rules.map(mapRule), + }; +} + +function mapRule(rule: IRule): Rule { + return { + treatment: rule.treatment, + conditions: rule.conditions.map((condition) => { + return { + op: condition.op, + value: condition.value as RawConditionValue, + negate: condition.negate, + attribute: condition.attribute, + }; + }), + conditionLogic: rule.conditionLogic, + }; +} diff --git a/src/clients/entitlements/storage/in-memory/mappers/helper.ts b/src/clients/entitlements/storage/in-memory/mappers/helper.ts new file mode 100644 index 0000000..207bf74 --- /dev/null +++ b/src/clients/entitlements/storage/in-memory/mappers/helper.ts @@ -0,0 +1,23 @@ +export function ensureSetInMap(map: Map>, mapKey: K): Set { + if (!map.has(mapKey)) { + map.set(mapKey, new Set()); + } + + return map.get(mapKey)!; +} + +export function ensureMapInMap>(map: Map, mapKey: K): T { + if (!map.has(mapKey)) { + map.set(mapKey, new Map() as T); + } + + return map.get(mapKey)!; +} + +export function ensureArrayInMap(map: Map, mapKey: K): T[] { + if (!map.has(mapKey)) { + map.set(mapKey, []); + } + + return map.get(mapKey)!; +} diff --git a/src/clients/entitlements/storage/in-memory/mappers/sources.mapper.ts b/src/clients/entitlements/storage/in-memory/mappers/sources.mapper.ts new file mode 100644 index 0000000..ffb9dd5 --- /dev/null +++ b/src/clients/entitlements/storage/in-memory/mappers/sources.mapper.ts @@ -0,0 +1,127 @@ +import { EntitlementTuple, FeatureBundleTuple, FeatureId, FeatureTuple } from '../../../types'; +import { FeatureFlagTuple } from '../../../feature-flags.types'; +import { BundlesSource, FeatureFlagsSource, FeatureSource, Sources, UNBUNDLED_SRC_ID } from '../types'; +import { DTO } from '@frontegg/entitlements-service-types'; +import { mapFromTuple } from './feature-flag-tuple.mapper'; +import { ExpirationTime, NO_EXPIRE } from '../../types'; +import { ensureArrayInMap, ensureMapInMap } from './helper'; + +export class SourcesMapper { + constructor(private readonly dto: DTO.VendorEntitlementsV1.GetDTO['data']) {} + + buildSources(): Sources { + return { + entitlements: this.buildEntitlementsSources(this.dto.featureBundles, this.dto.features, this.dto.entitlements), + featureFlags: this.buildFeatureFlagsSources(this.dto.featureFlags, this.dto.features), + }; + } + + private buildEntitlementsSources( + bundles: FeatureBundleTuple[], + features: FeatureTuple[], + entitlements: EntitlementTuple[], + ): BundlesSource { + const bundlesMap: BundlesSource = new Map(); + const unbundledFeaturesIds: Set = new Set(); + + // helper features maps + const featuresMap: Map = new Map(); + features.forEach((feat) => { + const [id, key, permissions] = feat; + featuresMap.set(id, { + id, + key, + permissions: new Set(permissions || []), + }); + unbundledFeaturesIds.add(id); + }); + + // initialize bundles map + bundles.forEach((bundle) => { + const [id, featureIds] = bundle; + bundlesMap.set(id, { + id, + user_entitlements: new Map(), + tenant_entitlements: new Map(), + features: new Map( + featureIds.reduce>((prev, fId) => { + const featSource = featuresMap.get(fId); + + if (!featSource) { + // TODO: issue warning here! + } else { + prev.push([featSource.key, featSource]); + + // mark feature as bundled + unbundledFeaturesIds.delete(fId); + } + + return prev; + }, []), + ), + }); + }); + + // fill bundles with entitlements + entitlements.forEach((entitlement) => { + const [featureBundleId, tenantId, userId, expirationDate] = entitlement; + const bundle = bundlesMap.get(featureBundleId); + + if (bundle) { + if (userId) { + // that's user-targeted entitlement + const tenantUserEntitlements = ensureMapInMap(bundle.user_entitlements, tenantId); + const usersEntitlements = ensureArrayInMap(tenantUserEntitlements, userId); + + usersEntitlements.push(this.parseExpirationTime(expirationDate)); + } else { + // that's tenant-targeted entitlement + const tenantEntitlements = ensureArrayInMap(bundle.tenant_entitlements, tenantId); + + tenantEntitlements.push(this.parseExpirationTime(expirationDate)); + } + } else { + // TODO: issue warning here! + } + }); + + // make "dummy" bundle for unbundled features + bundlesMap.set(UNBUNDLED_SRC_ID, { + id: UNBUNDLED_SRC_ID, + user_entitlements: new Map(), + tenant_entitlements: new Map(), + features: new Map( + [...unbundledFeaturesIds.values()].map((fId) => { + const featSource = featuresMap.get(fId)!; + + return [featSource.key, featSource]; + }), + ), + }); + + return bundlesMap; + } + + private buildFeatureFlagsSources(flags: FeatureFlagTuple[], features: FeatureTuple[]): FeatureFlagsSource { + const featureKeys = features.map((tuple) => tuple[1]); + const source: FeatureFlagsSource = new Map(); + + flags.forEach((flagTuple) => { + const featureKey = flagTuple[0]; + + if (featureKeys.includes(featureKey)) { + ensureArrayInMap(source, featureKey).push(mapFromTuple(flagTuple)); + } + }, []); + + return source; + } + + private parseExpirationTime(time?: string | null): ExpirationTime { + if (time !== undefined && time !== null) { + return new Date(time).getTime(); + } + + return NO_EXPIRE; + } +} diff --git a/src/clients/entitlements/storage/in-memory/types.ts b/src/clients/entitlements/storage/in-memory/types.ts index ab31604..748ceee 100644 --- a/src/clients/entitlements/storage/in-memory/types.ts +++ b/src/clients/entitlements/storage/in-memory/types.ts @@ -1,6 +1,8 @@ import { Permission } from '../../../identity/types'; import { FeatureKey, TenantId, UserId } from '../../types'; import { ExpirationTime } from '../types'; +// TODO: make that lib VVV export types as well +import { FeatureFlag } from '@frontegg/entitlements-javascript-commons/dist/feature-flags/types'; export const UNBUNDLED_SRC_ID = '__unbundled__'; export type FeatureEntitlementKey = string; // tenant & user & feature key @@ -23,3 +25,9 @@ export type SingleBundleSource = { }; export type BundlesSource = Map; +export type FeatureFlagsSource = Map; + +export type Sources = { + entitlements: BundlesSource; + featureFlags: FeatureFlagsSource; +}; diff --git a/src/clients/entitlements/storage/types.ts b/src/clients/entitlements/storage/types.ts index e960fcf..7f0e021 100644 --- a/src/clients/entitlements/storage/types.ts +++ b/src/clients/entitlements/storage/types.ts @@ -1,4 +1,5 @@ import { FeatureKey } from '../types'; +import { FeatureFlag } from '@frontegg/entitlements-javascript-commons/dist/feature-flags/types'; export const NO_EXPIRE = -1; export type ExpirationTime = number | typeof NO_EXPIRE; @@ -18,6 +19,11 @@ export interface EntitlementsCache { */ getLinkedFeatures(permissionKey: string): Promise>; + /** + * Get all feature-flags with given feature configured. + */ + getFeatureFlags(featureKey: string): Promise; + /** * Remove all cached data. */ diff --git a/src/clients/entitlements/types.ts b/src/clients/entitlements/types.ts index 0c4166a..e35fcec 100644 --- a/src/clients/entitlements/types.ts +++ b/src/clients/entitlements/types.ts @@ -1,5 +1,5 @@ import { RetryOptions } from '../../utils'; -import { Permission } from '../identity/types'; +import { DTO } from '@frontegg/entitlements-service-types'; export enum EntitlementJustifications { MISSING_FEATURE = 'missing-feature', @@ -17,22 +17,11 @@ export type TenantId = string; export type UserId = string; export type FeatureId = string; -export type FeatureTuple = [FeatureId, FeatureKey, Permission[]]; -export type FeatureBundleId = string; -export type FeatureBundleTuple = [FeatureBundleId, FeatureId[]]; - -export type ExpirationDate = string | null; -export type EntitlementTuple = [FeatureBundleId, TenantId, UserId?, ExpirationDate?]; - -export interface VendorEntitlementsDto { - data: { - features: FeatureTuple[]; - featureBundles: FeatureBundleTuple[]; - entitlements: EntitlementTuple[]; - }; - snapshotOffset: number; -} +export type FeatureTuple = DTO.VendorEntitlementsV1.Entitlements.Feature.Tuple; +export type FeatureBundleTuple = DTO.VendorEntitlementsV1.Entitlements.FeatureSet.Tuple; +export type EntitlementTuple = DTO.VendorEntitlementsV1.Entitlements.Tuple; +export type FeatureFlagTuple = DTO.VendorEntitlementsV1.FeatureFlags.Tuple; export interface VendorEntitlementsSnapshotOffsetDto { snapshotOffset: number; diff --git a/src/clients/identity/identity-client.ts b/src/clients/identity/identity-client.ts index 0d6a817..1281b43 100644 --- a/src/clients/identity/identity-client.ts +++ b/src/clients/identity/identity-client.ts @@ -3,19 +3,10 @@ import { FronteggAuthenticator } from '../../authenticator'; import { config } from '../../config'; import Logger from '../../components/logger'; import { FronteggContext } from '../../components/frontegg-context'; -import { - AuthHeaderType, - ExtractCredentialsResult, - ITenantApiToken, - IUser, - IUserApiToken, - IValidateTokenOptions, - TEntity, -} from './types'; +import { AuthHeaderType, ExtractCredentialsResult, IUser, IValidateTokenOptions, TEntity } from './types'; import { accessTokenHeaderResolver, authorizationHeaderResolver, TokenResolver } from './token-resolvers'; import { FailedToAuthenticateException } from './exceptions/failed-to-authenticate.exception'; import { IFronteggContext } from '../../components/frontegg-context/types'; -import { type } from 'os'; const tokenResolvers = [authorizationHeaderResolver, accessTokenHeaderResolver]; diff --git a/src/clients/identity/token-resolvers/access-token-resolver.ts b/src/clients/identity/token-resolvers/access-token-resolver.ts index efa35dc..bdd4336 100644 --- a/src/clients/identity/token-resolvers/access-token-resolver.ts +++ b/src/clients/identity/token-resolvers/access-token-resolver.ts @@ -10,7 +10,6 @@ import { import { FailedToAuthenticateException } from '../exceptions'; import { TokenResolver } from './token-resolver'; import { IAccessTokenService } from './access-token-services/access-token.service.interface'; -import { LocalCacheManager, IORedisCacheManager, RedisCacheManager } from '../../../cache'; import { CacheTenantAccessTokenService, CacheUserAccessTokenService, @@ -20,6 +19,7 @@ import { import { FronteggAuthenticator } from '../../../authenticator'; import { HttpClient } from '../../http'; import { FronteggContext } from '../../../components/frontegg-context'; +import { FronteggCache } from '../../../components/cache'; export class AccessTokenResolver extends TokenResolver { private authenticator: FronteggAuthenticator = new FronteggAuthenticator(); @@ -51,10 +51,8 @@ export class AccessTokenResolver extends TokenResolver { } return { - ...(entityWithRoles || ( - options?.withRolesAndPermissions ? await this.getEntity(entity) : {} - )), - ...entity + ...(entityWithRoles || (options?.withRolesAndPermissions ? await this.getEntity(entity) : {})), + ...entity, }; } @@ -65,7 +63,7 @@ export class AccessTokenResolver extends TokenResolver { FRONTEGG_API_KEY || process.env.FRONTEGG_API_KEY || '', ); - this.initAccessTokenServices(); + await this.initAccessTokenServices(); } protected getEntity(entity: IAccessToken): Promise { @@ -93,52 +91,16 @@ export class AccessTokenResolver extends TokenResolver { return service; } - private initAccessTokenServices(): void { + private async initAccessTokenServices(): Promise { if (this.accessTokenServices.length) { return; } - const accessTokensOptions = FronteggContext.getOptions().accessTokensOptions; - - if (accessTokensOptions?.cache?.type === 'ioredis') { - this.accessTokenServices = [ - new CacheTenantAccessTokenService( - new IORedisCacheManager(accessTokensOptions.cache.options), - new IORedisCacheManager(accessTokensOptions.cache.options), - new TenantAccessTokenService(this.httpClient), - ), - new CacheUserAccessTokenService( - new IORedisCacheManager(accessTokensOptions.cache.options), - new IORedisCacheManager(accessTokensOptions.cache.options), - new UserAccessTokenService(this.httpClient), - ), - ]; - } else if (accessTokensOptions?.cache?.type === 'redis') { - this.accessTokenServices = [ - new CacheTenantAccessTokenService( - new RedisCacheManager(accessTokensOptions.cache.options), - new RedisCacheManager(accessTokensOptions.cache.options), - new TenantAccessTokenService(this.httpClient), - ), - new CacheUserAccessTokenService( - new RedisCacheManager(accessTokensOptions.cache.options), - new RedisCacheManager(accessTokensOptions.cache.options), - new UserAccessTokenService(this.httpClient), - ), - ]; - } else { - this.accessTokenServices = [ - new CacheTenantAccessTokenService( - new LocalCacheManager(), - new LocalCacheManager(), - new TenantAccessTokenService(this.httpClient), - ), - new CacheUserAccessTokenService( - new LocalCacheManager(), - new LocalCacheManager(), - new UserAccessTokenService(this.httpClient), - ), - ]; - } + const cache = await FronteggCache.getInstance(); + + this.accessTokenServices = [ + new CacheTenantAccessTokenService(cache, new TenantAccessTokenService(this.httpClient)), + new CacheUserAccessTokenService(cache, new UserAccessTokenService(this.httpClient)), + ]; } } diff --git a/src/clients/identity/token-resolvers/access-token-services/cache-services/cache-access-token.service.ts b/src/clients/identity/token-resolvers/access-token-services/cache-services/cache-access-token.service-abstract.ts similarity index 75% rename from src/clients/identity/token-resolvers/access-token-services/cache-services/cache-access-token.service.ts rename to src/clients/identity/token-resolvers/access-token-services/cache-services/cache-access-token.service-abstract.ts index fa0b7a6..a5e9f82 100644 --- a/src/clients/identity/token-resolvers/access-token-services/cache-services/cache-access-token.service.ts +++ b/src/clients/identity/token-resolvers/access-token-services/cache-services/cache-access-token.service-abstract.ts @@ -1,18 +1,25 @@ import { IAccessToken, IEmptyAccessToken, IEntityWithRoles, tokenTypes } from '../../../types'; import { IAccessTokenService } from '../access-token.service.interface'; -import { ICacheManager } from '../../../../../cache/cache.manager.interface'; +import { ICacheManager } from '../../../../../components/cache/managers/cache.manager.interface'; import { FailedToAuthenticateException } from '../../../exceptions'; -export abstract class CacheAccessTokenService implements IAccessTokenService { - constructor( - public readonly entityCacheManager: ICacheManager, - public readonly activeAccessTokensCacheManager: ICacheManager, +export abstract class CacheAccessTokenServiceAbstract implements IAccessTokenService { + protected abstract getCachePrefix(): string; + + public readonly entityCacheManager: ICacheManager; + public readonly activeAccessTokensCacheManager: ICacheManager; + + protected constructor( + cacheManager: ICacheManager, public readonly accessTokenService: IAccessTokenService, public readonly type: tokenTypes.UserAccessToken | tokenTypes.TenantAccessToken, - ) {} + ) { + this.entityCacheManager = cacheManager.forScope(this.getCachePrefix()); + this.activeAccessTokensCacheManager = cacheManager.forScope(this.getCachePrefix()); + } public async getEntity(entity: T): Promise { - const cacheKey = `${this.getCachePrefix()}_${entity.sub}`; + const cacheKey = entity.sub; const cachedData = await this.entityCacheManager.get(cacheKey); if (cachedData) { @@ -38,7 +45,7 @@ export abstract class CacheAccessTokenService implements } public async getActiveAccessTokenIds(): Promise { - const cacheKey = `${this.getCachePrefix()}_ids`; + const cacheKey = `ids`; const cachedData = await this.activeAccessTokensCacheManager.get(cacheKey); if (cachedData) { @@ -66,6 +73,4 @@ export abstract class CacheAccessTokenService implements private isEmptyAccessToken(accessToken: IEntityWithRoles | IEmptyAccessToken): accessToken is IEmptyAccessToken { return 'empty' in accessToken && accessToken.empty; } - - protected abstract getCachePrefix(): string; } diff --git a/src/clients/identity/token-resolvers/access-token-services/cache-services/cache-tenant-access-token.service.ts b/src/clients/identity/token-resolvers/access-token-services/cache-services/cache-tenant-access-token.service.ts index 70f0dd9..9648e46 100644 --- a/src/clients/identity/token-resolvers/access-token-services/cache-services/cache-tenant-access-token.service.ts +++ b/src/clients/identity/token-resolvers/access-token-services/cache-services/cache-tenant-access-token.service.ts @@ -1,18 +1,14 @@ -import { ICacheManager } from '../../../../../cache/cache.manager.interface'; -import { IEmptyAccessToken, IEntityWithRoles, ITenantAccessToken, tokenTypes } from '../../../types'; +import { ICacheManager } from '../../../../../components/cache/managers/cache.manager.interface'; +import { ITenantAccessToken, tokenTypes } from '../../../types'; import { AccessTokenService } from '../services/access-token.service'; -import { CacheAccessTokenService } from './cache-access-token.service'; +import { CacheAccessTokenServiceAbstract } from './cache-access-token.service-abstract'; -export class CacheTenantAccessTokenService extends CacheAccessTokenService { - constructor( - public readonly entityCacheManager: ICacheManager, - public readonly activeAccessTokensCacheManager: ICacheManager, - public readonly tenantAccessTokenService: AccessTokenService, - ) { - super(entityCacheManager, activeAccessTokensCacheManager, tenantAccessTokenService, tokenTypes.TenantAccessToken); +export class CacheTenantAccessTokenService extends CacheAccessTokenServiceAbstract { + constructor(cacheManager: ICacheManager, tenantAccessTokenService: AccessTokenService) { + super(cacheManager, tenantAccessTokenService, tokenTypes.TenantAccessToken); } protected getCachePrefix(): string { - return 'frontegg_sdk_v1_user_access_tokens'; + return 'frontegg_sdk_v1_user_access_tokens_'; } } diff --git a/src/clients/identity/token-resolvers/access-token-services/cache-services/cache-user-access-token.service.ts b/src/clients/identity/token-resolvers/access-token-services/cache-services/cache-user-access-token.service.ts index f00ec35..174cb46 100644 --- a/src/clients/identity/token-resolvers/access-token-services/cache-services/cache-user-access-token.service.ts +++ b/src/clients/identity/token-resolvers/access-token-services/cache-services/cache-user-access-token.service.ts @@ -1,18 +1,17 @@ -import { ICacheManager } from '../../../../../cache/cache.manager.interface'; -import { IEmptyAccessToken, IEntityWithRoles, IUserAccessToken, tokenTypes } from '../../../types'; +import { ICacheManager } from '../../../../../components/cache/managers/cache.manager.interface'; +import { IUserAccessToken, tokenTypes } from '../../../types'; import { AccessTokenService } from '../services/access-token.service'; -import { CacheAccessTokenService } from './cache-access-token.service'; +import { CacheAccessTokenServiceAbstract } from './cache-access-token.service-abstract'; -export class CacheUserAccessTokenService extends CacheAccessTokenService { +export class CacheUserAccessTokenService extends CacheAccessTokenServiceAbstract { constructor( - public readonly entityCacheManager: ICacheManager, - public readonly activeAccessTokensCacheManager: ICacheManager, + cacheManager: ICacheManager, public readonly userAccessTokenService: AccessTokenService, ) { - super(entityCacheManager, activeAccessTokensCacheManager, userAccessTokenService, tokenTypes.UserAccessToken); + super(cacheManager, userAccessTokenService, tokenTypes.UserAccessToken); } protected getCachePrefix(): string { - return 'frontegg_sdk_v1_tenant_access_tokens'; + return 'frontegg_sdk_v1_tenant_access_tokens_'; } } diff --git a/src/clients/identity/types.ts b/src/clients/identity/types.ts index d97c479..e2d5189 100644 --- a/src/clients/identity/types.ts +++ b/src/clients/identity/types.ts @@ -34,19 +34,19 @@ export type TTenantEntity = ITenantApiToken | ITenantAccessToken | ITenantAccess export type TEntity = TUserEntity | TTenantEntity; -export interface IEntity { +export type IEntity = { id?: string; sub: string; tenantId: string; type: tokenTypes; -} +}; -export interface IEntityWithRoles extends IEntity { +export type IEntityWithRoles = IEntity & { roles: Role[]; permissions: Permission[]; -} +}; -export interface IUser extends IEntityWithRoles { +export type IUser = IEntityWithRoles & { type: tokenTypes.UserToken; metadata: Record; userId: string; @@ -57,37 +57,37 @@ export interface IUser extends IEntityWithRoles { tenantIds?: string[]; profilePictureUrl?: string; superUser?: true; -} +}; -export interface IApiToken extends IEntityWithRoles { +export type IApiToken = IEntityWithRoles & { createdByUserId: string; type: tokenTypes.TenantApiToken | tokenTypes.UserApiToken; metadata: Record; -} +}; -export interface ITenantApiToken extends IApiToken { +export type ITenantApiToken = IApiToken & { type: tokenTypes.TenantApiToken; -} +}; -export interface IUserApiToken extends IApiToken { +export type IUserApiToken = IApiToken & { type: tokenTypes.UserApiToken; email: string; userMetadata: Record; userId: string; -} +}; -export interface IAccessToken extends IEntity { +export type IAccessToken = IEntity & { type: tokenTypes.TenantAccessToken | tokenTypes.UserAccessToken; -} +}; -export interface ITenantAccessToken extends IAccessToken { +export type ITenantAccessToken = IAccessToken & { type: tokenTypes.TenantAccessToken; -} +}; -export interface IUserAccessToken extends IAccessToken { +export type IUserAccessToken = IAccessToken & { type: tokenTypes.UserAccessToken; userId: string; -} +}; export interface IEmptyAccessToken { empty: true; diff --git a/src/components/cache/index.spec.ts b/src/components/cache/index.spec.ts new file mode 100644 index 0000000..df12d44 --- /dev/null +++ b/src/components/cache/index.spec.ts @@ -0,0 +1,83 @@ +import { IIORedisCacheOptions, ILocalCacheOptions, IRedisCacheOptions } from '../frontegg-context/types'; +import { FronteggContext } from '../frontegg-context'; + +describe('FronteggContext', () => { + beforeEach(() => { + /** + * In this test suite we need to reset Node modules and import them in every test case, so "fresh" modules are provided. + * This is the way to deal with singletons defined in the scope of module. + */ + jest.resetModules(); + }); + + function mockCache(name: string) { + jest.mock('./managers'); + const { [name]: Manager } = require('./managers'); + + const cacheManagerMock = {}; + jest.mocked(Manager.create).mockResolvedValue(cacheManagerMock); + + return cacheManagerMock; + } + + describe.each([ + { + cacheConfigInfo: 'no cache', + config: {}, + expectedCacheName: 'LocalCacheManager', + }, + { + cacheConfigInfo: 'explicit local cache', + config: { + type: 'local', + } as ILocalCacheOptions, + expectedCacheName: 'LocalCacheManager', + }, + { + cacheConfigInfo: "type of 'ioredis' in `$.cache`", + config: { + cache: { + type: 'ioredis', + options: { host: 'foo', password: 'bar', db: 0, port: 6372 }, + } as IIORedisCacheOptions, + }, + expectedCacheName: 'IORedisCacheManager', + }, + { + cacheConfigInfo: "type of 'redis' in `$.cache`", + config: { + cache: { + type: 'redis', + options: { url: 'redis://url:6372' }, + } as IRedisCacheOptions, + }, + expectedCacheName: 'RedisCacheManager', + }, + ])('given $cacheConfigInfo configuration in FronteggContext', ({ config, expectedCacheName }) => { + let expectedCache; + + beforeEach(() => { + expectedCache = mockCache(expectedCacheName); + const { FronteggContext } = require('../frontegg-context'); + + FronteggContext.init( + { + FRONTEGG_CLIENT_ID: 'foo', + FRONTEGG_API_KEY: 'bar', + }, + config, + ); + }); + + it(`when cache is initialized, then the ${expectedCacheName} is returned.`, async () => { + // given + const { FronteggCache } = require('./index'); + + // when + const cache = await FronteggCache.getInstance(); + + // then + expect(cache).toBe(expectedCache); + }); + }); +}); diff --git a/src/components/cache/index.ts b/src/components/cache/index.ts new file mode 100644 index 0000000..0140d9d --- /dev/null +++ b/src/components/cache/index.ts @@ -0,0 +1,28 @@ +import { CacheValue, ICacheManager, IORedisCacheManager, LocalCacheManager, RedisCacheManager } from './managers'; +import { FronteggContext } from '../frontegg-context'; + +let cacheInstance: ICacheManager; + +export class FronteggCache { + static async getInstance(): Promise> { + if (!cacheInstance) { + cacheInstance = await FronteggCache.initialize(); + } + + return cacheInstance as ICacheManager; + } + + private static async initialize(): Promise> { + const options = FronteggContext.getOptions(); + const { cache } = options; + + switch (cache.type) { + case 'ioredis': + return IORedisCacheManager.create(cache.options); + case 'redis': + return RedisCacheManager.create(cache.options); + default: + return LocalCacheManager.create(); + } + } +} diff --git a/src/components/cache/managers/cache.manager.interface.ts b/src/components/cache/managers/cache.manager.interface.ts new file mode 100644 index 0000000..475cdb2 --- /dev/null +++ b/src/components/cache/managers/cache.manager.interface.ts @@ -0,0 +1,44 @@ +type Primitive = bigint | boolean | null | number | string | undefined | object; + +type JSONValue = Primitive | JSONObject | JSONArray; +export interface JSONObject { + [k: string]: JSONValue; +} +type JSONArray = JSONValue[]; + +export type CacheValue = JSONValue; + +export interface SetOptions { + expiresInSeconds: number; +} + +export interface ICacheManagerMap { + set(field: string, data: T): Promise; + get(field: string): Promise; + del(field: string): Promise; +} + +export interface ICacheManagerCollection { + set(value: T): Promise; + has(value: T): Promise; + getAll(): Promise>; +} + +export interface ICacheManager { + set(key: string, data: V, options?: SetOptions): Promise; + get(key: string): Promise; + del(key: string[]): Promise; + + expire(keys: string[], ttlMs: number): Promise; + map(key: string): ICacheManagerMap; + collection(key: string): ICacheManagerCollection; + close(): Promise; + + /** + * This method should return the instance of ICacheManager with the same cache connector below, but scoped set/get + * methods to different type of values (defined by generic type S). + * + * If prefix is not given, the prefix of current instance should be used. + */ + forScope(prefix?: string): ICacheManager; +} diff --git a/src/components/cache/managers/index.ts b/src/components/cache/managers/index.ts new file mode 100644 index 0000000..09aa553 --- /dev/null +++ b/src/components/cache/managers/index.ts @@ -0,0 +1,4 @@ +export * from './cache.manager.interface'; +export * from './local/local-cache.manager'; +export * from './ioredis/ioredis-cache.manager'; +export * from './redis/redis-cache.manager'; diff --git a/src/components/cache/managers/ioredis/ioredis-cache.collection.ts b/src/components/cache/managers/ioredis/ioredis-cache.collection.ts new file mode 100644 index 0000000..aa333d7 --- /dev/null +++ b/src/components/cache/managers/ioredis/ioredis-cache.collection.ts @@ -0,0 +1,25 @@ +import IORedis from 'ioredis'; +import { ICacheValueSerializer } from '../../serializers/types'; +import { CacheValue, ICacheManagerCollection } from '../cache.manager.interface'; + +export class IORedisCacheCollection implements ICacheManagerCollection { + constructor( + private readonly key: string, + private readonly redis: IORedis, + private readonly serializer: ICacheValueSerializer, + ) {} + + async set(value: T): Promise { + await this.redis.sadd(this.key, this.serializer.serialize(value)); + } + + async has(value: T): Promise { + return (await this.redis.sismember(this.key, this.serializer.serialize(value))) > 0; + } + + async getAll(): Promise> { + const members = (await this.redis.smembers(this.key)).map((v) => this.serializer.deserialize(v)); + + return new Set(members); + } +} diff --git a/src/components/cache/managers/ioredis/ioredis-cache.manager.spec.ts b/src/components/cache/managers/ioredis/ioredis-cache.manager.spec.ts new file mode 100644 index 0000000..bc97714 --- /dev/null +++ b/src/components/cache/managers/ioredis/ioredis-cache.manager.spec.ts @@ -0,0 +1,140 @@ +import 'jest-extended'; +import { IORedisCacheManager } from './ioredis-cache.manager'; +import IORedis from 'ioredis'; +import { CacheValue } from '../cache.manager.interface'; + +// TODO: define all tests of Redis-based ICacheManager implementations in single file, only change the implementation +// for runs + +function delay(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} + +describe(IORedisCacheManager.name, () => { + let cut: IORedisCacheManager; + let redisTestConnection: IORedis; + + beforeAll(async () => { + // initialize test Redis connection + redisTestConnection = new IORedis(36279, 'localhost'); + + // initial clean-up of used key + await redisTestConnection.del('key'); + + cut = await IORedisCacheManager.create({ host: 'localhost', port: 36279 }); + }); + + afterEach(async () => { + await redisTestConnection.del('key'); + }); + + afterAll(async () => { + await cut.close(); + await redisTestConnection.quit(); + }); + + describe('given simple key/value with key "key"', () => { + it('when .set("key", "value") is called, then it is stored in Redis as JSON-encoded string.', async () => { + // when + await cut.set('key', 'value'); + + // then + await expect(redisTestConnection.get('key')).resolves.toStrictEqual('"value"'); + }); + + describe('given .set("key", "value", options) has been called with expiration time, then after expiration', () => { + beforeEach(() => cut.set('key', 'value', { expiresInSeconds: 1 })); + + it('when expiration time has not passed yet, then it is kept in Redis.', async () => { + // when & then + await expect(redisTestConnection.exists('key')).resolves.toBeGreaterThan(0); + }); + + it('when expiration time has passed already, then it is removed from Redis.', async () => { + // when + await delay(1500); + + // then + await expect(redisTestConnection.exists('key')).resolves.toEqual(0); + }); + }); + + describe('and in Redis key "key" there is JSON-encoded string \'"foo"\' stored', () => { + beforeEach(() => redisTestConnection.set('key', '"foo"')); + + it('when .get("key") is called, then it resolves to string "foo".', async () => { + // when + await expect(cut.get('key')).resolves.toStrictEqual('foo'); + }); + + it('when .del("key") is called, then key "key" is removed from Redis DB.', async () => { + // when + await cut.del(['key']); + + // then + await expect(redisTestConnection.exists('key')).resolves.toEqual(0); + }); + }); + }); + + describe('given .map("key") is called', () => { + it('when map\'s .set("field", "value") is called, then it is stored in Redis Hashset as JSON-encoded string.', async () => { + // when + await cut.map('key').set('field', 'value'); + + // then + await expect(redisTestConnection.hget('key', 'field')).resolves.toEqual('"value"'); + }); + + describe('and in Redis Hashset with field "foo" is already storing JSON-encoded value \'"bar"\'', () => { + beforeEach(() => redisTestConnection.hset('key', 'foo', '"bar"')); + + it('when map\'s .get("foo") is called, then it resolves to value "bar".', async () => { + // when & then + await expect(cut.map('key').get('foo')).resolves.toStrictEqual('bar'); + }); + + it('when map\'s .get("baz") is called, then it resolves to NULL. [non-existing key]', async () => { + // when & then + await expect(cut.map('key').get('baz')).resolves.toBeNull(); + }); + + it('when map\'s .del("foo") is called, then it drops the field "foo" from hashset "key".', async () => { + // when + await expect(cut.map('key').del('foo')).toResolve(); + + // then + await expect(redisTestConnection.hexists('key', 'foo')).resolves.toEqual(0); + }); + }); + }); + + describe('given .collection("key") is called', () => { + it('when collection\'s .set("value") is called, then it is stored in Redis Set as JSON-encoded string.', async () => { + // when + await cut.collection('key').set('value'); + + // then + await expect(redisTestConnection.sismember('key', '"value"')).resolves.toBeTruthy(); + }); + + describe('and in Redis Set value JSON-encoded value \'"foo"\' is stored', () => { + beforeEach(() => redisTestConnection.sadd('key', '"foo"')); + + it('when collection\'s .getAll() is called, then it resolves to the Set instance with value "foo".', async () => { + // when & then + await expect(cut.collection('key').getAll()).resolves.toStrictEqual(new Set(['foo'])); + }); + + it('when collection\'s .has("foo") is called, then it resolves to TRUE.', async () => { + // when & then + await expect(cut.collection('key').has('foo')).resolves.toBeTrue(); + }); + + it('when collection\'s .has("non-existing-field") is called, then it resolves to FALSE.', async () => { + // when & then + await expect(cut.collection('key').has('non-existing-field')).resolves.toBeFalsy(); + }); + }); + }); +}); \ No newline at end of file diff --git a/src/components/cache/managers/ioredis/ioredis-cache.manager.ts b/src/components/cache/managers/ioredis/ioredis-cache.manager.ts new file mode 100644 index 0000000..7aabe47 --- /dev/null +++ b/src/components/cache/managers/ioredis/ioredis-cache.manager.ts @@ -0,0 +1,74 @@ +import type { Redis } from 'ioredis'; +import { PackageUtils } from '../../../../utils/package-loader'; +import { PrefixedManager } from '../prefixed-manager.abstract'; +import { + CacheValue, + ICacheManager, + ICacheManagerCollection, + ICacheManagerMap, + SetOptions, +} from '../cache.manager.interface'; +import { IORedisCacheMap } from './ioredis-cache.map'; +import { IORedisCacheCollection } from './ioredis-cache.collection'; +import { ICacheValueSerializer } from '../../serializers/types'; +import { JsonSerializer } from '../../serializers/json.serializer'; +import type { RedisOptions } from 'ioredis'; + +export type IIORedisOptions = RedisOptions; + +export class IORedisCacheManager extends PrefixedManager implements ICacheManager { + private readonly serializer: ICacheValueSerializer; + + private constructor(private readonly redisManager: Redis, prefix = '') { + super(prefix); + + this.serializer = new JsonSerializer(); + } + + static async create(options?: IIORedisOptions, prefix = ''): Promise> { + const RedisCtor = PackageUtils.loadPackage('ioredis'); + + return new IORedisCacheManager(new RedisCtor(options), prefix); + } + + public async set(key: string, data: V, options?: SetOptions): Promise { + if (options?.expiresInSeconds) { + await this.redisManager.set(this.withPrefix(key), JSON.stringify(data), 'EX', options.expiresInSeconds); + } else { + await this.redisManager.set(this.withPrefix(key), JSON.stringify(data)); + } + } + + public async get(key: string): Promise { + const stringifiedData = await this.redisManager.get(this.withPrefix(key)); + return stringifiedData ? JSON.parse(stringifiedData) : null; + } + + public async del(key: string[]): Promise { + if (key.length) { + await this.redisManager.del(key.map(this.withPrefix.bind(this))); + } + } + + async expire(keys: string[], ttlMs: number): Promise { + for (const key of keys) { + await this.redisManager.pexpire(this.withPrefix(key), ttlMs); + } + } + + forScope(prefix?: string): ICacheManager { + return new IORedisCacheManager(this.redisManager, prefix ?? this.prefix); + } + + map(key: string): ICacheManagerMap { + return new IORedisCacheMap(this.withPrefix(key), this.redisManager, this.serializer); + } + + collection(key: string): ICacheManagerCollection { + return new IORedisCacheCollection(this.withPrefix(key), this.redisManager, this.serializer); + } + + async close(): Promise { + await this.redisManager.quit(); + } +} \ No newline at end of file diff --git a/src/components/cache/managers/ioredis/ioredis-cache.map.ts b/src/components/cache/managers/ioredis/ioredis-cache.map.ts new file mode 100644 index 0000000..d52c813 --- /dev/null +++ b/src/components/cache/managers/ioredis/ioredis-cache.map.ts @@ -0,0 +1,25 @@ +import type IORedis from 'ioredis'; +import { ICacheValueSerializer } from '../../serializers/types'; +import { ICacheManagerMap, CacheValue } from '../cache.manager.interface'; + +export class IORedisCacheMap implements ICacheManagerMap { + constructor( + private readonly key: string, + private readonly redis: IORedis, + private readonly serializer: ICacheValueSerializer, + ) {} + + async set(field: string, data: T): Promise { + await this.redis.hset(this.key, field, this.serializer.serialize(data)); + } + + async get(field: string): Promise { + const raw = await this.redis.hget(this.key, field); + + return raw !== null ? this.serializer.deserialize(raw) : null; + } + + async del(field: string): Promise { + await this.redis.hdel(this.key, field); + } +} \ No newline at end of file diff --git a/src/components/cache/managers/local/local-cache.collection.ts b/src/components/cache/managers/local/local-cache.collection.ts new file mode 100644 index 0000000..da9153e --- /dev/null +++ b/src/components/cache/managers/local/local-cache.collection.ts @@ -0,0 +1,26 @@ +import * as NodeCache from 'node-cache'; +import { ICacheManagerCollection } from '../cache.manager.interface'; + +export class LocalCacheCollection implements ICacheManagerCollection { + constructor(private readonly key: string, private readonly cache: NodeCache) {} + + private ensureSetInCache(): Set { + if (!this.cache.has(this.key)) { + this.cache.set(this.key, new Set()); + } + + return this.cache.get(this.key)!; + } + + async has(value: T): Promise { + return this.ensureSetInCache().has(value); + } + + async set(value: T): Promise { + this.ensureSetInCache().add(value); + } + + async getAll(): Promise> { + return this.ensureSetInCache(); + } +} \ No newline at end of file diff --git a/src/components/cache/managers/local/local-cache.manager.spec.ts b/src/components/cache/managers/local/local-cache.manager.spec.ts new file mode 100644 index 0000000..a43f44d --- /dev/null +++ b/src/components/cache/managers/local/local-cache.manager.spec.ts @@ -0,0 +1,138 @@ +import { LocalCacheManager } from './local-cache.manager'; +import { LocalCacheCollection } from './local-cache.collection'; +import { LocalCacheMap } from './local-cache.map'; +import { CacheValue } from '../cache.manager.interface'; + +describe('Local cache manager', () => { + let localCacheManager: LocalCacheManager; + + const cacheKey = 'key'; + const cacheValue = { data: 'value' }; + + beforeEach(async () => { + localCacheManager = await LocalCacheManager.create(); + }); + + it('should set, get and delete from local cache manager', async () => { + await localCacheManager.set(cacheKey, cacheValue); + const res = await localCacheManager.get(cacheKey); + expect(res).toEqual(cacheValue); + await localCacheManager.del([cacheKey]); + const resAfterDel = await localCacheManager.get(cacheKey); + expect(resAfterDel).toEqual(null); + }); + + it('should get null after expiration time', async () => { + await localCacheManager.set(cacheKey, cacheValue, { expiresInSeconds: 1 }); + await new Promise((r) => setTimeout(r, 500)); + const res = await localCacheManager.get(cacheKey); + expect(res).toEqual(cacheValue); + + await new Promise((r) => setTimeout(r, 600)); + + const resAfterDel = await localCacheManager.get(cacheKey); + expect(resAfterDel).toEqual(null); + }); + + it('when .collection() is called, then instance of LocalCacheCollection is returned.', async () => { + // given + const cut = await LocalCacheManager.create(); + + // when & then + expect(cut.collection('my-key')).toBeInstanceOf(LocalCacheCollection); + }); + + it('when .hashmap() is called, then instance of LocalCacheMap is returned.', async () => { + // given + const cut = await LocalCacheManager.create(); + + // when & then + expect(cut.map('my-key')).toBeInstanceOf(LocalCacheMap); + }); + + describe('given collection instance is received by .collection(key)', () => { + let cut: LocalCacheManager; + + beforeEach(async () => { + cut = await LocalCacheManager.create(); + }); + + describe('with key that has not been created yet', () => { + it('when .set(value) is called, then the underlying Set is created.', async () => { + // given + await expect(cut.get('my-key')).resolves.toBeNull(); + + // when + await cut.collection('my-key').set('foo'); + + // then + await expect(cut.get('my-key')).resolves.toStrictEqual(new Set(['foo'])); + }); + }); + + describe('with key that has been created already', () => { + let existingCollection: Set; + + beforeEach(() => { + existingCollection = new Set(['foo']); + cut.set('my-key', existingCollection); + }); + + it('when .set(value) is called, then new value is stored in the existing Set.', async () => { + // when + await cut.collection('my-key').set('foo'); + + // then + const expectedSet = await cut.get('my-key'); + + expect(expectedSet).toBe(existingCollection); + + // and + expect((expectedSet as Set).has('foo')).toBeTruthy(); + }); + }); + }); + + describe('given map instance is received by .map(key)', () => { + let cut: LocalCacheManager; + + beforeEach(async () => { + cut = await LocalCacheManager.create(); + }); + + describe('with key that has not been created yet', () => { + it('when .set(field, value) is called, then the underlying Map is created.', async () => { + // given + await expect(cut.get('my-key')).resolves.toBeNull(); + + // when + await cut.map('my-key').set('foo', 'bar'); + + // then + await expect(cut.get('my-key')).resolves.toStrictEqual(new Map([['foo', 'bar']])); + }); + }); + + describe('with key that has been created already', () => { + let existingMap: Map; + + beforeEach(() => { + existingMap = new Map([['foo', 'bar']]); + cut.set('my-key', existingMap); + }); + + it('when .set(field, value) is called, then new value is stored in the existing Map.', async () => { + // when + await cut.map('my-key').set('x', 'y'); + + // then + const expectedMap = await cut.get('my-key'); + + expect(expectedMap).toBe(existingMap); + + // and + expect((expectedMap as Map).get('x')).toStrictEqual('y'); + }); + }); + }); +}); \ No newline at end of file diff --git a/src/components/cache/managers/local/local-cache.manager.ts b/src/components/cache/managers/local/local-cache.manager.ts new file mode 100644 index 0000000..ceaee8a --- /dev/null +++ b/src/components/cache/managers/local/local-cache.manager.ts @@ -0,0 +1,63 @@ +import { + CacheValue, + ICacheManager, + ICacheManagerCollection, + ICacheManagerMap, + SetOptions, +} from '../cache.manager.interface'; +import * as NodeCache from 'node-cache'; +import { PrefixedManager } from '../prefixed-manager.abstract'; +import { LocalCacheMap } from './local-cache.map'; +import { LocalCacheCollection } from './local-cache.collection'; + +export class LocalCacheManager extends PrefixedManager implements ICacheManager { + private constructor(private readonly nodeCache: NodeCache, prefix = '') { + super(prefix); + } + + static async create(prefix = ''): Promise> { + return new LocalCacheManager(new NodeCache({ + useClones: false, + }), prefix); + } + + public async set(key: string, data: T, options?: SetOptions): Promise { + if (options?.expiresInSeconds) { + this.nodeCache.set(key, data, options.expiresInSeconds); + } else { + this.nodeCache.set(key, data); + } + } + + public async get(key: string): Promise { + return this.nodeCache.get(key) || null; + } + + public async del(key: string[]): Promise { + if (key.length) { + this.nodeCache.del(key.map(this.withPrefix.bind(this))); + } + } + + async expire(keys: string[], ttlMs: number): Promise { + const ttlSec = Math.round(ttlMs / 1000); + + keys.forEach((key) => this.nodeCache.ttl(this.withPrefix(key), ttlSec)); + } + + map(key: string): ICacheManagerMap { + return new LocalCacheMap(this.withPrefix(key), this.nodeCache); + } + + collection(key: string): ICacheManagerCollection { + return new LocalCacheCollection(this.withPrefix(key), this.nodeCache); + } + + forScope(prefix?: string): ICacheManager { + return new LocalCacheManager(this.nodeCache, prefix ?? this.prefix); + } + + async close(): Promise { + this.nodeCache.close(); + } +} diff --git a/src/components/cache/managers/local/local-cache.map.ts b/src/components/cache/managers/local/local-cache.map.ts new file mode 100644 index 0000000..8c24556 --- /dev/null +++ b/src/components/cache/managers/local/local-cache.map.ts @@ -0,0 +1,24 @@ +import * as NodeCache from 'node-cache'; +import { ICacheManagerMap } from '../cache.manager.interface'; + +export class LocalCacheMap implements ICacheManagerMap { + constructor(private readonly key: string, private readonly cache: NodeCache) {} + + private ensureMapInCache(): Map { + if (!this.cache.has(this.key)) { + this.cache.set(this.key, new Map()); + } + + return this.cache.get(this.key)!; + } + + async del(field: string): Promise { + this.ensureMapInCache().delete(field); + } + async get(field: string): Promise { + return this.ensureMapInCache().get(field) || null; + } + async set(field: string, data: T): Promise { + this.ensureMapInCache().set(field, data); + } +} \ No newline at end of file diff --git a/src/components/cache/managers/prefixed-manager.abstract.ts b/src/components/cache/managers/prefixed-manager.abstract.ts new file mode 100644 index 0000000..e77f967 --- /dev/null +++ b/src/components/cache/managers/prefixed-manager.abstract.ts @@ -0,0 +1,7 @@ +export abstract class PrefixedManager { + protected constructor(protected readonly prefix: string = '') {} + + protected withPrefix(key: string): string { + return this.prefix + key; + } +} diff --git a/src/components/cache/managers/redis/redis-cache.collection.ts b/src/components/cache/managers/redis/redis-cache.collection.ts new file mode 100644 index 0000000..6249fae --- /dev/null +++ b/src/components/cache/managers/redis/redis-cache.collection.ts @@ -0,0 +1,25 @@ +import { RedisClientType } from 'redis'; +import { ICacheValueSerializer } from '../../serializers/types'; +import { CacheValue, ICacheManagerCollection } from '../cache.manager.interface'; + +export class RedisCacheCollection implements ICacheManagerCollection { + constructor( + private readonly key: string, + private readonly redis: RedisClientType, + private readonly serializer: ICacheValueSerializer, + ) {} + + async set(value: T): Promise { + await this.redis.SADD(this.key, this.serializer.serialize(value)); + } + + async has(value: T): Promise { + return await this.redis.SISMEMBER(this.key, this.serializer.serialize(value)); + } + + async getAll(): Promise> { + const members = (await this.redis.SMEMBERS(this.key)).map((v) => this.serializer.deserialize(v)); + + return new Set(members); + } +} \ No newline at end of file diff --git a/src/components/cache/managers/redis/redis-cache.manager.spec.ts b/src/components/cache/managers/redis/redis-cache.manager.spec.ts new file mode 100644 index 0000000..ebe1c3e --- /dev/null +++ b/src/components/cache/managers/redis/redis-cache.manager.spec.ts @@ -0,0 +1,143 @@ +import 'jest-extended'; +import IORedis from 'ioredis'; +import { RedisCacheManager } from './redis-cache.manager'; +import { CacheValue } from '../cache.manager.interface'; + +// TODO: define all tests of Redis-based ICacheManager implementations in single file, only change the implementation +// for runs + +function delay(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} + +describe(RedisCacheManager.name, () => { + let cut: RedisCacheManager; + let redisTestConnection: IORedis; + + beforeAll(async () => { + // initialize test Redis connection + redisTestConnection = new IORedis(36279, 'localhost'); + + // initial clean-up of used key + await redisTestConnection.del('key'); + + cut = await RedisCacheManager.create({ url: 'redis://localhost:36279' }); + }); + + afterEach(async () => { + await redisTestConnection.del('key'); + }); + + afterAll(async () => { + await cut.close(); + await redisTestConnection.quit(); + }); + + describe('given simple key/value with key "key"', () => { + it('when .set("key", "value") is called, then it is stored in Redis as JSON-encoded string.', async () => { + // when + await cut.set('key', 'value'); + + // then + await expect(redisTestConnection.get('key')).resolves.toStrictEqual('"value"'); + }); + + describe('given .set("key", "value", options) has been called with expiration time, then after expiration', () => { + beforeEach(() => cut.set('key', 'value', { expiresInSeconds: 1 })); + + it('when expiration time has not passed yet, then it is kept in Redis.', async () => { + // when + await delay(100); + + // then + await expect(redisTestConnection.exists('key')).resolves.toBeGreaterThan(0); + }); + + it('when expiration time has passed already, then it is removed from Redis.', async () => { + // when + await delay(1500); + + // then + await expect(redisTestConnection.exists('key')).resolves.toEqual(0); + }); + }); + + describe('and in Redis key "key" there is JSON-encoded string \'"foo"\' stored', () => { + beforeEach(() => redisTestConnection.set('key', '"foo"')); + + it('when .get("key") is called, then it resolves to string "foo".', async () => { + // when + await expect(cut.get('key')).resolves.toStrictEqual('foo'); + }); + + it('when .del("key") is called, then key "key" is removed from Redis DB.', async () => { + // when + await cut.del(['key']); + + // then + await expect(redisTestConnection.exists('key')).resolves.toEqual(0); + }); + }); + }); + + describe('given .map("key") is called', () => { + it('when map\'s .set("field", "value") is called, then it is stored in Redis Hashset as JSON-encoded string.', async () => { + // when + await cut.map('key').set('field', 'value'); + + // then + await expect(redisTestConnection.hget('key', 'field')).resolves.toEqual('"value"'); + }); + + describe('and in Redis Hashset with field "foo" is already storing JSON-encoded value \'"bar"\'', () => { + beforeEach(() => redisTestConnection.hset('key', 'foo', '"bar"')); + + it('when map\'s .get("foo") is called, then it resolves to value "bar".', async () => { + // when & then + await expect(cut.map('key').get('foo')).resolves.toStrictEqual('bar'); + }); + + it('when map\'s .get("baz") is called, then it resolves to NULL. [non-existing key]', async () => { + // when & then + await expect(cut.map('key').get('baz')).resolves.toBeNull(); + }); + + it('when map\'s .del("foo") is called, then it drops the field "foo" from hashset "key".', async () => { + // when + await expect(cut.map('key').del('foo')).toResolve(); + + // then + await expect(redisTestConnection.hexists('key', 'foo')).resolves.toEqual(0); + }); + }); + }); + + describe('given .collection("key") is called', () => { + it('when collection\'s .set("value") is called, then it is stored in Redis Set as JSON-encoded string.', async () => { + // when + await cut.collection('key').set('value'); + + // then + await expect(redisTestConnection.sismember('key', '"value"')).resolves.toBeTruthy(); + }); + + describe('and in Redis Set value JSON-encoded value \'"foo"\' is stored', () => { + beforeEach(() => redisTestConnection.sadd('key', '"foo"')); + + it('when collection\'s .getAll() is called, then it resolves to the Set instance with value "foo".', async () => { + // when & then + await expect(cut.collection('key').getAll()).resolves.toStrictEqual(new Set(['foo'])); + }); + + it('when collection\'s .has("foo") is called, then it resolves to TRUE.', async () => { + // when & then + await expect(cut.collection('key').has('foo')).resolves.toBeTrue(); + }); + + it('when collection\'s .has("non-existing-field") is called, then it resolves to FALSE.', async () => { + // when & then + await expect(cut.collection('key').has('non-existing-field')).resolves.toBeFalsy(); + }); + }); + }); +}); \ No newline at end of file diff --git a/src/components/cache/managers/redis/redis-cache.manager.ts b/src/components/cache/managers/redis/redis-cache.manager.ts new file mode 100644 index 0000000..27ddea2 --- /dev/null +++ b/src/components/cache/managers/redis/redis-cache.manager.ts @@ -0,0 +1,84 @@ +import { + CacheValue, + ICacheManager, + ICacheManagerCollection, + ICacheManagerMap, + SetOptions, +} from '../cache.manager.interface'; +import { PackageUtils } from '../../../../utils/package-loader'; +import Logger from '../../../logger'; + +import type * as Redis from 'redis'; +import { PrefixedManager } from '../prefixed-manager.abstract'; +import { ICacheValueSerializer } from '../../serializers/types'; +import { JsonSerializer } from '../../serializers/json.serializer'; +import { RedisCacheCollection } from './redis-cache.collection'; +import { RedisCacheMap } from './redis-cache.map'; + +export interface IRedisOptions { + url: string; +} + +export class RedisCacheManager extends PrefixedManager implements ICacheManager { + private readonly serializer: ICacheValueSerializer; + private readonly isReadyPromise: Promise; + + private constructor(private readonly redisManager: Redis.RedisClientType, prefix = '') { + super(prefix); + + this.serializer = new JsonSerializer(); + + this.isReadyPromise = this.redisManager.connect(); + this.isReadyPromise.catch((e) => Logger.error('Failed to connect to redis', e)); + } + + static create(options: IRedisOptions, prefix = ''): Promise> { + const { createClient } = PackageUtils.loadPackage('redis') as typeof Redis; + + return new RedisCacheManager(createClient(options), prefix).ready(); + } + + ready(): Promise { + return this.isReadyPromise.then(() => this); + } + + public async set(key: string, data: T, options?: SetOptions): Promise { + if (options?.expiresInSeconds) { + await this.redisManager.set(this.withPrefix(key), JSON.stringify(data), { EX: options.expiresInSeconds }); + } else { + await this.redisManager.set(this.withPrefix(key), JSON.stringify(data)); + } + } + + public async get(key: string): Promise { + const stringifiedData = await this.redisManager.get(this.withPrefix(key)); + return stringifiedData ? JSON.parse(stringifiedData) : null; + } + + public async del(key: string[]): Promise { + if (key.length) { + await this.redisManager.del(key.map(this.withPrefix.bind(this))); + } + } + + map(key: string): ICacheManagerMap { + return new RedisCacheMap(this.withPrefix(key), this.redisManager, this.serializer); + } + + collection(key: string): ICacheManagerCollection { + return new RedisCacheCollection(this.withPrefix(key), this.redisManager, this.serializer); + } + + async expire(keys: string[], ttlMs: number): Promise { + for (const key of keys) { + await this.redisManager.PEXPIRE(this.withPrefix(key), ttlMs); + } + } + forScope(prefix?: string): ICacheManager { + return new RedisCacheManager(this.redisManager, prefix ?? this.prefix); + } + + close(): Promise { + return this.redisManager.disconnect(); + } +} diff --git a/src/components/cache/managers/redis/redis-cache.map.ts b/src/components/cache/managers/redis/redis-cache.map.ts new file mode 100644 index 0000000..f63e4fc --- /dev/null +++ b/src/components/cache/managers/redis/redis-cache.map.ts @@ -0,0 +1,25 @@ +import { RedisClientType } from 'redis'; +import { ICacheValueSerializer } from '../../serializers/types'; +import { CacheValue, ICacheManagerMap } from '../cache.manager.interface'; + +export class RedisCacheMap implements ICacheManagerMap { + constructor( + private readonly key: string, + private readonly redis: RedisClientType, + private readonly serializer: ICacheValueSerializer, + ) {} + + async set(field: string, data: T): Promise { + await this.redis.HSET(this.key, field, this.serializer.serialize(data)); + } + + async get(field: string): Promise { + const raw = await this.redis.HGET(this.key, field); + + return raw !== undefined ? this.serializer.deserialize(raw) : null; + } + + async del(field: string): Promise { + await this.redis.HDEL(this.key, field); + } +} \ No newline at end of file diff --git a/src/components/cache/serializers/json.serializer.ts b/src/components/cache/serializers/json.serializer.ts new file mode 100644 index 0000000..2dceeb4 --- /dev/null +++ b/src/components/cache/serializers/json.serializer.ts @@ -0,0 +1,11 @@ +import { ICacheValueSerializer } from './types'; + +export class JsonSerializer implements ICacheValueSerializer { + serialize(data: T): string { + return JSON.stringify(data); + } + + deserialize(raw: string): T { + return JSON.parse(raw) as T; + } +} \ No newline at end of file diff --git a/src/components/cache/serializers/types.ts b/src/components/cache/serializers/types.ts new file mode 100644 index 0000000..6e2fa4f --- /dev/null +++ b/src/components/cache/serializers/types.ts @@ -0,0 +1,4 @@ +export interface ICacheValueSerializer { + serialize(data: T): string; + deserialize(raw: string): T; +} \ No newline at end of file diff --git a/src/components/frontegg-context/index.ts b/src/components/frontegg-context/index.ts index 31dcda4..672ec25 100644 --- a/src/components/frontegg-context/index.ts +++ b/src/components/frontegg-context/index.ts @@ -1,6 +1,12 @@ -import { IIORedisCacheOptions, IRedisCacheOptions } from '../../cache/types'; import { PackageUtils } from '../../utils/package-loader'; -import { IFronteggContext, IFronteggOptions, IAccessTokensOptions } from './types'; +import { IFronteggContext, IFronteggOptions, IFronteggCacheOptions } from './types'; +import { IIORedisOptions, IRedisOptions } from '../cache/managers'; + +const DEFAULT_OPTIONS: IFronteggOptions = { + cache: { + type: 'local', + }, +}; export class FronteggContext { public static getInstance(): FronteggContext { @@ -11,10 +17,12 @@ export class FronteggContext { return FronteggContext.instance; } - public static init(context: IFronteggContext, options?: IFronteggOptions) { - FronteggContext.getInstance().context = context; + public static init(context: IFronteggContext, givenOptions?: Partial) { + const options = FronteggContext.prepareOptions(givenOptions); FronteggContext.getInstance().validateOptions(options); - FronteggContext.getInstance().options = options ?? {}; + FronteggContext.getInstance().options = options; + + FronteggContext.getInstance().context = context; } public static getContext(): IFronteggContext { @@ -27,37 +35,35 @@ export class FronteggContext { } public static getOptions(): IFronteggOptions { - return FronteggContext.getInstance().options || {}; + return FronteggContext.getInstance().options; } private static instance: FronteggContext; private context: IFronteggContext | null = null; - private options: IFronteggOptions = {}; - - private constructor() {} + private options: IFronteggOptions; - private validateOptions(options?: IFronteggOptions): void { - if (options?.accessTokensOptions) { - this.validateAccessTokensOptions(options.accessTokensOptions); - } + private constructor() { + this.options = DEFAULT_OPTIONS; } - private validateAccessTokensOptions(accessTokensOptions: IAccessTokensOptions): void { - if (!accessTokensOptions.cache) { - throw new Error(`'cache' is missing from access tokens options`); + private validateOptions(options: Partial): void { + if (options.cache) { + this.validateCacheOptions(options.cache); } + } - if (accessTokensOptions.cache.type === 'ioredis') { - this.validateIORedisOptions(accessTokensOptions.cache.options); - } else if (accessTokensOptions.cache.type === 'redis') { - this.validateRedisOptions(accessTokensOptions.cache.options); + private validateCacheOptions(cache: IFronteggCacheOptions): void { + if (cache.type === 'ioredis') { + this.validateIORedisOptions(cache.options); + } else if (cache.type === 'redis') { + this.validateRedisOptions(cache.options); } } - private validateIORedisOptions(redisOptions: IIORedisCacheOptions): void { + private validateIORedisOptions(redisOptions: IIORedisOptions): void { PackageUtils.loadPackage('ioredis'); - const requiredProperties: (keyof IIORedisCacheOptions)[] = ['host', 'port']; + const requiredProperties: (keyof IIORedisOptions)[] = ['host', 'port']; requiredProperties.forEach((requiredProperty) => { if (redisOptions[requiredProperty] === undefined) { throw new Error(`${requiredProperty} is missing from ioredis cache options`); @@ -65,14 +71,21 @@ export class FronteggContext { }); } - private validateRedisOptions(redisOptions: IRedisCacheOptions): void { + private validateRedisOptions(redisOptions: IRedisOptions): void { PackageUtils.loadPackage('redis'); - const requiredProperties: (keyof IRedisCacheOptions)[] = ['url']; + const requiredProperties: (keyof IRedisOptions)[] = ['url']; requiredProperties.forEach((requiredProperty) => { if (redisOptions[requiredProperty] === undefined) { throw new Error(`${requiredProperty} is missing from redis cache options`); } }); } + + private static prepareOptions(options?: Partial): IFronteggOptions { + return { + ...DEFAULT_OPTIONS, + ...(options || {}), + }; + } } diff --git a/src/components/frontegg-context/types.ts b/src/components/frontegg-context/types.ts index 80e9cca..f4f8f88 100644 --- a/src/components/frontegg-context/types.ts +++ b/src/components/frontegg-context/types.ts @@ -1,4 +1,4 @@ -import { IIORedisCacheOptions, IRedisCacheOptions } from '../../cache/types'; +import { IIORedisOptions, IRedisOptions } from '../cache/managers'; export interface IFronteggContext { FRONTEGG_CLIENT_ID: string; @@ -6,28 +6,25 @@ export interface IFronteggContext { } export interface IFronteggOptions { - cache?: IAccessTokensLocalCache | IAccessTokensIORedisCache | IAccessTokensRedisCache; - accessTokensOptions?: IAccessTokensOptions; + cache: IFronteggCacheOptions; } -export interface IAccessTokensOptions { - cache: IAccessTokensLocalCache | IAccessTokensIORedisCache | IAccessTokensRedisCache; -} - -export interface IAccessTokensCache { +export interface IBaseCacheOptions { type: 'ioredis' | 'local' | 'redis'; } -export interface IAccessTokensLocalCache extends IAccessTokensCache { +export interface ILocalCacheOptions extends IBaseCacheOptions { type: 'local'; } -export interface IAccessTokensIORedisCache extends IAccessTokensCache { +export interface IIORedisCacheOptions extends IBaseCacheOptions { type: 'ioredis'; - options: IIORedisCacheOptions; + options: IIORedisOptions; } -export interface IAccessTokensRedisCache extends IAccessTokensCache { +export interface IRedisCacheOptions extends IBaseCacheOptions, IRedisOptions { type: 'redis'; - options: IRedisCacheOptions; + options: IRedisOptions; } + +export type IFronteggCacheOptions = ILocalCacheOptions | IIORedisCacheOptions | IRedisCacheOptions; diff --git a/src/utils/package-loader.ts b/src/utils/package-loader.ts index 179bb3f..709a9fd 100644 --- a/src/utils/package-loader.ts +++ b/src/utils/package-loader.ts @@ -1,7 +1,7 @@ import * as path from 'path'; export class PackageUtils { - public static loadPackage(name: string): unknown { + public static loadPackage(name: string): T { const packagePath = path.resolve(process.cwd() + '/node_modules/' + name); try {