From 85db5230d7530e2e61dcea8e79174148e9cb1f6f Mon Sep 17 00:00:00 2001 From: Artur Wolny Date: Thu, 13 Jul 2023 14:31:09 +0200 Subject: [PATCH 01/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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 4b416f8bfa3f7c7f244cd1ef6597dffe61a6b259 Mon Sep 17 00:00:00 2001 From: Artur Wolny Date: Mon, 17 Jul 2023 10:28:29 +0200 Subject: [PATCH 11/17] feat(cache): Adjusted cache to support collections/sets and maps/hashmaps --- package-lock.json | 9 +++ package.json | 1 + src/components/cache/index.ts | 3 +- .../cache/managers/cache.manager.interface.ts | 14 ++++ .../in-memory/local-cache.collection.ts | 30 ++++++++ .../local-cache.manager.spec.ts | 0 .../{ => in-memory}/local-cache.manager.ts | 20 ++++-- .../managers/in-memory/local-cache.map.ts | 28 ++++++++ src/components/cache/managers/index.ts | 6 +- .../ioredis/ioredis-cache.collection.ts | 24 +++++++ .../ioredis-cache.manager.spec.ts | 0 .../{ => ioredis}/ioredis-cache.manager.ts | 26 +++++-- .../managers/ioredis/ioredis-cache.map.ts | 28 ++++++++ .../cache/managers/redis-cache.manager.ts | 54 -------------- .../managers/redis/redis-cache.collection.ts | 24 +++++++ .../managers/redis/redis-cache.manager.ts | 72 +++++++++++++++++++ .../cache/managers/redis/redis-cache.map.ts | 28 ++++++++ .../cache/serializers/json.serializer.ts | 11 +++ src/components/cache/serializers/types.ts | 4 ++ 19 files changed, 314 insertions(+), 68 deletions(-) create mode 100644 src/components/cache/managers/in-memory/local-cache.collection.ts rename src/components/cache/managers/{ => in-memory}/local-cache.manager.spec.ts (100%) rename src/components/cache/managers/{ => in-memory}/local-cache.manager.ts (54%) create mode 100644 src/components/cache/managers/in-memory/local-cache.map.ts create mode 100644 src/components/cache/managers/ioredis/ioredis-cache.collection.ts rename src/components/cache/managers/{ => ioredis}/ioredis-cache.manager.spec.ts (100%) rename src/components/cache/managers/{ => ioredis}/ioredis-cache.manager.ts (54%) create mode 100644 src/components/cache/managers/ioredis/ioredis-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.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/package-lock.json b/package-lock.json index 20b109f..a83c295 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1943,6 +1943,15 @@ "integrity": "sha512-/K3ds8TRAfBvi5vfjuz8y6+GiAYBZ0x4tXv1Av6CWBWn0IlADc+ZX9pMq7oU0fNQPnBwIZl3rmeLp6SBApbxSQ==", "dev": true }, + "@types/ioredis": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/ioredis/-/ioredis-5.0.0.tgz", + "integrity": "sha512-zJbJ3FVE17CNl5KXzdeSPtdltc4tMT3TzC6fxQS0sQngkbFZ6h+0uTafsRqu+eSLIugf6Yb0Ea0SUuRr42Nk9g==", + "dev": true, + "requires": { + "ioredis": "*" + } + }, "@types/is-stream": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@types/is-stream/-/is-stream-1.1.0.tgz", diff --git a/package.json b/package.json index 5a1f9f7..8d893e2 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "@semantic-release/git": "^10.0.1", "@types/axios-mock-adapter": "^1.10.0", "@types/express": "^4.17.14", + "@types/ioredis": "^5.0.0", "@types/jest": "^29.2.0", "@types/jsonwebtoken": "^9.0.0", "@types/node": "^12.20.55", diff --git a/src/components/cache/index.ts b/src/components/cache/index.ts index a867526..b6610f1 100644 --- a/src/components/cache/index.ts +++ b/src/components/cache/index.ts @@ -1,5 +1,6 @@ -import { ICacheManager, IORedisCacheManager, LocalCacheManager, RedisCacheManager } from './managers'; import { FronteggContext } from '../frontegg-context'; +import { ICacheManager } from './managers'; +import { IORedisCacheManager, LocalCacheManager, RedisCacheManager } from './managers'; let cacheInstance: ICacheManager; diff --git a/src/components/cache/managers/cache.manager.interface.ts b/src/components/cache/managers/cache.manager.interface.ts index 9b21fb7..6318c0e 100644 --- a/src/components/cache/managers/cache.manager.interface.ts +++ b/src/components/cache/managers/cache.manager.interface.ts @@ -6,6 +6,8 @@ export interface ICacheManager { set(key: string, data: V, options?: SetOptions): Promise; get(key: string): Promise; del(key: string[]): Promise; + hashmap(key: string): ICacheManagerMap; + collection(key: string): ICacheManagerCollection; /** * This method should return the instance of ICacheManager with the same cache connector below, but scoped set/get @@ -15,3 +17,15 @@ export interface ICacheManager { */ forScope(prefix?: string): 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; +} diff --git a/src/components/cache/managers/in-memory/local-cache.collection.ts b/src/components/cache/managers/in-memory/local-cache.collection.ts new file mode 100644 index 0000000..93e0ee3 --- /dev/null +++ b/src/components/cache/managers/in-memory/local-cache.collection.ts @@ -0,0 +1,30 @@ +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().values()]; + } +} \ No newline at end of file diff --git a/src/components/cache/managers/local-cache.manager.spec.ts b/src/components/cache/managers/in-memory/local-cache.manager.spec.ts similarity index 100% rename from src/components/cache/managers/local-cache.manager.spec.ts rename to src/components/cache/managers/in-memory/local-cache.manager.spec.ts diff --git a/src/components/cache/managers/local-cache.manager.ts b/src/components/cache/managers/in-memory/local-cache.manager.ts similarity index 54% rename from src/components/cache/managers/local-cache.manager.ts rename to src/components/cache/managers/in-memory/local-cache.manager.ts index e183a5d..7eab79e 100644 --- a/src/components/cache/managers/local-cache.manager.ts +++ b/src/components/cache/managers/in-memory/local-cache.manager.ts @@ -1,6 +1,8 @@ -import { ICacheManager, 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'; +import { PrefixedManager } from '../prefixed-manager.abstract'; +import { ICacheManager, ICacheManagerCollection, ICacheManagerMap, SetOptions } from '../cache.manager.interface'; export class LocalCacheManager extends PrefixedManager implements ICacheManager { private constructor(private readonly nodeCache: NodeCache, prefix = '') { @@ -13,14 +15,14 @@ export class LocalCacheManager extends PrefixedManager implements ICacheManag public async set(key: string, data: T, options?: SetOptions): Promise { if (options?.expiresInSeconds) { - this.nodeCache.set(key, data, options.expiresInSeconds); + this.nodeCache.set(this.withPrefix(key), data, options.expiresInSeconds); } else { - this.nodeCache.set(key, data); + this.nodeCache.set(this.withPrefix(key), data); } } public async get(key: string): Promise { - return this.nodeCache.get(key) || null; + return this.nodeCache.get(this.withPrefix(key)) || null; } public async del(key: string[]): Promise { @@ -29,6 +31,14 @@ export class LocalCacheManager extends PrefixedManager implements ICacheManag } } + hashmap(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); } diff --git a/src/components/cache/managers/in-memory/local-cache.map.ts b/src/components/cache/managers/in-memory/local-cache.map.ts new file mode 100644 index 0000000..d8aab1d --- /dev/null +++ b/src/components/cache/managers/in-memory/local-cache.map.ts @@ -0,0 +1,28 @@ +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/index.ts b/src/components/cache/managers/index.ts index f38d27f..6b07fc6 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 './in-memory/local-cache.manager'; +export * from './ioredis/ioredis-cache.manager'; +export * from './redis/redis-cache.manager'; diff --git a/src/components/cache/managers/ioredis/ioredis-cache.collection.ts b/src/components/cache/managers/ioredis/ioredis-cache.collection.ts new file mode 100644 index 0000000..80f3e07 --- /dev/null +++ b/src/components/cache/managers/ioredis/ioredis-cache.collection.ts @@ -0,0 +1,24 @@ +import IORedis from 'ioredis'; +import { ICacheValueSerializer } from '../../serializers/types'; +import { 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 { + return (await this.redis.smembers(this.key)).map(v => this.serializer.deserialize(v)); + } +} \ No newline at end of file diff --git a/src/components/cache/managers/ioredis-cache.manager.spec.ts b/src/components/cache/managers/ioredis/ioredis-cache.manager.spec.ts similarity index 100% rename from src/components/cache/managers/ioredis-cache.manager.spec.ts rename to src/components/cache/managers/ioredis/ioredis-cache.manager.spec.ts diff --git a/src/components/cache/managers/ioredis-cache.manager.ts b/src/components/cache/managers/ioredis/ioredis-cache.manager.ts similarity index 54% rename from src/components/cache/managers/ioredis-cache.manager.ts rename to src/components/cache/managers/ioredis/ioredis-cache.manager.ts index a2cd736..87561f2 100644 --- a/src/components/cache/managers/ioredis-cache.manager.ts +++ b/src/components/cache/managers/ioredis/ioredis-cache.manager.ts @@ -1,7 +1,11 @@ -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 { PackageUtils } from '../../../../utils/package-loader'; +import { PrefixedManager } from '../prefixed-manager.abstract'; +import { 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'; export interface IIORedisOptions { host: string; @@ -11,8 +15,12 @@ export interface IIORedisOptions { } 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> { @@ -21,7 +29,7 @@ export class IORedisCacheManager extends PrefixedManager implements ICacheMan return new IORedisCacheManager(new RedisCtor(options), prefix); } - public async set(key: string, data: T, options?: SetOptions): Promise { + public async set(key: string, data: V, options?: SetOptions): Promise { if (options?.expiresInSeconds) { this.redisManager.set(this.withPrefix(key), JSON.stringify(data), 'EX', options.expiresInSeconds); } else { @@ -29,7 +37,7 @@ export class IORedisCacheManager extends PrefixedManager implements ICacheMan } } - public async get(key: string): Promise { + public async get(key: string): Promise { const stringifiedData = await this.redisManager.get(this.withPrefix(key)); return stringifiedData ? JSON.parse(stringifiedData) : null; } @@ -43,4 +51,12 @@ export class IORedisCacheManager extends PrefixedManager implements ICacheMan forScope(prefix?: string): ICacheManager { return new IORedisCacheManager(this.redisManager, prefix ?? this.prefix); } + + hashmap(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); + } } 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..bb278f1 --- /dev/null +++ b/src/components/cache/managers/ioredis/ioredis-cache.map.ts @@ -0,0 +1,28 @@ +import type IORedis from "ioredis"; +import { ICacheValueSerializer } from '../../serializers/types'; +import { ICacheManagerMap } 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/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..3a58fac --- /dev/null +++ b/src/components/cache/managers/redis/redis-cache.collection.ts @@ -0,0 +1,24 @@ +import { RedisClientType } from 'redis'; +import { ICacheValueSerializer } from '../../serializers/types'; +import { 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 { + return (await this.redis.SMEMBERS(this.key)).map(v => this.serializer.deserialize(v)); + } +} \ 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..3719525 --- /dev/null +++ b/src/components/cache/managers/redis/redis-cache.manager.ts @@ -0,0 +1,72 @@ +import { PackageUtils } from '../../../../utils/package-loader'; +import Logger from '../../../logger'; +import type { RedisClientType } from 'redis'; +import { RedisCacheMap } from './redis-cache.map'; +import { RedisCacheCollection } from './redis-cache.collection'; +import { ICacheValueSerializer } from '../../serializers/types'; +import { JsonSerializer } from '../../serializers/json.serializer'; +import { PrefixedManager } from '../prefixed-manager.abstract'; +import type * as Redis from "redis"; +import { ICacheManager, ICacheManagerCollection, ICacheManagerMap, SetOptions } from '../cache.manager.interface'; + +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: 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); + } + + forScope(prefix?: string): ICacheManager { + return new RedisCacheManager(this.redisManager, prefix ?? this.prefix); + } + + hashmap(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); + } + + public async set(key: string, data: V, options?: SetOptions): Promise { + if (options?.expiresInSeconds) { + await this.redisManager.set( + this.withPrefix(key), this.serializer.serialize(data), { EX: options.expiresInSeconds } + ); + } else { + await this.redisManager.set(this.withPrefix(key), this.serializer.serialize(data)); + } + } + + public async get(key: string): Promise { + const rawData = await this.redisManager.get(this.withPrefix(key)); + return rawData ? this.serializer.deserialize(rawData) : null; + } + + public async del(key: string[]): Promise { + if (key.length) { + await this.redisManager.del(key.map(this.withPrefix.bind(this))); + } + } +} 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..0215875 --- /dev/null +++ b/src/components/cache/managers/redis/redis-cache.map.ts @@ -0,0 +1,28 @@ +import { RedisClientType } from 'redis'; +import { ICacheValueSerializer } from '../../serializers/types'; +import { 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 From dfb138e141160dbaf5f4c3037d4c19fba4837a24 Mon Sep 17 00:00:00 2001 From: Artur Wolny Date: Sun, 30 Jul 2023 16:13:53 +0300 Subject: [PATCH 12/17] feat(entitlements): Introduced the entitlements cache based of general FronteggSDK cache --- ci/docker-compose.yml | 7 + ci/run-test-suite.sh | 12 + jest.config.js | 1 + package-lock.json | 10 + package.json | 4 +- src/authenticator/index.ts | 6 +- .../entitlements/entitlements-client.ts | 62 ++--- .../entitlements.user-scoped.spec.ts | 4 +- .../entitlements/entitlements.user-scoped.ts | 4 +- .../storage/cache.revision-manager.ts | 90 +++++++ .../storage/dto-to-cache-sources.mapper.ts | 112 ++++++++ .../frontegg.cache-initializer.ts | 83 ++++++ .../frontegg.cache-key.utils.ts} | 8 +- .../frontegg.cache.spec.ts} | 33 +-- .../storage/frontegg-cache/frontegg.cache.ts | 32 +++ .../storage/in-memory/in-memory.cache.ts | 246 ------------------ .../entitlements/storage/in-memory/types.ts | 25 -- src/clients/entitlements/storage/types.ts | 27 +- src/clients/entitlements/types.ts | 1 + .../cache-access-token.service-abstract.ts | 2 +- .../cache/managers/cache.manager.interface.ts | 38 ++- .../in-memory/local-cache.collection.ts | 6 +- .../in-memory/local-cache.manager.spec.ts | 106 +++++++- .../managers/in-memory/local-cache.manager.ts | 14 +- .../managers/in-memory/local-cache.map.ts | 2 +- src/components/cache/managers/index.ts | 2 +- .../ioredis/ioredis-cache.collection.ts | 14 +- .../ioredis/ioredis-cache.manager.spec.ts | 166 +++++++++--- .../managers/ioredis/ioredis-cache.manager.ts | 32 ++- .../managers/ioredis/ioredis-cache.map.ts | 8 +- .../managers/redis/redis-cache.collection.ts | 14 +- .../redis/redis-cache.manager.spec.ts | 145 +++++++++++ .../managers/redis/redis-cache.manager.ts | 22 +- .../cache/managers/redis/redis-cache.map.ts | 6 +- src/utils/index.ts | 13 +- 35 files changed, 929 insertions(+), 428 deletions(-) create mode 100644 ci/docker-compose.yml create mode 100755 ci/run-test-suite.sh create mode 100644 src/clients/entitlements/storage/cache.revision-manager.ts create mode 100644 src/clients/entitlements/storage/dto-to-cache-sources.mapper.ts create mode 100644 src/clients/entitlements/storage/frontegg-cache/frontegg.cache-initializer.ts rename src/clients/entitlements/storage/{in-memory/in-memory.cache-key.utils.ts => frontegg-cache/frontegg.cache-key.utils.ts} (53%) rename src/clients/entitlements/storage/{in-memory/in-memory.cache.spec.ts => frontegg-cache/frontegg.cache.spec.ts} (84%) create mode 100644 src/clients/entitlements/storage/frontegg-cache/frontegg.cache.ts delete mode 100644 src/clients/entitlements/storage/in-memory/in-memory.cache.ts delete mode 100644 src/clients/entitlements/storage/in-memory/types.ts create mode 100644 src/components/cache/managers/redis/redis-cache.manager.spec.ts diff --git a/ci/docker-compose.yml b/ci/docker-compose.yml new file mode 100644 index 0000000..1a1fb86 --- /dev/null +++ b/ci/docker-compose.yml @@ -0,0 +1,7 @@ +version: "3" +services: + redis: + image: redis + restart: always + ports: + - 36279:6379 diff --git a/ci/run-test-suite.sh b/ci/run-test-suite.sh new file mode 100755 index 0000000..3d2ca78 --- /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 +RESULT=$@ + +docker compose -p nodejs-sdk-tests down + +exit $RESULT \ No newline at end of file diff --git a/jest.config.js b/jest.config.js index dd030d7..20173b8 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 a83c295..666bb54 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4653,6 +4653,16 @@ } } }, + "jest-extended": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jest-extended/-/jest-extended-4.0.0.tgz", + "integrity": "sha512-GMhMFdrwhYPB0y+cmI/5esz+F/Xc0OIzKbnr8SaiZ74YcWamxf7sVT78YlA15+JIQMTlpHBEgcxheyRBdHFqPA==", + "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 8d893e2..59873c7 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "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:jest": "npm run build && jest --runInBand", + "test": "(cd ci; ./run-test-suite.sh)", "test:coverage": "npm test -- --coverage", "test:watch": "npm run build && jest --watch", "dev": "tsc --watch" @@ -59,6 +60,7 @@ "ioredis": "^5.2.5", "ioredis-mock": "^8.2.2", "jest": "^28.1.3", + "jest-extended": "^4.0.0", "jest-junit": "^14.0.1", "jest-mock-extended": "^3.0.4", "prettier": "^2.7.1", diff --git a/src/authenticator/index.ts b/src/authenticator/index.ts index c05e6c3..0ad98e0 100644 --- a/src/authenticator/index.ts +++ b/src/authenticator/index.ts @@ -25,9 +25,9 @@ export class FronteggAuthenticator { return retry(() => this.authenticate(), { numberOfTries, - secondsDelayRange: { - min: 0.5, - max: 5, + delayRangeMs: { + min: 500, + max: 5000, }, }); } diff --git a/src/clients/entitlements/entitlements-client.ts b/src/clients/entitlements/entitlements-client.ts index cb221c8..94864ea 100644 --- a/src/clients/entitlements/entitlements-client.ts +++ b/src/clients/entitlements/entitlements-client.ts @@ -8,10 +8,11 @@ import Logger from '../../components/logger'; import { retry } from '../../utils'; import * as events from 'events'; import { EntitlementsClientEvents } from './entitlements-client.events'; -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 { CacheRevisionManager } from './storage/cache.revision-manager'; +import { LocalCacheManager } from '../../cache'; +import { hostname } from 'os'; export class EntitlementsClient extends events.EventEmitter { // periodical refresh handler @@ -19,21 +20,27 @@ export class EntitlementsClient extends events.EventEmitter { private readonly readyPromise: Promise; private readonly options: EntitlementsClientOptions; - // cache instance - private cache?: EntitlementsCache; - - // snapshot data - private offset: number = -1; + // cache handler + private cacheManager: CacheRevisionManager; private constructor(private readonly httpClient: HttpClient, options: Partial = {}) { super(); this.options = this.parseOptions(options); + this.cacheManager = new CacheRevisionManager( + this.options.instanceId, + // TODO: use FronteggCache.getInstance(); when it's merged + new LocalCacheManager() + ); this.readyPromise = new Promise((resolve) => { this.once(EntitlementsClientEvents.INITIALIZED, () => resolve(this)); }); + this.on(EntitlementsClientEvents.SNAPSHOT_UPDATED, (offset) => { + Logger.debug('[entitlements] Snapshot refreshed.', { offset }); + }); + this.refreshTimeout = setTimeout( () => this.refreshSnapshot().then(() => { @@ -45,7 +52,8 @@ export class EntitlementsClient extends events.EventEmitter { private parseOptions(givenOptions: Partial): EntitlementsClientOptions { return { - retry: { numberOfTries: 3, secondsDelayRange: { min: 0.5, max: 5 } }, + instanceId: hostname(), + retry: { numberOfTries: 3, delayRangeMs: { min: 500, max: 5_000 } }, initializationDelayMs: 0, refreshTimeoutMs: 30_000, ...givenOptions, @@ -57,40 +65,34 @@ export class EntitlementsClient extends events.EventEmitter { } forUser(entity: T): EntitlementsUserScoped { - if (!this.cache) { + const cache = this.cacheManager.getCache(); + if (!cache) { throw new Error('EntitlementsClient is not initialized yet.'); } - return new EntitlementsUserScoped(entity, this.cache); + return new EntitlementsUserScoped(entity, cache); } private async loadVendorEntitlements(): Promise { const entitlementsData = await this.httpClient.get('/api/v1/vendor-entitlements'); - const vendorEntitlementsDto = entitlementsData.data; - const newOffset = entitlementsData.data.snapshotOffset; - - const newCache = await InMemoryEntitlementsCache.initialize(vendorEntitlementsDto, newOffset.toString()); - const oldCache = this.cache; - this.cache = newCache; - this.offset = entitlementsData.data.snapshotOffset; + const { isUpdated, rev } = await this.cacheManager.loadSnapshot(vendorEntitlementsDto); - // clean - await oldCache?.clear(); - await oldCache?.shutdown(); - - // emit - this.emit(EntitlementsClientEvents.SNAPSHOT_UPDATED, entitlementsData.data.snapshotOffset); + if (isUpdated) { + // emit + this.emit(EntitlementsClientEvents.SNAPSHOT_UPDATED, rev); + } } private async refreshSnapshot(): Promise { + await this.cacheManager.waitUntilUpdated(); + await retry(async () => { if (!(await this.haveRecentSnapshot())) { - Logger.debug('[entitlements] Refreshing the outdated snapshot.', { currentOffset: this.offset }); + Logger.debug('[entitlements] Refreshing the outdated snapshot.'); await this.loadVendorEntitlements(); - Logger.debug('[entitlements] Snapshot refreshed.', { currentOffset: this.offset }); } }, this.options.retry); @@ -101,15 +103,7 @@ export class EntitlementsClient extends events.EventEmitter { const serverOffsetDto = await this.httpClient.get( '/api/v1/vendor-entitlements-snapshot-offset', ); - const isRecent = serverOffsetDto.data.snapshotOffset === this.offset; - - Logger.debug('[entitlements] Offsets compared.', { - isRecent, - serverOffset: serverOffsetDto.data.snapshotOffset, - localOffset: this.offset, - }); - - return isRecent; + return await this.cacheManager.hasRecentSnapshot(serverOffsetDto.data); } static async init( diff --git a/src/clients/entitlements/entitlements.user-scoped.spec.ts b/src/clients/entitlements/entitlements.user-scoped.spec.ts index 65adca2..b01cfa9 100644 --- a/src/clients/entitlements/entitlements.user-scoped.spec.ts +++ b/src/clients/entitlements/entitlements.user-scoped.spec.ts @@ -5,7 +5,7 @@ import { } from './entitlements.user-scoped'; import { IUser, IUserAccessToken, IUserApiToken, TEntityWithRoles, tokenTypes } from '../identity/types'; import { mock, mockReset } from 'jest-mock-extended'; -import { EntitlementsCache, NO_EXPIRE } from './storage/types'; +import { IEntitlementsCache, NO_EXPIRE } from './storage/types'; import { EntitlementJustifications } from './types'; import SpyInstance = jest.SpyInstance; @@ -38,7 +38,7 @@ const userTokenBase: Pick { - const cacheMock = mock(); + const cacheMock = mock(); let cut: EntitlementsUserScoped; afterEach(() => { diff --git a/src/clients/entitlements/entitlements.user-scoped.ts b/src/clients/entitlements/entitlements.user-scoped.ts index 9b0e48d..08c42e5 100644 --- a/src/clients/entitlements/entitlements.user-scoped.ts +++ b/src/clients/entitlements/entitlements.user-scoped.ts @@ -1,6 +1,6 @@ import { EntitlementJustifications, IsEntitledResult } from './types'; import { IEntityWithRoles, Permission, TEntity, tokenTypes, TUserEntity } from '../identity/types'; -import { EntitlementsCache, NO_EXPIRE } from './storage/types'; +import { IEntitlementsCache, NO_EXPIRE } from './storage/types'; import { pickExpTimestamp } from './storage/exp-time.utils'; export type IsEntitledToPermissionInput = { permissionKey: string }; @@ -11,7 +11,7 @@ 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: IEntitlementsCache) { this.tenantId = entity.tenantId; const entityWithUserId = entity as TUserEntity; diff --git a/src/clients/entitlements/storage/cache.revision-manager.ts b/src/clients/entitlements/storage/cache.revision-manager.ts new file mode 100644 index 0000000..dd8d222 --- /dev/null +++ b/src/clients/entitlements/storage/cache.revision-manager.ts @@ -0,0 +1,90 @@ +import { ICacheManager } from '../../../cache'; +import { VendorEntitlementsDto, VendorEntitlementsSnapshotOffsetDto } from '../types'; +import { IEntitlementsCache } from './types'; +import { FronteggEntitlementsCacheInitializer } from './frontegg-cache/frontegg.cache-initializer'; +import Logger from '../../../components/logger'; +import { retry } from '../../../utils'; + +const CURRENT_OFFSET_KEY = 'snapshot-offset'; +const UPDATE_IN_PROGRESS_KEY = 'snapshot-updating'; + +export class CacheRevisionManager { + private entitlementsCache?: IEntitlementsCache; + + constructor( + public readonly instanceId: string, + private readonly cache: ICacheManager, + private readonly options: { + maxUpdateLockTime: number + } = { + maxUpdateLockTime: 5 + } + ) { + } + + async waitUntilUpdated(): Promise { + return new Promise((resolve, reject) => { + retry(async () => { + if (await this.isUpdateInProgress()) { + throw new Error(); + } + }, { numberOfTries: 3, delayRangeMs: { + min: 100, + max: 2000 + }}) + .then(resolve) + .catch(err => reject(err)); + }); + } + + async loadSnapshot(dto: VendorEntitlementsDto): Promise<{ isUpdated: boolean, rev: number }> { + await this.waitUntilUpdated(); + + const currentOffset = await this.getOffset(); + if (currentOffset === dto.snapshotOffset) return { isUpdated: false, rev: currentOffset }; + + await this.cache.set(UPDATE_IN_PROGRESS_KEY, this.instanceId, { expiresInSeconds: this.options.maxUpdateLockTime }); + + // re-initialize the cache + const newCache = await FronteggEntitlementsCacheInitializer.initialize(dto); + const oldCache = this.entitlementsCache; + + this.entitlementsCache = newCache; + await this.setOffset(dto.snapshotOffset); + + // clean + await oldCache?.clear(); + await oldCache?.shutdown(); + + return { isUpdated: true, rev: dto.snapshotOffset } + } + + async hasRecentSnapshot(dto: VendorEntitlementsSnapshotOffsetDto): Promise { + const currentOffset = await this.getOffset(); + const isRecent = dto.snapshotOffset === currentOffset; + + Logger.debug('[entitlements] Offsets compared.', { + isRecent, + serverOffset: dto.snapshotOffset, + localOffset: currentOffset, + }); + + return isRecent; + } + + async isUpdateInProgress(): Promise { + return await this.cache.get(UPDATE_IN_PROGRESS_KEY) === null; + } + + private async setOffset(offset: number): Promise { + await this.cache.set(CURRENT_OFFSET_KEY, offset); + } + + async getOffset(): Promise { + return await this.cache.get(CURRENT_OFFSET_KEY) || 0; + } + + getCache(): IEntitlementsCache | undefined { + return this.entitlementsCache; + } +} \ No newline at end of file diff --git a/src/clients/entitlements/storage/dto-to-cache-sources.mapper.ts b/src/clients/entitlements/storage/dto-to-cache-sources.mapper.ts new file mode 100644 index 0000000..bf9a6e5 --- /dev/null +++ b/src/clients/entitlements/storage/dto-to-cache-sources.mapper.ts @@ -0,0 +1,112 @@ +import { FeatureId, VendorEntitlementsDto } from '../types'; +import { BundlesSource, ExpirationTime, FeatureSource, NO_EXPIRE, UNBUNDLED_SRC_ID } from './types'; + +export class DtoToCacheSourcesMapper { + map(dto: VendorEntitlementsDto): BundlesSource { + const { data: { features, entitlements, featureBundles } } = dto; + + 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 + featureBundles.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 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; + } +} diff --git a/src/clients/entitlements/storage/frontegg-cache/frontegg.cache-initializer.ts b/src/clients/entitlements/storage/frontegg-cache/frontegg.cache-initializer.ts new file mode 100644 index 0000000..1bf102a --- /dev/null +++ b/src/clients/entitlements/storage/frontegg-cache/frontegg.cache-initializer.ts @@ -0,0 +1,83 @@ +import { FronteggEntitlementsCache } from './frontegg.cache'; +import { ICacheManager, LocalCacheManager } from '../../../../cache'; +import { VendorEntitlementsDto } from '../../types'; +import { BundlesSource } from '../types'; +import { + ENTITLEMENTS_MAP_KEY, + getFeatureEntitlementKey, + getPermissionMappingKey, + OFFSET_KEY, +} from './frontegg.cache-key.utils'; +import { DtoToCacheSourcesMapper } from '../dto-to-cache-sources.mapper'; +import { pickExpTimestamp } from '../exp-time.utils'; + +export class FronteggEntitlementsCacheInitializer { + + constructor(private readonly cache: ICacheManager) { + } + + // TODO: make use of revPrefix !! + static async initialize(dto: VendorEntitlementsDto): Promise { + const revision = dto.snapshotOffset; + + // TODO: change to FronteggCache.getInstance() + const cache = new LocalCacheManager(); + const cacheInitializer = new FronteggEntitlementsCacheInitializer(cache); + + const sources = (new DtoToCacheSourcesMapper()).map(dto); + + await cacheInitializer.setupPermissionsReadModel(sources); + await cacheInitializer.setupEntitlementsReadModel(sources); + await cacheInitializer.setupRevisionNumber(revision); + + return new FronteggEntitlementsCache(cache, revision); + } + + private async setupPermissionsReadModel(src: BundlesSource): Promise { + for (const singleBundle of src.values()) { + for (const feature of singleBundle.features.values()) { + for (const permission of feature.permissions) { + // set permission => features mapping + await this.cache.collection(getPermissionMappingKey(permission)).set(feature.key); + } + } + } + } + + private async setupEntitlementsReadModel(src: BundlesSource): Promise { + const entitlementsHashMap = this.cache.map(ENTITLEMENTS_MAP_KEY); + + // iterating over bundles.. + for (const singleBundle of src.values()) { + // iterating over tenant&user entitlements + for (const [ tenantId, usersOfTenantEntitlements ] of singleBundle.user_entitlements) { + // iterating over per-user entitlements + for (const [ userId, expTimes ] of usersOfTenantEntitlements) { + const entitlementExpTime = pickExpTimestamp(expTimes); + + await Promise.all( + [...singleBundle.features.values()] + .map(feature => entitlementsHashMap.set( + getFeatureEntitlementKey(feature.key, tenantId, userId), entitlementExpTime) + ) + ); + } + } + + // iterating over tenant entitlements + for (const [ tenantId, expTimes ] of singleBundle.tenant_entitlements) { + for (const feature of singleBundle.features.values()) { + const entitlementExpTime = pickExpTimestamp(expTimes); + + await entitlementsHashMap.set( + getFeatureEntitlementKey(feature.key, tenantId), entitlementExpTime + ); + } + } + } + } + + private async setupRevisionNumber(revision: number): Promise { + await this.cache.set(OFFSET_KEY, revision); + } +} \ No newline at end of file diff --git a/src/clients/entitlements/storage/in-memory/in-memory.cache-key.utils.ts b/src/clients/entitlements/storage/frontegg-cache/frontegg.cache-key.utils.ts similarity index 53% rename from src/clients/entitlements/storage/in-memory/in-memory.cache-key.utils.ts rename to src/clients/entitlements/storage/frontegg-cache/frontegg.cache-key.utils.ts index 3cfa6b2..4dd55d8 100644 --- a/src/clients/entitlements/storage/in-memory/in-memory.cache-key.utils.ts +++ b/src/clients/entitlements/storage/frontegg-cache/frontegg.cache-key.utils.ts @@ -1,9 +1,13 @@ import { FeatureKey } from '../../types'; +import { Permission } from '../../../identity/types'; export const ENTITLEMENTS_MAP_KEY = 'entitlements'; -export const PERMISSIONS_MAP_KEY = 'permissions'; -export const SRC_BUNDLES_KEY = 'src_bundles'; +export const OFFSET_KEY = 'snapshot-offset' export function getFeatureEntitlementKey(featKey: FeatureKey, tenantId: string, userId = ''): string { return `${tenantId}:${userId}:${featKey}`; } + +export function getPermissionMappingKey(permissionKey: Permission): string { + return `perms:${permissionKey}`; +} \ No newline at end of file diff --git a/src/clients/entitlements/storage/in-memory/in-memory.cache.spec.ts b/src/clients/entitlements/storage/frontegg-cache/frontegg.cache.spec.ts similarity index 84% rename from src/clients/entitlements/storage/in-memory/in-memory.cache.spec.ts rename to src/clients/entitlements/storage/frontegg-cache/frontegg.cache.spec.ts index 45ce576..a7a2a80 100644 --- a/src/clients/entitlements/storage/in-memory/in-memory.cache.spec.ts +++ b/src/clients/entitlements/storage/frontegg-cache/frontegg.cache.spec.ts @@ -1,12 +1,15 @@ -import { InMemoryEntitlementsCache } from './in-memory.cache'; +import { FronteggEntitlementsCache } from './frontegg.cache'; import { NO_EXPIRE } from '../types'; +import { FronteggEntitlementsCacheInitializer } from './frontegg.cache-initializer'; -describe(InMemoryEntitlementsCache.name, () => { - let cut: InMemoryEntitlementsCache; +// TODO: define all tests of IEntitlementsCache implementation in single file, only change the implementation for runs + +describe(FronteggEntitlementsCache.name, () => { + let cut: FronteggEntitlementsCache; describe('given input data with no entitlements and bundle with feature "foo"', () => { - beforeEach(() => { - cut = InMemoryEntitlementsCache.initialize({ + beforeEach(async () => { + cut = await FronteggEntitlementsCacheInitializer.initialize({ snapshotOffset: 1, data: { entitlements: [], @@ -23,8 +26,8 @@ describe(InMemoryEntitlementsCache.name, () => { }); describe('given input data with entitlement to bundle with feature "foo" (no permissions) for user "u-1"', () => { - beforeEach(() => { - cut = InMemoryEntitlementsCache.initialize({ + beforeEach(async () => { + cut = await FronteggEntitlementsCacheInitializer.initialize({ snapshotOffset: 2, data: { features: [['f-1', 'foo', []]], @@ -41,8 +44,8 @@ describe(InMemoryEntitlementsCache.name, () => { }); describe('given input data with entitlement to bundle with feature "foo" (no permissions) for tenant "t-1"', () => { - beforeEach(() => { - cut = InMemoryEntitlementsCache.initialize({ + beforeEach(async () => { + cut = await FronteggEntitlementsCacheInitializer.initialize({ snapshotOffset: 3, data: { features: [['f-1', 'foo', []]], @@ -64,8 +67,8 @@ describe(InMemoryEntitlementsCache.name, () => { }); describe('given input data with multiple time-restricted entitlements to bundle with feature "foo" (no permissions) for user "u-1" and tenant "t-2"', () => { - beforeEach(() => { - cut = InMemoryEntitlementsCache.initialize({ + beforeEach(async () => { + cut = await FronteggEntitlementsCacheInitializer.initialize({ snapshotOffset: 4, data: { features: [['f-1', 'foo', []]], @@ -92,8 +95,8 @@ describe(InMemoryEntitlementsCache.name, () => { }); describe('given input data with mix of time-restricted and unrestricted entitlements to bundle with feature "foo" (no permissions) for user "u-1" and tenant "t-2"', () => { - beforeEach(() => { - cut = InMemoryEntitlementsCache.initialize({ + beforeEach(async () => { + cut = await FronteggEntitlementsCacheInitializer.initialize({ snapshotOffset: 4, data: { features: [['f-1', 'foo', []]], @@ -120,8 +123,8 @@ describe(InMemoryEntitlementsCache.name, () => { }); describe('given input data with unbundled feature "foo" (with permission "bar.baz")', () => { - beforeEach(() => { - cut = InMemoryEntitlementsCache.initialize({ + beforeEach(async () => { + cut = await FronteggEntitlementsCacheInitializer.initialize({ snapshotOffset: 5, data: { features: [['f-1', 'foo', ['bar.baz']]], diff --git a/src/clients/entitlements/storage/frontegg-cache/frontegg.cache.ts b/src/clients/entitlements/storage/frontegg-cache/frontegg.cache.ts new file mode 100644 index 0000000..f027119 --- /dev/null +++ b/src/clients/entitlements/storage/frontegg-cache/frontegg.cache.ts @@ -0,0 +1,32 @@ +import { ExpirationTime, IEntitlementsCache } from '../types'; +import { ICacheManager } from '../../../../cache'; +import { FeatureKey } from '../../types'; +import { ENTITLEMENTS_MAP_KEY, getFeatureEntitlementKey, getPermissionMappingKey } from './frontegg.cache-key.utils'; + +export class FronteggEntitlementsCache implements IEntitlementsCache { + + constructor( + private readonly cache: ICacheManager, readonly revision: number + ) { + } + + clear(): Promise { + return Promise.resolve(undefined); + } + + async getEntitlementExpirationTime(featKey: FeatureKey, tenantId: string, userId?: string): Promise { + const entitlementKey = getFeatureEntitlementKey(featKey, tenantId, userId); + const result = await this.cache.map(ENTITLEMENTS_MAP_KEY).get(entitlementKey); + + return result || undefined; + } + + getLinkedFeatures(permissionKey: string): Promise> { + return this.cache.collection(getPermissionMappingKey(permissionKey)).getAll(); + } + + shutdown(): Promise { + return Promise.resolve(undefined); + } + +} \ No newline at end of file diff --git a/src/clients/entitlements/storage/in-memory/in-memory.cache.ts b/src/clients/entitlements/storage/in-memory/in-memory.cache.ts deleted file mode 100644 index b27353e..0000000 --- a/src/clients/entitlements/storage/in-memory/in-memory.cache.ts +++ /dev/null @@ -1,246 +0,0 @@ -import { EntitlementsCache, ExpirationTime, NO_EXPIRE } from '../types'; -import { - EntitlementTuple, - FeatureBundleTuple, - FeatureTuple, - FeatureKey, - TenantId, - UserId, - VendorEntitlementsDto, - FeatureId, -} from '../../types'; -import { - ENTITLEMENTS_MAP_KEY, - PERMISSIONS_MAP_KEY, - SRC_BUNDLES_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 { Permission } from '../../../identity/types'; - -export class InMemoryEntitlementsCache implements EntitlementsCache { - private nodeCache: NodeCache; - - private constructor(readonly revision: string) { - this.nodeCache = new NodeCache({ - useClones: false, - errorOnMissing: true, - }); - } - - async getEntitlementExpirationTime( - featKey: FeatureKey, - tenantId: TenantId, - userId?: UserId, - ): Promise { - const entitlementsMap = this.nodeCache.get(ENTITLEMENTS_MAP_KEY); - if (!entitlementsMap) { - throw new Error('Cache is not properly initialized. Feature&Tenant&User => ExpirationTime map is missing.'); - } - - const entitlementKey = getFeatureEntitlementKey(featKey, tenantId, userId); - - return entitlementsMap.get(entitlementKey); - } - - async getLinkedFeatures(permissionKey: Permission): Promise> { - const permissionsMap = this.nodeCache.get(PERMISSIONS_MAP_KEY); - if (!permissionsMap) { - throw new Error('Cache is not properly initialized. Permissions => Features map is missing.'); - } - - const mapping = permissionsMap.get(permissionKey); - - return mapping || new Set(); - } - - static initialize(data: VendorEntitlementsDto, revPrefix?: string): InMemoryEntitlementsCache { - const cache = new InMemoryEntitlementsCache(revPrefix ?? data.snapshotOffset.toString()); - - const { - data: { features, entitlements, featureBundles }, - } = data; - - // build source structure - const sourceData = cache.buildSource(featureBundles, features, entitlements); - cache.nodeCache.set(SRC_BUNDLES_KEY, sourceData); - - // setup data for SDK to work - cache.setupEntitlementsReadModel(sourceData); - cache.setupPermissionsReadModel(sourceData); - - 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(); - - // iterating over bundles.. - src.forEach((singleBundle) => { - // iterating over tenant&user entitlements - singleBundle.user_entitlements.forEach((usersOfTenantEntitlements, tenantId) => { - // iterating over per-user entitlements - usersOfTenantEntitlements.forEach((expTimes, userId) => { - const entitlementExpTime = pickExpTimestamp(expTimes); - - singleBundle.features.forEach((feature) => { - entitlementsReadModel.set(getFeatureEntitlementKey(feature.key, tenantId, userId), entitlementExpTime); - }); - }); - }); - - // iterating over tenant entitlements - singleBundle.tenant_entitlements.forEach((expTimes, tenantId) => { - singleBundle.features.forEach((feature) => { - const entitlementExpTime = pickExpTimestamp(expTimes); - - entitlementsReadModel.set(getFeatureEntitlementKey(feature.key, tenantId), entitlementExpTime); - }); - }); - }); - - this.nodeCache.set(ENTITLEMENTS_MAP_KEY, entitlementsReadModel); - } - - private setupPermissionsReadModel(src: BundlesSource): void { - const permissionsReadModel: Map> = new Map(); - - src.forEach((singleBundle) => { - singleBundle.features.forEach((feature) => { - feature.permissions.forEach((permission) => { - this.ensureSetInMap(permissionsReadModel, permission).add(feature.key); - }); - }); - }); - - 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; - } - - async clear(): Promise { - this.nodeCache.del(this.nodeCache.keys()); - } - - async shutdown(): Promise { - this.nodeCache.close(); - } -} diff --git a/src/clients/entitlements/storage/in-memory/types.ts b/src/clients/entitlements/storage/in-memory/types.ts deleted file mode 100644 index ab31604..0000000 --- a/src/clients/entitlements/storage/in-memory/types.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { Permission } from '../../../identity/types'; -import { FeatureKey, TenantId, UserId } from '../../types'; -import { ExpirationTime } from '../types'; - -export const UNBUNDLED_SRC_ID = '__unbundled__'; -export type FeatureEntitlementKey = string; // tenant & user & feature key -export type EntitlementsMap = Map; -export type PermissionsMap = Map>; - -export type FeatureSource = { - id: string; - key: FeatureKey; - permissions: Set; -}; - -export type SingleEntityEntitlements = Map; - -export type SingleBundleSource = { - id: string; - features: Map; - user_entitlements: Map>; - tenant_entitlements: SingleEntityEntitlements; -}; - -export type BundlesSource = Map; diff --git a/src/clients/entitlements/storage/types.ts b/src/clients/entitlements/storage/types.ts index e960fcf..68ecb09 100644 --- a/src/clients/entitlements/storage/types.ts +++ b/src/clients/entitlements/storage/types.ts @@ -1,9 +1,16 @@ -import { FeatureKey } from '../types'; +import { FeatureKey, TenantId, UserId } from '../types'; +import { Permission } from '../../identity/types'; export const NO_EXPIRE = -1; export type ExpirationTime = number | typeof NO_EXPIRE; -export interface EntitlementsCache { +export interface IEntitlementsCache { + + /** + * The revision number to compare next entitlements cache versions. + */ + revision: number; + /** * Get the entitlement expiry time for given feature, tenant & user combination. */ @@ -28,3 +35,19 @@ export interface EntitlementsCache { */ shutdown(): Promise; } + +export const UNBUNDLED_SRC_ID = '__unbundled__'; +export type FeatureEntitlementKey = string; // tenant & user & feature key +export type FeatureSource = { + id: string; + key: FeatureKey; + permissions: Set; +}; +export type SingleEntityEntitlements = Map; +export type SingleBundleSource = { + id: string; + features: Map; + user_entitlements: Map>; + tenant_entitlements: SingleEntityEntitlements; +}; +export type BundlesSource = Map; \ No newline at end of file diff --git a/src/clients/entitlements/types.ts b/src/clients/entitlements/types.ts index 0c4166a..4c6ac1f 100644 --- a/src/clients/entitlements/types.ts +++ b/src/clients/entitlements/types.ts @@ -39,6 +39,7 @@ export interface VendorEntitlementsSnapshotOffsetDto { } export interface EntitlementsClientOptions { + instanceId: string; initializationDelayMs: number; refreshTimeoutMs: number; retry: RetryOptions; 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 a5e9f82..4c7184a 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 @@ -1,6 +1,6 @@ import { IAccessToken, IEmptyAccessToken, IEntityWithRoles, tokenTypes } from '../../../types'; import { IAccessTokenService } from '../access-token.service.interface'; -import { ICacheManager } from '../../../../../components/cache/managers/cache.manager.interface'; +import { ICacheManager } from '../../../../../components/cache/managers/cache-manager.interface'; import { FailedToAuthenticateException } from '../../../exceptions'; export abstract class CacheAccessTokenServiceAbstract implements IAccessTokenService { diff --git a/src/components/cache/managers/cache.manager.interface.ts b/src/components/cache/managers/cache.manager.interface.ts index 6318c0e..fb40b93 100644 --- a/src/components/cache/managers/cache.manager.interface.ts +++ b/src/components/cache/managers/cache.manager.interface.ts @@ -2,12 +2,30 @@ export interface SetOptions { expiresInSeconds: number; } -export interface ICacheManager { +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 ICacheManager { set(key: string, data: V, options?: SetOptions): Promise; get(key: string): Promise; del(key: string[]): Promise; - hashmap(key: string): ICacheManagerMap; - collection(key: string): ICacheManagerCollection; + 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 @@ -18,14 +36,14 @@ export interface ICacheManager { forScope(prefix?: string): ICacheManager; } -export interface ICacheManagerMap { - set(field: string, data: T): Promise; - get(field: string): Promise; +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 ICacheManagerCollection { + set(value: T): Promise; + has(value: T): Promise; + getAll(): Promise>; } diff --git a/src/components/cache/managers/in-memory/local-cache.collection.ts b/src/components/cache/managers/in-memory/local-cache.collection.ts index 93e0ee3..17bbd53 100644 --- a/src/components/cache/managers/in-memory/local-cache.collection.ts +++ b/src/components/cache/managers/in-memory/local-cache.collection.ts @@ -1,7 +1,7 @@ import * as NodeCache from 'node-cache'; import { ICacheManagerCollection } from '../cache.manager.interface'; -export class LocalCacheCollection implements ICacheManagerCollection { +export class LocalCacheCollection implements ICacheManagerCollection { constructor( private readonly key: string, private readonly cache: NodeCache @@ -24,7 +24,7 @@ export class LocalCacheCollection implements ICacheManagerCollection { this.ensureSetInCache().add(value); } - async getAll(): Promise { - return [...this.ensureSetInCache().values()]; + async getAll(): Promise> { + return this.ensureSetInCache(); } } \ No newline at end of file diff --git a/src/components/cache/managers/in-memory/local-cache.manager.spec.ts b/src/components/cache/managers/in-memory/local-cache.manager.spec.ts index 6534a89..d74f884 100644 --- a/src/components/cache/managers/in-memory/local-cache.manager.spec.ts +++ b/src/components/cache/managers/in-memory/local-cache.manager.spec.ts @@ -1,7 +1,9 @@ import { LocalCacheManager } from './local-cache.manager'; +import { LocalCacheCollection } from './local-cache.collection'; +import { LocalCacheMap } from './local-cache.map'; describe('Local cache manager', () => { - let localCacheManager: LocalCacheManager; + let localCacheManager: LocalCacheManager; const cacheKey = 'key'; const cacheValue = { data: 'value' }; @@ -30,4 +32,106 @@ describe('Local cache manager', () => { 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'); + }); + }); + }); }); diff --git a/src/components/cache/managers/in-memory/local-cache.manager.ts b/src/components/cache/managers/in-memory/local-cache.manager.ts index 7eab79e..630b6a4 100644 --- a/src/components/cache/managers/in-memory/local-cache.manager.ts +++ b/src/components/cache/managers/in-memory/local-cache.manager.ts @@ -4,13 +4,15 @@ import { LocalCacheCollection } from './local-cache.collection'; import { PrefixedManager } from '../prefixed-manager.abstract'; import { ICacheManager, ICacheManagerCollection, ICacheManagerMap, SetOptions } from '../cache.manager.interface'; -export class LocalCacheManager extends PrefixedManager implements ICacheManager { +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); + return new LocalCacheManager(new NodeCache({ + useClones: false + }), prefix); } public async set(key: string, data: T, options?: SetOptions): Promise { @@ -31,15 +33,19 @@ export class LocalCacheManager extends PrefixedManager implements ICacheManag } } - hashmap(key: string): ICacheManagerMap { + map(key: string): ICacheManagerMap { return new LocalCacheMap(this.withPrefix(key), this.nodeCache); } - collection(key: string): ICacheManagerCollection { + 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/in-memory/local-cache.map.ts b/src/components/cache/managers/in-memory/local-cache.map.ts index d8aab1d..b452e54 100644 --- a/src/components/cache/managers/in-memory/local-cache.map.ts +++ b/src/components/cache/managers/in-memory/local-cache.map.ts @@ -1,7 +1,7 @@ import * as NodeCache from 'node-cache'; import { ICacheManagerMap } from '../cache.manager.interface'; -export class LocalCacheMap implements ICacheManagerMap { +export class LocalCacheMap implements ICacheManagerMap { constructor( private readonly key: string, private readonly cache: NodeCache diff --git a/src/components/cache/managers/index.ts b/src/components/cache/managers/index.ts index 6b07fc6..8d3df0e 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 './cache-manager.interface'; export * from './in-memory/local-cache.manager'; export * from './ioredis/ioredis-cache.manager'; export * from './redis/redis-cache.manager'; diff --git a/src/components/cache/managers/ioredis/ioredis-cache.collection.ts b/src/components/cache/managers/ioredis/ioredis-cache.collection.ts index 80f3e07..0700a71 100644 --- a/src/components/cache/managers/ioredis/ioredis-cache.collection.ts +++ b/src/components/cache/managers/ioredis/ioredis-cache.collection.ts @@ -1,8 +1,8 @@ import IORedis from 'ioredis'; import { ICacheValueSerializer } from '../../serializers/types'; -import { ICacheManagerCollection } from '../cache.manager.interface'; +import { CacheValue, ICacheManagerCollection } from '../cache.manager.interface'; -export class IORedisCacheCollection implements ICacheManagerCollection { +export class IORedisCacheCollection implements ICacheManagerCollection { constructor( private readonly key: string, private readonly redis: IORedis, @@ -10,15 +10,17 @@ export class IORedisCacheCollection implements ICacheManagerCollection { ) { } - async set(value: T): Promise { + async set(value: T): Promise { await this.redis.sadd(this.key, this.serializer.serialize(value)); } - async has(value: T): Promise { + async has(value: T): Promise { return await this.redis.sismember(this.key, this.serializer.serialize(value)) > 0; } - async getAll(): Promise { - return (await this.redis.smembers(this.key)).map(v => this.serializer.deserialize(v)); + 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/ioredis/ioredis-cache.manager.spec.ts b/src/components/cache/managers/ioredis/ioredis-cache.manager.spec.ts index 1695284..dc18a8a 100644 --- a/src/components/cache/managers/ioredis/ioredis-cache.manager.spec.ts +++ b/src/components/cache/managers/ioredis/ioredis-cache.manager.spec.ts @@ -1,44 +1,142 @@ +import 'jest-extended'; import { IORedisCacheManager } from './ioredis-cache.manager'; +import IORedis from 'ioredis'; +import { CacheValue } from '../cache.manager.interface'; -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(); +// 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); + }); + }); }); - const cacheKey = 'key'; - const cacheValue = { data: 'value' }; + 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('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('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); + }); + }); }); - 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); + 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(); + }); - await new Promise((r) => setTimeout(r, 600)); + describe('and in Redis Set value JSON-encoded value \'"foo"\' is stored', () => { + beforeEach(() => redisTestConnection.sadd('key', '"foo"')); - const resAfterDel = await redisCacheManager.get(cacheKey); - expect(resAfterDel).toEqual(null); + 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 index 87561f2..752f3a0 100644 --- a/src/components/cache/managers/ioredis/ioredis-cache.manager.ts +++ b/src/components/cache/managers/ioredis/ioredis-cache.manager.ts @@ -1,11 +1,18 @@ import type { Redis } from 'ioredis'; import { PackageUtils } from '../../../../utils/package-loader'; import { PrefixedManager } from '../prefixed-manager.abstract'; -import { ICacheManager, ICacheManagerCollection, ICacheManagerMap, SetOptions } from '../cache.manager.interface'; +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 interface IIORedisOptions { host: string; @@ -14,7 +21,7 @@ export interface IIORedisOptions { db?: number; } -export class IORedisCacheManager extends PrefixedManager implements ICacheManager { +export class IORedisCacheManager extends PrefixedManager implements ICacheManager { private readonly serializer: ICacheValueSerializer; private constructor(private readonly redisManager: Redis, prefix = '') { @@ -23,17 +30,17 @@ export class IORedisCacheManager extends PrefixedManager implements ICacheMan this.serializer = new JsonSerializer(); } - static async create(options?: IIORedisOptions, prefix = ''): Promise> { + 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 as RedisOptions), prefix); } public async set(key: string, data: V, options?: SetOptions): Promise { if (options?.expiresInSeconds) { - this.redisManager.set(this.withPrefix(key), JSON.stringify(data), 'EX', options.expiresInSeconds); + await this.redisManager.set(this.withPrefix(key), JSON.stringify(data), 'EX', options.expiresInSeconds); } else { - this.redisManager.set(this.withPrefix(key), JSON.stringify(data)); + await this.redisManager.set(this.withPrefix(key), JSON.stringify(data)); } } @@ -48,15 +55,20 @@ export class IORedisCacheManager extends PrefixedManager implements ICacheMan } } - forScope(prefix?: string): ICacheManager { - return new IORedisCacheManager(this.redisManager, prefix ?? this.prefix); + forScope(prefix?: string): ICacheManager { + return new IORedisCacheManager(this.redisManager, prefix ?? this.prefix); } - hashmap(key: string): ICacheManagerMap { + map(key: string): ICacheManagerMap { return new IORedisCacheMap(this.withPrefix(key), this.redisManager, this.serializer); } - collection(key: string): ICacheManagerCollection { + collection(key: string): ICacheManagerCollection { return new IORedisCacheCollection(this.withPrefix(key), this.redisManager, this.serializer); } + + + async close(): Promise { + await this.redisManager.quit(); + } } diff --git a/src/components/cache/managers/ioredis/ioredis-cache.map.ts b/src/components/cache/managers/ioredis/ioredis-cache.map.ts index bb278f1..0cc3e85 100644 --- a/src/components/cache/managers/ioredis/ioredis-cache.map.ts +++ b/src/components/cache/managers/ioredis/ioredis-cache.map.ts @@ -1,8 +1,8 @@ import type IORedis from "ioredis"; import { ICacheValueSerializer } from '../../serializers/types'; -import { ICacheManagerMap } from '../cache.manager.interface'; +import { ICacheManagerMap, CacheValue } from '../cache.manager.interface'; -export class IORedisCacheMap implements ICacheManagerMap { +export class IORedisCacheMap implements ICacheManagerMap { constructor( private readonly key: string, private readonly redis: IORedis, @@ -10,11 +10,11 @@ export class IORedisCacheMap implements ICacheManagerMap { ) { } - async set(field: string, data: T): Promise { + async set(field: string, data: T): Promise { await this.redis.hset(this.key, field, this.serializer.serialize(data)); } - async get(field: string): Promise { + async get(field: string): Promise { const raw = await this.redis.hget(this.key, field); return raw !== null ? diff --git a/src/components/cache/managers/redis/redis-cache.collection.ts b/src/components/cache/managers/redis/redis-cache.collection.ts index 3a58fac..8062b2b 100644 --- a/src/components/cache/managers/redis/redis-cache.collection.ts +++ b/src/components/cache/managers/redis/redis-cache.collection.ts @@ -1,8 +1,8 @@ import { RedisClientType } from 'redis'; import { ICacheValueSerializer } from '../../serializers/types'; -import { ICacheManagerCollection } from '../cache.manager.interface'; +import { CacheValue, ICacheManagerCollection } from '../cache.manager.interface'; -export class RedisCacheCollection implements ICacheManagerCollection { +export class RedisCacheCollection implements ICacheManagerCollection { constructor( private readonly key: string, private readonly redis: RedisClientType, @@ -10,15 +10,17 @@ export class RedisCacheCollection implements ICacheManagerCollection { ) { } - async set(value: T): Promise { + async set(value: T): Promise { await this.redis.SADD(this.key, this.serializer.serialize(value)); } - async has(value: T): Promise { + async has(value: T): Promise { return await this.redis.SISMEMBER(this.key, this.serializer.serialize(value)); } - async getAll(): Promise { - return (await this.redis.SMEMBERS(this.key)).map(v => this.serializer.deserialize(v)); + 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..0dd4710 --- /dev/null +++ b/src/components/cache/managers/redis/redis-cache.manager.spec.ts @@ -0,0 +1,145 @@ +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 index 3719525..bcc91b5 100644 --- a/src/components/cache/managers/redis/redis-cache.manager.ts +++ b/src/components/cache/managers/redis/redis-cache.manager.ts @@ -7,13 +7,19 @@ import { ICacheValueSerializer } from '../../serializers/types'; import { JsonSerializer } from '../../serializers/json.serializer'; import { PrefixedManager } from '../prefixed-manager.abstract'; import type * as Redis from "redis"; -import { ICacheManager, ICacheManagerCollection, ICacheManagerMap, SetOptions } from '../cache.manager.interface'; +import { + CacheValue, + ICacheManager, + ICacheManagerCollection, + ICacheManagerMap, + SetOptions, +} from '../cache.manager.interface'; export interface IRedisOptions { url: string; } -export class RedisCacheManager extends PrefixedManager implements ICacheManager { +export class RedisCacheManager extends PrefixedManager implements ICacheManager { private readonly serializer: ICacheValueSerializer; private readonly isReadyPromise: Promise; @@ -27,7 +33,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 = ''): Promise> { + static create(options: IRedisOptions, prefix = ''): Promise> { const { createClient } = PackageUtils.loadPackage('redis') as typeof Redis; return new RedisCacheManager(createClient(options), prefix).ready(); @@ -37,15 +43,15 @@ export class RedisCacheManager extends PrefixedManager implements ICacheManag return this.isReadyPromise.then(() => this); } - forScope(prefix?: string): ICacheManager { + forScope(prefix?: string): ICacheManager { return new RedisCacheManager(this.redisManager, prefix ?? this.prefix); } - hashmap(key: string): ICacheManagerMap { + map(key: string): ICacheManagerMap { return new RedisCacheMap(this.withPrefix(key), this.redisManager, this.serializer); } - collection(key: string): ICacheManagerCollection { + collection(key: string): ICacheManagerCollection { return new RedisCacheCollection(this.withPrefix(key), this.redisManager, this.serializer); } @@ -69,4 +75,8 @@ export class RedisCacheManager extends PrefixedManager implements ICacheManag await this.redisManager.del(key.map(this.withPrefix.bind(this))); } } + + 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 index 0215875..f829a98 100644 --- a/src/components/cache/managers/redis/redis-cache.map.ts +++ b/src/components/cache/managers/redis/redis-cache.map.ts @@ -2,7 +2,7 @@ import { RedisClientType } from 'redis'; import { ICacheValueSerializer } from '../../serializers/types'; import { ICacheManagerMap } from '../cache.manager.interface'; -export class RedisCacheMap implements ICacheManagerMap { +export class RedisCacheMap implements ICacheManagerMap { constructor( private readonly key: string, private readonly redis: RedisClientType, @@ -10,11 +10,11 @@ export class RedisCacheMap implements ICacheManagerMap { ) { } - async set(field: string, data: T): Promise { + async set(field: string, data: T): Promise { await this.redis.HSET(this.key, field, this.serializer.serialize(data)); } - async get(field: string): Promise { + async get(field: string): Promise { const raw = await this.redis.HGET(this.key, field); return raw !== undefined ? diff --git a/src/utils/index.ts b/src/utils/index.ts index 1a7d418..d892e44 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -2,7 +2,7 @@ import Logger from '../components/logger'; export interface RetryOptions { numberOfTries: number; - secondsDelayRange: { + delayRangeMs: { min: number; max: number; }; @@ -10,7 +10,7 @@ export interface RetryOptions { export const retry = async ( func: () => Promise | unknown, - { numberOfTries, secondsDelayRange }: RetryOptions, + { numberOfTries, delayRangeMs }: RetryOptions, ) => { try { return await func(); @@ -20,10 +20,11 @@ export const retry = async ( throw error; } const delayTime = - Math.floor(Math.random() * (secondsDelayRange.max - secondsDelayRange.min + 1)) + secondsDelayRange.min; - Logger.debug(`trying again in ${delayTime} seconds`); - await delay(delayTime * 1000); - return retry(func, { numberOfTries: numberOfTries - 1, secondsDelayRange }); + Math.floor(Math.random() * (delayRangeMs.max - delayRangeMs.min + 1)) + delayRangeMs.min; + Logger.debug(`trying again in ${delayTime} ms`); + await delay(delayTime); + + return retry(func, { numberOfTries: numberOfTries - 1, delayRangeMs }); } }; From 83be07df470928f3a2783f1af1d5f341c54a142c Mon Sep 17 00:00:00 2001 From: Artur Wolny Date: Tue, 1 Aug 2023 12:02:43 +0300 Subject: [PATCH 13/17] refactor(entitlements): adjust the entitlements cache with refactored frontegg cache component --- .../entitlements/entitlements-client.spec.ts | 9 +++++++-- src/clients/entitlements/entitlements-client.ts | 15 ++++++++++----- .../storage/cache.revision-manager.ts | 6 +++--- .../frontegg-cache/frontegg.cache-initializer.ts | 6 +++--- .../frontegg-cache/frontegg.cache.spec.ts | 10 +++++++++- .../storage/frontegg-cache/frontegg.cache.ts | 2 +- .../cache-access-token.service-abstract.ts | 2 +- src/components/cache/index.ts | 8 ++++---- .../cache/managers/cache.manager.interface.ts | 4 ++-- .../in-memory/local-cache.manager.spec.ts | 7 ++++--- .../managers/in-memory/local-cache.manager.ts | 16 +++++++++++----- src/components/cache/managers/index.ts | 2 +- .../cache/managers/redis/redis-cache.map.ts | 2 +- 13 files changed, 57 insertions(+), 32 deletions(-) diff --git a/src/clients/entitlements/entitlements-client.spec.ts b/src/clients/entitlements/entitlements-client.spec.ts index 12f86f8..c56e57c 100644 --- a/src/clients/entitlements/entitlements-client.spec.ts +++ b/src/clients/entitlements/entitlements-client.spec.ts @@ -9,7 +9,8 @@ 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 { FronteggCache } from '../../components/cache'; +import { LocalCacheManager } from '../../components/cache/managers'; const { EntitlementsUserScoped: EntitlementsUserScopedActual } = jest.requireActual('./entitlements.user-scoped'); @@ -19,11 +20,15 @@ const httpMock = mock(); jest.mock('../../authenticator'); jest.mock('../http'); jest.mock('./entitlements.user-scoped'); +jest.mock('../../components/cache'); describe(EntitlementsClient.name, () => { let entitlementsClient: EntitlementsClient; beforeEach(() => { + // given + jest.mocked(FronteggCache.getInstance).mockImplementation(async () => LocalCacheManager.create()); + // given jest.mocked(FronteggAuthenticator).mockReturnValue(authenticatorMock); authenticatorMock.init.mockResolvedValue(undefined); @@ -210,7 +215,7 @@ describe(EntitlementsClient.name, () => { expect(scoped).toBeInstanceOf(EntitlementsUserScopedActual); // and - expect(EntitlementsUserScoped).toHaveBeenCalledWith(entity, expect.any(InMemoryEntitlementsCache)); + expect(EntitlementsUserScoped).toHaveBeenCalledWith(entity, expect.anything()); }); afterEach(() => { diff --git a/src/clients/entitlements/entitlements-client.ts b/src/clients/entitlements/entitlements-client.ts index 94864ea..bfe0efb 100644 --- a/src/clients/entitlements/entitlements-client.ts +++ b/src/clients/entitlements/entitlements-client.ts @@ -11,8 +11,9 @@ import { EntitlementsClientEvents } from './entitlements-client.events'; import { TEntity } from '../identity/types'; import { EntitlementsUserScoped } from './entitlements.user-scoped'; import { CacheRevisionManager } from './storage/cache.revision-manager'; -import { LocalCacheManager } from '../../cache'; +import { CacheValue, ICacheManager } from '../../components/cache/managers'; import { hostname } from 'os'; +import { FronteggCache } from '../../components/cache'; export class EntitlementsClient extends events.EventEmitter { // periodical refresh handler @@ -23,14 +24,17 @@ export class EntitlementsClient extends events.EventEmitter { // cache handler private cacheManager: CacheRevisionManager; - private constructor(private readonly httpClient: HttpClient, options: Partial = {}) { + private constructor( + private readonly httpClient: HttpClient, + cache: ICacheManager, + options: Partial = {} + ) { super(); this.options = this.parseOptions(options); this.cacheManager = new CacheRevisionManager( this.options.instanceId, - // TODO: use FronteggCache.getInstance(); when it's merged - new LocalCacheManager() + cache ); this.readyPromise = new Promise((resolve) => { @@ -114,8 +118,9 @@ export class EntitlementsClient extends events.EventEmitter { await authenticator.init(context.FRONTEGG_CLIENT_ID, context.FRONTEGG_API_KEY); const httpClient = new HttpClient(authenticator, { baseURL: config.urls.entitlementsService }); + const cache = await FronteggCache.getInstance(); - return new EntitlementsClient(httpClient, options); + return new EntitlementsClient(httpClient, cache, options); } destroy(): void { diff --git a/src/clients/entitlements/storage/cache.revision-manager.ts b/src/clients/entitlements/storage/cache.revision-manager.ts index dd8d222..6f534e3 100644 --- a/src/clients/entitlements/storage/cache.revision-manager.ts +++ b/src/clients/entitlements/storage/cache.revision-manager.ts @@ -1,4 +1,4 @@ -import { ICacheManager } from '../../../cache'; +import { CacheValue, ICacheManager } from '../../../components/cache/managers'; import { VendorEntitlementsDto, VendorEntitlementsSnapshotOffsetDto } from '../types'; import { IEntitlementsCache } from './types'; import { FronteggEntitlementsCacheInitializer } from './frontegg-cache/frontegg.cache-initializer'; @@ -13,7 +13,7 @@ export class CacheRevisionManager { constructor( public readonly instanceId: string, - private readonly cache: ICacheManager, + private readonly cache: ICacheManager, private readonly options: { maxUpdateLockTime: number } = { @@ -73,7 +73,7 @@ export class CacheRevisionManager { } async isUpdateInProgress(): Promise { - return await this.cache.get(UPDATE_IN_PROGRESS_KEY) === null; + return await this.cache.get(UPDATE_IN_PROGRESS_KEY) !== null; } private async setOffset(offset: number): Promise { diff --git a/src/clients/entitlements/storage/frontegg-cache/frontegg.cache-initializer.ts b/src/clients/entitlements/storage/frontegg-cache/frontegg.cache-initializer.ts index 1bf102a..d38fccc 100644 --- a/src/clients/entitlements/storage/frontegg-cache/frontegg.cache-initializer.ts +++ b/src/clients/entitlements/storage/frontegg-cache/frontegg.cache-initializer.ts @@ -1,5 +1,4 @@ import { FronteggEntitlementsCache } from './frontegg.cache'; -import { ICacheManager, LocalCacheManager } from '../../../../cache'; import { VendorEntitlementsDto } from '../../types'; import { BundlesSource } from '../types'; import { @@ -10,6 +9,8 @@ import { } from './frontegg.cache-key.utils'; import { DtoToCacheSourcesMapper } from '../dto-to-cache-sources.mapper'; import { pickExpTimestamp } from '../exp-time.utils'; +import { ICacheManager } from '../../../../components/cache/managers'; +import { FronteggCache } from '../../../../components/cache'; export class FronteggEntitlementsCacheInitializer { @@ -20,8 +21,7 @@ export class FronteggEntitlementsCacheInitializer { static async initialize(dto: VendorEntitlementsDto): Promise { const revision = dto.snapshotOffset; - // TODO: change to FronteggCache.getInstance() - const cache = new LocalCacheManager(); + const cache = await FronteggCache.getInstance(); const cacheInitializer = new FronteggEntitlementsCacheInitializer(cache); const sources = (new DtoToCacheSourcesMapper()).map(dto); diff --git a/src/clients/entitlements/storage/frontegg-cache/frontegg.cache.spec.ts b/src/clients/entitlements/storage/frontegg-cache/frontegg.cache.spec.ts index a7a2a80..d62e688 100644 --- a/src/clients/entitlements/storage/frontegg-cache/frontegg.cache.spec.ts +++ b/src/clients/entitlements/storage/frontegg-cache/frontegg.cache.spec.ts @@ -1,11 +1,19 @@ import { FronteggEntitlementsCache } from './frontegg.cache'; import { NO_EXPIRE } from '../types'; import { FronteggEntitlementsCacheInitializer } from './frontegg.cache-initializer'; +import { FronteggCache } from '../../../../components/cache'; +import { CacheValue, ICacheManager, LocalCacheManager } from '../../../../components/cache/managers'; -// TODO: define all tests of IEntitlementsCache implementation in single file, only change the implementation for runs +jest.mock('../../../../components/cache'); describe(FronteggEntitlementsCache.name, () => { let cut: FronteggEntitlementsCache; + let cache: ICacheManager; + + beforeEach(async () => { + cache = await LocalCacheManager.create(); + jest.mocked(FronteggCache.getInstance).mockResolvedValue(cache); + }); describe('given input data with no entitlements and bundle with feature "foo"', () => { beforeEach(async () => { diff --git a/src/clients/entitlements/storage/frontegg-cache/frontegg.cache.ts b/src/clients/entitlements/storage/frontegg-cache/frontegg.cache.ts index f027119..f84b08f 100644 --- a/src/clients/entitlements/storage/frontegg-cache/frontegg.cache.ts +++ b/src/clients/entitlements/storage/frontegg-cache/frontegg.cache.ts @@ -1,7 +1,7 @@ import { ExpirationTime, IEntitlementsCache } from '../types'; -import { ICacheManager } from '../../../../cache'; import { FeatureKey } from '../../types'; import { ENTITLEMENTS_MAP_KEY, getFeatureEntitlementKey, getPermissionMappingKey } from './frontegg.cache-key.utils'; +import { ICacheManager } from '../../../../components/cache/managers'; export class FronteggEntitlementsCache implements IEntitlementsCache { 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 4c7184a..f41151e 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 @@ -1,6 +1,6 @@ import { IAccessToken, IEmptyAccessToken, IEntityWithRoles, tokenTypes } from '../../../types'; import { IAccessTokenService } from '../access-token.service.interface'; -import { ICacheManager } from '../../../../../components/cache/managers/cache-manager.interface'; +import { ICacheManager } from '../../../../../components/cache/managers'; import { FailedToAuthenticateException } from '../../../exceptions'; export abstract class CacheAccessTokenServiceAbstract implements IAccessTokenService { diff --git a/src/components/cache/index.ts b/src/components/cache/index.ts index b6610f1..4092ede 100644 --- a/src/components/cache/index.ts +++ b/src/components/cache/index.ts @@ -1,11 +1,11 @@ import { FronteggContext } from '../frontegg-context'; -import { ICacheManager } from './managers'; +import { CacheValue, ICacheManager } from './managers'; import { IORedisCacheManager, LocalCacheManager, RedisCacheManager } from './managers'; -let cacheInstance: ICacheManager; +let cacheInstance: ICacheManager; export class FronteggCache { - static async getInstance(): Promise> { + static async getInstance(): Promise> { if (!cacheInstance) { cacheInstance = await FronteggCache.initialize(); } @@ -13,7 +13,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 fb40b93..4338ae6 100644 --- a/src/components/cache/managers/cache.manager.interface.ts +++ b/src/components/cache/managers/cache.manager.interface.ts @@ -19,7 +19,7 @@ type JSONArray = JSONValue[]; export type CacheValue = JSONValue; -export interface ICacheManager { +export interface ICacheManager { set(key: string, data: V, options?: SetOptions): Promise; get(key: string): Promise; del(key: string[]): Promise; @@ -33,7 +33,7 @@ export interface ICacheManager { * * If prefix is not given, the prefix of current instance should be used. */ - forScope(prefix?: string): ICacheManager; + forScope(prefix?: string): ICacheManager; } export interface ICacheManagerMap { diff --git a/src/components/cache/managers/in-memory/local-cache.manager.spec.ts b/src/components/cache/managers/in-memory/local-cache.manager.spec.ts index d74f884..e25d1af 100644 --- a/src/components/cache/managers/in-memory/local-cache.manager.spec.ts +++ b/src/components/cache/managers/in-memory/local-cache.manager.spec.ts @@ -1,9 +1,10 @@ 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; + let localCacheManager: LocalCacheManager; const cacheKey = 'key'; const cacheValue = { data: 'value' }; @@ -50,7 +51,7 @@ describe('Local cache manager', () => { }); describe('given collection instance is received by .collection(key)', () => { - let cut: LocalCacheManager; + let cut: LocalCacheManager; beforeEach(async () => { cut = await LocalCacheManager.create(); @@ -93,7 +94,7 @@ describe('Local cache manager', () => { }); describe('given map instance is received by .map(key)', () => { - let cut: LocalCacheManager; + let cut: LocalCacheManager; beforeEach(async () => { cut = await LocalCacheManager.create(); diff --git a/src/components/cache/managers/in-memory/local-cache.manager.ts b/src/components/cache/managers/in-memory/local-cache.manager.ts index 630b6a4..6842731 100644 --- a/src/components/cache/managers/in-memory/local-cache.manager.ts +++ b/src/components/cache/managers/in-memory/local-cache.manager.ts @@ -2,14 +2,20 @@ import * as NodeCache from 'node-cache'; import { LocalCacheMap } from './local-cache.map'; import { LocalCacheCollection } from './local-cache.collection'; import { PrefixedManager } from '../prefixed-manager.abstract'; -import { ICacheManager, ICacheManagerCollection, ICacheManagerMap, SetOptions } from '../cache.manager.interface'; - -export class LocalCacheManager extends PrefixedManager implements ICacheManager { +import { + CacheValue, + ICacheManager, + ICacheManagerCollection, + ICacheManagerMap, + SetOptions, +} from '../cache.manager.interface'; + +export class LocalCacheManager extends PrefixedManager implements ICacheManager { private constructor(private readonly nodeCache: NodeCache, prefix = '') { super(prefix); } - static async create(prefix = ''): Promise> { + static async create(prefix = ''): Promise> { return new LocalCacheManager(new NodeCache({ useClones: false }), prefix); @@ -41,7 +47,7 @@ export class LocalCacheManager extends PrefixedManager implements ICach return new LocalCacheCollection(this.withPrefix(key), this.nodeCache); } - forScope(prefix?: string): ICacheManager { + forScope(prefix?: string): ICacheManager { return new LocalCacheManager(this.nodeCache, prefix ?? this.prefix); } diff --git a/src/components/cache/managers/index.ts b/src/components/cache/managers/index.ts index 8d3df0e..6b07fc6 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 './cache.manager.interface'; export * from './in-memory/local-cache.manager'; export * from './ioredis/ioredis-cache.manager'; export * from './redis/redis-cache.manager'; diff --git a/src/components/cache/managers/redis/redis-cache.map.ts b/src/components/cache/managers/redis/redis-cache.map.ts index f829a98..9617a9d 100644 --- a/src/components/cache/managers/redis/redis-cache.map.ts +++ b/src/components/cache/managers/redis/redis-cache.map.ts @@ -1,6 +1,6 @@ import { RedisClientType } from 'redis'; import { ICacheValueSerializer } from '../../serializers/types'; -import { ICacheManagerMap } from '../cache.manager.interface'; +import { CacheValue, ICacheManagerMap } from '../cache.manager.interface'; export class RedisCacheMap implements ICacheManagerMap { constructor( From 0eec25ecf2f90d43496673f49d9ee0d8342a612f Mon Sep 17 00:00:00 2001 From: Artur Wolny Date: Tue, 1 Aug 2023 12:10:07 +0300 Subject: [PATCH 14/17] refactor(sdk): reformatting applied --- jest.config.js | 2 +- .../entitlements/entitlements-client.ts | 7 +--- .../storage/cache.revision-manager.ts | 39 +++++++++++-------- .../storage/dto-to-cache-sources.mapper.ts | 4 +- .../frontegg.cache-initializer.ts | 25 +++++------- .../frontegg.cache-key.utils.ts | 4 +- .../frontegg-cache/frontegg.cache.spec.ts | 2 +- .../storage/frontegg-cache/frontegg.cache.ts | 15 ++++--- src/clients/entitlements/storage/types.ts | 3 +- .../cache/managers/cache.manager.interface.ts | 9 +---- .../in-memory/local-cache.collection.ts | 8 +--- .../managers/in-memory/local-cache.manager.ts | 11 ++++-- .../managers/in-memory/local-cache.map.ts | 8 +--- .../ioredis/ioredis-cache.collection.ts | 11 +++--- .../ioredis/ioredis-cache.manager.spec.ts | 6 +-- .../managers/ioredis/ioredis-cache.manager.ts | 3 +- .../managers/ioredis/ioredis-cache.map.ts | 13 +++---- .../managers/redis/redis-cache.collection.ts | 9 ++--- .../redis/redis-cache.manager.spec.ts | 4 +- .../managers/redis/redis-cache.manager.ts | 8 ++-- .../cache/managers/redis/redis-cache.map.ts | 11 ++---- .../cache/serializers/json.serializer.ts | 2 +- src/components/cache/serializers/types.ts | 2 +- src/utils/index.ts | 8 +--- 24 files changed, 91 insertions(+), 123 deletions(-) diff --git a/jest.config.js b/jest.config.js index 20173b8..1332537 100644 --- a/jest.config.js +++ b/jest.config.js @@ -13,7 +13,7 @@ module.exports = { lines: 18, }, }, - setupFilesAfterEnv: ["jest-extended/all"], + setupFilesAfterEnv: ['jest-extended/all'], reporters: [ 'default', [ diff --git a/src/clients/entitlements/entitlements-client.ts b/src/clients/entitlements/entitlements-client.ts index bfe0efb..a49a988 100644 --- a/src/clients/entitlements/entitlements-client.ts +++ b/src/clients/entitlements/entitlements-client.ts @@ -27,15 +27,12 @@ export class EntitlementsClient extends events.EventEmitter { private constructor( private readonly httpClient: HttpClient, cache: ICacheManager, - options: Partial = {} + options: Partial = {}, ) { super(); this.options = this.parseOptions(options); - this.cacheManager = new CacheRevisionManager( - this.options.instanceId, - cache - ); + this.cacheManager = new CacheRevisionManager(this.options.instanceId, cache); this.readyPromise = new Promise((resolve) => { this.once(EntitlementsClientEvents.INITIALIZED, () => resolve(this)); diff --git a/src/clients/entitlements/storage/cache.revision-manager.ts b/src/clients/entitlements/storage/cache.revision-manager.ts index 6f534e3..6e09067 100644 --- a/src/clients/entitlements/storage/cache.revision-manager.ts +++ b/src/clients/entitlements/storage/cache.revision-manager.ts @@ -15,29 +15,34 @@ export class CacheRevisionManager { public readonly instanceId: string, private readonly cache: ICacheManager, private readonly options: { - maxUpdateLockTime: number + maxUpdateLockTime: number; } = { - maxUpdateLockTime: 5 - } - ) { - } + maxUpdateLockTime: 5, + }, + ) {} async waitUntilUpdated(): Promise { return new Promise((resolve, reject) => { - retry(async () => { + retry( + async () => { if (await this.isUpdateInProgress()) { throw new Error(); } - }, { numberOfTries: 3, delayRangeMs: { - min: 100, - max: 2000 - }}) - .then(resolve) - .catch(err => reject(err)); + }, + { + numberOfTries: 3, + delayRangeMs: { + min: 100, + max: 2000, + }, + }, + ) + .then(resolve) + .catch((err) => reject(err)); }); } - async loadSnapshot(dto: VendorEntitlementsDto): Promise<{ isUpdated: boolean, rev: number }> { + async loadSnapshot(dto: VendorEntitlementsDto): Promise<{ isUpdated: boolean; rev: number }> { await this.waitUntilUpdated(); const currentOffset = await this.getOffset(); @@ -56,7 +61,7 @@ export class CacheRevisionManager { await oldCache?.clear(); await oldCache?.shutdown(); - return { isUpdated: true, rev: dto.snapshotOffset } + return { isUpdated: true, rev: dto.snapshotOffset }; } async hasRecentSnapshot(dto: VendorEntitlementsSnapshotOffsetDto): Promise { @@ -73,7 +78,7 @@ export class CacheRevisionManager { } async isUpdateInProgress(): Promise { - return await this.cache.get(UPDATE_IN_PROGRESS_KEY) !== null; + return (await this.cache.get(UPDATE_IN_PROGRESS_KEY)) !== null; } private async setOffset(offset: number): Promise { @@ -81,10 +86,10 @@ export class CacheRevisionManager { } async getOffset(): Promise { - return await this.cache.get(CURRENT_OFFSET_KEY) || 0; + return (await this.cache.get(CURRENT_OFFSET_KEY)) || 0; } getCache(): IEntitlementsCache | undefined { return this.entitlementsCache; } -} \ No newline at end of file +} diff --git a/src/clients/entitlements/storage/dto-to-cache-sources.mapper.ts b/src/clients/entitlements/storage/dto-to-cache-sources.mapper.ts index bf9a6e5..d5ab0ee 100644 --- a/src/clients/entitlements/storage/dto-to-cache-sources.mapper.ts +++ b/src/clients/entitlements/storage/dto-to-cache-sources.mapper.ts @@ -3,7 +3,9 @@ import { BundlesSource, ExpirationTime, FeatureSource, NO_EXPIRE, UNBUNDLED_SRC_ export class DtoToCacheSourcesMapper { map(dto: VendorEntitlementsDto): BundlesSource { - const { data: { features, entitlements, featureBundles } } = dto; + const { + data: { features, entitlements, featureBundles }, + } = dto; const bundlesMap: BundlesSource = new Map(); const unbundledFeaturesIds: Set = new Set(); diff --git a/src/clients/entitlements/storage/frontegg-cache/frontegg.cache-initializer.ts b/src/clients/entitlements/storage/frontegg-cache/frontegg.cache-initializer.ts index d38fccc..79d5bad 100644 --- a/src/clients/entitlements/storage/frontegg-cache/frontegg.cache-initializer.ts +++ b/src/clients/entitlements/storage/frontegg-cache/frontegg.cache-initializer.ts @@ -13,9 +13,7 @@ import { ICacheManager } from '../../../../components/cache/managers'; import { FronteggCache } from '../../../../components/cache'; export class FronteggEntitlementsCacheInitializer { - - constructor(private readonly cache: ICacheManager) { - } + constructor(private readonly cache: ICacheManager) {} // TODO: make use of revPrefix !! static async initialize(dto: VendorEntitlementsDto): Promise { @@ -24,7 +22,7 @@ export class FronteggEntitlementsCacheInitializer { const cache = await FronteggCache.getInstance(); const cacheInitializer = new FronteggEntitlementsCacheInitializer(cache); - const sources = (new DtoToCacheSourcesMapper()).map(dto); + const sources = new DtoToCacheSourcesMapper().map(dto); await cacheInitializer.setupPermissionsReadModel(sources); await cacheInitializer.setupEntitlementsReadModel(sources); @@ -50,28 +48,25 @@ export class FronteggEntitlementsCacheInitializer { // iterating over bundles.. for (const singleBundle of src.values()) { // iterating over tenant&user entitlements - for (const [ tenantId, usersOfTenantEntitlements ] of singleBundle.user_entitlements) { + for (const [tenantId, usersOfTenantEntitlements] of singleBundle.user_entitlements) { // iterating over per-user entitlements - for (const [ userId, expTimes ] of usersOfTenantEntitlements) { + for (const [userId, expTimes] of usersOfTenantEntitlements) { const entitlementExpTime = pickExpTimestamp(expTimes); await Promise.all( - [...singleBundle.features.values()] - .map(feature => entitlementsHashMap.set( - getFeatureEntitlementKey(feature.key, tenantId, userId), entitlementExpTime) - ) + [...singleBundle.features.values()].map((feature) => + entitlementsHashMap.set(getFeatureEntitlementKey(feature.key, tenantId, userId), entitlementExpTime), + ), ); } } // iterating over tenant entitlements - for (const [ tenantId, expTimes ] of singleBundle.tenant_entitlements) { + for (const [tenantId, expTimes] of singleBundle.tenant_entitlements) { for (const feature of singleBundle.features.values()) { const entitlementExpTime = pickExpTimestamp(expTimes); - await entitlementsHashMap.set( - getFeatureEntitlementKey(feature.key, tenantId), entitlementExpTime - ); + await entitlementsHashMap.set(getFeatureEntitlementKey(feature.key, tenantId), entitlementExpTime); } } } @@ -80,4 +75,4 @@ export class FronteggEntitlementsCacheInitializer { private async setupRevisionNumber(revision: number): Promise { await this.cache.set(OFFSET_KEY, revision); } -} \ No newline at end of file +} diff --git a/src/clients/entitlements/storage/frontegg-cache/frontegg.cache-key.utils.ts b/src/clients/entitlements/storage/frontegg-cache/frontegg.cache-key.utils.ts index 4dd55d8..e58df9c 100644 --- a/src/clients/entitlements/storage/frontegg-cache/frontegg.cache-key.utils.ts +++ b/src/clients/entitlements/storage/frontegg-cache/frontegg.cache-key.utils.ts @@ -2,7 +2,7 @@ import { FeatureKey } from '../../types'; import { Permission } from '../../../identity/types'; export const ENTITLEMENTS_MAP_KEY = 'entitlements'; -export const OFFSET_KEY = 'snapshot-offset' +export const OFFSET_KEY = 'snapshot-offset'; export function getFeatureEntitlementKey(featKey: FeatureKey, tenantId: string, userId = ''): string { return `${tenantId}:${userId}:${featKey}`; @@ -10,4 +10,4 @@ export function getFeatureEntitlementKey(featKey: FeatureKey, tenantId: string, export function getPermissionMappingKey(permissionKey: Permission): string { return `perms:${permissionKey}`; -} \ No newline at end of file +} diff --git a/src/clients/entitlements/storage/frontegg-cache/frontegg.cache.spec.ts b/src/clients/entitlements/storage/frontegg-cache/frontegg.cache.spec.ts index d62e688..d021418 100644 --- a/src/clients/entitlements/storage/frontegg-cache/frontegg.cache.spec.ts +++ b/src/clients/entitlements/storage/frontegg-cache/frontegg.cache.spec.ts @@ -76,7 +76,7 @@ describe(FronteggEntitlementsCache.name, () => { describe('given input data with multiple time-restricted entitlements to bundle with feature "foo" (no permissions) for user "u-1" and tenant "t-2"', () => { beforeEach(async () => { - cut = await FronteggEntitlementsCacheInitializer.initialize({ + cut = await FronteggEntitlementsCacheInitializer.initialize({ snapshotOffset: 4, data: { features: [['f-1', 'foo', []]], diff --git a/src/clients/entitlements/storage/frontegg-cache/frontegg.cache.ts b/src/clients/entitlements/storage/frontegg-cache/frontegg.cache.ts index f84b08f..d5739cf 100644 --- a/src/clients/entitlements/storage/frontegg-cache/frontegg.cache.ts +++ b/src/clients/entitlements/storage/frontegg-cache/frontegg.cache.ts @@ -4,17 +4,17 @@ import { ENTITLEMENTS_MAP_KEY, getFeatureEntitlementKey, getPermissionMappingKey import { ICacheManager } from '../../../../components/cache/managers'; export class FronteggEntitlementsCache implements IEntitlementsCache { - - constructor( - private readonly cache: ICacheManager, readonly revision: number - ) { - } + constructor(private readonly cache: ICacheManager, readonly revision: number) {} clear(): Promise { return Promise.resolve(undefined); } - async getEntitlementExpirationTime(featKey: FeatureKey, tenantId: string, userId?: string): Promise { + async getEntitlementExpirationTime( + featKey: FeatureKey, + tenantId: string, + userId?: string, + ): Promise { const entitlementKey = getFeatureEntitlementKey(featKey, tenantId, userId); const result = await this.cache.map(ENTITLEMENTS_MAP_KEY).get(entitlementKey); @@ -28,5 +28,4 @@ export class FronteggEntitlementsCache implements IEntitlementsCache { shutdown(): Promise { return Promise.resolve(undefined); } - -} \ No newline at end of file +} diff --git a/src/clients/entitlements/storage/types.ts b/src/clients/entitlements/storage/types.ts index 68ecb09..d03de16 100644 --- a/src/clients/entitlements/storage/types.ts +++ b/src/clients/entitlements/storage/types.ts @@ -5,7 +5,6 @@ export const NO_EXPIRE = -1; export type ExpirationTime = number | typeof NO_EXPIRE; export interface IEntitlementsCache { - /** * The revision number to compare next entitlements cache versions. */ @@ -50,4 +49,4 @@ export type SingleBundleSource = { user_entitlements: Map>; tenant_entitlements: SingleEntityEntitlements; }; -export type BundlesSource = Map; \ No newline at end of file +export type BundlesSource = Map; diff --git a/src/components/cache/managers/cache.manager.interface.ts b/src/components/cache/managers/cache.manager.interface.ts index 4338ae6..2a32852 100644 --- a/src/components/cache/managers/cache.manager.interface.ts +++ b/src/components/cache/managers/cache.manager.interface.ts @@ -2,14 +2,7 @@ export interface SetOptions { expiresInSeconds: number; } -type Primitive = - | bigint - | boolean - | null - | number - | string - | undefined - | object; +type Primitive = bigint | boolean | null | number | string | undefined | object; type JSONValue = Primitive | JSONObject | JSONArray; export interface JSONObject { diff --git a/src/components/cache/managers/in-memory/local-cache.collection.ts b/src/components/cache/managers/in-memory/local-cache.collection.ts index 17bbd53..e28ca7e 100644 --- a/src/components/cache/managers/in-memory/local-cache.collection.ts +++ b/src/components/cache/managers/in-memory/local-cache.collection.ts @@ -2,11 +2,7 @@ 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 - ) { - } + constructor(private readonly key: string, private readonly cache: NodeCache) {} private ensureSetInCache(): Set { if (!this.cache.has(this.key)) { @@ -27,4 +23,4 @@ export class LocalCacheCollection implements ICacheManagerCollection { async getAll(): Promise> { return this.ensureSetInCache(); } -} \ No newline at end of file +} diff --git a/src/components/cache/managers/in-memory/local-cache.manager.ts b/src/components/cache/managers/in-memory/local-cache.manager.ts index 6842731..c8be0cf 100644 --- a/src/components/cache/managers/in-memory/local-cache.manager.ts +++ b/src/components/cache/managers/in-memory/local-cache.manager.ts @@ -16,9 +16,12 @@ export class LocalCacheManager extends PrefixedManager imp } static async create(prefix = ''): Promise> { - return new LocalCacheManager(new NodeCache({ - useClones: false - }), prefix); + return new LocalCacheManager( + new NodeCache({ + useClones: false, + }), + prefix, + ); } public async set(key: string, data: T, options?: SetOptions): Promise { @@ -52,6 +55,6 @@ export class LocalCacheManager extends PrefixedManager imp } async close(): Promise { - this.nodeCache.close() + this.nodeCache.close(); } } diff --git a/src/components/cache/managers/in-memory/local-cache.map.ts b/src/components/cache/managers/in-memory/local-cache.map.ts index b452e54..9f69df1 100644 --- a/src/components/cache/managers/in-memory/local-cache.map.ts +++ b/src/components/cache/managers/in-memory/local-cache.map.ts @@ -2,11 +2,7 @@ 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 - ) { - } + constructor(private readonly key: string, private readonly cache: NodeCache) {} private ensureMapInCache(): Map { if (!this.cache.has(this.key)) { @@ -25,4 +21,4 @@ export class LocalCacheMap implements ICacheManagerMap { 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/ioredis/ioredis-cache.collection.ts b/src/components/cache/managers/ioredis/ioredis-cache.collection.ts index 0700a71..aa333d7 100644 --- a/src/components/cache/managers/ioredis/ioredis-cache.collection.ts +++ b/src/components/cache/managers/ioredis/ioredis-cache.collection.ts @@ -6,21 +6,20 @@ export class IORedisCacheCollection implements ICacheManagerCollection(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; + 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)); + 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/ioredis/ioredis-cache.manager.spec.ts b/src/components/cache/managers/ioredis/ioredis-cache.manager.spec.ts index dc18a8a..37416a7 100644 --- a/src/components/cache/managers/ioredis/ioredis-cache.manager.spec.ts +++ b/src/components/cache/managers/ioredis/ioredis-cache.manager.spec.ts @@ -11,7 +11,6 @@ function delay(ms: number): Promise { } describe(IORedisCacheManager.name, () => { - let cut: IORedisCacheManager; let redisTestConnection: IORedis; @@ -22,7 +21,7 @@ describe(IORedisCacheManager.name, () => { // initial clean-up of used key await redisTestConnection.del('key'); - cut = await IORedisCacheManager.create({ host: 'localhost', port: 36279 }) + cut = await IORedisCacheManager.create({ host: 'localhost', port: 36279 }); }); afterEach(async () => { @@ -138,5 +137,4 @@ describe(IORedisCacheManager.name, () => { }); }); }); - -}); \ 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 index 752f3a0..931f18e 100644 --- a/src/components/cache/managers/ioredis/ioredis-cache.manager.ts +++ b/src/components/cache/managers/ioredis/ioredis-cache.manager.ts @@ -12,7 +12,7 @@ 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"; +import type { RedisOptions } from 'ioredis'; export interface IIORedisOptions { host: string; @@ -67,7 +67,6 @@ export class IORedisCacheManager extends PrefixedManager i return new IORedisCacheCollection(this.withPrefix(key), this.redisManager, this.serializer); } - async close(): Promise { await this.redisManager.quit(); } diff --git a/src/components/cache/managers/ioredis/ioredis-cache.map.ts b/src/components/cache/managers/ioredis/ioredis-cache.map.ts index 0cc3e85..334b9fc 100644 --- a/src/components/cache/managers/ioredis/ioredis-cache.map.ts +++ b/src/components/cache/managers/ioredis/ioredis-cache.map.ts @@ -1,4 +1,4 @@ -import type IORedis from "ioredis"; +import type IORedis from 'ioredis'; import { ICacheValueSerializer } from '../../serializers/types'; import { ICacheManagerMap, CacheValue } from '../cache.manager.interface'; @@ -6,9 +6,8 @@ export class IORedisCacheMap implements ICacheManagerMap { constructor( private readonly key: string, private readonly redis: IORedis, - private readonly serializer: ICacheValueSerializer - ) { - } + private readonly serializer: ICacheValueSerializer, + ) {} async set(field: string, data: T): Promise { await this.redis.hset(this.key, field, this.serializer.serialize(data)); @@ -17,12 +16,10 @@ export class IORedisCacheMap implements ICacheManagerMap { async get(field: string): Promise { const raw = await this.redis.hget(this.key, field); - return raw !== null ? - this.serializer.deserialize(raw) : - null; + 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/redis/redis-cache.collection.ts b/src/components/cache/managers/redis/redis-cache.collection.ts index 8062b2b..629d0ae 100644 --- a/src/components/cache/managers/redis/redis-cache.collection.ts +++ b/src/components/cache/managers/redis/redis-cache.collection.ts @@ -6,9 +6,8 @@ export class RedisCacheCollection implements ICacheManagerCollection constructor( private readonly key: string, private readonly redis: RedisClientType, - private readonly serializer: ICacheValueSerializer - ) { - } + private readonly serializer: ICacheValueSerializer, + ) {} async set(value: T): Promise { await this.redis.SADD(this.key, this.serializer.serialize(value)); @@ -19,8 +18,8 @@ export class RedisCacheCollection implements ICacheManagerCollection } async getAll(): Promise> { - const members = (await this.redis.SMEMBERS(this.key)).map(v => this.serializer.deserialize(v)); + 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 index 0dd4710..d079c78 100644 --- a/src/components/cache/managers/redis/redis-cache.manager.spec.ts +++ b/src/components/cache/managers/redis/redis-cache.manager.spec.ts @@ -11,7 +11,6 @@ function delay(ms: number): Promise { } describe(RedisCacheManager.name, () => { - let cut: RedisCacheManager; let redisTestConnection: IORedis; @@ -141,5 +140,4 @@ describe(RedisCacheManager.name, () => { }); }); }); - -}); \ 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 index bcc91b5..312628b 100644 --- a/src/components/cache/managers/redis/redis-cache.manager.ts +++ b/src/components/cache/managers/redis/redis-cache.manager.ts @@ -6,7 +6,7 @@ import { RedisCacheCollection } from './redis-cache.collection'; import { ICacheValueSerializer } from '../../serializers/types'; import { JsonSerializer } from '../../serializers/json.serializer'; import { PrefixedManager } from '../prefixed-manager.abstract'; -import type * as Redis from "redis"; +import type * as Redis from 'redis'; import { CacheValue, ICacheManager, @@ -57,9 +57,9 @@ export class RedisCacheManager extends PrefixedManager imp public async set(key: string, data: V, options?: SetOptions): Promise { if (options?.expiresInSeconds) { - await this.redisManager.set( - this.withPrefix(key), this.serializer.serialize(data), { EX: options.expiresInSeconds } - ); + await this.redisManager.set(this.withPrefix(key), this.serializer.serialize(data), { + EX: options.expiresInSeconds, + }); } else { await this.redisManager.set(this.withPrefix(key), this.serializer.serialize(data)); } diff --git a/src/components/cache/managers/redis/redis-cache.map.ts b/src/components/cache/managers/redis/redis-cache.map.ts index 9617a9d..c4eb280 100644 --- a/src/components/cache/managers/redis/redis-cache.map.ts +++ b/src/components/cache/managers/redis/redis-cache.map.ts @@ -6,9 +6,8 @@ export class RedisCacheMap implements ICacheManagerMap { constructor( private readonly key: string, private readonly redis: RedisClientType, - private readonly serializer: ICacheValueSerializer - ) { - } + private readonly serializer: ICacheValueSerializer, + ) {} async set(field: string, data: T): Promise { await this.redis.HSET(this.key, field, this.serializer.serialize(data)); @@ -17,12 +16,10 @@ export class RedisCacheMap implements ICacheManagerMap { async get(field: string): Promise { const raw = await this.redis.HGET(this.key, field); - return raw !== undefined ? - this.serializer.deserialize(raw) : - null; + 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 index 2dceeb4..c4d9b40 100644 --- a/src/components/cache/serializers/json.serializer.ts +++ b/src/components/cache/serializers/json.serializer.ts @@ -8,4 +8,4 @@ export class JsonSerializer implements ICacheValueSerializer { 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 index 6e2fa4f..95ab81c 100644 --- a/src/components/cache/serializers/types.ts +++ b/src/components/cache/serializers/types.ts @@ -1,4 +1,4 @@ export interface ICacheValueSerializer { serialize(data: T): string; deserialize(raw: string): T; -} \ No newline at end of file +} diff --git a/src/utils/index.ts b/src/utils/index.ts index d892e44..ca4b470 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -8,10 +8,7 @@ export interface RetryOptions { }; } -export const retry = async ( - func: () => Promise | unknown, - { numberOfTries, delayRangeMs }: RetryOptions, -) => { +export const retry = async (func: () => Promise | unknown, { numberOfTries, delayRangeMs }: RetryOptions) => { try { return await func(); } catch (error) { @@ -19,8 +16,7 @@ export const retry = async ( if (numberOfTries === 1) { throw error; } - const delayTime = - Math.floor(Math.random() * (delayRangeMs.max - delayRangeMs.min + 1)) + delayRangeMs.min; + const delayTime = Math.floor(Math.random() * (delayRangeMs.max - delayRangeMs.min + 1)) + delayRangeMs.min; Logger.debug(`trying again in ${delayTime} ms`); await delay(delayTime); From 3ec0c67c01255379552248be8a886b5de9d960f5 Mon Sep 17 00:00:00 2001 From: Artur Wolny Date: Tue, 1 Aug 2023 14:28:46 +0300 Subject: [PATCH 15/17] ci(sdk): fixed tests in CI --- ci/run-test-suite.sh | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ci/run-test-suite.sh b/ci/run-test-suite.sh index 3d2ca78..48673c3 100755 --- a/ci/run-test-suite.sh +++ b/ci/run-test-suite.sh @@ -4,7 +4,7 @@ set -e docker compose -p nodejs-sdk-tests up -d --wait -npm run --prefix ../ test:jest +npm run --prefix ../ test:jest --coverage RESULT=$@ docker compose -p nodejs-sdk-tests down diff --git a/package.json b/package.json index 59873c7..fa6cdcc 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "format": "prettier --write \"**/*.+(js|ts|json)\"", "test:jest": "npm run build && jest --runInBand", "test": "(cd ci; ./run-test-suite.sh)", - "test:coverage": "npm test -- --coverage", + "test:coverage": "npm test", "test:watch": "npm run build && jest --watch", "dev": "tsc --watch" }, From 5c3bbf3de427d150ca8466c2dea8a281eacc8a40 Mon Sep 17 00:00:00 2001 From: Artur Wolny Date: Mon, 14 Aug 2023 11:19:55 +0200 Subject: [PATCH 16/17] feature(entitlements): leader election for entitlements client cache update --- package-lock.json | 124 +++++++++-- package.json | 5 +- ....ts => entitlements-client-events.enum.ts} | 2 +- .../entitlements/entitlements-client.ts | 131 +++++++++--- .../storage/cache.revision-manager.spec.ts | 196 ++++++++++++++++++ .../storage/cache.revision-manager.ts | 91 ++++---- .../frontegg.cache-initializer.spec.ts | 173 ++++++++++++++++ .../frontegg.cache-initializer.ts | 48 ++++- .../frontegg.cache-key.utils.ts | 1 + .../frontegg-cache/frontegg.cache.spec.ts | 12 +- .../storage/frontegg-cache/frontegg.cache.ts | 19 +- src/clients/entitlements/types.ts | 4 + .../cache/managers/cache.manager.interface.ts | 1 + .../managers/in-memory/local-cache.manager.ts | 6 + .../managers/ioredis/ioredis-cache.manager.ts | 19 +- .../managers/redis/redis-cache.manager.ts | 10 + src/components/frontegg-context/types.ts | 2 +- .../always-leader.lock-handler.ts | 11 + src/components/leader-election/factory.ts | 31 +++ src/components/leader-election/index.spec.ts | 175 ++++++++++++++++ src/components/leader-election/index.ts | 109 ++++++++++ .../leader-election/ioredis.lock-handler.ts | 27 +++ .../redis-based.lock-handlers.spec.ts | 122 +++++++++++ .../leader-election/redis.lock-handler.ts | 22 ++ src/components/leader-election/types.ts | 18 ++ 25 files changed, 1228 insertions(+), 131 deletions(-) rename src/clients/entitlements/{entitlements-client.events.ts => entitlements-client-events.enum.ts} (63%) create mode 100644 src/clients/entitlements/storage/cache.revision-manager.spec.ts create mode 100644 src/clients/entitlements/storage/frontegg-cache/frontegg.cache-initializer.spec.ts create mode 100644 src/components/leader-election/always-leader.lock-handler.ts create mode 100644 src/components/leader-election/factory.ts create mode 100644 src/components/leader-election/index.spec.ts create mode 100644 src/components/leader-election/index.ts create mode 100644 src/components/leader-election/ioredis.lock-handler.ts create mode 100644 src/components/leader-election/redis-based.lock-handlers.spec.ts create mode 100644 src/components/leader-election/redis.lock-handler.ts create mode 100644 src/components/leader-election/types.ts diff --git a/package-lock.json b/package-lock.json index 666bb54..f67b23c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -450,6 +450,27 @@ "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==" }, + "@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "requires": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "dependencies": { + "@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "requires": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + } + } + }, "@dabh/diagnostics": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.3.tgz", @@ -1835,6 +1856,30 @@ "p-retry": "^4.0.0" } }, + "@tsconfig/node10": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.9.tgz", + "integrity": "sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA==", + "dev": true + }, + "@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true + }, + "@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true + }, + "@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true + }, "@types/axios-mock-adapter": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@types/axios-mock-adapter/-/axios-mock-adapter-1.10.0.tgz", @@ -1943,15 +1988,6 @@ "integrity": "sha512-/K3ds8TRAfBvi5vfjuz8y6+GiAYBZ0x4tXv1Av6CWBWn0IlADc+ZX9pMq7oU0fNQPnBwIZl3rmeLp6SBApbxSQ==", "dev": true }, - "@types/ioredis": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@types/ioredis/-/ioredis-5.0.0.tgz", - "integrity": "sha512-zJbJ3FVE17CNl5KXzdeSPtdltc4tMT3TzC6fxQS0sQngkbFZ6h+0uTafsRqu+eSLIugf6Yb0Ea0SUuRr42Nk9g==", - "dev": true, - "requires": { - "ioredis": "*" - } - }, "@types/is-stream": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@types/is-stream/-/is-stream-1.1.0.tgz", @@ -2022,9 +2058,9 @@ "dev": true }, "@types/node": { - "version": "12.20.55", - "resolved": "https://registry.npmjs.org/@types/node/-/node-12.20.55.tgz", - "integrity": "sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==" + "version": "13.13.52", + "resolved": "https://registry.npmjs.org/@types/node/-/node-13.13.52.tgz", + "integrity": "sha512-s3nugnZumCC//n4moGGe6tkNMyYEdaDBitVjwPxXmR5lnMG5dHePinH2EdxkG3Rh1ghFHHixAG4NJhpJW1rthQ==" }, "@types/normalize-package-data": { "version": "2.4.1", @@ -2254,6 +2290,12 @@ "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", "dev": true }, + "acorn-walk": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.2.0.tgz", + "integrity": "sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==", + "dev": true + }, "agent-base": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.0.tgz", @@ -2333,6 +2375,12 @@ "picomatch": "^2.0.4" } }, + "arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true + }, "argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -2915,6 +2963,12 @@ "path-type": "^4.0.0" } }, + "create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true + }, "cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -9873,6 +9927,11 @@ "xtend": "~4.0.1" } }, + "tiny-typed-emitter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tiny-typed-emitter/-/tiny-typed-emitter-2.1.0.tgz", + "integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==" + }, "tmp": { "version": "0.0.33", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", @@ -9976,6 +10035,35 @@ } } }, + "ts-node": { + "version": "10.9.1", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.1.tgz", + "integrity": "sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==", + "dev": true, + "requires": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "dependencies": { + "diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "dev": true + } + } + }, "tslib": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", @@ -10104,6 +10192,12 @@ "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", "dev": true }, + "v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true + }, "v8-to-istanbul": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.1.0.tgz", @@ -10308,6 +10402,12 @@ "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", "dev": true }, + "yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true + }, "yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index fa6cdcc..2b93e85 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "axios": "^0.27.2", "jsonwebtoken": "^9.0.0", "node-cache": "^5.1.2", + "tiny-typed-emitter": "^2.1.0", "winston": "^3.8.2" }, "peerDependencies": { @@ -46,10 +47,9 @@ "@semantic-release/git": "^10.0.1", "@types/axios-mock-adapter": "^1.10.0", "@types/express": "^4.17.14", - "@types/ioredis": "^5.0.0", "@types/jest": "^29.2.0", "@types/jsonwebtoken": "^9.0.0", - "@types/node": "^12.20.55", + "@types/node": "^13.13.52", "@types/sinon": "^10.0.15", "@typescript-eslint/eslint-plugin": "^5.38.1", "@typescript-eslint/parser": "^5.38.1", @@ -68,6 +68,7 @@ "semantic-release": "^21.0.5", "sinon": "^15.2.0", "ts-jest": "^28.0.8", + "ts-node": "^10.9.1", "typescript": "^4.8.4" } } diff --git a/src/clients/entitlements/entitlements-client.events.ts b/src/clients/entitlements/entitlements-client-events.enum.ts similarity index 63% rename from src/clients/entitlements/entitlements-client.events.ts rename to src/clients/entitlements/entitlements-client-events.enum.ts index d4245f7..5272d58 100644 --- a/src/clients/entitlements/entitlements-client.events.ts +++ b/src/clients/entitlements/entitlements-client-events.enum.ts @@ -1,4 +1,4 @@ -export enum EntitlementsClientEvents { +export enum EntitlementsClientEventsEnum { INITIALIZED = 'initialized', SNAPSHOT_UPDATED = 'snapshot-updated', } diff --git a/src/clients/entitlements/entitlements-client.ts b/src/clients/entitlements/entitlements-client.ts index a49a988..343b2bf 100644 --- a/src/clients/entitlements/entitlements-client.ts +++ b/src/clients/entitlements/entitlements-client.ts @@ -1,62 +1,110 @@ import { IFronteggContext } from '../../components/frontegg-context/types'; import { FronteggContext } from '../../components/frontegg-context'; import { FronteggAuthenticator } from '../../authenticator'; -import { EntitlementsClientOptions, VendorEntitlementsDto, VendorEntitlementsSnapshotOffsetDto } from './types'; +import { + EntitlementsClientGivenOptions, + EntitlementsClientOptions, + VendorEntitlementsDto, + VendorEntitlementsSnapshotOffsetDto, +} from './types'; import { config } from '../../config'; import { HttpClient } from '../http'; import Logger from '../../components/logger'; import { retry } from '../../utils'; -import * as events from 'events'; -import { EntitlementsClientEvents } from './entitlements-client.events'; +import { EntitlementsClientEventsEnum } from './entitlements-client-events.enum'; import { TEntity } from '../identity/types'; import { EntitlementsUserScoped } from './entitlements.user-scoped'; import { CacheRevisionManager } from './storage/cache.revision-manager'; import { CacheValue, ICacheManager } from '../../components/cache/managers'; import { hostname } from 'os'; import { FronteggCache } from '../../components/cache'; +import { LeaderElection } from '../../components/leader-election'; +import { TypedEmitter } from 'tiny-typed-emitter'; +import { LeaderElectionFactory } from '../../components/leader-election/factory'; -export class EntitlementsClient extends events.EventEmitter { +interface IEntitlementsClientEvents { + [EntitlementsClientEventsEnum.INITIALIZED]: () => void; + [EntitlementsClientEventsEnum.SNAPSHOT_UPDATED]: (revision: number) => void; +} + +export class EntitlementsClient extends TypedEmitter { // periodical refresh handler - private refreshTimeout: NodeJS.Timeout; + private refreshTimeout?: NodeJS.Timeout; private readonly readyPromise: Promise; - private readonly options: EntitlementsClientOptions; - // cache handler private cacheManager: CacheRevisionManager; private constructor( private readonly httpClient: HttpClient, cache: ICacheManager, - options: Partial = {}, + private readonly leaderElection: LeaderElection, + private readonly options: EntitlementsClientOptions, ) { super(); - this.options = this.parseOptions(options); - this.cacheManager = new CacheRevisionManager(this.options.instanceId, cache); + this.cacheManager = new CacheRevisionManager(cache); - this.readyPromise = new Promise((resolve) => { - this.once(EntitlementsClientEvents.INITIALIZED, () => resolve(this)); + this.on(EntitlementsClientEventsEnum.SNAPSHOT_UPDATED, (offset) => { + Logger.debug('[entitlements] Snapshot refreshed.', { offset }); }); - this.on(EntitlementsClientEvents.SNAPSHOT_UPDATED, (offset) => { - Logger.debug('[entitlements] Snapshot refreshed.', { offset }); + this.readyPromise = this.setupInitialization(); + this.setupLeaderElection(); + } + + private setupInitialization(): Promise { + this.once(EntitlementsClientEventsEnum.SNAPSHOT_UPDATED, () => { + this.emit(EntitlementsClientEventsEnum.INITIALIZED); }); - this.refreshTimeout = setTimeout( - () => - this.refreshSnapshot().then(() => { - this.emit(EntitlementsClientEvents.INITIALIZED); - }), - this.options.initializationDelayMs, - ); + return new Promise((resolve) => { + this.once(EntitlementsClientEventsEnum.INITIALIZED, () => resolve(this)); + }); + } + + private setupLeaderElection(): void { + this.leaderElection.on('leader', () => { + this.stopPeriodicJob(); + this.setupLeaderPeriodicJob(); + }); + + this.leaderElection.on('follower', () => { + this.stopPeriodicJob(); + this.setupFollowerPeriodicJob(); + }); + + this.leaderElection.start(); + } + + /** + * This method starts the periodic job that tries to fetch the latest version of cache from Redis. + * It's called only when current EntitlementsClient instance becomes the leader. + */ + setupLeaderPeriodicJob(): void { + this.refreshSnapshot(); + } + + /** + * This method starts the periodic job that tries to swap the EntitlementsCache + * to the latest available revision of RedisCache. + * + * It's called only when current EntitlementsClient instance becomes the follower. + */ + setupFollowerPeriodicJob(): void { + this.swapToLatestSnapshot(); + } + + stopPeriodicJob(): void { + this.refreshTimeout && clearTimeout(this.refreshTimeout); } - private parseOptions(givenOptions: Partial): EntitlementsClientOptions { + private static parseOptions(givenOptions: EntitlementsClientGivenOptions): EntitlementsClientOptions { return { instanceId: hostname(), retry: { numberOfTries: 3, delayRangeMs: { min: 500, max: 5_000 } }, initializationDelayMs: 0, refreshTimeoutMs: 30_000, + leaderElection: { key: 'entitlements_client_leader' }, ...givenOptions, }; } @@ -78,19 +126,16 @@ export class EntitlementsClient extends events.EventEmitter { const entitlementsData = await this.httpClient.get('/api/v1/vendor-entitlements'); const vendorEntitlementsDto = entitlementsData.data; - const { isUpdated, rev } = await this.cacheManager.loadSnapshot(vendorEntitlementsDto); + const { isUpdated, revision } = await this.cacheManager.loadSnapshotAsCurrent(vendorEntitlementsDto); if (isUpdated) { - // emit - this.emit(EntitlementsClientEvents.SNAPSHOT_UPDATED, rev); + this.emit(EntitlementsClientEventsEnum.SNAPSHOT_UPDATED, revision); } } private async refreshSnapshot(): Promise { - await this.cacheManager.waitUntilUpdated(); - await retry(async () => { - if (!(await this.haveRecentSnapshot())) { + if (!(await this.isCacheUpToDate())) { Logger.debug('[entitlements] Refreshing the outdated snapshot.'); await this.loadVendorEntitlements(); @@ -100,27 +145,45 @@ export class EntitlementsClient extends events.EventEmitter { this.refreshTimeout = setTimeout(() => this.refreshSnapshot(), this.options.refreshTimeoutMs); } - private async haveRecentSnapshot(): Promise { + private async swapToLatestSnapshot(): Promise { + const { isUpdated, revision } = await this.cacheManager.followRevision( + await this.cacheManager.getCurrentCacheRevision(), + ); + if (isUpdated) { + this.emit(EntitlementsClientEventsEnum.SNAPSHOT_UPDATED, revision); + } + + this.refreshTimeout = setTimeout(() => this.swapToLatestSnapshot(), this.options.refreshTimeoutMs); + } + + private async isCacheUpToDate(): Promise { const serverOffsetDto = await this.httpClient.get( '/api/v1/vendor-entitlements-snapshot-offset', ); - return await this.cacheManager.hasRecentSnapshot(serverOffsetDto.data); + return await this.cacheManager.hasGivenSnapshot(serverOffsetDto.data); } static async init( context: IFronteggContext = FronteggContext.getContext(), - options: Partial = {}, + givenOptions: EntitlementsClientGivenOptions = {}, ): Promise { + const options = EntitlementsClient.parseOptions(givenOptions); + const authenticator = new FronteggAuthenticator(); await authenticator.init(context.FRONTEGG_CLIENT_ID, context.FRONTEGG_API_KEY); const httpClient = new HttpClient(authenticator, { baseURL: config.urls.entitlementsService }); const cache = await FronteggCache.getInstance(); - return new EntitlementsClient(httpClient, cache, options); + return new EntitlementsClient( + httpClient, + cache, + LeaderElectionFactory.fromCache(options.instanceId, cache, options.leaderElection), + options, + ); } destroy(): void { - clearTimeout(this.refreshTimeout); + this.refreshTimeout && clearTimeout(this.refreshTimeout); } -} +} \ No newline at end of file diff --git a/src/clients/entitlements/storage/cache.revision-manager.spec.ts b/src/clients/entitlements/storage/cache.revision-manager.spec.ts new file mode 100644 index 0000000..9d4fc54 --- /dev/null +++ b/src/clients/entitlements/storage/cache.revision-manager.spec.ts @@ -0,0 +1,196 @@ +import { CacheRevisionManager, CURRENT_CACHE_REVISION } from './cache.revision-manager'; +import { mock, mockReset } from 'jest-mock-extended'; +import { CacheValue, ICacheManager } from '../../../components/cache/managers'; +import { VendorEntitlementsDto } from '../types'; +import type { FronteggEntitlementsCache } from './frontegg-cache/frontegg.cache'; +import { FronteggEntitlementsCacheInitializer } from './frontegg-cache/frontegg.cache-initializer'; + +jest.mock('./frontegg-cache/frontegg.cache-initializer'); + +describe(CacheRevisionManager.name, () => { + const entitlementsCacheMock = mock(); + + const cacheMock = mock>(); + let cut: CacheRevisionManager; + + beforeAll(() => { + jest.mocked(FronteggEntitlementsCacheInitializer.forFollower).mockResolvedValue(entitlementsCacheMock); + jest.mocked(FronteggEntitlementsCacheInitializer.forLeader).mockResolvedValue(entitlementsCacheMock); + }); + + beforeEach(() => { + cut = new CacheRevisionManager(cacheMock); + }); + + afterEach(() => { + mockReset(cacheMock); + mockReset(entitlementsCacheMock); + + jest.mocked(FronteggEntitlementsCacheInitializer.forFollower).mockClear(); + jest.mocked(FronteggEntitlementsCacheInitializer.forLeader).mockClear(); + }); + + describe('given the currently supported revision is: 1', () => { + beforeEach(() => { + cacheMock.get.calledWith(CURRENT_CACHE_REVISION).mockResolvedValue(1); + + cut.followRevision(1); + }); + + it('when I call .getCurrentCacheRevision(), then it resolves to that revision (1), taken from cache.', async () => { + // when & then + await expect(cut.getCurrentCacheRevision()).resolves.toEqual(1); + + // then + expect(cacheMock.get).toHaveBeenCalledWith(CURRENT_CACHE_REVISION); + }); + + describe('when .loadSnapshotAsCurrent(..) is called with DTO having different offset', () => { + function getDTO(rev: number): VendorEntitlementsDto { + return { + snapshotOffset: rev, + data: { + features: [], + entitlements: [], + featureBundles: [], + }, + }; + } + + let loadingSnapshotResult; + + describe('with DTO having different offset (333)', () => { + let expectedNewEntitlementsCache; + + beforeEach(async () => { + // given: expected new entitlements cache instance following given revision + expectedNewEntitlementsCache = mock(); + jest.mocked(FronteggEntitlementsCacheInitializer.forLeader).mockResolvedValue(expectedNewEntitlementsCache); + + // when + loadingSnapshotResult = await cut.loadSnapshotAsCurrent(getDTO(333)); + }); + + it('then it resolves to IsUpdatedToRev structure telling with updated revision.', async () => { + // then + expect(loadingSnapshotResult).toEqual({ isUpdated: true, revision: 333 }); + }); + + it('then new offset is stored as current cache revision.', async () => { + // then + expect(cacheMock.set).toHaveBeenCalledWith(CURRENT_CACHE_REVISION, 333); + }); + + it('then new instance of entitlements cache is created from given DTO.', () => { + // then + expect(FronteggEntitlementsCacheInitializer.forLeader).toHaveBeenCalledWith(getDTO(333)); + + // and + expect(cut.getCache()).toBe(expectedNewEntitlementsCache); + }); + }); + + describe('with DTO having the same offset (1)', () => { + beforeEach(async () => { + // given + jest.mocked(FronteggEntitlementsCacheInitializer.forLeader).mockClear(); + jest.mocked(FronteggEntitlementsCacheInitializer.forFollower).mockClear(); + + // when + loadingSnapshotResult = await cut.loadSnapshotAsCurrent(getDTO(1)); + }); + + it('then it resolves to IsUpdatedToRev structure telling nothing got updated and revision (1).', async () => { + // then + expect(loadingSnapshotResult).toEqual({ isUpdated: false, revision: 1 }); + }); + + it('then new entitlements cache instance was not created.', async () => { + // then + expect(FronteggEntitlementsCacheInitializer.forLeader).not.toHaveBeenCalled(); + expect(FronteggEntitlementsCacheInitializer.forFollower).not.toHaveBeenCalled(); + + // and + expect(cut.getCache()).toBe(entitlementsCacheMock); + }); + + it('then DTO revision (1) is not updated.', async () => { + // then + expect(cacheMock.set).not.toHaveBeenCalledWith(CURRENT_CACHE_REVISION, expect.anything()); + }); + }); + }); + + describe('when .followRevision(..) is called', () => { + let resultPromise; + + describe('with the same revision (1) as currently stored', () => { + let resultPromise; + beforeEach(async () => { + // given: clear the execution count here + jest.mocked(FronteggEntitlementsCacheInitializer.forFollower).mockClear(); + + // when: follow the same revision + resultPromise = cut.followRevision(1); + }); + + it('then entitlements cache is not replaced.', async () => { + // when + await resultPromise; + + // then + expect(FronteggEntitlementsCacheInitializer.forFollower).not.toHaveBeenCalled(); + }); + + it('then it resolves to IsUpdatedToRev structure with the current revision (1).', async () => { + // when & then + await expect(resultPromise).resolves.toEqual({ + isUpdated: false, + revision: 1, + }); + }); + }); + + describe('with the different revision (3) than currently stored', () => { + let expectedNewEntitlementsCache; + + beforeEach(async () => { + // given: clear the execution count here + jest.mocked(FronteggEntitlementsCacheInitializer.forFollower).mockClear(); + + // given: expected new entitlements cache instance following given revision + expectedNewEntitlementsCache = mock(); + jest.mocked(FronteggEntitlementsCacheInitializer.forFollower).mockResolvedValue(expectedNewEntitlementsCache); + + // when: follow the same revision + resultPromise = cut.followRevision(3); + }); + + it('then it resolves to IsUpdatedToRev structure with the new revision and "isUpdated" flag up.', async () => { + // when & then + await expect(resultPromise).resolves.toEqual({ + isUpdated: true, + revision: 3, + }); + }); + + it('then new instance of entitlements cache for new revision (3) is created.', async () => { + // when + await resultPromise; + + // then + expect(FronteggEntitlementsCacheInitializer.forFollower).toHaveBeenCalledWith(3); + + // and + expect(cut.getCache() === expectedNewEntitlementsCache).toBeTruthy(); + }); + }); + }); + }); + + describe('given the instance does neither follow any revision, nor loaded any revision to cache', () => { + it('when I call .getCache(), then it returns undefined.', () => { + expect(cut.getCache()).toBeUndefined(); + }); + }); +}); diff --git a/src/clients/entitlements/storage/cache.revision-manager.ts b/src/clients/entitlements/storage/cache.revision-manager.ts index 6e09067..a357745 100644 --- a/src/clients/entitlements/storage/cache.revision-manager.ts +++ b/src/clients/entitlements/storage/cache.revision-manager.ts @@ -3,90 +3,69 @@ import { VendorEntitlementsDto, VendorEntitlementsSnapshotOffsetDto } from '../t import { IEntitlementsCache } from './types'; import { FronteggEntitlementsCacheInitializer } from './frontegg-cache/frontegg.cache-initializer'; import Logger from '../../../components/logger'; -import { retry } from '../../../utils'; -const CURRENT_OFFSET_KEY = 'snapshot-offset'; -const UPDATE_IN_PROGRESS_KEY = 'snapshot-updating'; +export const CURRENT_CACHE_REVISION = 'latest-cache-rev'; + +type IsUpdatedToRev = { isUpdated: boolean; revision: number }; export class CacheRevisionManager { private entitlementsCache?: IEntitlementsCache; - constructor( - public readonly instanceId: string, - private readonly cache: ICacheManager, - private readonly options: { - maxUpdateLockTime: number; - } = { - maxUpdateLockTime: 5, - }, - ) {} - - async waitUntilUpdated(): Promise { - return new Promise((resolve, reject) => { - retry( - async () => { - if (await this.isUpdateInProgress()) { - throw new Error(); - } - }, - { - numberOfTries: 3, - delayRangeMs: { - min: 100, - max: 2000, - }, - }, - ) - .then(resolve) - .catch((err) => reject(err)); - }); - } + private localRev?: number; - async loadSnapshot(dto: VendorEntitlementsDto): Promise<{ isUpdated: boolean; rev: number }> { - await this.waitUntilUpdated(); + constructor(private readonly cache: ICacheManager) {} - const currentOffset = await this.getOffset(); - if (currentOffset === dto.snapshotOffset) return { isUpdated: false, rev: currentOffset }; + async loadSnapshotAsCurrent(dto: VendorEntitlementsDto): Promise { + const currentRevision = await this.getCurrentCacheRevision(); + const givenRevision = dto.snapshotOffset; - await this.cache.set(UPDATE_IN_PROGRESS_KEY, this.instanceId, { expiresInSeconds: this.options.maxUpdateLockTime }); + if (currentRevision === givenRevision) return { isUpdated: false, revision: currentRevision }; // re-initialize the cache - const newCache = await FronteggEntitlementsCacheInitializer.initialize(dto); const oldCache = this.entitlementsCache; + this.entitlementsCache = await FronteggEntitlementsCacheInitializer.forLeader(dto); - this.entitlementsCache = newCache; - await this.setOffset(dto.snapshotOffset); + await this.setCurrentCacheRevision(givenRevision); + this.localRev = givenRevision; // clean await oldCache?.clear(); - await oldCache?.shutdown(); - return { isUpdated: true, rev: dto.snapshotOffset }; + return { isUpdated: true, revision: givenRevision }; + } + + async followRevision(revision: number): Promise { + if (revision && this.localRev !== revision) { + this.localRev = revision; + + // trigger the revision update here + this.entitlementsCache = await FronteggEntitlementsCacheInitializer.forFollower(revision); + + return { isUpdated: true, revision }; + } + + return { isUpdated: false, revision: this.localRev || 0 }; } - async hasRecentSnapshot(dto: VendorEntitlementsSnapshotOffsetDto): Promise { - const currentOffset = await this.getOffset(); - const isRecent = dto.snapshotOffset === currentOffset; + async hasGivenSnapshot(dto: VendorEntitlementsSnapshotOffsetDto): Promise { + const currentOffset = await this.getCurrentCacheRevision(); + const isEqual = dto.snapshotOffset === currentOffset; Logger.debug('[entitlements] Offsets compared.', { - isRecent, + isEqual, serverOffset: dto.snapshotOffset, localOffset: currentOffset, }); - return isRecent; - } - - async isUpdateInProgress(): Promise { - return (await this.cache.get(UPDATE_IN_PROGRESS_KEY)) !== null; + return isEqual; } - private async setOffset(offset: number): Promise { - await this.cache.set(CURRENT_OFFSET_KEY, offset); + private async setCurrentCacheRevision(offset: number): Promise { + await this.cache.set(CURRENT_CACHE_REVISION, offset); } - async getOffset(): Promise { - return (await this.cache.get(CURRENT_OFFSET_KEY)) || 0; + async getCurrentCacheRevision(): Promise { + return (await this.cache.get(CURRENT_CACHE_REVISION)) || 0; } getCache(): IEntitlementsCache | undefined { diff --git a/src/clients/entitlements/storage/frontegg-cache/frontegg.cache-initializer.spec.ts b/src/clients/entitlements/storage/frontegg-cache/frontegg.cache-initializer.spec.ts new file mode 100644 index 0000000..88471fe --- /dev/null +++ b/src/clients/entitlements/storage/frontegg-cache/frontegg.cache-initializer.spec.ts @@ -0,0 +1,173 @@ +import 'jest-extended'; +import { FronteggEntitlementsCacheInitializer } from './frontegg.cache-initializer'; +import { FronteggEntitlementsCache } from './frontegg.cache'; +import { ICacheManager, ICacheManagerCollection, ICacheManagerMap } from '../../../../components/cache/managers'; +import { mock, mockClear } from 'jest-mock-extended'; +import { FronteggCache } from '../../../../components/cache'; +import { EntitlementTuple, FeatureBundleTuple, FeatureTuple, VendorEntitlementsDto } from '../../types'; +import { ENTITLEMENTS_MAP_KEY, PERMISSIONS_COLLECTION_LIST } from './frontegg.cache-key.utils'; + +jest.mock('./frontegg.cache'); +jest.mock('../../../../components/cache'); + +const FronteggEntitlementsCache_Actual: typeof FronteggEntitlementsCache = + jest.requireActual('./frontegg.cache').FronteggEntitlementsCache; + +describe(FronteggEntitlementsCacheInitializer.name, () => { + let cacheMock = mock>(); + let entitlementsCacheMock = mock(); + + beforeAll(() => { + // given: frontegg cache is mocked + jest.mocked(FronteggCache.getInstance).mockResolvedValue(cacheMock); + + // given: mocked cache will scope to itself + cacheMock.forScope.mockReturnValue(cacheMock); + + // given: entitlements cache returns the mocked cache + entitlementsCacheMock.getCacheManager.mockReturnValue(cacheMock); + }); + + beforeEach(() => { + // given: by default return the mocked entitlements cache + jest.mocked(FronteggEntitlementsCache).mockReturnValue(entitlementsCacheMock); + }); + + afterEach(() => { + mockClear(cacheMock); + mockClear(entitlementsCacheMock); + + jest.mocked(FronteggEntitlementsCache).mockReset(); + }); + + describe('when .forFollower(..) is called with revision (3)', () => { + it('then it sets up FronteggEntitlementsCache to track revision in FronteggCache cache.', async () => { + // given: call the real constructor + jest + .mocked(FronteggEntitlementsCache) + .mockImplementationOnce( + (cache: ICacheManager, revision: number) => new FronteggEntitlementsCache_Actual(cache, revision), + ); + + // when & then + await expect(FronteggEntitlementsCacheInitializer.forFollower(33)).resolves.toBeInstanceOf( + FronteggEntitlementsCache_Actual, + ); + + // then + expect(FronteggEntitlementsCache).toHaveBeenCalledWith(cacheMock, 33); + }); + + it('then it does not set any value to cache.', async () => { + // when + await FronteggEntitlementsCacheInitializer.forFollower(33); + + // then + expect(cacheMock.set).not.toHaveBeenCalled(); + expect(cacheMock.map).not.toHaveBeenCalled(); + expect(cacheMock.del).not.toHaveBeenCalled(); + expect(cacheMock.collection).not.toHaveBeenCalled(); + }); + }); + + describe('given vendor entitlements DTO with offset (5)', () => { + function buildDTO( + offset: number, + features: FeatureTuple[] = [], + bundles: FeatureBundleTuple[] = [], + entitlements: EntitlementTuple[] = [], + ): VendorEntitlementsDto { + return { + snapshotOffset: offset, + data: { + features, + featureBundles: bundles, + entitlements, + }, + }; + } + + describe('and feature, bundle and entitlement', () => { + let dto: VendorEntitlementsDto; + + beforeEach(() => { + dto = buildDTO( + 5, + [ + ['f-1', 'foo', ['foo.read']], + ['f-2', 'boo', ['foo.write']], + ], + [['b-1', ['f-1', 'f-2']]], + [ + ['b-1', 't-1', 'u-1', undefined], + ['b-1', 't-2', undefined, undefined], + ], + ); + }); + + describe('when .forLeader(dto) is called', () => { + let result; + + const permissionToFeatureCollectionMock = mock>(); + const permissionsCollectionMock = mock>(); + const entitlementsMapMock = mock>(); + + beforeAll(() => { + cacheMock.collection.calledWith(PERMISSIONS_COLLECTION_LIST).mockReturnValue(permissionsCollectionMock); + cacheMock.map.calledWith(ENTITLEMENTS_MAP_KEY).mockReturnValue(entitlementsMapMock); + cacheMock.collection + .calledWith(expect.stringContaining('perms:')) + .mockReturnValue(permissionToFeatureCollectionMock); + }); + + beforeEach(async () => { + result = await FronteggEntitlementsCacheInitializer.forLeader(dto); + }); + + afterEach(() => { + mockClear(permissionsCollectionMock); + mockClear(entitlementsMapMock); + }); + + it('then list of all permissions is written to cache.', () => { + // then + expect(permissionsCollectionMock.set).toHaveBeenCalledWith('foo.read'); + expect(permissionsCollectionMock.set).toHaveBeenCalledWith('foo.write'); + + expect(permissionsCollectionMock.set).toHaveBeenCalledTimes(2); + + // and: + expect(cacheMock.collection).toHaveBeenCalledWith('perms:foo.read'); + expect(permissionToFeatureCollectionMock.set).toHaveBeenCalledWith('foo'); + + // and: + expect(cacheMock.collection).toHaveBeenCalledWith('perms:foo.write'); + expect(permissionToFeatureCollectionMock.set).toHaveBeenCalledWith('boo'); + }); + + it('then mapping of permission to feature is written to cache.', () => { + // then + expect(cacheMock.collection).toHaveBeenCalledWith('perms:foo.read'); + expect(permissionToFeatureCollectionMock.set).toHaveBeenCalledWith('foo'); + + // and: + expect(cacheMock.collection).toHaveBeenCalledWith('perms:foo.write'); + expect(permissionToFeatureCollectionMock.set).toHaveBeenCalledWith('boo'); + }); + + it('then each entitlement to bundle is resolved to "entitlement to feature" and written to cache.', () => { + // then + expect(cacheMock.map).toHaveBeenCalledWith(ENTITLEMENTS_MAP_KEY); + + // and + expect(entitlementsMapMock.set).toHaveBeenCalledWith('t-1:u-1:foo', expect.toBeNumber()); + expect(entitlementsMapMock.set).toHaveBeenCalledWith('t-1:u-1:boo', expect.toBeNumber()); + + // and + expect(entitlementsMapMock.set).toHaveBeenCalledWith('t-2::foo', expect.toBeNumber()); + expect(entitlementsMapMock.set).toHaveBeenCalledWith('t-2::boo', expect.toBeNumber()); + }); + }); + }); + }); +}); diff --git a/src/clients/entitlements/storage/frontegg-cache/frontegg.cache-initializer.ts b/src/clients/entitlements/storage/frontegg-cache/frontegg.cache-initializer.ts index 79d5bad..16410eb 100644 --- a/src/clients/entitlements/storage/frontegg-cache/frontegg.cache-initializer.ts +++ b/src/clients/entitlements/storage/frontegg-cache/frontegg.cache-initializer.ts @@ -6,21 +6,24 @@ import { getFeatureEntitlementKey, getPermissionMappingKey, OFFSET_KEY, + PERMISSIONS_COLLECTION_LIST, } from './frontegg.cache-key.utils'; import { DtoToCacheSourcesMapper } from '../dto-to-cache-sources.mapper'; import { pickExpTimestamp } from '../exp-time.utils'; -import { ICacheManager } from '../../../../components/cache/managers'; import { FronteggCache } from '../../../../components/cache'; export class FronteggEntitlementsCacheInitializer { - constructor(private readonly cache: ICacheManager) {} + static readonly CLEAR_TTL = 60 * 60 * 1000; - // TODO: make use of revPrefix !! - static async initialize(dto: VendorEntitlementsDto): Promise { + constructor(private readonly entitlementsCache: FronteggEntitlementsCache) {} + + static async forLeader(dto: VendorEntitlementsDto): Promise { const revision = dto.snapshotOffset; const cache = await FronteggCache.getInstance(); - const cacheInitializer = new FronteggEntitlementsCacheInitializer(cache); + const entitlementsCache = new FronteggEntitlementsCache(cache, revision); + + const cacheInitializer = new FronteggEntitlementsCacheInitializer(entitlementsCache); const sources = new DtoToCacheSourcesMapper().map(dto); @@ -28,22 +31,32 @@ export class FronteggEntitlementsCacheInitializer { await cacheInitializer.setupEntitlementsReadModel(sources); await cacheInitializer.setupRevisionNumber(revision); - return new FronteggEntitlementsCache(cache, revision); + return entitlementsCache; + } + + static async forFollower(revision: number): Promise { + return new FronteggEntitlementsCache(await FronteggCache.getInstance(), revision); } private async setupPermissionsReadModel(src: BundlesSource): Promise { + const cache = this.entitlementsCache.getCacheManager(); + const permissionsList = cache.collection(PERMISSIONS_COLLECTION_LIST); + for (const singleBundle of src.values()) { for (const feature of singleBundle.features.values()) { for (const permission of feature.permissions) { // set permission => features mapping - await this.cache.collection(getPermissionMappingKey(permission)).set(feature.key); + await cache.collection(getPermissionMappingKey(permission)).set(feature.key); + + // add permission to the list + await permissionsList.set(permission); } } } } private async setupEntitlementsReadModel(src: BundlesSource): Promise { - const entitlementsHashMap = this.cache.map(ENTITLEMENTS_MAP_KEY); + const entitlementsHashMap = this.entitlementsCache.getCacheManager().map(ENTITLEMENTS_MAP_KEY); // iterating over bundles.. for (const singleBundle of src.values()) { @@ -73,6 +86,23 @@ export class FronteggEntitlementsCacheInitializer { } private async setupRevisionNumber(revision: number): Promise { - await this.cache.set(OFFSET_KEY, revision); + await this.entitlementsCache.getCacheManager().set(OFFSET_KEY, revision); + } + + async clear(): Promise { + const cache = this.entitlementsCache.getCacheManager(); + + // clear permissions maps + const allPermissions = await cache.collection(PERMISSIONS_COLLECTION_LIST).getAll(); + + for (const permission of allPermissions) { + await cache.expire([ getPermissionMappingKey(permission)], FronteggEntitlementsCacheInitializer.CLEAR_TTL); + } + + // clear static fields + await cache.expire( + [PERMISSIONS_COLLECTION_LIST, ENTITLEMENTS_MAP_KEY], + FronteggEntitlementsCacheInitializer.CLEAR_TTL, + ); } } diff --git a/src/clients/entitlements/storage/frontegg-cache/frontegg.cache-key.utils.ts b/src/clients/entitlements/storage/frontegg-cache/frontegg.cache-key.utils.ts index e58df9c..137494e 100644 --- a/src/clients/entitlements/storage/frontegg-cache/frontegg.cache-key.utils.ts +++ b/src/clients/entitlements/storage/frontegg-cache/frontegg.cache-key.utils.ts @@ -2,6 +2,7 @@ import { FeatureKey } from '../../types'; import { Permission } from '../../../identity/types'; export const ENTITLEMENTS_MAP_KEY = 'entitlements'; +export const PERMISSIONS_COLLECTION_LIST = 'permissions'; export const OFFSET_KEY = 'snapshot-offset'; export function getFeatureEntitlementKey(featKey: FeatureKey, tenantId: string, userId = ''): string { diff --git a/src/clients/entitlements/storage/frontegg-cache/frontegg.cache.spec.ts b/src/clients/entitlements/storage/frontegg-cache/frontegg.cache.spec.ts index d021418..82e3912 100644 --- a/src/clients/entitlements/storage/frontegg-cache/frontegg.cache.spec.ts +++ b/src/clients/entitlements/storage/frontegg-cache/frontegg.cache.spec.ts @@ -17,7 +17,7 @@ describe(FronteggEntitlementsCache.name, () => { describe('given input data with no entitlements and bundle with feature "foo"', () => { beforeEach(async () => { - cut = await FronteggEntitlementsCacheInitializer.initialize({ + cut = await FronteggEntitlementsCacheInitializer.forLeader({ snapshotOffset: 1, data: { entitlements: [], @@ -35,7 +35,7 @@ describe(FronteggEntitlementsCache.name, () => { describe('given input data with entitlement to bundle with feature "foo" (no permissions) for user "u-1"', () => { beforeEach(async () => { - cut = await FronteggEntitlementsCacheInitializer.initialize({ + cut = await FronteggEntitlementsCacheInitializer.forLeader({ snapshotOffset: 2, data: { features: [['f-1', 'foo', []]], @@ -53,7 +53,7 @@ describe(FronteggEntitlementsCache.name, () => { describe('given input data with entitlement to bundle with feature "foo" (no permissions) for tenant "t-1"', () => { beforeEach(async () => { - cut = await FronteggEntitlementsCacheInitializer.initialize({ + cut = await FronteggEntitlementsCacheInitializer.forLeader({ snapshotOffset: 3, data: { features: [['f-1', 'foo', []]], @@ -76,7 +76,7 @@ describe(FronteggEntitlementsCache.name, () => { describe('given input data with multiple time-restricted entitlements to bundle with feature "foo" (no permissions) for user "u-1" and tenant "t-2"', () => { beforeEach(async () => { - cut = await FronteggEntitlementsCacheInitializer.initialize({ + cut = await FronteggEntitlementsCacheInitializer.forLeader({ snapshotOffset: 4, data: { features: [['f-1', 'foo', []]], @@ -104,7 +104,7 @@ describe(FronteggEntitlementsCache.name, () => { describe('given input data with mix of time-restricted and unrestricted entitlements to bundle with feature "foo" (no permissions) for user "u-1" and tenant "t-2"', () => { beforeEach(async () => { - cut = await FronteggEntitlementsCacheInitializer.initialize({ + cut = await FronteggEntitlementsCacheInitializer.forLeader({ snapshotOffset: 4, data: { features: [['f-1', 'foo', []]], @@ -132,7 +132,7 @@ describe(FronteggEntitlementsCache.name, () => { describe('given input data with unbundled feature "foo" (with permission "bar.baz")', () => { beforeEach(async () => { - cut = await FronteggEntitlementsCacheInitializer.initialize({ + cut = await FronteggEntitlementsCacheInitializer.forLeader({ snapshotOffset: 5, data: { features: [['f-1', 'foo', ['bar.baz']]], diff --git a/src/clients/entitlements/storage/frontegg-cache/frontegg.cache.ts b/src/clients/entitlements/storage/frontegg-cache/frontegg.cache.ts index d5739cf..9eb2bfa 100644 --- a/src/clients/entitlements/storage/frontegg-cache/frontegg.cache.ts +++ b/src/clients/entitlements/storage/frontegg-cache/frontegg.cache.ts @@ -2,12 +2,21 @@ import { ExpirationTime, IEntitlementsCache } from '../types'; import { FeatureKey } from '../../types'; import { ENTITLEMENTS_MAP_KEY, getFeatureEntitlementKey, getPermissionMappingKey } from './frontegg.cache-key.utils'; import { ICacheManager } from '../../../../components/cache/managers'; +import { FronteggEntitlementsCacheInitializer } from './frontegg.cache-initializer'; export class FronteggEntitlementsCache implements IEntitlementsCache { - constructor(private readonly cache: ICacheManager, readonly revision: number) {} + private readonly cache: ICacheManager; + + constructor(cache: ICacheManager, readonly revision: number) { + this.cache = cache.forScope(FronteggEntitlementsCache.getCachePrefix(revision)); + } + + static getCachePrefix(revision: number): string { + return `vendor_entitlements_${revision}_`; + } clear(): Promise { - return Promise.resolve(undefined); + return new FronteggEntitlementsCacheInitializer(this).clear(); } async getEntitlementExpirationTime( @@ -26,6 +35,10 @@ export class FronteggEntitlementsCache implements IEntitlementsCache { } shutdown(): Promise { - return Promise.resolve(undefined); + return this.cache.close(); + } + + getCacheManager(): ICacheManager { + return this.cache; } } diff --git a/src/clients/entitlements/types.ts b/src/clients/entitlements/types.ts index 4c6ac1f..a9d0a23 100644 --- a/src/clients/entitlements/types.ts +++ b/src/clients/entitlements/types.ts @@ -1,5 +1,6 @@ import { RetryOptions } from '../../utils'; import { Permission } from '../identity/types'; +import { ILeadershipElectionGivenOptions } from '../../components/leader-election/types'; export enum EntitlementJustifications { MISSING_FEATURE = 'missing-feature', @@ -43,4 +44,7 @@ export interface EntitlementsClientOptions { initializationDelayMs: number; refreshTimeoutMs: number; retry: RetryOptions; + leaderElection: ILeadershipElectionGivenOptions; } + +export type EntitlementsClientGivenOptions = Partial; diff --git a/src/components/cache/managers/cache.manager.interface.ts b/src/components/cache/managers/cache.manager.interface.ts index 2a32852..c804b55 100644 --- a/src/components/cache/managers/cache.manager.interface.ts +++ b/src/components/cache/managers/cache.manager.interface.ts @@ -16,6 +16,7 @@ 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; diff --git a/src/components/cache/managers/in-memory/local-cache.manager.ts b/src/components/cache/managers/in-memory/local-cache.manager.ts index c8be0cf..b644da6 100644 --- a/src/components/cache/managers/in-memory/local-cache.manager.ts +++ b/src/components/cache/managers/in-memory/local-cache.manager.ts @@ -42,6 +42,12 @@ export class LocalCacheManager extends PrefixedManager imp } } + 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); } diff --git a/src/components/cache/managers/ioredis/ioredis-cache.manager.ts b/src/components/cache/managers/ioredis/ioredis-cache.manager.ts index 931f18e..39b290b 100644 --- a/src/components/cache/managers/ioredis/ioredis-cache.manager.ts +++ b/src/components/cache/managers/ioredis/ioredis-cache.manager.ts @@ -14,12 +14,7 @@ import { ICacheValueSerializer } from '../../serializers/types'; import { JsonSerializer } from '../../serializers/json.serializer'; import type { RedisOptions } from 'ioredis'; -export interface IIORedisOptions { - host: string; - password?: string; - port: number; - db?: number; -} +export type IIORedisOptions = RedisOptions; export class IORedisCacheManager extends PrefixedManager implements ICacheManager { private readonly serializer: ICacheValueSerializer; @@ -33,7 +28,7 @@ export class IORedisCacheManager extends PrefixedManager i static async create(options?: IIORedisOptions, prefix = ''): Promise> { const RedisCtor = PackageUtils.loadPackage('ioredis'); - return new IORedisCacheManager(new RedisCtor(options as RedisOptions), prefix); + return new IORedisCacheManager(new RedisCtor(options), prefix); } public async set(key: string, data: V, options?: SetOptions): Promise { @@ -55,6 +50,12 @@ export class IORedisCacheManager extends PrefixedManager i } } + 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); } @@ -67,6 +68,10 @@ export class IORedisCacheManager extends PrefixedManager i return new IORedisCacheCollection(this.withPrefix(key), this.redisManager, this.serializer); } + getRedis(): Redis { + return this.redisManager; + } + async close(): Promise { await this.redisManager.quit(); } diff --git a/src/components/cache/managers/redis/redis-cache.manager.ts b/src/components/cache/managers/redis/redis-cache.manager.ts index 312628b..5fb1563 100644 --- a/src/components/cache/managers/redis/redis-cache.manager.ts +++ b/src/components/cache/managers/redis/redis-cache.manager.ts @@ -76,6 +76,16 @@ export class RedisCacheManager extends PrefixedManager imp } } + async expire(keys: string[], ttlMs: number): Promise { + for (const key of keys) { + await this.redisManager.PEXPIRE(this.withPrefix(key), ttlMs); + } + } + + getRedis(): RedisClientType { + return this.redisManager; + } + close(): Promise { return this.redisManager.disconnect(); } diff --git a/src/components/frontegg-context/types.ts b/src/components/frontegg-context/types.ts index f4f8f88..46c5da9 100644 --- a/src/components/frontegg-context/types.ts +++ b/src/components/frontegg-context/types.ts @@ -22,7 +22,7 @@ export interface IIORedisCacheOptions extends IBaseCacheOptions { options: IIORedisOptions; } -export interface IRedisCacheOptions extends IBaseCacheOptions, IRedisOptions { +export interface IRedisCacheOptions extends IBaseCacheOptions { type: 'redis'; options: IRedisOptions; } diff --git a/src/components/leader-election/always-leader.lock-handler.ts b/src/components/leader-election/always-leader.lock-handler.ts new file mode 100644 index 0000000..6030722 --- /dev/null +++ b/src/components/leader-election/always-leader.lock-handler.ts @@ -0,0 +1,11 @@ +import { ILockHandler } from './types'; + +export class AlwaysLeaderLockHandler implements ILockHandler { + async tryToMaintainTheLock(_key: string, _value: string, _expirationTimeMs: number): Promise { + return true; + } + + async tryToLockLeaderResource(_key: string, _value: string, _expirationTimeMs: number): Promise { + return true; + } +} diff --git a/src/components/leader-election/factory.ts b/src/components/leader-election/factory.ts new file mode 100644 index 0000000..2a48676 --- /dev/null +++ b/src/components/leader-election/factory.ts @@ -0,0 +1,31 @@ +import { CacheValue, ICacheManager, IORedisCacheManager, RedisCacheManager } from '../cache/managers'; +import { ILeadershipElectionGivenOptions } from './types'; +import { RedisLockHandler } from './redis.lock-handler'; +import { IORedisLockHandler } from './ioredis.lock-handler'; +import { AlwaysLeaderLockHandler } from './always-leader.lock-handler'; +import { LeaderElection } from './index'; + +export class LeaderElectionFactory { + static fromCache( + identifier: string, + manager: ICacheManager, + options: ILeadershipElectionGivenOptions, + ): LeaderElection { + switch (true) { + case manager instanceof RedisCacheManager: + return new LeaderElection( + new RedisLockHandler((manager as RedisCacheManager).getRedis()), + identifier, + options, + ); + case manager instanceof IORedisCacheManager: + return new LeaderElection( + new IORedisLockHandler((manager as IORedisCacheManager).getRedis()), + identifier, + options, + ); + default: + return new LeaderElection(new AlwaysLeaderLockHandler(), identifier, options); + } + } +} diff --git a/src/components/leader-election/index.spec.ts b/src/components/leader-election/index.spec.ts new file mode 100644 index 0000000..189d8a1 --- /dev/null +++ b/src/components/leader-election/index.spec.ts @@ -0,0 +1,175 @@ +import 'jest-extended'; +import { LeaderElection } from './index'; +import { mock, mockClear, mockReset } from 'jest-mock-extended'; +import { ILockHandler } from './types'; +import { SinonFakeTimers, useFakeTimers } from 'sinon'; + +describe(LeaderElection.name, () => { + let cut: LeaderElection; + let fakeTimer: SinonFakeTimers; + + const lockHandlerMock = mock(); + + beforeEach(() => { + cut = new LeaderElection(lockHandlerMock, 'foo', { + key: 'my-lock-key', + expireInMs: 5000, + prolongLeadershipIntervalMs: 2000, + }); + + fakeTimer = useFakeTimers(); + }); + + afterEach(() => { + cut.close(); + + // + mockReset(lockHandlerMock); + fakeTimer.restore(); + }); + + describe('when the instance is not started manually', () => { + it('then the resource locking tries are not performed.', async () => { + // when + await fakeTimer.tickAsync(10000); + + // then + expect(lockHandlerMock.tryToLockLeaderResource).not.toHaveBeenCalled(); + }); + }); + + describe('given the instance is started', () => { + beforeEach(() => { + cut.start(); + }); + + it('when it closed, then the instance no longer tries to lock the resource.', async () => { + // given: when started, it tries to lock the resource + await fakeTimer.tickAsync(6000); + expect(lockHandlerMock.tryToLockLeaderResource).toHaveBeenCalled(); + + mockClear(lockHandlerMock); + + // when: stopped + cut.close(); + + // and: some time elapsed + await fakeTimer.tickAsync(10000); + + // then: resource is no longer being tried to lock + expect(lockHandlerMock.tryToLockLeaderResource).not.toHaveBeenCalled(); + }); + + describe('and resource is already locked', () => { + beforeEach(() => { + lockHandlerMock.tryToLockLeaderResource.mockResolvedValue(false); + }); + + it('when the instance fails in locking the resource, then "leader" event is NOT emitted.', async () => { + // given + const onLeader = jest.fn(); + cut.on('leader', onLeader); + + // when + await fakeTimer.tickAsync(6000); + + // then + expect(onLeader).not.toHaveBeenCalled(); + }); + }); + + describe('and resource is not locked', () => { + beforeEach(() => { + lockHandlerMock.tryToLockLeaderResource.mockResolvedValue(true); + }); + + it('when the instance succeeded in locking the resource, then "leader" event is emitted.', async () => { + // given + const onLeader = jest.fn(); + cut.on('leader', onLeader); + + // when + await fakeTimer.tickAsync(6000); + + // then + expect(onLeader).toHaveBeenCalled(); + }); + }); + }); + + describe('given the instance is leader', () => { + beforeEach(async () => { + lockHandlerMock.tryToLockLeaderResource.mockResolvedValue(true); + + const isLeaderAlready = new Promise((resolve) => { + cut.once('leader', resolve); + }); + + cut.start(); + + // wait for the leadership + await fakeTimer.tickAsync(100); + await isLeaderAlready; + }); + + it('then periodically it extends the resource lock.', async () => { + // when: 2000 ms (configured extension time) + await fakeTimer.tickAsync(2000); + + // then + expect(lockHandlerMock.tryToMaintainTheLock).toHaveBeenCalled(); + }); + + describe('but the resource lock cannot be extended', () => { + beforeEach(() => { + lockHandlerMock.tryToMaintainTheLock.mockResolvedValue(false); + }); + + it('when the periodic extension job executes, then the instance becomes the follower.', async () => { + const isFollower = new Promise((resolve) => { + cut.once('follower', resolve); + }); + + // when + await fakeTimer.tickAsync(5000); + + // then + await expect(isFollower).toResolve(); + }); + }); + }); + + describe('given the instance is follower', () => { + beforeEach(async () => { + lockHandlerMock.tryToLockLeaderResource.mockResolvedValue(false); + + const isFollowerAlready = new Promise((resolve) => { + cut.once('follower', resolve); + }); + + cut.start(); + + // wait for the leadership + await fakeTimer.tickAsync(6000); + await isFollowerAlready; + }); + + describe('and the leader died and freed the resource', () => { + beforeEach(() => { + lockHandlerMock.tryToLockLeaderResource.mockResolvedValue(true); + }); + + it('when instance locked the resource, then it becomes the leader.', async () => { + const isLeader = new Promise((resolve) => { + cut.once('leader', resolve); + }); + + // when + await fakeTimer.tickAsync(6000); + + // then + await expect(isLeader).toResolve(); + }); + }); + }); +}); diff --git a/src/components/leader-election/index.ts b/src/components/leader-election/index.ts new file mode 100644 index 0000000..3308f1e --- /dev/null +++ b/src/components/leader-election/index.ts @@ -0,0 +1,109 @@ +import { + ILeadershipElectionGivenOptions, + ILeadershipElectionOptions, + ILockHandler, + LeaderElectionEvents, +} from './types'; +import { TypedEmitter } from 'tiny-typed-emitter'; + +export class LeaderElection extends TypedEmitter { + private isLeader?: boolean; + private options: ILeadershipElectionOptions; + + private electionExtensionTimeout?: NodeJS.Timeout; + private electionTimeout?: NodeJS.Timeout; + + private withDefaults(givenOptions: ILeadershipElectionGivenOptions): ILeadershipElectionOptions { + return { + expireInMs: 60_000, + ...givenOptions, + }; + } + + constructor( + private readonly lockHandler: ILockHandler, + readonly identifier: string, + options: ILeadershipElectionGivenOptions, + ) { + super(); + + // compose options + this.options = this.withDefaults(options); + } + + start(): void { + // immediately start the "fight" for leadership + this.scheduleLeadershipTry(0); + } + + close(): void { + // cleanup leadership extension (if started) + this.electionExtensionTimeout && clearTimeout(this.electionExtensionTimeout); + + // cleanup follower trials of becoming the leader (if started) + this.electionTimeout && clearTimeout(this.electionTimeout); + } + + private async becomeLeader(): Promise { + this.isLeader = true; + + // cleanup follower trials of becoming the leader + this.electionTimeout && clearTimeout(this.electionTimeout); + + // start the periodic leadership extension process + this.scheduleLeadershipExtension(); + + // notify everyone we're leader + this.emit('leader'); + } + + private becomeFollower(): void { + // do nothing for now + this.isLeader = false; + + // cleanup leadership extension (if started) + this.electionExtensionTimeout && clearTimeout(this.electionExtensionTimeout); + + // notify everyone we're follower + this.emit('follower'); + } + + private async tryToBecomeLeader(): Promise { + const becameLeader = await this.lockHandler.tryToLockLeaderResource( + this.options.key, + this.identifier, + this.options.expireInMs, + ); + + this.scheduleLeadershipTry(); + + if (becameLeader === this.isLeader) { + return; + } + + if (becameLeader) { + await this.becomeLeader(); + } else { + await this.becomeFollower(); + } + } + + private async extendLeadership(): Promise { + if (await this.lockHandler.tryToMaintainTheLock(this.options.key, this.identifier, this.options.expireInMs)) { + this.scheduleLeadershipExtension(); + } else { + await this.becomeFollower(); + } + } + + private scheduleLeadershipExtension(): void { + this.electionExtensionTimeout = setTimeout( + () => this.extendLeadership(), + this.options.prolongLeadershipIntervalMs || this.options.expireInMs / 2, + ); + } + + private scheduleLeadershipTry(timeout = this.options.expireInMs) { + this.electionTimeout = setTimeout(() => this.tryToBecomeLeader(), timeout); + } +} diff --git a/src/components/leader-election/ioredis.lock-handler.ts b/src/components/leader-election/ioredis.lock-handler.ts new file mode 100644 index 0000000..1b34e4d --- /dev/null +++ b/src/components/leader-election/ioredis.lock-handler.ts @@ -0,0 +1,27 @@ +import { ILockHandler } from './types'; +import IORedis from 'ioredis'; + +const NUM_OF_KEYS_IN_LUA_SCRIPT = 1; + +export class IORedisLockHandler implements ILockHandler { + constructor(private readonly redis: IORedis) {} + + private static EXTEND_LEADERSHIP_SCRIPT = + "if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('PEXPIRE', KEYS[1], ARGV[2]) else return 0 end"; + + async tryToMaintainTheLock(key: string, value: string, expirationTimeMs: number): Promise { + const extended = await this.redis.eval( + IORedisLockHandler.EXTEND_LEADERSHIP_SCRIPT, + NUM_OF_KEYS_IN_LUA_SCRIPT, + key, + value, + expirationTimeMs, + ); + + return (extended as number) > 0; + } + + async tryToLockLeaderResource(key: string, value: string, expirationTimeMs: number): Promise { + return (await this.redis.set(key, value, 'PX', expirationTimeMs, 'NX')) !== null; + } +} diff --git a/src/components/leader-election/redis-based.lock-handlers.spec.ts b/src/components/leader-election/redis-based.lock-handlers.spec.ts new file mode 100644 index 0000000..6dd649c --- /dev/null +++ b/src/components/leader-election/redis-based.lock-handlers.spec.ts @@ -0,0 +1,122 @@ +import 'jest-extended'; +import { IORedisLockHandler } from './ioredis.lock-handler'; +import { RedisLockHandler } from './redis.lock-handler'; +import IORedis from 'ioredis'; +import { ILockHandler } from './types'; +import { createClient, RedisClientType } from 'redis'; + +let testRedisConnection: IORedis; + +const RESOURCE_KEY = 'key_to_lock'; + +beforeAll(() => { + testRedisConnection = new IORedis(36279, 'localhost'); +}); + +afterEach(async () => { + await testRedisConnection.del(RESOURCE_KEY); +}); + +describe.each([ + { + classname: IORedisLockHandler.name, + factory: async () => { + const redis = new IORedis(36279, 'localhost'); + + return { + instance: new IORedisLockHandler(redis), + closeFn: () => redis.quit(), + }; + }, + }, + { + classname: RedisLockHandler.name, + factory: async () => { + const redis: RedisClientType = createClient({ url: 'redis://localhost:36279' }); + await redis.connect(); + + return { + instance: new RedisLockHandler(redis), + closeFn: () => redis.quit(), + }; + }, + }, +])('$classname lock handler', ({ factory }) => { + let cut: ILockHandler; + let close: () => Promise; + + beforeAll(async () => { + const { instance, closeFn } = await factory(); + + cut = instance; + close = closeFn; + }); + + describe('given the resource is not locked', () => { + it('when .tryToLockLeaderResource(..) is called, then it resolves to TRUE and given value is written to resource key with given TTL.', async () => { + // when & then + await expect(cut.tryToLockLeaderResource(RESOURCE_KEY, 'bar', 1000)).resolves.toBeTruthy(); + + // then + await expect(testRedisConnection.get(RESOURCE_KEY)).resolves.toEqual('bar'); + + // and: key is about to expire + await expect(testRedisConnection.pttl(RESOURCE_KEY)).resolves.toBeWithin(0, 1000); + }); + + it('when .tryToMaintainTheLock(..) is called, then it resolves to FALSE and no value is written to resource key.', async () => { + // when & then + await expect(cut.tryToMaintainTheLock(RESOURCE_KEY, 'bar', 1000)).resolves.toBeFalsy(); + + // then + await expect(testRedisConnection.exists(RESOURCE_KEY)).resolves.toEqual(0); + }); + }); + + describe('given the resource is already locked', () => { + const ALREADY_LOCKED_VALUE = 'foo'; + + beforeEach(async () => { + // given + await testRedisConnection.set(RESOURCE_KEY, ALREADY_LOCKED_VALUE); + }); + + it('when .tryToLockLeaderResource(..) is called, then it resolves to FALSE and resource identifier is not changed.', async () => { + // when & then + await expect(cut.tryToLockLeaderResource(RESOURCE_KEY, 'bar', 1000)).resolves.toBeFalsy(); + + // then + await expect(testRedisConnection.get(RESOURCE_KEY)).resolves.toEqual('foo'); + }); + + describe('when .tryToMaintainTheLock(..) is called', () => { + it('with the same value as already stored, then it resolves to TRUE and the resource TTL is updated.', async () => { + // when & then + await expect(cut.tryToMaintainTheLock(RESOURCE_KEY, ALREADY_LOCKED_VALUE, 1000)).resolves.toBeTruthy(); + + // then: key is about to expire + const pttl = await testRedisConnection.pttl(RESOURCE_KEY); + expect(pttl).toBeGreaterThan(0); + expect(pttl).toBeLessThanOrEqual(1000); + + // and: the value is the same + await expect(testRedisConnection.get(RESOURCE_KEY)).resolves.toEqual(ALREADY_LOCKED_VALUE); + }); + + it('with different value than already stored, then it resolves to FALSE and the resource is intact.', async () => { + // when & then + await expect(cut.tryToMaintainTheLock(RESOURCE_KEY, 'bar', 1000)).resolves.toBeFalsy(); + + // then: key is still in non-expiry mode + await expect(testRedisConnection.pttl(RESOURCE_KEY)).resolves.toEqual(-1); + + // and: the value is the same + await expect(testRedisConnection.get(RESOURCE_KEY)).resolves.toEqual(ALREADY_LOCKED_VALUE); + }); + }); + }); + + afterAll(() => close()); +}); + +afterAll(() => testRedisConnection.quit()); diff --git a/src/components/leader-election/redis.lock-handler.ts b/src/components/leader-election/redis.lock-handler.ts new file mode 100644 index 0000000..20b3e35 --- /dev/null +++ b/src/components/leader-election/redis.lock-handler.ts @@ -0,0 +1,22 @@ +import { ILockHandler } from './types'; +import { RedisClientType } from 'redis'; + +export class RedisLockHandler implements ILockHandler { + constructor(private readonly redis: RedisClientType) {} + + private static EXTEND_LEADERSHIP_SCRIPT = + "if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('PEXPIRE', KEYS[1], ARGV[2]) else return 0 end"; + + async tryToMaintainTheLock(key: string, value: string, expirationTimeMs: number): Promise { + const extended = await this.redis.EVAL(RedisLockHandler.EXTEND_LEADERSHIP_SCRIPT, { + keys: [key], + arguments: [value, expirationTimeMs.toString()], + }); + + return (extended as number) > 0; + } + + async tryToLockLeaderResource(key: string, value: string, expirationTimeMs: number): Promise { + return (await this.redis.SET(key, value, { PX: expirationTimeMs, NX: true })) !== null; + } +} diff --git a/src/components/leader-election/types.ts b/src/components/leader-election/types.ts new file mode 100644 index 0000000..56ac508 --- /dev/null +++ b/src/components/leader-election/types.ts @@ -0,0 +1,18 @@ +export interface ILockHandler { + tryToLockLeaderResource(key: string, value: string, expirationTimeMs: number): Promise; + tryToMaintainTheLock(key: string, value: string, expirationTimeMs: number): Promise; +} + +export interface ILeadershipElectionOptions { + key: string; + expireInMs: number; + prolongLeadershipIntervalMs?: number; +} + +export type ILeadershipElectionGivenOptions = Partial & + Pick; + +export interface LeaderElectionEvents { + leader: () => void; + follower: () => void; +} From 3ec695b2443b75c103057b424ba50fad51657421 Mon Sep 17 00:00:00 2001 From: Artur Wolny Date: Mon, 28 Aug 2023 14:30:54 +0200 Subject: [PATCH 17/17] feature(entitlements): CR fixes --- .../entitlements/entitlements-client.ts | 4 +- .../storage/cache.revision-manager.spec.ts | 4 +- .../storage/cache.revision-manager.ts | 2 +- .../storage/dto-to-cache-sources.mapper.ts | 60 +++++++++---------- .../frontegg.cache-initializer.ts | 4 +- .../leader-election/ioredis.lock-handler.ts | 34 +++++++++-- .../redis-based.lock-handlers.spec.ts | 5 +- .../leader-election/redis.lock-handler.ts | 36 +++++++++-- src/components/leader-election/types.ts | 25 +++++++- 9 files changed, 124 insertions(+), 50 deletions(-) diff --git a/src/clients/entitlements/entitlements-client.ts b/src/clients/entitlements/entitlements-client.ts index 343b2bf..e8179ec 100644 --- a/src/clients/entitlements/entitlements-client.ts +++ b/src/clients/entitlements/entitlements-client.ts @@ -126,7 +126,7 @@ export class EntitlementsClient extends TypedEmitter const entitlementsData = await this.httpClient.get('/api/v1/vendor-entitlements'); const vendorEntitlementsDto = entitlementsData.data; - const { isUpdated, revision } = await this.cacheManager.loadSnapshotAsCurrent(vendorEntitlementsDto); + const { isUpdated, revision } = await this.cacheManager.loadSnapshotAsCurrentRevision(vendorEntitlementsDto); if (isUpdated) { this.emit(EntitlementsClientEventsEnum.SNAPSHOT_UPDATED, revision); @@ -186,4 +186,4 @@ export class EntitlementsClient extends TypedEmitter destroy(): void { this.refreshTimeout && clearTimeout(this.refreshTimeout); } -} \ No newline at end of file +} diff --git a/src/clients/entitlements/storage/cache.revision-manager.spec.ts b/src/clients/entitlements/storage/cache.revision-manager.spec.ts index 9d4fc54..02e782b 100644 --- a/src/clients/entitlements/storage/cache.revision-manager.spec.ts +++ b/src/clients/entitlements/storage/cache.revision-manager.spec.ts @@ -68,7 +68,7 @@ describe(CacheRevisionManager.name, () => { jest.mocked(FronteggEntitlementsCacheInitializer.forLeader).mockResolvedValue(expectedNewEntitlementsCache); // when - loadingSnapshotResult = await cut.loadSnapshotAsCurrent(getDTO(333)); + loadingSnapshotResult = await cut.loadSnapshotAsCurrentRevision(getDTO(333)); }); it('then it resolves to IsUpdatedToRev structure telling with updated revision.', async () => { @@ -97,7 +97,7 @@ describe(CacheRevisionManager.name, () => { jest.mocked(FronteggEntitlementsCacheInitializer.forFollower).mockClear(); // when - loadingSnapshotResult = await cut.loadSnapshotAsCurrent(getDTO(1)); + loadingSnapshotResult = await cut.loadSnapshotAsCurrentRevision(getDTO(1)); }); it('then it resolves to IsUpdatedToRev structure telling nothing got updated and revision (1).', async () => { diff --git a/src/clients/entitlements/storage/cache.revision-manager.ts b/src/clients/entitlements/storage/cache.revision-manager.ts index a357745..2a7d0ab 100644 --- a/src/clients/entitlements/storage/cache.revision-manager.ts +++ b/src/clients/entitlements/storage/cache.revision-manager.ts @@ -15,7 +15,7 @@ export class CacheRevisionManager { constructor(private readonly cache: ICacheManager) {} - async loadSnapshotAsCurrent(dto: VendorEntitlementsDto): Promise { + async loadSnapshotAsCurrentRevision(dto: VendorEntitlementsDto): Promise { const currentRevision = await this.getCurrentCacheRevision(); const givenRevision = dto.snapshotOffset; diff --git a/src/clients/entitlements/storage/dto-to-cache-sources.mapper.ts b/src/clients/entitlements/storage/dto-to-cache-sources.mapper.ts index d5ab0ee..bb2c5d7 100644 --- a/src/clients/entitlements/storage/dto-to-cache-sources.mapper.ts +++ b/src/clients/entitlements/storage/dto-to-cache-sources.mapper.ts @@ -1,8 +1,32 @@ import { FeatureId, VendorEntitlementsDto } from '../types'; import { BundlesSource, ExpirationTime, FeatureSource, NO_EXPIRE, UNBUNDLED_SRC_ID } from './types'; +function ensureMapInMap>(map: Map, mapKey: K): T { + if (!map.has(mapKey)) { + map.set(mapKey, new Map() as T); + } + + return map.get(mapKey)!; +} + +function ensureArrayInMap(map: Map, mapKey: K): T[] { + if (!map.has(mapKey)) { + map.set(mapKey, []); + } + + return map.get(mapKey)!; +} + +function parseExpirationTime(time?: string | null): ExpirationTime { + if (time !== undefined && time !== null) { + return new Date(time).getTime(); + } + + return NO_EXPIRE; +} + export class DtoToCacheSourcesMapper { - map(dto: VendorEntitlementsDto): BundlesSource { + static map(dto: VendorEntitlementsDto): BundlesSource { const { data: { features, entitlements, featureBundles }, } = dto; @@ -56,15 +80,15 @@ export class DtoToCacheSourcesMapper { if (bundle) { if (userId) { // that's user-targeted entitlement - const tenantUserEntitlements = this.ensureMapInMap(bundle.user_entitlements, tenantId); - const usersEntitlements = this.ensureArrayInMap(tenantUserEntitlements, userId); + const tenantUserEntitlements = ensureMapInMap(bundle.user_entitlements, tenantId); + const usersEntitlements = ensureArrayInMap(tenantUserEntitlements, userId); - usersEntitlements.push(this.parseExpirationTime(expirationDate)); + usersEntitlements.push(parseExpirationTime(expirationDate)); } else { // that's tenant-targeted entitlement - const tenantEntitlements = this.ensureArrayInMap(bundle.tenant_entitlements, tenantId); + const tenantEntitlements = ensureArrayInMap(bundle.tenant_entitlements, tenantId); - tenantEntitlements.push(this.parseExpirationTime(expirationDate)); + tenantEntitlements.push(parseExpirationTime(expirationDate)); } } else { // TODO: issue warning here! @@ -87,28 +111,4 @@ export class DtoToCacheSourcesMapper { return bundlesMap; } - - 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; - } } diff --git a/src/clients/entitlements/storage/frontegg-cache/frontegg.cache-initializer.ts b/src/clients/entitlements/storage/frontegg-cache/frontegg.cache-initializer.ts index 16410eb..1e128dd 100644 --- a/src/clients/entitlements/storage/frontegg-cache/frontegg.cache-initializer.ts +++ b/src/clients/entitlements/storage/frontegg-cache/frontegg.cache-initializer.ts @@ -25,7 +25,7 @@ export class FronteggEntitlementsCacheInitializer { const cacheInitializer = new FronteggEntitlementsCacheInitializer(entitlementsCache); - const sources = new DtoToCacheSourcesMapper().map(dto); + const sources = DtoToCacheSourcesMapper.map(dto); await cacheInitializer.setupPermissionsReadModel(sources); await cacheInitializer.setupEntitlementsReadModel(sources); @@ -96,7 +96,7 @@ export class FronteggEntitlementsCacheInitializer { const allPermissions = await cache.collection(PERMISSIONS_COLLECTION_LIST).getAll(); for (const permission of allPermissions) { - await cache.expire([ getPermissionMappingKey(permission)], FronteggEntitlementsCacheInitializer.CLEAR_TTL); + await cache.expire([getPermissionMappingKey(permission)], FronteggEntitlementsCacheInitializer.CLEAR_TTL); } // clear static fields diff --git a/src/components/leader-election/ioredis.lock-handler.ts b/src/components/leader-election/ioredis.lock-handler.ts index 1b34e4d..f4d1b26 100644 --- a/src/components/leader-election/ioredis.lock-handler.ts +++ b/src/components/leader-election/ioredis.lock-handler.ts @@ -9,19 +9,43 @@ export class IORedisLockHandler implements ILockHandler { private static EXTEND_LEADERSHIP_SCRIPT = "if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('PEXPIRE', KEYS[1], ARGV[2]) else return 0 end"; - async tryToMaintainTheLock(key: string, value: string, expirationTimeMs: number): Promise { + /** + * This method calls the Lua script that prolongs the lock on given `leadershipResourceKey` only, when stored value + * equals to given `instanceIdentifier` and then method resolves to `true`. + * + * When `leadershipResourceKey` doesn't exist, or it has a different value, then the leadership is not prolonged and + * method resolves to `false`. + * + * Using Lua script ensures the atomicity of the whole process. Without it there is no guarantee that other Redis + * client doesn't execute operation on `leadershipResourceKey` in-between `GET` and `PEXPIRE` commands. + */ + async tryToMaintainTheLock( + leadershipResourceKey: string, + instanceIdentifier: string, + expirationTimeMs: number, + ): Promise { const extended = await this.redis.eval( IORedisLockHandler.EXTEND_LEADERSHIP_SCRIPT, NUM_OF_KEYS_IN_LUA_SCRIPT, - key, - value, + leadershipResourceKey, + instanceIdentifier, expirationTimeMs, ); return (extended as number) > 0; } - async tryToLockLeaderResource(key: string, value: string, expirationTimeMs: number): Promise { - return (await this.redis.set(key, value, 'PX', expirationTimeMs, 'NX')) !== null; + /** + * This stores the `instanceIdentifier` value into `leadershipResourceKey` only, when the key doesn't exist. If value + * is stored, then TTL is also set to `expirationTimeMs` and method resolves to `true`. + * + * Otherwise method resolved to `false` and no change to `leadershipResourceKey` is introduced. + */ + async tryToLockLeaderResource( + leadershipResourceKey: string, + instanceIdentifier: string, + expirationTimeMs: number, + ): Promise { + return (await this.redis.set(leadershipResourceKey, instanceIdentifier, 'PX', expirationTimeMs, 'NX')) !== null; } } diff --git a/src/components/leader-election/redis-based.lock-handlers.spec.ts b/src/components/leader-election/redis-based.lock-handlers.spec.ts index 6dd649c..23184e7 100644 --- a/src/components/leader-election/redis-based.lock-handlers.spec.ts +++ b/src/components/leader-election/redis-based.lock-handlers.spec.ts @@ -61,7 +61,10 @@ describe.each([ await expect(testRedisConnection.get(RESOURCE_KEY)).resolves.toEqual('bar'); // and: key is about to expire - await expect(testRedisConnection.pttl(RESOURCE_KEY)).resolves.toBeWithin(0, 1000); + const pttl = await testRedisConnection.pttl(RESOURCE_KEY); + + expect(pttl).toBeGreaterThan(0); + expect(pttl).toBeLessThanOrEqual(1000); }); it('when .tryToMaintainTheLock(..) is called, then it resolves to FALSE and no value is written to resource key.', async () => { diff --git a/src/components/leader-election/redis.lock-handler.ts b/src/components/leader-election/redis.lock-handler.ts index 20b3e35..879b734 100644 --- a/src/components/leader-election/redis.lock-handler.ts +++ b/src/components/leader-election/redis.lock-handler.ts @@ -7,16 +7,42 @@ export class RedisLockHandler implements ILockHandler { private static EXTEND_LEADERSHIP_SCRIPT = "if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('PEXPIRE', KEYS[1], ARGV[2]) else return 0 end"; - async tryToMaintainTheLock(key: string, value: string, expirationTimeMs: number): Promise { + /** + * This method calls the Lua script that prolongs the lock on given `leadershipResourceKey` only, when stored value + * equals to given `instanceIdentifier` and then method resolves to `true`. + * + * When `leadershipResourceKey` doesn't exist, or it has a different value, then the leadership is not prolonged and + * method resolves to `false`. + * + * Using Lua script ensures the atomicity of the whole process. Without it there is no guarantee that other Redis + * client doesn't execute operation on `leadershipResourceKey` in-between `GET` and `PEXPIRE` commands. + */ + async tryToMaintainTheLock( + leadershipResourceKey: string, + instanceIdentifier: string, + expirationTimeMs: number, + ): Promise { const extended = await this.redis.EVAL(RedisLockHandler.EXTEND_LEADERSHIP_SCRIPT, { - keys: [key], - arguments: [value, expirationTimeMs.toString()], + keys: [leadershipResourceKey], + arguments: [instanceIdentifier, expirationTimeMs.toString()], }); return (extended as number) > 0; } - async tryToLockLeaderResource(key: string, value: string, expirationTimeMs: number): Promise { - return (await this.redis.SET(key, value, { PX: expirationTimeMs, NX: true })) !== null; + /** + * This stores the `instanceIdentifier` value into `leadershipResourceKey` only, when the key doesn't exist. If value + * is stored, then TTL is also set to `expirationTimeMs` and method resolves to `true`. + * + * Otherwise method resolved to `false` and no change to `leadershipResourceKey` is introduced. + */ + async tryToLockLeaderResource( + leadershipResourceKey: string, + instanceIdentifier: string, + expirationTimeMs: number, + ): Promise { + return ( + (await this.redis.SET(leadershipResourceKey, instanceIdentifier, { PX: expirationTimeMs, NX: true })) !== null + ); } } diff --git a/src/components/leader-election/types.ts b/src/components/leader-election/types.ts index 56ac508..6b64690 100644 --- a/src/components/leader-election/types.ts +++ b/src/components/leader-election/types.ts @@ -1,6 +1,27 @@ export interface ILockHandler { - tryToLockLeaderResource(key: string, value: string, expirationTimeMs: number): Promise; - tryToMaintainTheLock(key: string, value: string, expirationTimeMs: number): Promise; + /** + * This method is about to lock the `leadershipResourceKey` by writing its `instanceIdentifier` to it. The lock should + * not be permanent, but limited to given `expirationTimeMs`. Then the lock can be kept (extended) by calling + * `tryToMaintainTheLock` method. + */ + tryToLockLeaderResource( + leadershipResourceKey: string, + instanceIdentifier: string, + expirationTimeMs: number, + ): Promise; + + /** + * This method is about to prolong the `leadershipResourceKey` time-to-live only, when the key contains value equal to + * given `instanceIdentifier`. Each instance competing for a leadership role needs to have a unique identifier. + * + * This way we know, that only the leader process can prolong its leadership. If leader dies, for any reason, no other + * process can extend its leadership. + */ + tryToMaintainTheLock( + leadershipResourceKey: string, + instanceIdentifier: string, + expirationTimeMs: number, + ): Promise; } export interface ILeadershipElectionOptions {