diff --git a/.changeset/long-tables-yawn.md b/.changeset/long-tables-yawn.md new file mode 100644 index 0000000000..db1ae8c209 --- /dev/null +++ b/.changeset/long-tables-yawn.md @@ -0,0 +1,5 @@ +--- +'@sap-cloud-sdk/connectivity': minor +--- + +[New Functionality] Add interface `DestinationCacheInterface` and method `setDestinationCache` to support implementation of custom destination cache. diff --git a/packages/connectivity/src/index.ts b/packages/connectivity/src/index.ts index 251705db00..c6154e42d7 100755 --- a/packages/connectivity/src/index.ts +++ b/packages/connectivity/src/index.ts @@ -8,6 +8,8 @@ export { parseDestination, toDestinationNameUrl, sanitizeDestination, + DestinationCacheInterface, + CacheEntry, CachingOptions, getDestination, useOrFetchDestination, @@ -35,7 +37,8 @@ export { buildHeadersForDestination, getClientCredentialsToken, getUserToken, - registerDestination + registerDestination, + setDestinationCache } from './scp-cf'; export type { diff --git a/packages/connectivity/src/scp-cf/cache.spec.ts b/packages/connectivity/src/scp-cf/cache.spec.ts index b475d97ce6..016ebc3caf 100755 --- a/packages/connectivity/src/scp-cf/cache.spec.ts +++ b/packages/connectivity/src/scp-cf/cache.spec.ts @@ -27,27 +27,26 @@ const cacheOne = new Cache({ hours: 0, minutes: 5, seconds: 0 }); const cacheTwo = new Cache(); describe('Cache', () => { - afterEach(() => { + afterEach(async () => { cacheOne.clear(); cacheTwo.clear(); - destinationCache.clear(); + await destinationCache.clear(); clientCredentialsTokenCache.clear(); }); - it('non-existing item in cache should return undefined', () => { - const actual = cacheOne.get('notExistingDest'); - expect(actual).toBeUndefined(); + it('non-existing item in cache should return undefined', async () => { + expect(cacheOne.get('notExistingDest')).toBeUndefined(); }); it('item should be retrieved correctly', () => { - cacheOne.set('one', destinationOne); + cacheOne.set('one', { entry: destinationOne }); const actual = cacheOne.get('one'); expect(actual).toEqual(destinationOne); }); it('retrieving expired item should return undefined', () => { jest.useFakeTimers('modern'); - cacheOne.set('one', destinationOne); + cacheOne.set('one', { entry: destinationOne }); const minutesToExpire = 6; // Shift time to expire the set item @@ -56,7 +55,7 @@ describe('Cache', () => { }); it('clear() should remove all entries in cache', () => { - cacheOne.set('one', destinationOne); + cacheOne.set('one', { entry: destinationOne }); cacheOne.clear(); expect(cacheOne.hasKey('one')).toBeFalsy(); }); @@ -69,7 +68,7 @@ describe('Cache', () => { jti: '', scope: '' }; - cacheTwo.set('someToken', dummyToken); + cacheTwo.set('someToken', { entry: dummyToken }); expect(cacheTwo.get('someToken')).toEqual(dummyToken); }); @@ -81,14 +80,17 @@ describe('Cache', () => { jti: '', scope: '' }; - cacheTwo.set('expiredToken', dummyToken, 10); - cacheTwo.set('validToken', dummyToken, Date.now() + 5000); + cacheTwo.set('expiredToken', { entry: dummyToken, expires: 10 }); + cacheTwo.set('validToken', { + entry: dummyToken, + expires: Date.now() + 5000 + }); expect(cacheTwo.get('expiredToken')).toBeUndefined(); expect(cacheTwo.get('validToken')).toBe(dummyToken); }); it('should not hit cache for undefined key', () => { - cacheOne.set(undefined, {} as Destination); + cacheOne.set(undefined, { entry: {} as Destination }); const actual = cacheOne.get(undefined); expect(actual).toBeUndefined(); }); diff --git a/packages/connectivity/src/scp-cf/cache.ts b/packages/connectivity/src/scp-cf/cache.ts index 4f3be2cd0c..24516efed6 100755 --- a/packages/connectivity/src/scp-cf/cache.ts +++ b/packages/connectivity/src/scp-cf/cache.ts @@ -1,10 +1,14 @@ interface CacheInterface { hasKey(key: string): boolean; - get(key: string): T | undefined; - set(key: string, item: T, expirationTime?: number): void; + get(key: string | undefined): T | undefined; + set(key: string | undefined, item: CacheEntry): void; + clear(): void; } -interface DateInputObject { +/** + * @internal + */ +export interface DateInputObject { hours?: number; minutes?: number; seconds?: number; @@ -12,7 +16,7 @@ interface DateInputObject { } /** - * @internal + * Respresentation of a cached object. */ export interface CacheEntry { expires?: number; @@ -81,15 +85,13 @@ export class Cache implements CacheInterface { /** * Setter of entries in cache. * @param key - The entry's key. - * @param entry - The entry to cache. - * @param expirationTime - The time expressed in UTC in which the given entry expires. + * @param item - The entry to cache. */ - set(key: string | undefined, entry: T, expirationTime?: number): void { + set(key: string | undefined, item: CacheEntry): void { if (key) { - const expires = expirationTime - ? expirationTime - : inferExpirationTime(this.defaultValidityTime); - this.cache[key] = { entry, expires }; + const expires = + item.expires ?? inferExpirationTime(this.defaultValidityTime); + this.cache[key] = { entry: item.entry, expires }; } } } diff --git a/packages/connectivity/src/scp-cf/client-credentials-token-cache.ts b/packages/connectivity/src/scp-cf/client-credentials-token-cache.ts index f1fe65f8c6..8153f461b3 100755 --- a/packages/connectivity/src/scp-cf/client-credentials-token-cache.ts +++ b/packages/connectivity/src/scp-cf/client-credentials-token-cache.ts @@ -18,11 +18,12 @@ const ClientCredentialsTokenCache = ( clientId: string, token: ClientCredentialsResponse ): void => { - cache.set( - getCacheKey(url, clientId), - token, - token.expires_in ? Date.now() + token.expires_in * 1000 : undefined - ); + cache.set(getCacheKey(url, clientId), { + entry: token, + expires: token.expires_in + ? Date.now() + token.expires_in * 1000 + : undefined + }); }, clear: (): void => { cache.clear(); diff --git a/packages/connectivity/src/scp-cf/destination/destination-accessor-loading-precendence.spec.ts b/packages/connectivity/src/scp-cf/destination/destination-accessor-loading-precendence.spec.ts index fa25ed3135..20c1d4f325 100755 --- a/packages/connectivity/src/scp-cf/destination/destination-accessor-loading-precendence.spec.ts +++ b/packages/connectivity/src/scp-cf/destination/destination-accessor-loading-precendence.spec.ts @@ -83,7 +83,7 @@ describe('destination loading precedence', () => { it('retrieves service binding destinations third', async () => { delete process.env['destinations']; - registerDestinationCache.clear(); + await registerDestinationCache.clear(); const actual = await getDestination({ destinationName }); @@ -92,7 +92,7 @@ describe('destination loading precedence', () => { it('retrieves destinations from destination-service last', async () => { delete process.env['destinations']; - registerDestinationCache.clear(); + await registerDestinationCache.clear(); mockServiceBindings(); await expect( diff --git a/packages/connectivity/src/scp-cf/destination/destination-accessor.ts b/packages/connectivity/src/scp-cf/destination/destination-accessor.ts index 9048ee9c4e..039a3ab6a1 100755 --- a/packages/connectivity/src/scp-cf/destination/destination-accessor.ts +++ b/packages/connectivity/src/scp-cf/destination/destination-accessor.ts @@ -45,7 +45,7 @@ export async function getDestination( ): Promise { return ( searchEnvVariablesForDestination(options) || - searchRegisteredDestination(options) || + (await searchRegisteredDestination(options)) || searchServiceBindingForDestination(options.destinationName) || getDestinationFromDestinationService(options) ); diff --git a/packages/connectivity/src/scp-cf/destination/destination-cache.spec.ts b/packages/connectivity/src/scp-cf/destination/destination-cache.spec.ts index 773b54c7b7..1be1e1b608 100755 --- a/packages/connectivity/src/scp-cf/destination/destination-cache.spec.ts +++ b/packages/connectivity/src/scp-cf/destination/destination-cache.spec.ts @@ -34,6 +34,7 @@ import { } from '../../../../../test-resources/test/test-util/example-destination-service-responses'; import { decodeJwt, wrapJwtInHeader } from '../jwt'; import { signedJwt } from '../../../../../test-resources/test/test-util'; +import { TestCache } from '../../../../../test-resources/test/test-util/test-cache'; import { destinationServiceCache } from './destination-service-cache'; import { getDestination } from './destination-accessor'; import { @@ -43,6 +44,7 @@ import { } from './destination-selection-strategies'; import { destinationCache, + setDestinationCache, getDestinationCacheKey, IsolationStrategy } from './destination-cache'; @@ -67,7 +69,7 @@ const destinationOne: Destination = { isTrustingAllCertificates: false }; -function getSubscriberCache( +async function getSubscriberCache( isolationStrategy: IsolationStrategy, destName = 'SubscriberDest' ) { @@ -78,7 +80,7 @@ function getSubscriberCache( isolationStrategy ); } -function getProviderCache(isolationStrategy: IsolationStrategy) { +async function getProviderCache(isolationStrategy: IsolationStrategy) { const decodedProviderJwt = decodeJwt(providerUserJwt); return destinationCache.retrieveDestinationFromCache( decodedProviderJwt, @@ -106,14 +108,14 @@ function mockDestinationsWithSameName() { } describe('destination cache', () => { - afterAll(() => { - destinationCache.clear(); + afterAll(async () => { + await destinationCache.clear(); destinationServiceCache.clear(); nock.cleanAll(); }); - beforeEach(() => { - destinationCache.clear(); + beforeEach(async () => { + await destinationCache.clear(); destinationServiceCache.clear(); nock.cleanAll(); }); @@ -168,7 +170,7 @@ describe('destination cache', () => { iasToXsuaaTokenExchange: false }); const cacheKeys = Object.keys( - (destinationCache.getCacheInstance() as any).cache + await (destinationCache.getCacheInstance() as any).cache.cache ); expect(cacheKeys[0]).toBe( getDestinationCacheKey( @@ -188,10 +190,10 @@ describe('destination cache', () => { iasToXsuaaTokenExchange: false }); - const c1 = getSubscriberCache(IsolationStrategy.Tenant); - const c2 = getProviderCache(IsolationStrategy.Tenant); - const c5 = getSubscriberCache(IsolationStrategy.Tenant_User); - const c6 = getProviderCache(IsolationStrategy.Tenant_User); + const c1 = await getSubscriberCache(IsolationStrategy.Tenant); + const c2 = await getProviderCache(IsolationStrategy.Tenant); + const c5 = await getSubscriberCache(IsolationStrategy.Tenant_User); + const c6 = await getProviderCache(IsolationStrategy.Tenant_User); expect(c1).toBeUndefined(); expect(c2).toBeUndefined(); @@ -210,8 +212,8 @@ describe('destination cache', () => { iasToXsuaaTokenExchange: false }); - const c1 = getSubscriberCache(IsolationStrategy.Tenant); - const c2 = getProviderCache(IsolationStrategy.Tenant); + const c1 = await getSubscriberCache(IsolationStrategy.Tenant); + const c2 = await getProviderCache(IsolationStrategy.Tenant); expect(c1!.url).toBe('https://subscriber.example'); expect(c2).toBeUndefined(); @@ -228,8 +230,8 @@ describe('destination cache', () => { iasToXsuaaTokenExchange: false }); - const c1 = getSubscriberCache(IsolationStrategy.Tenant); - const c2 = getProviderCache(IsolationStrategy.Tenant); + const c1 = await getSubscriberCache(IsolationStrategy.Tenant); + const c2 = await getProviderCache(IsolationStrategy.Tenant); expect(c1).toBeUndefined(); expect(c2!.url).toBe('https://provider.example'); @@ -247,8 +249,8 @@ describe('destination cache', () => { iasToXsuaaTokenExchange: false }); - const c1 = getSubscriberCache(IsolationStrategy.Tenant); - const c2 = getProviderCache(IsolationStrategy.Tenant); + const c1 = await getSubscriberCache(IsolationStrategy.Tenant); + const c2 = await getProviderCache(IsolationStrategy.Tenant); expect(c1!.url).toBe('https://subscriber.example'); expect(c2).toBeUndefined(); @@ -266,8 +268,8 @@ describe('destination cache', () => { iasToXsuaaTokenExchange: false }); - const c1 = getSubscriberCache(IsolationStrategy.Tenant); - const c2 = getProviderCache(IsolationStrategy.Tenant); + const c1 = await getSubscriberCache(IsolationStrategy.Tenant); + const c2 = await getProviderCache(IsolationStrategy.Tenant); expect(c1).toBeUndefined(); expect(c2).toBeUndefined(); @@ -285,8 +287,11 @@ describe('destination cache', () => { iasToXsuaaTokenExchange: false }); - const c1 = getSubscriberCache(IsolationStrategy.Tenant, 'SubscriberDest'); - const c2 = getSubscriberCache( + const c1 = await getSubscriberCache( + IsolationStrategy.Tenant, + 'SubscriberDest' + ); + const c2 = await getSubscriberCache( IsolationStrategy.Tenant, 'SubscriberDest2' ); @@ -306,7 +311,7 @@ describe('destination cache', () => { const destName = destinationOne.name!; it('disables the cache by default', async () => { - destinationCache.cacheRetrievedDestination( + await destinationCache.cacheRetrievedDestination( { user_id: 'user', zid: 'tenant' }, destinationOne, IsolationStrategy.Tenant @@ -317,7 +322,7 @@ describe('destination cache', () => { }); it('uses cache with isolation strategy Tenant if no JWT is provided', async () => { - destinationCache.cacheRetrievedDestination( + await destinationCache.cacheRetrievedDestination( decodeJwt(providerServiceToken), destinationOne, IsolationStrategy.Tenant @@ -330,12 +335,11 @@ describe('destination cache', () => { }); it('uses cache with isolation strategy Tenant and iss ', async () => { - destinationCache + await destinationCache .getCacheInstance() - .set( - `${TestTenants.SUBSCRIBER_ONLY_ISS}::${destinationOne.name}`, - destinationOne - ); + .set(`${TestTenants.SUBSCRIBER_ONLY_ISS}::${destinationOne.name}`, { + entry: destinationOne + }); const actual = await getDestination({ destinationName: destName, @@ -346,7 +350,7 @@ describe('destination cache', () => { }); it('uses cache with isolation strategy TenantUser if JWT is provided', async () => { - destinationCache.cacheRetrievedDestination( + await destinationCache.cacheRetrievedDestination( decodeJwt(subscriberUserJwt), destinationOne, IsolationStrategy.Tenant_User @@ -361,7 +365,7 @@ describe('destination cache', () => { }); it('enables cache if isolation strategy Tenant is provided', async () => { - destinationCache.cacheRetrievedDestination( + await destinationCache.cacheRetrievedDestination( decodeJwt(providerServiceToken), destinationOne, IsolationStrategy.Tenant @@ -375,7 +379,7 @@ describe('destination cache', () => { }); it('enables cache if isolation strategy TenantUser is provided', async () => { - destinationCache.cacheRetrievedDestination( + await destinationCache.cacheRetrievedDestination( decodeJwt(subscriberUserJwt), destinationOne, IsolationStrategy.Tenant_User @@ -472,7 +476,9 @@ describe('destination cache', () => { parseDestination(certificateSingleResponse) ); expect(destinationFromCache).toEqual(destinationFromService); - expect(retrieveFromCacheSpy).toHaveReturnedWith(destinationFromCache); + expect(retrieveFromCacheSpy).toHaveReturnedWith( + Promise.resolve(destinationFromCache) + ); httpMocks.forEach(mock => expect(mock.isDone()).toBe(true)); }); @@ -524,7 +530,9 @@ describe('destination cache', () => { parseDestination(oauthSingleResponse) ); expect(destinationFromCache).toEqual(destinationFromService); - expect(retrieveFromCacheSpy).toHaveReturnedWith(destinationFromCache); + expect(retrieveFromCacheSpy).toHaveReturnedWith( + Promise.resolve(destinationFromCache) + ); httpMocks.forEach(mock => expect(mock.isDone()).toBe(true)); }); @@ -581,7 +589,9 @@ describe('destination cache', () => { expect(destinationFromFirstCall).toEqual(expected); expect(destinationFromCache).toEqual(destinationFromFirstCall); - expect(retrieveFromCacheSpy).toHaveReturnedWith(destinationFromCache); + expect(retrieveFromCacheSpy).toHaveReturnedWith( + Promise.resolve(destinationFromCache) + ); httpMocks.forEach(mock => expect(mock.isDone()).toBe(true)); }); @@ -598,7 +608,7 @@ describe('destination cache', () => { }; const parsedDestination = parseDestination(subscriberDest); // Cache destination to retrieve - destinationCache.cacheRetrievedDestination( + await destinationCache.cacheRetrievedDestination( decodeJwt(subscriberUserJwt), parsedDestination, IsolationStrategy.Tenant_User @@ -629,7 +639,7 @@ describe('destination cache', () => { Authentication: authType }; const parsedDestination = parseDestination(providerDest); - destinationCache.cacheRetrievedDestination( + await destinationCache.cacheRetrievedDestination( decodeJwt(providerServiceToken), parsedDestination, IsolationStrategy.Tenant @@ -661,7 +671,7 @@ describe('destination cache', () => { Authentication: authType }; const parsedDestination = parseDestination(providerDest); - destinationCache.cacheRetrievedDestination( + await destinationCache.cacheRetrievedDestination( decodeJwt(providerUserJwt), parsedDestination, IsolationStrategy.Tenant @@ -688,20 +698,20 @@ describe('destination cache', () => { }); describe('caching without mocs', () => { - it('should cache the destination correctly', () => { + it('should cache the destination correctly', async () => { const dummyJwt = { user_id: 'user', zid: 'tenant' }; - destinationCache.cacheRetrievedDestination( + await destinationCache.cacheRetrievedDestination( dummyJwt, destinationOne, IsolationStrategy.Tenant_User ); - const actual1 = destinationCache.retrieveDestinationFromCache( + const actual1 = await destinationCache.retrieveDestinationFromCache( dummyJwt, 'destToCache1', IsolationStrategy.Tenant_User ); - const actual2 = destinationCache.retrieveDestinationFromCache( + const actual2 = await destinationCache.retrieveDestinationFromCache( dummyJwt, 'destToCache1', IsolationStrategy.Tenant @@ -712,20 +722,20 @@ describe('destination cache', () => { expect([actual1, actual2]).toEqual(expected); }); - it('should not hit cache when Tenant_User is chosen but user id is missing', () => { + it('should not hit cache when Tenant_User is chosen but user id is missing', async () => { const dummyJwt = { zid: 'tenant' }; - destinationCache.cacheRetrievedDestination( + await destinationCache.cacheRetrievedDestination( dummyJwt, destinationOne, IsolationStrategy.Tenant_User ); - const actual1 = destinationCache.retrieveDestinationFromCache( + const actual1 = await destinationCache.retrieveDestinationFromCache( dummyJwt, 'destToCache1', IsolationStrategy.Tenant ); - const actual2 = destinationCache.retrieveDestinationFromCache( + const actual2 = await destinationCache.retrieveDestinationFromCache( dummyJwt, 'destToCache1', IsolationStrategy.Tenant_User @@ -736,19 +746,19 @@ describe('destination cache', () => { expect([actual1, actual2]).toEqual(expected); }); - it('should not hit cache when Tenant is chosen but tenant id is missing', () => { + it('should not hit cache when Tenant is chosen but tenant id is missing', async () => { const dummyJwt = { user_id: 'user' }; - destinationCache.cacheRetrievedDestination( + await destinationCache.cacheRetrievedDestination( dummyJwt, destinationOne, IsolationStrategy.Tenant ); - const actual1 = destinationCache.retrieveDestinationFromCache( + const actual1 = await destinationCache.retrieveDestinationFromCache( dummyJwt, 'destToCache1', IsolationStrategy.Tenant ); - const actual2 = destinationCache.retrieveDestinationFromCache( + const actual2 = await destinationCache.retrieveDestinationFromCache( dummyJwt, 'destToCache1', IsolationStrategy.Tenant_User @@ -759,10 +769,10 @@ describe('destination cache', () => { expect([actual1, actual2]).toEqual(expected); }); - it('should return undefined when the destination is not valid', () => { + it('should return undefined when the destination is not valid', async () => { jest.useFakeTimers('modern'); const dummyJwt = { user_id: 'user', zid: 'tenant' }; - destinationCache.cacheRetrievedDestination( + await destinationCache.cacheRetrievedDestination( dummyJwt, destinationOne, IsolationStrategy.Tenant_User @@ -770,7 +780,7 @@ describe('destination cache', () => { const minutesToExpire = 6; jest.advanceTimersByTime(60000 * minutesToExpire); - const actual = destinationCache.retrieveDestinationFromCache( + const actual = await destinationCache.retrieveDestinationFromCache( dummyJwt, 'destToCache1', IsolationStrategy.Tenant_User @@ -779,14 +789,14 @@ describe('destination cache', () => { expect(actual).toBeUndefined(); }); - it('should return undefined when the destination is not valid and has an auth token expiration time', () => { + it('should return undefined when the destination is not valid and has an auth token expiration time', async () => { jest.useFakeTimers('modern'); const dummyJwt = { user_id: 'user', zid: 'tenant' }; const destination = { ...destinationOne, authTokens: [{ expiresIn: '60' } as DestinationAuthToken] }; - destinationCache.cacheRetrievedDestination( + await destinationCache.cacheRetrievedDestination( dummyJwt, destination, IsolationStrategy.Tenant_User @@ -798,12 +808,47 @@ describe('destination cache', () => { IsolationStrategy.Tenant_User ); - expect(retrieveDestination()).toEqual(destination); + await expect(retrieveDestination()).resolves.toEqual(destination); const minutesToExpire = 2; jest.advanceTimersByTime(60000 * minutesToExpire); - expect(retrieveDestination()).toBeUndefined(); + await expect(retrieveDestination()).resolves.toBeUndefined(); + }); + }); + + describe('custom destination cache', () => { + // Cache with expiration time + const testCacheOne = new TestCache(); + // Setting the destinationCache with custom class instance + it('custom cache overrides the default implementation', async () => { + setDestinationCache(testCacheOne); + + const setSpy = jest.spyOn(testCacheOne, 'set'); + const getSpy = jest.spyOn(testCacheOne, 'get'); + const clearSpy = jest.spyOn(testCacheOne, 'clear'); + + await destinationCache.cacheRetrievedDestination( + { user_id: 'user', zid: 'tenant' }, + destinationOne, + IsolationStrategy.Tenant_User + ); + + expect(setSpy).toHaveBeenCalled(); + + const retrieveDestination = () => + destinationCache.retrieveDestinationFromCache( + { user_id: 'user', zid: 'tenant' }, + 'destToCache1', + IsolationStrategy.Tenant_User + ); + + await expect(retrieveDestination()).resolves.toEqual(destinationOne); + expect(getSpy).toHaveBeenCalled(); + + await destinationCache.clear(); + expect(clearSpy).toHaveBeenCalled(); + await expect(retrieveDestination()).resolves.toBeUndefined(); }); }); }); diff --git a/packages/connectivity/src/scp-cf/destination/destination-cache.ts b/packages/connectivity/src/scp-cf/destination/destination-cache.ts index 337a334471..84c0e2338d 100755 --- a/packages/connectivity/src/scp-cf/destination/destination-cache.ts +++ b/packages/connectivity/src/scp-cf/destination/destination-cache.ts @@ -1,5 +1,5 @@ import { createLogger, first } from '@sap-cloud-sdk/util'; -import { Cache } from '../cache'; +import { Cache, CacheEntry, DateInputObject } from '../cache'; import { tenantId } from '../tenant'; import { userId } from '../user'; import { JwtPayload } from '../jsonwebtoken-type'; @@ -20,6 +20,66 @@ export enum IsolationStrategy { Tenant_User = 'TenantUser' } +/** + * Interface to implement custom destination caching. + * To set a custom implementation, call method [[setDestinationCache]] and pass the cache instance. + */ +export interface DestinationCacheInterface { + hasKey(key: string): Promise; + get(key: string | undefined): Promise; + set(key: string | undefined, item: CacheEntry): Promise; + clear(): Promise; +} + +/** + * @internal + * This wrapper class wraps methods of [[Cache]] class as asynchronous methods. + */ +export class DefaultDestinationCache implements DestinationCacheInterface { + cache: Cache; + + constructor(validityTime?: DateInputObject) { + this.cache = new Cache(validityTime); + } + + /** + * Specifies whether an entry with a given key is defined in cache. + * @param key - The entry's key. + * @returns A boolean value that indicates whether the entry exists in cache. + */ + async hasKey(key: string): Promise { + return this.cache.hasKey(key); + } + + /** + * Getter of cached entries. + * @param key - The key of the entry to retrieve. + * @returns The corresponding entry to the provided key if it is still valid, returns `undefined` otherwise. + */ + async get(key: string | undefined): Promise { + return this.cache.get(key); + } + + /** + * Setter of entries in cache. + * @param key - The entry's key. + * @param item - The entry to cache. + */ + async set( + key: string | undefined, + item: CacheEntry + ): Promise { + return this.cache.set(key, item); + } + + /** + * Clear all cached items. + */ + async clear(): Promise { + return this.cache.clear(); + } +} + /** * @internal */ @@ -28,19 +88,19 @@ export interface DestinationCacheType { decodedJwt: Record, name: string, isolation: IsolationStrategy - ) => Destination | undefined; + ) => Promise; cacheRetrievedDestination: ( decodedJwt: Record, destination: Destination, isolation: IsolationStrategy - ) => void; + ) => Promise; cacheRetrievedDestinations: ( decodedJwt: Record, retrievedDestinations: DestinationsByType, isolation: IsolationStrategy - ) => void; - clear: () => void; - getCacheInstance: () => Cache; + ) => Promise; + clear: () => Promise; + getCacheInstance: () => DestinationCacheInterface; } /** @@ -50,26 +110,30 @@ export interface DestinationCacheType { * @internal */ export const DestinationCache = ( - cache: Cache + cache: DestinationCacheInterface = new DefaultDestinationCache({ + hours: 0, + minutes: 5, + seconds: 0 + }) ): DestinationCacheType => ({ - retrieveDestinationFromCache: ( + retrieveDestinationFromCache: async ( decodedJwt: Record, name: string, isolation: IsolationStrategy - ): Destination | undefined => + ): Promise => cache.get(getDestinationCacheKey(decodedJwt, name, isolation)), - cacheRetrievedDestination: ( + cacheRetrievedDestination: async ( decodedJwt: Record, destination: Destination, isolation: IsolationStrategy - ): void => { + ): Promise => { cacheRetrievedDestination(decodedJwt, destination, isolation, cache); }, - cacheRetrievedDestinations: ( + cacheRetrievedDestinations: async ( decodedJwt: Record, retrievedDestinations: DestinationsByType, isolation: IsolationStrategy - ): void => { + ): Promise => { retrievedDestinations.subaccount.forEach(dest => cacheRetrievedDestination(decodedJwt, dest, isolation, cache) ); @@ -77,7 +141,7 @@ export const DestinationCache = ( cacheRetrievedDestination(decodedJwt, dest, isolation, cache) ); }, - clear: (): void => { + clear: async (): Promise => { cache.clear(); }, getCacheInstance: () => cache @@ -125,12 +189,12 @@ export function getDestinationCacheKey( } } -function cacheRetrievedDestination( +async function cacheRetrievedDestination( decodedJwt: Record, destination: Destination, isolation: IsolationStrategy, - cache: Cache -): void { + cache: T +): Promise { if (!destination.name) { throw new Error('The destination name is undefined.'); } @@ -140,16 +204,24 @@ function cacheRetrievedDestination( const expirationTime = expiresIn ? Date.now() + parseInt(expiresIn) * 1000 : undefined; - cache.set(key, destination, expirationTime); + cache.set(key, { entry: destination, expires: expirationTime }); } /** - * @internal + * Sets the custom destination cache instance. + * Call this method with an instance of [[DestinationCacheInterface]] to override the default cache instance set by the SDK. + * + * NOTE: This function should be called at the beginning before any calls to either [[getDestination]] or [[executeHttpRequest]]. + * @param cache - An instance of [[DestinationCacheInterface]]. */ -export const destinationCache = DestinationCache( - new Cache({ hours: 0, minutes: 5, seconds: 0 }) -); +export function setDestinationCache(cache: DestinationCacheInterface): void { + destinationCache = DestinationCache(cache); +} +/** + * @internal + */ +export let destinationCache: DestinationCacheType = DestinationCache(); /** * Determin the default Isolation strategy if not given as option. * @param jwt - JWT to determine the default isolation strategy diff --git a/packages/connectivity/src/scp-cf/destination/destination-from-registration.spec.ts b/packages/connectivity/src/scp-cf/destination/destination-from-registration.spec.ts index ccda3d24f6..84970fd37b 100755 --- a/packages/connectivity/src/scp-cf/destination/destination-from-registration.spec.ts +++ b/packages/connectivity/src/scp-cf/destination/destination-from-registration.spec.ts @@ -39,7 +39,7 @@ describe('register-destination', () => { }); it('registers destination and retrieves it', async () => { - registerDestination(testDestination); + await registerDestination(testDestination); const actual = await getDestination({ destinationName: testDestination.name }); @@ -47,7 +47,7 @@ describe('register-destination', () => { }); it('registers destination and retrieves it with JWT', async () => { - registerDestination(testDestination, { jwt: providerServiceToken }); + await registerDestination(testDestination, { jwt: providerServiceToken }); const actual = await getDestination({ destinationName: testDestination.name, jwt: providerServiceToken @@ -56,52 +56,53 @@ describe('register-destination', () => { }); it('returns undefined if destination key is not found', async () => { - const actual = searchRegisteredDestination({ + const actual = await searchRegisteredDestination({ destinationName: 'Non-existing-destination' }); expect(actual).toBeNull(); }); - it('caches with tenant-isolation if no JWT is given', () => { - registerDestination(testDestination); - expect( + it('caches with tenant-isolation if no JWT is given', async () => { + await registerDestination(testDestination); + await expect( registerDestinationCache .getCacheInstance() .hasKey('provider::RegisteredDestination') - ).toBe(true); + ).resolves.toBe(true); }); - it('caches with tenant isolation if JWT does not contain user-id', () => { - registerDestination(testDestination, { jwt: subscriberServiceToken }); - expect( + it('caches with tenant isolation if JWT does not contain user-id', async () => { + await registerDestination(testDestination, { jwt: subscriberServiceToken }); + await expect( registerDestinationCache .getCacheInstance() .hasKey('subscriber::RegisteredDestination') - ).toBe(true); + ).resolves.toBe(true); }); - it('caches with tenant-user-isolation if JWT is given', () => { - registerDestination(testDestination, { jwt: subscriberUserJwt }); - expect( + it('caches with tenant-user-isolation if JWT is given', async () => { + await registerDestination(testDestination, { jwt: subscriberUserJwt }); + await expect( registerDestinationCache .getCacheInstance() .hasKey('user-sub:subscriber:RegisteredDestination') - ).toBe(true); + ).resolves.toBe(true); }); it('cache if tenant if you want', async () => { - registerDestination(testDestination, { + await registerDestination(testDestination, { jwt: subscriberUserJwt, isolationStrategy: IsolationStrategy.Tenant }); - expect( + await expect( registerDestinationCache .getCacheInstance() .hasKey('subscriber::RegisteredDestination') - ).toBe(true); + ).resolves.toBe(true); }); it('caches with unlimited time', async () => { + jest.useFakeTimers('modern'); registerDestination(testDestination); const minutesToExpire = 9999; // Shift time to expire the set item @@ -175,13 +176,13 @@ describe('register-destination without xsuaa binding', () => { mockServiceBindings(undefined, false); }); - afterEach(() => { - registerDestinationCache.clear(); + afterEach(async () => { + await registerDestinationCache.clear(); unmockDestinationsEnv(); }); it('registers destination and retrieves it with JWT', async () => { - registerDestination(testDestination, { jwt: providerServiceToken }); + await registerDestination(testDestination, { jwt: providerServiceToken }); const actual = await getDestination({ destinationName: testDestination.name, jwt: providerServiceToken @@ -190,8 +191,8 @@ describe('register-destination without xsuaa binding', () => { }); it('throws an error when no JWT is provided', async () => { - expect(() => + await expect(() => registerDestination(testDestination) - ).toThrowErrorMatchingSnapshot(); + ).rejects.toThrowErrorMatchingSnapshot(); }); }); diff --git a/packages/connectivity/src/scp-cf/destination/destination-from-registration.ts b/packages/connectivity/src/scp-cf/destination/destination-from-registration.ts index 26c9f77ef4..e6ecfe6713 100755 --- a/packages/connectivity/src/scp-cf/destination/destination-from-registration.ts +++ b/packages/connectivity/src/scp-cf/destination/destination-from-registration.ts @@ -1,11 +1,11 @@ import { createLogger } from '@sap-cloud-sdk/util'; -import { Cache } from '../cache'; import { decodeJwt } from '../jwt'; import { getXsuaaServiceCredentials } from '../environment-accessor'; import { parseSubdomain } from '../subdomain-replacer'; import { Destination, DestinationAuthToken } from './destination-service-types'; import { DestinationFetchOptions } from './destination-accessor-types'; import { + DefaultDestinationCache, DestinationCache, getDefaultIsolationStrategy, IsolationStrategy @@ -25,7 +25,7 @@ const logger = createLogger({ * @internal */ export const registerDestinationCache = DestinationCache( - new Cache(undefined) + new DefaultDestinationCache(undefined) ); type RegisterDestinationOptions = Pick< @@ -40,17 +40,17 @@ type RegisterDestinationOptions = Pick< * @param destination - A destination to add to the `destinations` cache. * @param options - Options how to cache the destination. */ -export function registerDestination( +export async function registerDestination( destination: DestinationWithName, options?: RegisterDestinationOptions -): void { +): Promise { if (!destination.name || !destination.url) { throw Error( 'Registering destinations requires a destination name and url.' ); } - registerDestinationCache.cacheRetrievedDestination( + await registerDestinationCache.cacheRetrievedDestination( decodedJwtOrZid(options), destination, isolationStrategy(options) @@ -67,15 +67,15 @@ export type DestinationWithName = Destination & { name: string }; * @param options - The options for searching the cahce * @returns Destination - the destination from cache */ -export function searchRegisteredDestination( +export async function searchRegisteredDestination( options: DestinationFetchOptions -): Destination | null { +): Promise { const destination = - registerDestinationCache.retrieveDestinationFromCache( + (await registerDestinationCache.retrieveDestinationFromCache( decodedJwtOrZid(options), options.destinationName, isolationStrategy(options) - ) || null; + )) || null; if (destination?.forwardAuthToken) { destination.authTokens = destinationAuthToken(options.jwt); diff --git a/packages/connectivity/src/scp-cf/destination/destination-from-service.ts b/packages/connectivity/src/scp-cf/destination/destination-from-service.ts index 10858faadb..74c77dcc03 100755 --- a/packages/connectivity/src/scp-cf/destination/destination-from-service.ts +++ b/packages/connectivity/src/scp-cf/destination/destination-from-service.ts @@ -143,7 +143,7 @@ class DestinationFromServiceRetriever { withProxySetting, destinationResult.origin ); - da.updateDestinationCache(withProxySetting, destinationResult.origin); + await da.updateDestinationCache(withProxySetting, destinationResult.origin); return withProxySetting; } @@ -440,14 +440,14 @@ Possible alternatives for such technical user authentication are BasicAuthentica ); } - private updateDestinationCache( + private async updateDestinationCache( destination: Destination, destinationOrigin: DestinationOrigin ) { if (!this.options.useCache) { return destination; } - destinationCache.cacheRetrievedDestination( + await destinationCache.cacheRetrievedDestination( destinationOrigin === 'subscriber' ? this.selectSubscriberJwt() : this.providerServiceToken.decoded, @@ -478,8 +478,10 @@ Possible alternatives for such technical user authentication are BasicAuthentica } } - private getProviderDestinationCache(): DestinationSearchResult | undefined { - const destination = destinationCache.retrieveDestinationFromCache( + private async getProviderDestinationCache(): Promise< + DestinationSearchResult | undefined + > { + const destination = await destinationCache.retrieveDestinationFromCache( this.providerServiceToken.decoded, this.options.destinationName, this.options.isolationStrategy @@ -515,8 +517,10 @@ Possible alternatives for such technical user authentication are BasicAuthentica } } - private getSubscriberDestinationCache(): DestinationSearchResult | undefined { - const destination = destinationCache.retrieveDestinationFromCache( + private async getSubscriberDestinationCache(): Promise< + DestinationSearchResult | undefined + > { + const destination = await destinationCache.retrieveDestinationFromCache( this.selectSubscriberJwt(), this.options.destinationName, this.options.isolationStrategy @@ -570,7 +574,7 @@ Possible alternatives for such technical user authentication are BasicAuthentica DestinationSearchResult | undefined > { return ( - (this.options.useCache && this.getProviderDestinationCache()) || + (this.options.useCache && (await this.getProviderDestinationCache())) || this.getProviderDestinationService() ); } @@ -579,7 +583,7 @@ Possible alternatives for such technical user authentication are BasicAuthentica DestinationSearchResult | undefined > { return ( - (this.options.useCache && this.getSubscriberDestinationCache()) || + (this.options.useCache && (await this.getSubscriberDestinationCache())) || this.getSubscriberDestinationService() ); } diff --git a/packages/connectivity/src/scp-cf/destination/destination-service-cache.ts b/packages/connectivity/src/scp-cf/destination/destination-service-cache.ts index ed6c64cfa1..37ee70ce06 100755 --- a/packages/connectivity/src/scp-cf/destination/destination-service-cache.ts +++ b/packages/connectivity/src/scp-cf/destination/destination-service-cache.ts @@ -21,9 +21,9 @@ const DestinationServiceCache = (cache: Cache) => ({ destinationServiceUri, IsolationStrategy.Tenant ); - cache.set(key, destinations); + cache.set(key, { entry: destinations }); }, - clear: () => { + clear: (): void => { cache.clear(); }, getCacheInstance: () => cache diff --git a/test-resources/test/test-util/test-cache.ts b/test-resources/test/test-util/test-cache.ts new file mode 100644 index 0000000000..76a24fd363 --- /dev/null +++ b/test-resources/test/test-util/test-cache.ts @@ -0,0 +1,59 @@ +import { Destination, DestinationCacheInterface, CacheEntry } from '@sap-cloud-sdk/connectivity'; + +/** + * Representation of a custom cache. + */ +export class TestCache implements DestinationCacheInterface { + /** + * Object that stores all cached entries. + */ + cache: any; + + private defaultValidityTime: number | undefined; + + constructor(validityTime?: number) { + this.cache = {}; + const currentDate = new Date(); + this.defaultValidityTime = validityTime + ? currentDate + .setMilliseconds(currentDate.getMilliseconds() + validityTime * 1000) + .valueOf() + : undefined; + } + + async clear(): Promise { + this.cache = {}; + } + + /** + * Specifies whether an entry with a given key is defined in cache. + * @param key - The entry's key. + * @returns A boolean value that indicates whether the entry exists in cache. + */ + hasKey(key: string): Promise { + return this.cache.hasOwnProperty(key); + } + + /** + * Getter of cached entries. + * @param key - The key of the entry to retrieve. + * @returns The corresponding entry to the provided key if it is still valid, returns `undefined` otherwise. + */ + async get(key: string | undefined): Promise { + return key && this.hasKey(key) && !(this.cache[key].expires ? false : this.cache[key].expires < Date.now()) + ? this.cache[key].entry + : undefined; + } + + /** + * Setter of entries in cache. + * @param key - The entry's key. + * @param item - The entry to cache. + */ + async set(key: string | undefined, item: CacheEntry): Promise { + if (key) { + const expires = item.expires ?? this.defaultValidityTime; + this.cache[key] = { entry: item.entry, expires }; + } + } +}