From 85db5230d7530e2e61dcea8e79174148e9cb1f6f Mon Sep 17 00:00:00 2001 From: Artur Wolny Date: Thu, 13 Jul 2023 14:31:09 +0200 Subject: [PATCH 01/12] feat(cache): decoupled cache managers from AccessTokens --- README.md | 52 ++++++- package.json | 3 + src/cache/cache.manager.interface.ts | 9 -- src/cache/types.ts | 10 -- .../token-resolvers/access-token-resolver.ts | 48 +----- .../cache-access-token.service.ts | 21 +-- .../cache-tenant-access-token.service.ts | 9 +- .../cache-user-access-token.service.ts | 9 +- src/components/cache/index.spec.ts | 141 ++++++++++++++++++ src/components/cache/index.ts | 28 ++++ .../cache/managers/cache.manager.interface.ts | 9 ++ .../cache/managers}/index.ts | 0 .../managers}/ioredis-cache.manager.spec.ts | 2 +- .../cache/managers}/ioredis-cache.manager.ts | 18 ++- .../managers}/local-cache.manager.spec.ts | 2 +- .../cache/managers}/local-cache.manager.ts | 6 +- .../cache/managers}/redis-cache.manager.ts | 17 ++- src/components/frontegg-context/index.ts | 66 +++++--- src/components/frontegg-context/types.ts | 20 +-- src/utils/warning.ts | 13 ++ 20 files changed, 353 insertions(+), 130 deletions(-) delete mode 100644 src/cache/cache.manager.interface.ts create mode 100644 src/components/cache/index.spec.ts create mode 100644 src/components/cache/index.ts create mode 100644 src/components/cache/managers/cache.manager.interface.ts rename src/{cache => components/cache/managers}/index.ts (100%) rename src/{cache => components/cache/managers}/ioredis-cache.manager.spec.ts (96%) rename src/{cache => components/cache/managers}/ioredis-cache.manager.ts (62%) rename src/{cache => components/cache/managers}/local-cache.manager.spec.ts (93%) rename src/{cache => components/cache/managers}/local-cache.manager.ts (71%) rename src/{cache => components/cache/managers}/redis-cache.manager.ts (66%) create mode 100644 src/utils/warning.ts diff --git a/README.md b/README.md index a5eb56d..3833133 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. @@ -87,6 +129,9 @@ By default access tokens will be cached locally, however you can use two other k - redis #### Use ioredis as your cache +> **Deprecation Warning!** +> This section is deprecated. See Redis cache section for cache configuration. + When initializing your context, pass an access tokens options object with your ioredis parameters ```javascript @@ -116,6 +161,9 @@ FronteggContext.init( ``` #### Use redis as your cache +> **Deprecation Warning!** +> This section is deprecated. See Redis cache section for cache configuration. + When initializing your context, pass an access tokens options object with your redis parameters ```javascript @@ -154,7 +202,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 +343,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/package.json b/package.json index 5a1f9f7..b7057c6 100644 --- a/package.json +++ b/package.json @@ -24,8 +24,10 @@ "dependencies": { "@slack/web-api": "^6.7.2", "axios": "^0.27.2", + "debug": "^4.3.4", "jsonwebtoken": "^9.0.0", "node-cache": "^5.1.2", + "process-warning": "^2.2.0", "winston": "^3.8.2" }, "peerDependencies": { @@ -44,6 +46,7 @@ "@semantic-release/changelog": "^6.0.1", "@semantic-release/git": "^10.0.1", "@types/axios-mock-adapter": "^1.10.0", + "@types/debug": "^4.1.8", "@types/express": "^4.17.14", "@types/jest": "^29.2.0", "@types/jsonwebtoken": "^9.0.0", 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/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/identity/token-resolvers/access-token-resolver.ts b/src/clients/identity/token-resolvers/access-token-resolver.ts index efa35dc..17c85ba 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(); @@ -98,47 +98,9 @@ export class AccessTokenResolver extends TokenResolver { 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), - ), - ]; - } + this.accessTokenServices = [ + new CacheTenantAccessTokenService(FronteggCache.getInstance(), new TenantAccessTokenService(this.httpClient)), + new CacheUserAccessTokenService(FronteggCache.getInstance(), 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.ts index fa0b7a6..e6f27dc 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.ts @@ -1,19 +1,18 @@ 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, + public readonly cacheManager: ICacheManager, public readonly accessTokenService: IAccessTokenService, public readonly type: tokenTypes.UserAccessToken | tokenTypes.TenantAccessToken, ) {} public async getEntity(entity: T): Promise { const cacheKey = `${this.getCachePrefix()}_${entity.sub}`; - const cachedData = await this.entityCacheManager.get(cacheKey); + const cachedData = await this.cacheManager.get(cacheKey); if (cachedData) { if (this.isEmptyAccessToken(cachedData)) { @@ -25,12 +24,16 @@ export abstract class CacheAccessTokenService implements try { const data = await this.accessTokenService.getEntity(entity); - await this.entityCacheManager.set(cacheKey, data, { expiresInSeconds: 10 }); + await this.cacheManager.set(cacheKey, data, { expiresInSeconds: 10 }); return data; } catch (e) { if (e instanceof FailedToAuthenticateException) { - await this.entityCacheManager.set(cacheKey, { empty: true }, { expiresInSeconds: 10 }); + await this.cacheManager.set( + cacheKey, + { empty: true }, + { expiresInSeconds: 10 }, + ); } throw e; @@ -39,7 +42,7 @@ export abstract class CacheAccessTokenService implements public async getActiveAccessTokenIds(): Promise { const cacheKey = `${this.getCachePrefix()}_ids`; - const cachedData = await this.activeAccessTokensCacheManager.get(cacheKey); + const cachedData = await this.cacheManager.get(cacheKey); if (cachedData) { return cachedData; @@ -47,12 +50,12 @@ export abstract class CacheAccessTokenService implements try { const data = await this.accessTokenService.getActiveAccessTokenIds(); - this.activeAccessTokensCacheManager.set(cacheKey, data, { expiresInSeconds: 10 }); + this.cacheManager.set(cacheKey, data, { expiresInSeconds: 10 }); return data; } catch (e) { if (e instanceof FailedToAuthenticateException) { - await this.activeAccessTokensCacheManager.set(cacheKey, [], { expiresInSeconds: 10 }); + await this.cacheManager.set(cacheKey, [], { expiresInSeconds: 10 }); } throw e; 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..fd2f0f7 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,15 +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'; export class CacheTenantAccessTokenService extends CacheAccessTokenService { constructor( - public readonly entityCacheManager: ICacheManager, - public readonly activeAccessTokensCacheManager: ICacheManager, + public readonly cacheManager: ICacheManager, public readonly tenantAccessTokenService: AccessTokenService, ) { - super(entityCacheManager, activeAccessTokensCacheManager, tenantAccessTokenService, tokenTypes.TenantAccessToken); + super(cacheManager, tenantAccessTokenService, tokenTypes.TenantAccessToken); } protected getCachePrefix(): string { 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..ce97635 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,15 +1,14 @@ -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'; export class CacheUserAccessTokenService extends CacheAccessTokenService { constructor( - public readonly entityCacheManager: ICacheManager, - public readonly activeAccessTokensCacheManager: ICacheManager, + public readonly cacheManager: ICacheManager, public readonly userAccessTokenService: AccessTokenService, ) { - super(entityCacheManager, activeAccessTokensCacheManager, userAccessTokenService, tokenTypes.UserAccessToken); + super(cacheManager, userAccessTokenService, tokenTypes.UserAccessToken); } protected getCachePrefix(): string { diff --git a/src/components/cache/index.spec.ts b/src/components/cache/index.spec.ts new file mode 100644 index 0000000..da5d0c4 --- /dev/null +++ b/src/components/cache/index.spec.ts @@ -0,0 +1,141 @@ +import { + IFronteggOptions, + IIORedisCacheOptions, + ILocalCacheOptions, + IRedisCacheOptions, +} from '../frontegg-context/types'; +import { FronteggContext } from '../frontegg-context'; +import { FronteggWarningCodes } from '../../utils/warning'; + +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).mockReturnValue(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', + }, + { + cacheConfigInfo: "type of 'ioredis' in `$.accessTokensOptions.cache` and empty `$.cache`", + config: { + accessTokensOptions: { + cache: { + type: 'ioredis', + options: { host: 'foo', password: 'bar', db: 0, port: 6372 }, + } as IIORedisCacheOptions, + }, + } as IFronteggOptions, + expectedCacheName: 'IORedisCacheManager', + }, + { + cacheConfigInfo: "type of 'redis' in `$.accessTokensOptions.cache` and empty `$.cache`", + config: { + accessTokensOptions: { + cache: { + type: 'redis', + options: { url: 'redis://url:6372' }, + } as IRedisCacheOptions, + }, + } as IFronteggOptions, + 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.`, () => { + // given + const { FronteggCache } = require('./index'); + + // when + const cache = FronteggCache.getInstance(); + + // then + expect(cache).toBe(expectedCache); + }); + }); + + describe('given cache defined in deprecated `$.accessTokensOptions.cache`', () => { + it('when cache is initialized, then Node warning is issued.', () => { + // given + const { FronteggContext } = require('../frontegg-context'); + FronteggContext.init( + { + FRONTEGG_CLIENT_ID: 'foo', + FRONTEGG_API_KEY: 'bar', + }, + { + accessTokensOptions: { + cache: { + type: 'local', + }, + }, + }, + ); + + // when + require('./index').FronteggCache.getInstance(); + + // then + expect( + require('../../utils/warning').warning.emitted.get(FronteggWarningCodes.CONFIG_KEY_MOVED_DEPRECATION), + ).toBeTruthy(); + }); + }); +}); diff --git a/src/components/cache/index.ts b/src/components/cache/index.ts new file mode 100644 index 0000000..0fe554b --- /dev/null +++ b/src/components/cache/index.ts @@ -0,0 +1,28 @@ +import { ICacheManager, IORedisCacheManager, LocalCacheManager, RedisCacheManager } from './managers'; +import { FronteggContext } from '../frontegg-context'; + +let cacheInstance: ICacheManager; + +export class FronteggCache { + static getInstance(): ICacheManager { + if (!cacheInstance) { + cacheInstance = FronteggCache.initialize(); + } + + return cacheInstance; + } + + private static initialize(): ICacheManager { + const options = FronteggContext.getOptions(); + const cache = options.accessTokensOptions?.cache || options.cache; + + switch (cache.type) { + case 'ioredis': + return new IORedisCacheManager(cache.options); + case 'redis': + return new RedisCacheManager(cache.options); + default: + return new LocalCacheManager(); + } + } +} 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..e324e23 --- /dev/null +++ b/src/components/cache/managers/cache.manager.interface.ts @@ -0,0 +1,9 @@ +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/components/cache/managers/index.ts similarity index 100% rename from src/cache/index.ts rename to src/components/cache/managers/index.ts diff --git a/src/cache/ioredis-cache.manager.spec.ts b/src/components/cache/managers/ioredis-cache.manager.spec.ts similarity index 96% rename from src/cache/ioredis-cache.manager.spec.ts rename to src/components/cache/managers/ioredis-cache.manager.spec.ts index 926c40c..a3776fb 100644 --- a/src/cache/ioredis-cache.manager.spec.ts +++ b/src/components/cache/managers/ioredis-cache.manager.spec.ts @@ -1,6 +1,6 @@ import { IORedisCacheManager } from './ioredis-cache.manager'; -jest.mock('../utils/package-loader', () => ({ +jest.mock('../../../utils/package-loader', () => ({ PackageUtils: { loadPackage: (name: string) => { switch (name) { diff --git a/src/cache/ioredis-cache.manager.ts b/src/components/cache/managers/ioredis-cache.manager.ts similarity index 62% rename from src/cache/ioredis-cache.manager.ts rename to src/components/cache/managers/ioredis-cache.manager.ts index f830fdd..16cf708 100644 --- a/src/cache/ioredis-cache.manager.ts +++ b/src/components/cache/managers/ioredis-cache.manager.ts @@ -1,16 +1,22 @@ import { ICacheManager, SetOptions } from './cache.manager.interface'; -import { PackageUtils } from '../utils/package-loader'; -import { IIORedisCacheOptions } from './types'; +import { PackageUtils } from '../../../utils/package-loader'; -export class IORedisCacheManager implements ICacheManager { +export interface IIORedisOptions { + host: string; + password: string; + port: number; + db: number; +} + +export class IORedisCacheManager implements ICacheManager { private redisManager: any; - constructor(options: IIORedisCacheOptions) { + constructor(options: IIORedisOptions) { const RedisInstance = PackageUtils.loadPackage('ioredis') as any; this.redisManager = new RedisInstance(options); } - public async set(key: string, data: T, options?: SetOptions): Promise { + public async set(key: string, data: T, options?: SetOptions): Promise { if (options?.expiresInSeconds) { this.redisManager.set(key, JSON.stringify(data), 'EX', options.expiresInSeconds); } else { @@ -18,7 +24,7 @@ export class IORedisCacheManager implements ICacheManager { } } - public async get(key: string): Promise { + public async get(key: string): Promise { const stringifiedData = await this.redisManager.get(key); return stringifiedData ? JSON.parse(stringifiedData) : null; } diff --git a/src/cache/local-cache.manager.spec.ts b/src/components/cache/managers/local-cache.manager.spec.ts similarity index 93% rename from src/cache/local-cache.manager.spec.ts rename to src/components/cache/managers/local-cache.manager.spec.ts index ec61587..f212347 100644 --- a/src/cache/local-cache.manager.spec.ts +++ b/src/components/cache/managers/local-cache.manager.spec.ts @@ -1,7 +1,7 @@ import { LocalCacheManager } from './local-cache.manager'; describe('Local cache manager', () => { - const localCacheManager = new LocalCacheManager<{ data: string }>(); + const localCacheManager = new LocalCacheManager(); const cacheKey = 'key'; const cacheValue = { data: 'value' }; diff --git a/src/cache/local-cache.manager.ts b/src/components/cache/managers/local-cache.manager.ts similarity index 71% rename from src/cache/local-cache.manager.ts rename to src/components/cache/managers/local-cache.manager.ts index 7a05871..604250b 100644 --- a/src/cache/local-cache.manager.ts +++ b/src/components/cache/managers/local-cache.manager.ts @@ -1,10 +1,10 @@ import { ICacheManager, SetOptions } from './cache.manager.interface'; import * as NodeCache from 'node-cache'; -export class LocalCacheManager implements ICacheManager { +export class LocalCacheManager implements ICacheManager { private nodeCache: NodeCache = new NodeCache(); - public async set(key: string, data: T, options?: SetOptions): Promise { + public async set(key: string, data: T, options?: SetOptions): Promise { if (options?.expiresInSeconds) { this.nodeCache.set(key, data, options.expiresInSeconds); } else { @@ -12,7 +12,7 @@ export class LocalCacheManager implements ICacheManager { } } - public async get(key: string): Promise { + public async get(key: string): Promise { return this.nodeCache.get(key) || null; } diff --git a/src/cache/redis-cache.manager.ts b/src/components/cache/managers/redis-cache.manager.ts similarity index 66% rename from src/cache/redis-cache.manager.ts rename to src/components/cache/managers/redis-cache.manager.ts index b799ad4..2f64b39 100644 --- a/src/cache/redis-cache.manager.ts +++ b/src/components/cache/managers/redis-cache.manager.ts @@ -1,18 +1,21 @@ import { ICacheManager, SetOptions } from './cache.manager.interface'; -import { PackageUtils } from '../utils/package-loader'; -import { IRedisCacheOptions } from './types'; -import Logger from '../components/logger'; +import { PackageUtils } from '../../../utils/package-loader'; +import Logger from '../../logger'; -export class RedisCacheManager implements ICacheManager { +export interface IRedisOptions { + url: string; +} + +export class RedisCacheManager implements ICacheManager { private redisManager: any; - constructor(options: IRedisCacheOptions) { + constructor(options: IRedisOptions) { 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 { + public async set(key: string, data: T, options?: SetOptions): Promise { if (options?.expiresInSeconds) { this.redisManager.set(key, JSON.stringify(data), { EX: options.expiresInSeconds }); } else { @@ -20,7 +23,7 @@ export class RedisCacheManager implements ICacheManager { } } - public async get(key: string): Promise { + public async get(key: string): Promise { const stringifiedData = await this.redisManager.get(key); return stringifiedData ? JSON.parse(stringifiedData) : null; } diff --git a/src/components/frontegg-context/index.ts b/src/components/frontegg-context/index.ts index 31dcda4..8f7427d 100644 --- a/src/components/frontegg-context/index.ts +++ b/src/components/frontegg-context/index.ts @@ -1,6 +1,13 @@ -import { IIORedisCacheOptions, IRedisCacheOptions } from '../../cache/types'; import { PackageUtils } from '../../utils/package-loader'; -import { IFronteggContext, IFronteggOptions, IAccessTokensOptions } from './types'; +import { IFronteggContext, IFronteggOptions, IAccessTokensOptions, IFronteggCacheOptions } from './types'; +import { IIORedisOptions, IRedisOptions } from '../cache/managers'; +import { FronteggWarningCodes, warning } from '../../utils/warning'; + +const DEFAULT_OPTIONS: IFronteggOptions = { + cache: { + type: 'local', + }, +}; export class FronteggContext { public static getInstance(): FronteggContext { @@ -11,10 +18,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 +36,47 @@ 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 options: IFronteggOptions; - private constructor() {} + private constructor() { + this.options = DEFAULT_OPTIONS; + } - private validateOptions(options?: IFronteggOptions): void { - if (options?.accessTokensOptions) { + private validateOptions(options: Partial): void { + if (options.cache) { + this.validateCacheOptions(options.cache); + } + + if (options.accessTokensOptions) { this.validateAccessTokensOptions(options.accessTokensOptions); } } private validateAccessTokensOptions(accessTokensOptions: IAccessTokensOptions): void { - if (!accessTokensOptions.cache) { - throw new Error(`'cache' is missing from access tokens options`); + if (accessTokensOptions.cache) { + warning.emit(FronteggWarningCodes.CONFIG_KEY_MOVED_DEPRECATION, '$.accessTokenOptions.cache', '$.cache'); } - if (accessTokensOptions.cache.type === 'ioredis') { - this.validateIORedisOptions(accessTokensOptions.cache.options); - } else if (accessTokensOptions.cache.type === 'redis') { - this.validateRedisOptions(accessTokensOptions.cache.options); + this.validateCacheOptions(accessTokensOptions.cache); + } + + 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 +84,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..dae9cd7 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,30 @@ export interface IFronteggContext { } export interface IFronteggOptions { - cache?: IAccessTokensLocalCache | IAccessTokensIORedisCache | IAccessTokensRedisCache; + cache: IFronteggCacheOptions; accessTokensOptions?: IAccessTokensOptions; } export interface IAccessTokensOptions { - cache: IAccessTokensLocalCache | IAccessTokensIORedisCache | IAccessTokensRedisCache; + cache: IFronteggCacheOptions; } -export interface IAccessTokensCache { +export interface IAccessTokensCacheOptions { type: 'ioredis' | 'local' | 'redis'; } -export interface IAccessTokensLocalCache extends IAccessTokensCache { +export interface ILocalCacheOptions extends IAccessTokensCacheOptions { type: 'local'; } -export interface IAccessTokensIORedisCache extends IAccessTokensCache { +export interface IIORedisCacheOptions extends IAccessTokensCacheOptions { type: 'ioredis'; - options: IIORedisCacheOptions; + options: IIORedisOptions; } -export interface IAccessTokensRedisCache extends IAccessTokensCache { +export interface IRedisCacheOptions extends IAccessTokensCacheOptions, IRedisOptions { type: 'redis'; - options: IRedisCacheOptions; + options: IRedisOptions; } + +export type IFronteggCacheOptions = ILocalCacheOptions | IIORedisCacheOptions | IRedisCacheOptions; diff --git a/src/utils/warning.ts b/src/utils/warning.ts new file mode 100644 index 0000000..7364f18 --- /dev/null +++ b/src/utils/warning.ts @@ -0,0 +1,13 @@ +import processWarning = require('process-warning'); + +export enum FronteggWarningCodes { + CONFIG_KEY_MOVED_DEPRECATION = 'CONFIG_KEY_MOVED_DEPRECATION', +} + +export const warning = processWarning(); + +warning.create( + 'FronteggWarning', + FronteggWarningCodes.CONFIG_KEY_MOVED_DEPRECATION, + "Config key '%s' is deprecated. Put the configuration in '%s'.", +); From 9051f018e07826b7c18f20ac736a9e94634da9db Mon Sep 17 00:00:00 2001 From: Artur Wolny Date: Fri, 14 Jul 2023 13:44:43 +0200 Subject: [PATCH 02/12] build(sdk): npm audit fix --- package.json | 2 -- 1 file changed, 2 deletions(-) diff --git a/package.json b/package.json index b7057c6..fc1f5ee 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,6 @@ "dependencies": { "@slack/web-api": "^6.7.2", "axios": "^0.27.2", - "debug": "^4.3.4", "jsonwebtoken": "^9.0.0", "node-cache": "^5.1.2", "process-warning": "^2.2.0", @@ -46,7 +45,6 @@ "@semantic-release/changelog": "^6.0.1", "@semantic-release/git": "^10.0.1", "@types/axios-mock-adapter": "^1.10.0", - "@types/debug": "^4.1.8", "@types/express": "^4.17.14", "@types/jest": "^29.2.0", "@types/jsonwebtoken": "^9.0.0", From 7d04440ab94e66d0155032597d42ec8b17c4b1da Mon Sep 17 00:00:00 2001 From: Artur Wolny Date: Mon, 17 Jul 2023 16:12:39 +0200 Subject: [PATCH 03/12] fix(cache): Bringing back the ICacheManager generic to the class level --- .../cache-services/cache-access-token.service.ts | 16 ++++++++-------- .../cache-tenant-access-token.service.ts | 4 ++-- .../cache-user-access-token.service.ts | 4 ++-- src/components/cache/index.ts | 10 +++++----- .../cache/managers/cache.manager.interface.ts | 6 +++--- .../cache/managers/ioredis-cache.manager.ts | 2 +- .../cache/managers/local-cache.manager.ts | 2 +- .../cache/managers/redis-cache.manager.ts | 2 +- 8 files changed, 23 insertions(+), 23 deletions(-) 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.ts index e6f27dc..bf40346 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.ts @@ -5,14 +5,14 @@ import { FailedToAuthenticateException } from '../../../exceptions'; export abstract class CacheAccessTokenService implements IAccessTokenService { constructor( - public readonly cacheManager: ICacheManager, + public readonly cacheManager: ICacheManager, public readonly accessTokenService: IAccessTokenService, public readonly type: tokenTypes.UserAccessToken | tokenTypes.TenantAccessToken, ) {} public async getEntity(entity: T): Promise { const cacheKey = `${this.getCachePrefix()}_${entity.sub}`; - const cachedData = await this.cacheManager.get(cacheKey); + const cachedData = await this.cacheManager.get(cacheKey) as (IEntityWithRoles | undefined); if (cachedData) { if (this.isEmptyAccessToken(cachedData)) { @@ -24,12 +24,12 @@ export abstract class CacheAccessTokenService implements try { const data = await this.accessTokenService.getEntity(entity); - await this.cacheManager.set(cacheKey, data, { expiresInSeconds: 10 }); + await this.cacheManager.set(cacheKey, data, { expiresInSeconds: 10 }); return data; } catch (e) { if (e instanceof FailedToAuthenticateException) { - await this.cacheManager.set( + await this.cacheManager.set( cacheKey, { empty: true }, { expiresInSeconds: 10 }, @@ -42,20 +42,20 @@ export abstract class CacheAccessTokenService implements public async getActiveAccessTokenIds(): Promise { const cacheKey = `${this.getCachePrefix()}_ids`; - const cachedData = await this.cacheManager.get(cacheKey); + const cachedData = await this.cacheManager.get(cacheKey); if (cachedData) { - return cachedData; + return cachedData as string[]; } try { const data = await this.accessTokenService.getActiveAccessTokenIds(); - this.cacheManager.set(cacheKey, data, { expiresInSeconds: 10 }); + this.cacheManager.set(cacheKey, data, { expiresInSeconds: 10 }); return data; } catch (e) { if (e instanceof FailedToAuthenticateException) { - await this.cacheManager.set(cacheKey, [], { expiresInSeconds: 10 }); + await this.cacheManager.set(cacheKey, [], { expiresInSeconds: 10 }); } throw e; 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 fd2f0f7..531a45f 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,11 +1,11 @@ import { ICacheManager } from '../../../../../components/cache/managers/cache.manager.interface'; -import { ITenantAccessToken, tokenTypes } from '../../../types'; +import { IEmptyAccessToken, IEntityWithRoles, ITenantAccessToken, tokenTypes } from '../../../types'; import { AccessTokenService } from '../services/access-token.service'; import { CacheAccessTokenService } from './cache-access-token.service'; export class CacheTenantAccessTokenService extends CacheAccessTokenService { constructor( - public readonly cacheManager: ICacheManager, + public readonly cacheManager: ICacheManager, public readonly tenantAccessTokenService: AccessTokenService, ) { super(cacheManager, tenantAccessTokenService, tokenTypes.TenantAccessToken); 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 ce97635..2aabc29 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,11 +1,11 @@ import { ICacheManager } from '../../../../../components/cache/managers/cache.manager.interface'; -import { IUserAccessToken, tokenTypes } from '../../../types'; +import { IEmptyAccessToken, IEntityWithRoles, IUserAccessToken, tokenTypes } from '../../../types'; import { AccessTokenService } from '../services/access-token.service'; import { CacheAccessTokenService } from './cache-access-token.service'; export class CacheUserAccessTokenService extends CacheAccessTokenService { constructor( - public readonly cacheManager: ICacheManager, + public readonly cacheManager: ICacheManager, public readonly userAccessTokenService: AccessTokenService, ) { super(cacheManager, userAccessTokenService, tokenTypes.UserAccessToken); diff --git a/src/components/cache/index.ts b/src/components/cache/index.ts index 0fe554b..72e07c8 100644 --- a/src/components/cache/index.ts +++ b/src/components/cache/index.ts @@ -1,18 +1,18 @@ import { ICacheManager, IORedisCacheManager, LocalCacheManager, RedisCacheManager } from './managers'; import { FronteggContext } from '../frontegg-context'; -let cacheInstance: ICacheManager; +let cacheInstance: ICacheManager; export class FronteggCache { - static getInstance(): ICacheManager { + static getInstance(): ICacheManager { if (!cacheInstance) { - cacheInstance = FronteggCache.initialize(); + cacheInstance = FronteggCache.initialize(); } - return cacheInstance; + return cacheInstance as ICacheManager; } - private static initialize(): ICacheManager { + private static initialize(): ICacheManager { const options = FronteggContext.getOptions(); const cache = options.accessTokensOptions?.cache || options.cache; diff --git a/src/components/cache/managers/cache.manager.interface.ts b/src/components/cache/managers/cache.manager.interface.ts index e324e23..60221ed 100644 --- a/src/components/cache/managers/cache.manager.interface.ts +++ b/src/components/cache/managers/cache.manager.interface.ts @@ -2,8 +2,8 @@ export interface SetOptions { expiresInSeconds: number; } -export interface ICacheManager { - set(key: string, data: T, options?: SetOptions): Promise; - get(key: string): Promise; +export interface ICacheManager { + set(key: string, data: T, options?: SetOptions): Promise; + get(key: string): Promise; del(key: string[]): Promise; } diff --git a/src/components/cache/managers/ioredis-cache.manager.ts b/src/components/cache/managers/ioredis-cache.manager.ts index 16cf708..bbcf584 100644 --- a/src/components/cache/managers/ioredis-cache.manager.ts +++ b/src/components/cache/managers/ioredis-cache.manager.ts @@ -8,7 +8,7 @@ export interface IIORedisOptions { db: number; } -export class IORedisCacheManager implements ICacheManager { +export class IORedisCacheManager implements ICacheManager { private redisManager: any; constructor(options: IIORedisOptions) { diff --git a/src/components/cache/managers/local-cache.manager.ts b/src/components/cache/managers/local-cache.manager.ts index 604250b..de4933d 100644 --- a/src/components/cache/managers/local-cache.manager.ts +++ b/src/components/cache/managers/local-cache.manager.ts @@ -1,7 +1,7 @@ import { ICacheManager, SetOptions } from './cache.manager.interface'; import * as NodeCache from 'node-cache'; -export class LocalCacheManager implements ICacheManager { +export class LocalCacheManager implements ICacheManager { private nodeCache: NodeCache = new NodeCache(); public async set(key: string, data: T, options?: SetOptions): Promise { diff --git a/src/components/cache/managers/redis-cache.manager.ts b/src/components/cache/managers/redis-cache.manager.ts index 2f64b39..45ddd67 100644 --- a/src/components/cache/managers/redis-cache.manager.ts +++ b/src/components/cache/managers/redis-cache.manager.ts @@ -6,7 +6,7 @@ export interface IRedisOptions { url: string; } -export class RedisCacheManager implements ICacheManager { +export class RedisCacheManager implements ICacheManager { private redisManager: any; constructor(options: IRedisOptions) { From 3bbe93926e52eda261db11bb6fbdd65671074e4e Mon Sep 17 00:00:00 2001 From: Artur Wolny Date: Wed, 19 Jul 2023 13:46:53 +0200 Subject: [PATCH 04/12] refactor(sdk): removed irrelevant accessTokenOptions; refactored cache manager implementations BREAKING CHANGE: removed accessTokenOptions from FronteggContext configuration --- .../token-resolvers/access-token-resolver.ts | 10 ++-- ...=> cache-access-token.service-abstract.ts} | 39 ++++++++------- .../cache-tenant-access-token.service.ts | 12 ++--- .../cache-user-access-token.service.ts | 10 ++-- src/components/cache/index.spec.ts | 10 ++-- src/components/cache/index.ts | 14 +++--- .../cache/managers/cache.manager.interface.ts | 12 ++++- .../managers/ioredis-cache.manager.spec.ts | 8 +++- .../cache/managers/ioredis-cache.manager.ts | 30 ++++++++---- .../managers/local-cache.manager.spec.ts | 7 ++- .../cache/managers/local-cache.manager.ts | 17 +++++-- .../managers/prefixed-manager.abstract.ts | 9 ++++ .../cache/managers/redis-cache.manager.ts | 47 +++++++++++++++---- src/components/frontegg-context/index.ts | 15 +----- src/components/frontegg-context/types.ts | 13 ++--- src/utils/package-loader.ts | 2 +- 16 files changed, 159 insertions(+), 96 deletions(-) rename src/clients/identity/token-resolvers/access-token-services/cache-services/{cache-access-token.service.ts => cache-access-token.service-abstract.ts} (59%) create mode 100644 src/components/cache/managers/prefixed-manager.abstract.ts diff --git a/src/clients/identity/token-resolvers/access-token-resolver.ts b/src/clients/identity/token-resolvers/access-token-resolver.ts index 17c85ba..30d61ec 100644 --- a/src/clients/identity/token-resolvers/access-token-resolver.ts +++ b/src/clients/identity/token-resolvers/access-token-resolver.ts @@ -65,7 +65,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,14 +93,16 @@ export class AccessTokenResolver extends TokenResolver { return service; } - private initAccessTokenServices(): void { + private async initAccessTokenServices(): Promise { if (this.accessTokenServices.length) { return; } + const cache = await FronteggCache.getInstance(); + this.accessTokenServices = [ - new CacheTenantAccessTokenService(FronteggCache.getInstance(), new TenantAccessTokenService(this.httpClient)), - new CacheUserAccessTokenService(FronteggCache.getInstance(), new UserAccessTokenService(this.httpClient)), + 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 59% 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 bf40346..0ad3f61 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 @@ -3,16 +3,24 @@ import { IAccessTokenService } from '../access-token.service.interface'; import { ICacheManager } from '../../../../../components/cache/managers/cache.manager.interface'; import { FailedToAuthenticateException } from '../../../exceptions'; -export abstract class CacheAccessTokenService implements IAccessTokenService { - constructor( - public readonly cacheManager: 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 cachedData = await this.cacheManager.get(cacheKey) as (IEntityWithRoles | undefined); + const cacheKey = entity.sub; + const cachedData = await this.entityCacheManager.get(cacheKey); if (cachedData) { if (this.isEmptyAccessToken(cachedData)) { @@ -24,16 +32,12 @@ export abstract class CacheAccessTokenService implements try { const data = await this.accessTokenService.getEntity(entity); - await this.cacheManager.set(cacheKey, data, { expiresInSeconds: 10 }); + await this.entityCacheManager.set(cacheKey, data, { expiresInSeconds: 10 }); return data; } catch (e) { if (e instanceof FailedToAuthenticateException) { - await this.cacheManager.set( - cacheKey, - { empty: true }, - { expiresInSeconds: 10 }, - ); + await this.entityCacheManager.set(cacheKey, { empty: true }, { expiresInSeconds: 10 }); } throw e; @@ -41,21 +45,21 @@ export abstract class CacheAccessTokenService implements } public async getActiveAccessTokenIds(): Promise { - const cacheKey = `${this.getCachePrefix()}_ids`; - const cachedData = await this.cacheManager.get(cacheKey); + const cacheKey = `ids`; + const cachedData = await this.activeAccessTokensCacheManager.get(cacheKey); if (cachedData) { - return cachedData as string[]; + return cachedData; } try { const data = await this.accessTokenService.getActiveAccessTokenIds(); - this.cacheManager.set(cacheKey, data, { expiresInSeconds: 10 }); + this.activeAccessTokensCacheManager.set(cacheKey, data, { expiresInSeconds: 10 }); return data; } catch (e) { if (e instanceof FailedToAuthenticateException) { - await this.cacheManager.set(cacheKey, [], { expiresInSeconds: 10 }); + await this.activeAccessTokensCacheManager.set(cacheKey, [], { expiresInSeconds: 10 }); } throw e; @@ -70,5 +74,4 @@ export abstract class CacheAccessTokenService implements 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 531a45f..9f06012 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,17 +1,17 @@ import { ICacheManager } from '../../../../../components/cache/managers/cache.manager.interface'; -import { IEmptyAccessToken, IEntityWithRoles, ITenantAccessToken, tokenTypes } from '../../../types'; +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 { +export class CacheTenantAccessTokenService extends CacheAccessTokenServiceAbstract { constructor( - public readonly cacheManager: ICacheManager, - public readonly tenantAccessTokenService: AccessTokenService, + 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 2aabc29..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,17 +1,17 @@ import { ICacheManager } from '../../../../../components/cache/managers/cache.manager.interface'; -import { IEmptyAccessToken, IEntityWithRoles, IUserAccessToken, tokenTypes } from '../../../types'; +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 cacheManager: ICacheManager, + cacheManager: ICacheManager, public readonly userAccessTokenService: AccessTokenService, ) { 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/components/cache/index.spec.ts b/src/components/cache/index.spec.ts index da5d0c4..0cfad7a 100644 --- a/src/components/cache/index.spec.ts +++ b/src/components/cache/index.spec.ts @@ -21,7 +21,7 @@ describe('FronteggContext', () => { const { [name]: Manager } = require('./managers'); const cacheManagerMock = {}; - jest.mocked(Manager).mockReturnValue(cacheManagerMock); + jest.mocked(Manager.create).mockResolvedValue(cacheManagerMock); return cacheManagerMock; } @@ -99,12 +99,12 @@ describe('FronteggContext', () => { ); }); - it(`when cache is initialized, then the ${expectedCacheName} is returned.`, () => { + it(`when cache is initialized, then the ${expectedCacheName} is returned.`, async () => { // given const { FronteggCache } = require('./index'); // when - const cache = FronteggCache.getInstance(); + const cache = await FronteggCache.getInstance(); // then expect(cache).toBe(expectedCache); @@ -112,7 +112,7 @@ describe('FronteggContext', () => { }); describe('given cache defined in deprecated `$.accessTokensOptions.cache`', () => { - it('when cache is initialized, then Node warning is issued.', () => { + it('when cache is initialized, then Node warning is issued.', async () => { // given const { FronteggContext } = require('../frontegg-context'); FronteggContext.init( @@ -130,7 +130,7 @@ describe('FronteggContext', () => { ); // when - require('./index').FronteggCache.getInstance(); + await require('./index').FronteggCache.getInstance(); // then expect( diff --git a/src/components/cache/index.ts b/src/components/cache/index.ts index 72e07c8..a867526 100644 --- a/src/components/cache/index.ts +++ b/src/components/cache/index.ts @@ -4,25 +4,25 @@ import { FronteggContext } from '../frontegg-context'; let cacheInstance: ICacheManager; export class FronteggCache { - static getInstance(): ICacheManager { + static async getInstance(): Promise> { if (!cacheInstance) { - cacheInstance = FronteggCache.initialize(); + cacheInstance = await FronteggCache.initialize(); } return cacheInstance as ICacheManager; } - private static initialize(): ICacheManager { + private static async initialize(): Promise> { const options = FronteggContext.getOptions(); - const cache = options.accessTokensOptions?.cache || options.cache; + const { cache } = options; switch (cache.type) { case 'ioredis': - return new IORedisCacheManager(cache.options); + return IORedisCacheManager.create(cache.options); case 'redis': - return new RedisCacheManager(cache.options); + return RedisCacheManager.create(cache.options); default: - return new LocalCacheManager(); + return LocalCacheManager.create(); } } } diff --git a/src/components/cache/managers/cache.manager.interface.ts b/src/components/cache/managers/cache.manager.interface.ts index 60221ed..4b64552 100644 --- a/src/components/cache/managers/cache.manager.interface.ts +++ b/src/components/cache/managers/cache.manager.interface.ts @@ -3,7 +3,15 @@ export interface SetOptions { } export interface ICacheManager { - set(key: string, data: T, options?: SetOptions): Promise; - get(key: string): Promise; + set(key: string, data: V, options?: SetOptions): Promise; + get(key: string): Promise; del(key: string[]): 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/ioredis-cache.manager.spec.ts b/src/components/cache/managers/ioredis-cache.manager.spec.ts index a3776fb..1695284 100644 --- a/src/components/cache/managers/ioredis-cache.manager.spec.ts +++ b/src/components/cache/managers/ioredis-cache.manager.spec.ts @@ -12,8 +12,12 @@ jest.mock('../../../utils/package-loader', () => ({ })); describe('IORedis cache manager', () => { - //@ts-ignore - const redisCacheManager = new IORedisCacheManager<{ data: string }>(); + let redisCacheManager: IORedisCacheManager<{ data: string }>; + + beforeEach(async () => { + redisCacheManager = await IORedisCacheManager.create(); + }); + const cacheKey = 'key'; const cacheValue = { data: 'value' }; diff --git a/src/components/cache/managers/ioredis-cache.manager.ts b/src/components/cache/managers/ioredis-cache.manager.ts index bbcf584..7d12357 100644 --- a/src/components/cache/managers/ioredis-cache.manager.ts +++ b/src/components/cache/managers/ioredis-cache.manager.ts @@ -1,5 +1,7 @@ import { ICacheManager, SetOptions } from './cache.manager.interface'; import { PackageUtils } from '../../../utils/package-loader'; +import { PrefixedManager } from './prefixed-manager.abstract'; +import type { Redis } from "ioredis"; export interface IIORedisOptions { host: string; @@ -8,30 +10,40 @@ export interface IIORedisOptions { db: number; } -export class IORedisCacheManager implements ICacheManager { - private redisManager: any; +export class IORedisCacheManager extends PrefixedManager implements ICacheManager { + private constructor(private readonly redisManager: Redis, prefix: string = '') { + super(prefix); + } + + static async create(options?: IIORedisOptions, prefix: string = ''): Promise> { + const RedisCtor = PackageUtils.loadPackage('ioredis'); - constructor(options: IIORedisOptions) { - const RedisInstance = PackageUtils.loadPackage('ioredis') as any; - this.redisManager = new RedisInstance(options); + return new IORedisCacheManager( + new RedisCtor(options), + prefix + ); } public async set(key: string, data: T, options?: SetOptions): Promise { if (options?.expiresInSeconds) { - this.redisManager.set(key, JSON.stringify(data), 'EX', options.expiresInSeconds); + this.redisManager.set(this.withPrefix(key), JSON.stringify(data), 'EX', options.expiresInSeconds); } else { - this.redisManager.set(key, JSON.stringify(data)); + this.redisManager.set(this.withPrefix(key), JSON.stringify(data)); } } public async get(key: string): Promise { - const stringifiedData = await this.redisManager.get(key); + 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); + await this.redisManager.del(key.map(this.withPrefix.bind(this))); } } + + forScope(prefix?: string): ICacheManager { + return new IORedisCacheManager(this.redisManager, prefix ?? this.prefix) + } } diff --git a/src/components/cache/managers/local-cache.manager.spec.ts b/src/components/cache/managers/local-cache.manager.spec.ts index f212347..6534a89 100644 --- a/src/components/cache/managers/local-cache.manager.spec.ts +++ b/src/components/cache/managers/local-cache.manager.spec.ts @@ -1,10 +1,15 @@ import { LocalCacheManager } from './local-cache.manager'; describe('Local cache manager', () => { - const localCacheManager = new LocalCacheManager(); + 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); diff --git a/src/components/cache/managers/local-cache.manager.ts b/src/components/cache/managers/local-cache.manager.ts index de4933d..3161fb9 100644 --- a/src/components/cache/managers/local-cache.manager.ts +++ b/src/components/cache/managers/local-cache.manager.ts @@ -1,8 +1,15 @@ import { ICacheManager, SetOptions } from './cache.manager.interface'; import * as NodeCache from 'node-cache'; +import { PrefixedManager } from './prefixed-manager.abstract'; -export class LocalCacheManager implements ICacheManager { - private nodeCache: NodeCache = new NodeCache(); +export class LocalCacheManager extends PrefixedManager implements ICacheManager { + private constructor(private readonly nodeCache: NodeCache, prefix: string = '') { + super(prefix); + } + + static async create(prefix: string = ''): Promise> { + return new LocalCacheManager(new NodeCache(), prefix); + } public async set(key: string, data: T, options?: SetOptions): Promise { if (options?.expiresInSeconds) { @@ -18,7 +25,11 @@ export class LocalCacheManager implements ICacheManager { public async del(key: string[]): Promise { if (key.length) { - this.nodeCache.del(key); + this.nodeCache.del(key.map(this.withPrefix.bind(this))); } } + + forScope(prefix?: string): ICacheManager { + return new LocalCacheManager(this.nodeCache, prefix ?? this.prefix); + } } 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..cf078a4 --- /dev/null +++ b/src/components/cache/managers/prefixed-manager.abstract.ts @@ -0,0 +1,9 @@ +export abstract class PrefixedManager { + + protected constructor(protected readonly prefix: string = '') { + } + + protected withPrefix(key: string): string { + return this.prefix + key; + } +} \ No newline at end of file diff --git a/src/components/cache/managers/redis-cache.manager.ts b/src/components/cache/managers/redis-cache.manager.ts index 45ddd67..b17c9f9 100644 --- a/src/components/cache/managers/redis-cache.manager.ts +++ b/src/components/cache/managers/redis-cache.manager.ts @@ -2,35 +2,62 @@ import { ICacheManager, 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'; + export interface IRedisOptions { url: string; } -export class RedisCacheManager implements ICacheManager { - private redisManager: any; +export class RedisCacheManager extends PrefixedManager implements ICacheManager { + private readonly isReadyPromise: Promise; + + private constructor( + private readonly redisManager: Redis.RedisClientType, + prefix: string = '' + ) { + super(prefix); + + this.isReadyPromise = this.redisManager.connect(); + this.isReadyPromise.catch((e) => Logger.error('Failed to connect to redis', e)); + } + + static create(options: IRedisOptions, prefix: string = ''): Promise> { + const { createClient } = PackageUtils.loadPackage('redis') as typeof Redis; - constructor(options: IRedisOptions) { - const { createClient } = PackageUtils.loadPackage('redis') as any; - this.redisManager = createClient(options); - this.redisManager.connect().catch((e) => Logger.error('Failed to connect to redis', e)); + 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) { - this.redisManager.set(key, JSON.stringify(data), { EX: options.expiresInSeconds }); + await this.redisManager.set(this.withPrefix(key), JSON.stringify(data), { EX: options.expiresInSeconds }); } else { - this.redisManager.set(key, JSON.stringify(data)); + await this.redisManager.set(this.withPrefix(key), JSON.stringify(data)); } } public async get(key: string): Promise { - const stringifiedData = await this.redisManager.get(key); + 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); + await this.redisManager.del(key.map(this.withPrefix.bind(this))); } } + + forScope(prefix?: string): ICacheManager { + return new RedisCacheManager( + this.redisManager, + prefix ?? this.prefix + ); + } } diff --git a/src/components/frontegg-context/index.ts b/src/components/frontegg-context/index.ts index 8f7427d..672ec25 100644 --- a/src/components/frontegg-context/index.ts +++ b/src/components/frontegg-context/index.ts @@ -1,7 +1,6 @@ import { PackageUtils } from '../../utils/package-loader'; -import { IFronteggContext, IFronteggOptions, IAccessTokensOptions, IFronteggCacheOptions } from './types'; +import { IFronteggContext, IFronteggOptions, IFronteggCacheOptions } from './types'; import { IIORedisOptions, IRedisOptions } from '../cache/managers'; -import { FronteggWarningCodes, warning } from '../../utils/warning'; const DEFAULT_OPTIONS: IFronteggOptions = { cache: { @@ -51,18 +50,6 @@ export class FronteggContext { if (options.cache) { this.validateCacheOptions(options.cache); } - - if (options.accessTokensOptions) { - this.validateAccessTokensOptions(options.accessTokensOptions); - } - } - - private validateAccessTokensOptions(accessTokensOptions: IAccessTokensOptions): void { - if (accessTokensOptions.cache) { - warning.emit(FronteggWarningCodes.CONFIG_KEY_MOVED_DEPRECATION, '$.accessTokenOptions.cache', '$.cache'); - } - - this.validateCacheOptions(accessTokensOptions.cache); } private validateCacheOptions(cache: IFronteggCacheOptions): void { diff --git a/src/components/frontegg-context/types.ts b/src/components/frontegg-context/types.ts index dae9cd7..f4f8f88 100644 --- a/src/components/frontegg-context/types.ts +++ b/src/components/frontegg-context/types.ts @@ -7,27 +7,22 @@ export interface IFronteggContext { export interface IFronteggOptions { cache: IFronteggCacheOptions; - accessTokensOptions?: IAccessTokensOptions; } -export interface IAccessTokensOptions { - cache: IFronteggCacheOptions; -} - -export interface IAccessTokensCacheOptions { +export interface IBaseCacheOptions { type: 'ioredis' | 'local' | 'redis'; } -export interface ILocalCacheOptions extends IAccessTokensCacheOptions { +export interface ILocalCacheOptions extends IBaseCacheOptions { type: 'local'; } -export interface IIORedisCacheOptions extends IAccessTokensCacheOptions { +export interface IIORedisCacheOptions extends IBaseCacheOptions { type: 'ioredis'; options: IIORedisOptions; } -export interface IRedisCacheOptions extends IAccessTokensCacheOptions, IRedisOptions { +export interface IRedisCacheOptions extends IBaseCacheOptions, IRedisOptions { type: 'redis'; options: IRedisOptions; } 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 { From 579d601d191c158f16d5ca8a520bbec3411ffb9a Mon Sep 17 00:00:00 2001 From: Artur Wolny Date: Fri, 21 Jul 2023 13:14:59 +0200 Subject: [PATCH 05/12] build(sdk): updated lock file --- package-lock.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/package-lock.json b/package-lock.json index 20b109f..c2ae1d7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8706,6 +8706,11 @@ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "dev": true }, + "process-warning": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-2.2.0.tgz", + "integrity": "sha512-/1WZ8+VQjR6avWOgHeEPd7SDQmFQ1B5mC1eRXsCm5TarlNmx/wCsa5GEaxGm05BORRtyG/Ex/3xq3TuRvq57qg==" + }, "prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", From e30ee785e1e1d7978811c49d048cfce65e783bf7 Mon Sep 17 00:00:00 2001 From: Artur Wolny Date: Sat, 22 Jul 2023 07:20:27 +0200 Subject: [PATCH 06/12] refactor(sdk): ran lint fix --- src/clients/identity/identity-client.ts | 3 --- src/components/cache/managers/cache.manager.interface.ts | 4 ++-- src/components/cache/managers/ioredis-cache.manager.ts | 4 ++-- src/components/cache/managers/local-cache.manager.ts | 4 ++-- src/components/cache/managers/redis-cache.manager.ts | 4 ++-- 5 files changed, 8 insertions(+), 11 deletions(-) diff --git a/src/clients/identity/identity-client.ts b/src/clients/identity/identity-client.ts index 0d6a817..2059fc2 100644 --- a/src/clients/identity/identity-client.ts +++ b/src/clients/identity/identity-client.ts @@ -6,16 +6,13 @@ import { FronteggContext } from '../../components/frontegg-context'; import { AuthHeaderType, ExtractCredentialsResult, - ITenantApiToken, IUser, - IUserApiToken, 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/components/cache/managers/cache.manager.interface.ts b/src/components/cache/managers/cache.manager.interface.ts index 4b64552..9b21fb7 100644 --- a/src/components/cache/managers/cache.manager.interface.ts +++ b/src/components/cache/managers/cache.manager.interface.ts @@ -8,8 +8,8 @@ export interface ICacheManager { del(key: string[]): 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). + * 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. */ diff --git a/src/components/cache/managers/ioredis-cache.manager.ts b/src/components/cache/managers/ioredis-cache.manager.ts index 7d12357..0d2ec65 100644 --- a/src/components/cache/managers/ioredis-cache.manager.ts +++ b/src/components/cache/managers/ioredis-cache.manager.ts @@ -11,11 +11,11 @@ export interface IIORedisOptions { } export class IORedisCacheManager extends PrefixedManager implements ICacheManager { - private constructor(private readonly redisManager: Redis, prefix: string = '') { + private constructor(private readonly redisManager: Redis, prefix = '') { super(prefix); } - static async create(options?: IIORedisOptions, prefix: string = ''): Promise> { + static async create(options?: IIORedisOptions, prefix = ''): Promise> { const RedisCtor = PackageUtils.loadPackage('ioredis'); return new IORedisCacheManager( diff --git a/src/components/cache/managers/local-cache.manager.ts b/src/components/cache/managers/local-cache.manager.ts index 3161fb9..e183a5d 100644 --- a/src/components/cache/managers/local-cache.manager.ts +++ b/src/components/cache/managers/local-cache.manager.ts @@ -3,11 +3,11 @@ import * as NodeCache from 'node-cache'; import { PrefixedManager } from './prefixed-manager.abstract'; export class LocalCacheManager extends PrefixedManager implements ICacheManager { - private constructor(private readonly nodeCache: NodeCache, prefix: string = '') { + private constructor(private readonly nodeCache: NodeCache, prefix = '') { super(prefix); } - static async create(prefix: string = ''): Promise> { + static async create(prefix = ''): Promise> { return new LocalCacheManager(new NodeCache(), prefix); } diff --git a/src/components/cache/managers/redis-cache.manager.ts b/src/components/cache/managers/redis-cache.manager.ts index b17c9f9..2fa8df9 100644 --- a/src/components/cache/managers/redis-cache.manager.ts +++ b/src/components/cache/managers/redis-cache.manager.ts @@ -14,7 +14,7 @@ export class RedisCacheManager extends PrefixedManager implements ICacheManag private constructor( private readonly redisManager: Redis.RedisClientType, - prefix: string = '' + prefix = '' ) { super(prefix); @@ -22,7 +22,7 @@ export class RedisCacheManager extends PrefixedManager implements ICacheManag this.isReadyPromise.catch((e) => Logger.error('Failed to connect to redis', e)); } - static create(options: IRedisOptions, prefix: string = ''): Promise> { + static create(options: IRedisOptions, prefix = ''): Promise> { const { createClient } = PackageUtils.loadPackage('redis') as typeof Redis; return new RedisCacheManager( From 756dfb94627c7f81870071f3700898abe9464ddf Mon Sep 17 00:00:00 2001 From: Artur Wolny Date: Sun, 30 Jul 2023 16:19:33 +0300 Subject: [PATCH 07/12] refactor(sdk): removed irrelevant process warning --- package-lock.json | 5 ----- package.json | 1 - src/utils/warning.ts | 13 ------------- 3 files changed, 19 deletions(-) delete mode 100644 src/utils/warning.ts diff --git a/package-lock.json b/package-lock.json index c2ae1d7..20b109f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8706,11 +8706,6 @@ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "dev": true }, - "process-warning": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-2.2.0.tgz", - "integrity": "sha512-/1WZ8+VQjR6avWOgHeEPd7SDQmFQ1B5mC1eRXsCm5TarlNmx/wCsa5GEaxGm05BORRtyG/Ex/3xq3TuRvq57qg==" - }, "prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", diff --git a/package.json b/package.json index fc1f5ee..5a1f9f7 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,6 @@ "axios": "^0.27.2", "jsonwebtoken": "^9.0.0", "node-cache": "^5.1.2", - "process-warning": "^2.2.0", "winston": "^3.8.2" }, "peerDependencies": { diff --git a/src/utils/warning.ts b/src/utils/warning.ts deleted file mode 100644 index 7364f18..0000000 --- a/src/utils/warning.ts +++ /dev/null @@ -1,13 +0,0 @@ -import processWarning = require('process-warning'); - -export enum FronteggWarningCodes { - CONFIG_KEY_MOVED_DEPRECATION = 'CONFIG_KEY_MOVED_DEPRECATION', -} - -export const warning = processWarning(); - -warning.create( - 'FronteggWarning', - FronteggWarningCodes.CONFIG_KEY_MOVED_DEPRECATION, - "Config key '%s' is deprecated. Put the configuration in '%s'.", -); From e226bac9eef6dcc225baf13afbe6f3aa473dcc08 Mon Sep 17 00:00:00 2001 From: Artur Wolny Date: Sun, 30 Jul 2023 16:23:40 +0300 Subject: [PATCH 08/12] docs(sdk): removed examples of Redis cache configuration in access token section --- README.md | 63 ++----------------------------------------------------- 1 file changed, 2 insertions(+), 61 deletions(-) diff --git a/README.md b/README.md index 3833133..5d84292 100644 --- a/README.md +++ b/README.md @@ -123,71 +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 -> **Deprecation Warning!** -> This section is deprecated. See Redis cache section for cache configuration. - -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 -> **Deprecation Warning!** -> This section is deprecated. See Redis cache section for cache configuration. - -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 From fcc357c42a4d91af89e93ccd7be3de103ccd4f9e Mon Sep 17 00:00:00 2001 From: Artur Wolny Date: Sun, 30 Jul 2023 16:51:14 +0300 Subject: [PATCH 09/12] refactor(sdk): formatted code test(context): fixed tests of FronteggContext --- .../entitlements.user-scoped.spec.ts | 25 ++++---- .../entitlements/entitlements.user-scoped.ts | 2 +- src/clients/identity/identity-client.ts | 8 +-- .../token-resolvers/access-token-resolver.ts | 6 +- .../cache-access-token.service-abstract.ts | 1 - .../cache-tenant-access-token.service.ts | 5 +- src/components/cache/index.spec.ts | 60 +------------------ .../cache/managers/ioredis-cache.manager.ts | 13 ++-- .../managers/prefixed-manager.abstract.ts | 6 +- .../cache/managers/redis-cache.manager.ts | 15 +---- 10 files changed, 29 insertions(+), 112 deletions(-) diff --git a/src/clients/entitlements/entitlements.user-scoped.spec.ts b/src/clients/entitlements/entitlements.user-scoped.spec.ts index 6418d4f..65adca2 100644 --- a/src/clients/entitlements/entitlements.user-scoped.spec.ts +++ b/src/clients/entitlements/entitlements.user-scoped.spec.ts @@ -26,16 +26,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(); @@ -46,13 +46,14 @@ describe(EntitlementsUserScoped.name, () => { }); 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 +62,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); diff --git a/src/clients/entitlements/entitlements.user-scoped.ts b/src/clients/entitlements/entitlements.user-scoped.ts index 5eaaf57..9b0e48d 100644 --- a/src/clients/entitlements/entitlements.user-scoped.ts +++ b/src/clients/entitlements/entitlements.user-scoped.ts @@ -31,7 +31,7 @@ export class EntitlementsUserScoped { return entity.sub; case tokenTypes.UserApiToken: case tokenTypes.UserAccessToken: - return entity.userId; + return entity.userId; } } diff --git a/src/clients/identity/identity-client.ts b/src/clients/identity/identity-client.ts index 2059fc2..1281b43 100644 --- a/src/clients/identity/identity-client.ts +++ b/src/clients/identity/identity-client.ts @@ -3,13 +3,7 @@ import { FronteggAuthenticator } from '../../authenticator'; import { config } from '../../config'; import Logger from '../../components/logger'; import { FronteggContext } from '../../components/frontegg-context'; -import { - AuthHeaderType, - ExtractCredentialsResult, - IUser, - 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'; diff --git a/src/clients/identity/token-resolvers/access-token-resolver.ts b/src/clients/identity/token-resolvers/access-token-resolver.ts index 30d61ec..bdd4336 100644 --- a/src/clients/identity/token-resolvers/access-token-resolver.ts +++ b/src/clients/identity/token-resolvers/access-token-resolver.ts @@ -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, }; } diff --git a/src/clients/identity/token-resolvers/access-token-services/cache-services/cache-access-token.service-abstract.ts b/src/clients/identity/token-resolvers/access-token-services/cache-services/cache-access-token.service-abstract.ts index 0ad3f61..a5e9f82 100644 --- a/src/clients/identity/token-resolvers/access-token-services/cache-services/cache-access-token.service-abstract.ts +++ b/src/clients/identity/token-resolvers/access-token-services/cache-services/cache-access-token.service-abstract.ts @@ -73,5 +73,4 @@ export abstract class CacheAccessTokenServiceAbstract im private isEmptyAccessToken(accessToken: IEntityWithRoles | IEmptyAccessToken): accessToken is IEmptyAccessToken { return 'empty' in accessToken && accessToken.empty; } - } 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 9f06012..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 @@ -4,10 +4,7 @@ import { AccessTokenService } from '../services/access-token.service'; import { CacheAccessTokenServiceAbstract } from './cache-access-token.service-abstract'; export class CacheTenantAccessTokenService extends CacheAccessTokenServiceAbstract { - constructor( - cacheManager: ICacheManager, - tenantAccessTokenService: AccessTokenService, - ) { + constructor(cacheManager: ICacheManager, tenantAccessTokenService: AccessTokenService) { super(cacheManager, tenantAccessTokenService, tokenTypes.TenantAccessToken); } diff --git a/src/components/cache/index.spec.ts b/src/components/cache/index.spec.ts index 0cfad7a..df12d44 100644 --- a/src/components/cache/index.spec.ts +++ b/src/components/cache/index.spec.ts @@ -1,11 +1,5 @@ -import { - IFronteggOptions, - IIORedisCacheOptions, - ILocalCacheOptions, - IRedisCacheOptions, -} from '../frontegg-context/types'; +import { IIORedisCacheOptions, ILocalCacheOptions, IRedisCacheOptions } from '../frontegg-context/types'; import { FronteggContext } from '../frontegg-context'; -import { FronteggWarningCodes } from '../../utils/warning'; describe('FronteggContext', () => { beforeEach(() => { @@ -59,30 +53,6 @@ describe('FronteggContext', () => { }, expectedCacheName: 'RedisCacheManager', }, - { - cacheConfigInfo: "type of 'ioredis' in `$.accessTokensOptions.cache` and empty `$.cache`", - config: { - accessTokensOptions: { - cache: { - type: 'ioredis', - options: { host: 'foo', password: 'bar', db: 0, port: 6372 }, - } as IIORedisCacheOptions, - }, - } as IFronteggOptions, - expectedCacheName: 'IORedisCacheManager', - }, - { - cacheConfigInfo: "type of 'redis' in `$.accessTokensOptions.cache` and empty `$.cache`", - config: { - accessTokensOptions: { - cache: { - type: 'redis', - options: { url: 'redis://url:6372' }, - } as IRedisCacheOptions, - }, - } as IFronteggOptions, - expectedCacheName: 'RedisCacheManager', - }, ])('given $cacheConfigInfo configuration in FronteggContext', ({ config, expectedCacheName }) => { let expectedCache; @@ -110,32 +80,4 @@ describe('FronteggContext', () => { expect(cache).toBe(expectedCache); }); }); - - describe('given cache defined in deprecated `$.accessTokensOptions.cache`', () => { - it('when cache is initialized, then Node warning is issued.', async () => { - // given - const { FronteggContext } = require('../frontegg-context'); - FronteggContext.init( - { - FRONTEGG_CLIENT_ID: 'foo', - FRONTEGG_API_KEY: 'bar', - }, - { - accessTokensOptions: { - cache: { - type: 'local', - }, - }, - }, - ); - - // when - await require('./index').FronteggCache.getInstance(); - - // then - expect( - require('../../utils/warning').warning.emitted.get(FronteggWarningCodes.CONFIG_KEY_MOVED_DEPRECATION), - ).toBeTruthy(); - }); - }); }); diff --git a/src/components/cache/managers/ioredis-cache.manager.ts b/src/components/cache/managers/ioredis-cache.manager.ts index 0d2ec65..a2cd736 100644 --- a/src/components/cache/managers/ioredis-cache.manager.ts +++ b/src/components/cache/managers/ioredis-cache.manager.ts @@ -1,13 +1,13 @@ import { ICacheManager, SetOptions } from './cache.manager.interface'; import { PackageUtils } from '../../../utils/package-loader'; import { PrefixedManager } from './prefixed-manager.abstract'; -import type { Redis } from "ioredis"; +import type { Redis } from 'ioredis'; export interface IIORedisOptions { host: string; - password: string; + password?: string; port: number; - db: number; + db?: number; } export class IORedisCacheManager extends PrefixedManager implements ICacheManager { @@ -18,10 +18,7 @@ export class IORedisCacheManager extends PrefixedManager implements ICacheMan static async create(options?: IIORedisOptions, prefix = ''): Promise> { const RedisCtor = PackageUtils.loadPackage('ioredis'); - return new IORedisCacheManager( - new RedisCtor(options), - prefix - ); + return new IORedisCacheManager(new RedisCtor(options), prefix); } public async set(key: string, data: T, options?: SetOptions): Promise { @@ -44,6 +41,6 @@ export class IORedisCacheManager extends PrefixedManager implements ICacheMan } forScope(prefix?: string): ICacheManager { - return new IORedisCacheManager(this.redisManager, prefix ?? this.prefix) + return new IORedisCacheManager(this.redisManager, prefix ?? this.prefix); } } diff --git a/src/components/cache/managers/prefixed-manager.abstract.ts b/src/components/cache/managers/prefixed-manager.abstract.ts index cf078a4..e77f967 100644 --- a/src/components/cache/managers/prefixed-manager.abstract.ts +++ b/src/components/cache/managers/prefixed-manager.abstract.ts @@ -1,9 +1,7 @@ export abstract class PrefixedManager { - - protected constructor(protected readonly prefix: string = '') { - } + protected constructor(protected readonly prefix: string = '') {} protected withPrefix(key: string): string { return this.prefix + key; } -} \ No newline at end of file +} diff --git a/src/components/cache/managers/redis-cache.manager.ts b/src/components/cache/managers/redis-cache.manager.ts index 2fa8df9..33b1c2b 100644 --- a/src/components/cache/managers/redis-cache.manager.ts +++ b/src/components/cache/managers/redis-cache.manager.ts @@ -12,10 +12,7 @@ export interface IRedisOptions { export class RedisCacheManager extends PrefixedManager implements ICacheManager { private readonly isReadyPromise: Promise; - private constructor( - private readonly redisManager: Redis.RedisClientType, - prefix = '' - ) { + private constructor(private readonly redisManager: Redis.RedisClientType, prefix = '') { super(prefix); this.isReadyPromise = this.redisManager.connect(); @@ -25,10 +22,7 @@ export class RedisCacheManager extends PrefixedManager implements ICacheManag static create(options: IRedisOptions, prefix = ''): Promise> { const { createClient } = PackageUtils.loadPackage('redis') as typeof Redis; - return new RedisCacheManager( - createClient(options), - prefix - ).ready(); + return new RedisCacheManager(createClient(options), prefix).ready(); } ready(): Promise { @@ -55,9 +49,6 @@ export class RedisCacheManager extends PrefixedManager implements ICacheManag } forScope(prefix?: string): ICacheManager { - return new RedisCacheManager( - this.redisManager, - prefix ?? this.prefix - ); + return new RedisCacheManager(this.redisManager, prefix ?? this.prefix); } } From 5b1789898a8ff91a916f882f5a5d25444d0c587d Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Mon, 31 Jul 2023 08:29:59 +0000 Subject: [PATCH 10/12] chore(release): 6.0.0-alpha.1 [skip ci] # [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 --- docs/CHANGELOG.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) 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) From 39c8743d00a93af2599204032b0aec038046d075 Mon Sep 17 00:00:00 2001 From: Artur Wolny Date: Tue, 17 Oct 2023 14:39:01 +0200 Subject: [PATCH 11/12] feat(feature-flags): introduced feature-flags functionality --- package-lock.json | 52 ++++++ package.json | 3 + .../entitlements/entitlements-client.spec.ts | 9 +- .../entitlements/entitlements-client.ts | 23 ++- .../entitlements.user-scoped.spec.ts | 82 ++++++++- .../entitlements/entitlements.user-scoped.ts | 75 ++++++-- .../entitlements/feature-flags.types.ts | 3 + .../in-memory/in-memory.cache-key.utils.ts | 2 + .../storage/in-memory/in-memory.cache.spec.ts | 14 +- .../storage/in-memory/in-memory.cache.ts | 160 +++--------------- .../mappers/feature-flag-tuple.mapper.spec.ts | 91 ++++++++++ .../mappers/feature-flag-tuple.mapper.ts | 30 ++++ .../storage/in-memory/mappers/helper.ts | 23 +++ .../in-memory/mappers/sources.mapper.ts | 127 ++++++++++++++ .../entitlements/storage/in-memory/types.ts | 8 + src/clients/entitlements/storage/types.ts | 6 + src/clients/entitlements/types.ts | 21 +-- src/clients/identity/types.ts | 36 ++-- 18 files changed, 567 insertions(+), 198 deletions(-) create mode 100644 src/clients/entitlements/feature-flags.types.ts create mode 100644 src/clients/entitlements/storage/in-memory/mappers/feature-flag-tuple.mapper.spec.ts create mode 100644 src/clients/entitlements/storage/in-memory/mappers/feature-flag-tuple.mapper.ts create mode 100644 src/clients/entitlements/storage/in-memory/mappers/helper.ts create mode 100644 src/clients/entitlements/storage/in-memory/mappers/sources.mapper.ts diff --git a/package-lock.json b/package-lock.json index 20b109f..d378eac 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", @@ -8738,6 +8784,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..665efda 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,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 +42,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", 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 65adca2..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, @@ -43,6 +47,7 @@ describe(EntitlementsUserScoped.name, () => { afterEach(() => { mockReset(cacheMock); + jest.mocked(evaluateFeatureFlag).mockReset(); }); describe.each([ @@ -117,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(() => { @@ -149,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"', () => { @@ -200,7 +254,7 @@ describe(EntitlementsUserScoped.name, () => { await cut.isEntitledTo({ permissionKey: 'foo' }); // then - expect(isEntitledToPermissionSpy).toHaveBeenCalledWith('foo'); + expect(isEntitledToPermissionSpy).toHaveBeenCalledWith('foo', {}); expect(isEntitledToFeatureSpy).not.toHaveBeenCalled(); }); @@ -210,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 9b0e48d..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; @@ -35,7 +40,22 @@ export class EntitlementsUserScoped { } } - 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/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; From 40fb241ba77c25306084da33ba28a5bd808555c3 Mon Sep 17 00:00:00 2001 From: Artur Wolny Date: Fri, 20 Oct 2023 13:33:21 +0200 Subject: [PATCH 12/12] feat(cache): support for collections and maps for FronteggCache --- ci/docker-compose.yml | 7 + ci/run-test-suite.sh | 12 ++ jest.config.js | 1 + package-lock.json | 10 ++ package.json | 6 +- src/components/cache/index.ts | 8 +- .../cache/managers/cache.manager.interface.ts | 31 +++- src/components/cache/managers/index.ts | 6 +- .../managers/ioredis-cache.manager.spec.ts | 44 ------ .../cache/managers/ioredis-cache.manager.ts | 46 ------ .../ioredis/ioredis-cache.collection.ts | 25 +++ .../ioredis/ioredis-cache.manager.spec.ts | 140 +++++++++++++++++ .../managers/ioredis/ioredis-cache.manager.ts | 74 +++++++++ .../managers/ioredis/ioredis-cache.map.ts | 25 +++ .../managers/local-cache.manager.spec.ts | 33 ---- .../cache/managers/local-cache.manager.ts | 35 ----- .../managers/local/local-cache.collection.ts | 26 ++++ .../local/local-cache.manager.spec.ts | 138 +++++++++++++++++ .../managers/local/local-cache.manager.ts | 63 ++++++++ .../cache/managers/local/local-cache.map.ts | 24 +++ .../cache/managers/redis-cache.manager.ts | 54 ------- .../managers/redis/redis-cache.collection.ts | 25 +++ .../redis/redis-cache.manager.spec.ts | 143 ++++++++++++++++++ .../managers/redis/redis-cache.manager.ts | 84 ++++++++++ .../cache/managers/redis/redis-cache.map.ts | 25 +++ .../cache/serializers/json.serializer.ts | 11 ++ src/components/cache/serializers/types.ts | 4 + 27 files changed, 877 insertions(+), 223 deletions(-) create mode 100644 ci/docker-compose.yml create mode 100755 ci/run-test-suite.sh delete mode 100644 src/components/cache/managers/ioredis-cache.manager.spec.ts delete mode 100644 src/components/cache/managers/ioredis-cache.manager.ts create mode 100644 src/components/cache/managers/ioredis/ioredis-cache.collection.ts create mode 100644 src/components/cache/managers/ioredis/ioredis-cache.manager.spec.ts create mode 100644 src/components/cache/managers/ioredis/ioredis-cache.manager.ts create mode 100644 src/components/cache/managers/ioredis/ioredis-cache.map.ts delete mode 100644 src/components/cache/managers/local-cache.manager.spec.ts delete mode 100644 src/components/cache/managers/local-cache.manager.ts create mode 100644 src/components/cache/managers/local/local-cache.collection.ts create mode 100644 src/components/cache/managers/local/local-cache.manager.spec.ts create mode 100644 src/components/cache/managers/local/local-cache.manager.ts create mode 100644 src/components/cache/managers/local/local-cache.map.ts delete mode 100644 src/components/cache/managers/redis-cache.manager.ts create mode 100644 src/components/cache/managers/redis/redis-cache.collection.ts create mode 100644 src/components/cache/managers/redis/redis-cache.manager.spec.ts create mode 100644 src/components/cache/managers/redis/redis-cache.manager.ts create mode 100644 src/components/cache/managers/redis/redis-cache.map.ts create mode 100644 src/components/cache/serializers/json.serializer.ts create mode 100644 src/components/cache/serializers/types.ts 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/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 d378eac..7aed92c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4690,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", diff --git a/package.json b/package.json index 665efda..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" }, @@ -61,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/components/cache/index.ts b/src/components/cache/index.ts index a867526..0140d9d 100644 --- a/src/components/cache/index.ts +++ b/src/components/cache/index.ts @@ -1,10 +1,10 @@ -import { ICacheManager, IORedisCacheManager, LocalCacheManager, RedisCacheManager } from './managers'; +import { CacheValue, ICacheManager, IORedisCacheManager, LocalCacheManager, RedisCacheManager } from './managers'; import { FronteggContext } from '../frontegg-context'; -let cacheInstance: ICacheManager; +let cacheInstance: ICacheManager; export class FronteggCache { - static async getInstance(): Promise> { + static async getInstance(): Promise> { if (!cacheInstance) { cacheInstance = await FronteggCache.initialize(); } @@ -12,7 +12,7 @@ export class FronteggCache { return cacheInstance as ICacheManager; } - private static async initialize(): Promise> { + private static async initialize(): Promise> { const options = FronteggContext.getOptions(); const { cache } = options; diff --git a/src/components/cache/managers/cache.manager.interface.ts b/src/components/cache/managers/cache.manager.interface.ts index 9b21fb7..475cdb2 100644 --- a/src/components/cache/managers/cache.manager.interface.ts +++ b/src/components/cache/managers/cache.manager.interface.ts @@ -1,17 +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 ICacheManager { +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; + forScope(prefix?: string): ICacheManager; } diff --git a/src/components/cache/managers/index.ts b/src/components/cache/managers/index.ts index f38d27f..09aa553 100644 --- a/src/components/cache/managers/index.ts +++ b/src/components/cache/managers/index.ts @@ -1,4 +1,4 @@ export * from './cache.manager.interface'; -export * from './local-cache.manager'; -export * from './ioredis-cache.manager'; -export * from './redis-cache.manager'; +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-cache.manager.spec.ts b/src/components/cache/managers/ioredis-cache.manager.spec.ts deleted file mode 100644 index 1695284..0000000 --- a/src/components/cache/managers/ioredis-cache.manager.spec.ts +++ /dev/null @@ -1,44 +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', () => { - let redisCacheManager: IORedisCacheManager<{ data: string }>; - - beforeEach(async () => { - redisCacheManager = await IORedisCacheManager.create(); - }); - - 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/components/cache/managers/ioredis-cache.manager.ts b/src/components/cache/managers/ioredis-cache.manager.ts deleted file mode 100644 index a2cd736..0000000 --- a/src/components/cache/managers/ioredis-cache.manager.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { ICacheManager, SetOptions } from './cache.manager.interface'; -import { PackageUtils } from '../../../utils/package-loader'; -import { PrefixedManager } from './prefixed-manager.abstract'; -import type { Redis } from 'ioredis'; - -export interface IIORedisOptions { - host: string; - password?: string; - port: number; - db?: number; -} - -export class IORedisCacheManager extends PrefixedManager implements ICacheManager { - private constructor(private readonly redisManager: Redis, prefix = '') { - super(prefix); - } - - 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: T, options?: SetOptions): Promise { - if (options?.expiresInSeconds) { - this.redisManager.set(this.withPrefix(key), JSON.stringify(data), 'EX', options.expiresInSeconds); - } else { - 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))); - } - } - - forScope(prefix?: string): ICacheManager { - return new IORedisCacheManager(this.redisManager, prefix ?? this.prefix); - } -} 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-cache.manager.spec.ts b/src/components/cache/managers/local-cache.manager.spec.ts deleted file mode 100644 index 6534a89..0000000 --- a/src/components/cache/managers/local-cache.manager.spec.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { LocalCacheManager } from './local-cache.manager'; - -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); - }); -}); diff --git a/src/components/cache/managers/local-cache.manager.ts b/src/components/cache/managers/local-cache.manager.ts deleted file mode 100644 index e183a5d..0000000 --- a/src/components/cache/managers/local-cache.manager.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { ICacheManager, SetOptions } from './cache.manager.interface'; -import * as NodeCache from 'node-cache'; -import { PrefixedManager } from './prefixed-manager.abstract'; - -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(), 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))); - } - } - - forScope(prefix?: string): ICacheManager { - return new LocalCacheManager(this.nodeCache, prefix ?? this.prefix); - } -} 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/redis-cache.manager.ts b/src/components/cache/managers/redis-cache.manager.ts deleted file mode 100644 index 33b1c2b..0000000 --- a/src/components/cache/managers/redis-cache.manager.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { ICacheManager, 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'; - -export interface IRedisOptions { - url: string; -} - -export class RedisCacheManager extends PrefixedManager implements ICacheManager { - private readonly isReadyPromise: Promise; - - private constructor(private readonly redisManager: Redis.RedisClientType, prefix = '') { - super(prefix); - - 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))); - } - } - - forScope(prefix?: string): ICacheManager { - return new RedisCacheManager(this.redisManager, prefix ?? this.prefix); - } -} 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