From 1623cabd22dbdb79f285549d0a281875135bfa5c Mon Sep 17 00:00:00 2001 From: Timo Glastra Date: Mon, 29 Jan 2024 17:57:37 +0700 Subject: [PATCH] feat: did caching Signed-off-by: Timo Glastra --- .../anoncreds-rs/tests/LocalDidResolver.ts | 1 + packages/cheqd/src/dids/CheqdDidResolver.ts | 1 + .../__tests__/InMemoryDidRegistry.ts | 2 + .../src/modules/dids/domain/DidResolver.ts | 2 + .../dids/methods/jwk/JwkDidResolver.ts | 5 ++ .../dids/methods/key/KeyDidResolver.ts | 5 ++ .../dids/methods/peer/PeerDidResolver.ts | 5 ++ .../dids/methods/web/WebDidResolver.ts | 2 + .../dids/services/DidResolverService.ts | 65 ++++++++++++++++- .../__tests__/DidResolverService.test.ts | 71 ++++++++++++++++++- packages/core/src/modules/dids/types.ts | 25 ++++++- .../src/dids/IndySdkIndyDidResolver.ts | 2 + .../src/dids/IndySdkSovDidResolver.ts | 2 + .../src/dids/IndyVdrIndyDidResolver.ts | 2 + .../src/dids/IndyVdrSovDidResolver.ts | 2 + packages/tenants/tests/tenants.e2e.test.ts | 8 ++- 16 files changed, 195 insertions(+), 5 deletions(-) diff --git a/packages/anoncreds-rs/tests/LocalDidResolver.ts b/packages/anoncreds-rs/tests/LocalDidResolver.ts index 1f50c1b3aa..c10434f205 100644 --- a/packages/anoncreds-rs/tests/LocalDidResolver.ts +++ b/packages/anoncreds-rs/tests/LocalDidResolver.ts @@ -4,6 +4,7 @@ import { DidsApi } from '@aries-framework/core' export class LocalDidResolver implements DidResolver { public readonly supportedMethods = ['sov', 'indy'] + public readonly allowsCaching = false public async resolve(agentContext: AgentContext, did: string): Promise { const didDocumentMetadata = {} diff --git a/packages/cheqd/src/dids/CheqdDidResolver.ts b/packages/cheqd/src/dids/CheqdDidResolver.ts index edd94c2aa1..0b87fe9061 100644 --- a/packages/cheqd/src/dids/CheqdDidResolver.ts +++ b/packages/cheqd/src/dids/CheqdDidResolver.ts @@ -19,6 +19,7 @@ import { filterResourcesByNameAndType, getClosestResourceVersion, renderResource export class CheqdDidResolver implements DidResolver { public readonly supportedMethods = ['cheqd'] + public readonly allowsCaching = true public async resolve(agentContext: AgentContext, did: string, parsed: ParsedDid): Promise { const didDocumentMetadata = {} diff --git a/packages/core/src/modules/connections/__tests__/InMemoryDidRegistry.ts b/packages/core/src/modules/connections/__tests__/InMemoryDidRegistry.ts index dc9e9e8107..ba17b1bb28 100644 --- a/packages/core/src/modules/connections/__tests__/InMemoryDidRegistry.ts +++ b/packages/core/src/modules/connections/__tests__/InMemoryDidRegistry.ts @@ -15,6 +15,8 @@ import { DidRecord, DidDocumentRole, DidRepository } from '../../dids' export class InMemoryDidRegistry implements DidRegistrar, DidResolver { public readonly supportedMethods = ['inmemory'] + public readonly allowsCaching = false + private dids: Record = {} public async create(agentContext: AgentContext, options: DidCreateOptions): Promise { diff --git a/packages/core/src/modules/dids/domain/DidResolver.ts b/packages/core/src/modules/dids/domain/DidResolver.ts index 050ea2cd97..6154030023 100644 --- a/packages/core/src/modules/dids/domain/DidResolver.ts +++ b/packages/core/src/modules/dids/domain/DidResolver.ts @@ -3,6 +3,8 @@ import type { ParsedDid, DidResolutionResult, DidResolutionOptions } from '../ty export interface DidResolver { readonly supportedMethods: string[] + readonly allowsCaching: boolean + resolve( agentContext: AgentContext, did: string, diff --git a/packages/core/src/modules/dids/methods/jwk/JwkDidResolver.ts b/packages/core/src/modules/dids/methods/jwk/JwkDidResolver.ts index 8207814895..5ac705c860 100644 --- a/packages/core/src/modules/dids/methods/jwk/JwkDidResolver.ts +++ b/packages/core/src/modules/dids/methods/jwk/JwkDidResolver.ts @@ -7,6 +7,11 @@ import { DidJwk } from './DidJwk' export class JwkDidResolver implements DidResolver { public readonly supportedMethods = ['jwk'] + /** + * No remote resolving done, did document is dynamically constructed. To not pollute the cache we don't allow caching + */ + public readonly allowsCaching = false + public async resolve(agentContext: AgentContext, did: string): Promise { const didDocumentMetadata = {} diff --git a/packages/core/src/modules/dids/methods/key/KeyDidResolver.ts b/packages/core/src/modules/dids/methods/key/KeyDidResolver.ts index 41f4a0e221..7719c29d1c 100644 --- a/packages/core/src/modules/dids/methods/key/KeyDidResolver.ts +++ b/packages/core/src/modules/dids/methods/key/KeyDidResolver.ts @@ -7,6 +7,11 @@ import { DidKey } from './DidKey' export class KeyDidResolver implements DidResolver { public readonly supportedMethods = ['key'] + /** + * No remote resolving done, did document is dynamically constructed. To not pollute the cache we don't allow caching + */ + public readonly allowsCaching = false + public async resolve(agentContext: AgentContext, did: string): Promise { const didDocumentMetadata = {} diff --git a/packages/core/src/modules/dids/methods/peer/PeerDidResolver.ts b/packages/core/src/modules/dids/methods/peer/PeerDidResolver.ts index 37b0968820..4e15bee8fd 100644 --- a/packages/core/src/modules/dids/methods/peer/PeerDidResolver.ts +++ b/packages/core/src/modules/dids/methods/peer/PeerDidResolver.ts @@ -14,6 +14,11 @@ import { didToNumAlgo4DidDocument, isShortFormDidPeer4 } from './peerDidNumAlgo4 export class PeerDidResolver implements DidResolver { public readonly supportedMethods = ['peer'] + /** + * No remote resolving done, did document is fetched from storage. To not pollute the cache we don't allow caching + */ + public readonly allowsCaching = false + public async resolve(agentContext: AgentContext, did: string): Promise { const didRepository = agentContext.dependencyManager.resolve(DidRepository) diff --git a/packages/core/src/modules/dids/methods/web/WebDidResolver.ts b/packages/core/src/modules/dids/methods/web/WebDidResolver.ts index 77d9b1e295..f56dbebbfd 100644 --- a/packages/core/src/modules/dids/methods/web/WebDidResolver.ts +++ b/packages/core/src/modules/dids/methods/web/WebDidResolver.ts @@ -11,6 +11,8 @@ import { DidDocument } from '../../domain' export class WebDidResolver implements DidResolver { public readonly supportedMethods + public readonly allowsCaching = true + // FIXME: Would be nice if we don't have to provide a did resolver instance private _resolverInstance = new Resolver() private resolver = didWeb.getResolver() diff --git a/packages/core/src/modules/dids/services/DidResolverService.ts b/packages/core/src/modules/dids/services/DidResolverService.ts index 7f97d3f9d1..a24c8908ba 100644 --- a/packages/core/src/modules/dids/services/DidResolverService.ts +++ b/packages/core/src/modules/dids/services/DidResolverService.ts @@ -6,7 +6,10 @@ import { InjectionSymbols } from '../../../constants' import { AriesFrameworkError } from '../../../error' import { Logger } from '../../../logger' import { injectable, inject } from '../../../plugins' +import { JsonTransformer } from '../../../utils' +import { CacheModuleConfig } from '../../cache' import { DidsModuleConfig } from '../DidsModuleConfig' +import { DidDocument } from '../domain' import { parseDid } from '../domain/parse' @injectable() @@ -53,9 +56,69 @@ export class DidResolverService { } } - return resolver.resolve(agentContext, parsed.did, parsed, options) + // extract caching options and set defaults + const { useCache = true, cacheDurationInSeconds = 300, persistInCache = true } = options + const cacheKey = `did:resolver:${parsed.did}` + + if (resolver.allowsCaching && useCache) { + const cache = agentContext.dependencyManager.resolve(CacheModuleConfig).cache + // FIXME: in multi-tenancy it can be that the same cache is used for different agent contexts + // This may become a problem when resolving dids, as you can get back a cache hit for a different + // tenant. did:peer has disabled caching, and I think we should just recommend disabling caching + // for these private dids + // We could allow providing a custom cache prefix in the resolver options, so that the cache key + // can be recognized in the cache implementation + const cachedDidDocument = await cache.get }>( + agentContext, + cacheKey + ) + + if (cachedDidDocument) { + return { + ...cachedDidDocument, + didDocument: JsonTransformer.fromJSON(cachedDidDocument.didDocument, DidDocument), + didResolutionMetadata: { + ...cachedDidDocument.didResolutionMetadata, + servedFromCache: true, + }, + } + } + } + + let resolutionResult = await resolver.resolve(agentContext, parsed.did, parsed, options) + // Avoid overwriting existing document + resolutionResult = { + ...resolutionResult, + didResolutionMetadata: { + ...resolutionResult.didResolutionMetadata, + resolutionTime: Date.now(), + // Did resolver implementation might use did method specific caching strategy + // We only set to false if not defined by the resolver + servedFromCache: resolutionResult.didResolutionMetadata.servedFromCache ?? false, + }, + } + + if (resolutionResult.didDocument && resolver.allowsCaching && persistInCache) { + const cache = agentContext.dependencyManager.resolve(CacheModuleConfig).cache + await cache.set( + agentContext, + cacheKey, + { + ...resolutionResult, + didDocument: resolutionResult.didDocument.toJSON(), + }, + // Set cache duration + cacheDurationInSeconds + ) + } + + return resolutionResult } + /** + * Resolve a did document. This uses the default resolution options, and thus + * will use caching if available. + */ public async resolveDidDocument(agentContext: AgentContext, did: string) { const { didDocument, diff --git a/packages/core/src/modules/dids/services/__tests__/DidResolverService.test.ts b/packages/core/src/modules/dids/services/__tests__/DidResolverService.test.ts index 00b17ad458..76573d0ed3 100644 --- a/packages/core/src/modules/dids/services/__tests__/DidResolverService.test.ts +++ b/packages/core/src/modules/dids/services/__tests__/DidResolverService.test.ts @@ -2,6 +2,7 @@ import type { DidResolver } from '../../domain' import { getAgentConfig, getAgentContext, mockFunction } from '../../../../../tests/helpers' import { JsonTransformer } from '../../../../utils/JsonTransformer' +import { CacheModuleConfig, InMemoryLruCache } from '../../../cache' import { DidsModuleConfig } from '../../DidsModuleConfig' import didKeyEd25519Fixture from '../../__tests__/__fixtures__/didKeyEd25519.json' import { DidDocument } from '../../domain' @@ -9,12 +10,16 @@ import { parseDid } from '../../domain/parse' import { DidResolverService } from '../DidResolverService' const didResolverMock = { + allowsCaching: true, supportedMethods: ['key'], resolve: jest.fn(), } as DidResolver +const cache = new InMemoryLruCache({ limit: 10 }) const agentConfig = getAgentConfig('DidResolverService') -const agentContext = getAgentContext() +const agentContext = getAgentContext({ + registerInstances: [[CacheModuleConfig, new CacheModuleConfig({ cache })]], +}) describe('DidResolverService', () => { const didResolverService = new DidResolverService( @@ -22,6 +27,10 @@ describe('DidResolverService', () => { new DidsModuleConfig({ resolvers: [didResolverMock] }) ) + afterEach(() => { + jest.clearAllMocks() + }) + it('should correctly find and call the correct resolver for a specified did', async () => { const returnValue = { didDocument: JsonTransformer.fromJSON(didKeyEd25519Fixture, DidDocument), @@ -33,7 +42,14 @@ describe('DidResolverService', () => { mockFunction(didResolverMock.resolve).mockResolvedValue(returnValue) const result = await didResolverService.resolve(agentContext, 'did:key:xxxx', { someKey: 'string' }) - expect(result).toEqual(returnValue) + expect(result).toEqual({ + ...returnValue, + didResolutionMetadata: { + ...returnValue.didResolutionMetadata, + resolutionTime: expect.any(Number), + servedFromCache: false, + }, + }) expect(didResolverMock.resolve).toHaveBeenCalledTimes(1) expect(didResolverMock.resolve).toHaveBeenCalledWith(agentContext, 'did:key:xxxx', parseDid('did:key:xxxx'), { @@ -41,6 +57,57 @@ describe('DidResolverService', () => { }) }) + it('should return cached did document when resolved multiple times within caching duration', async () => { + const returnValue = { + didDocument: JsonTransformer.fromJSON(didKeyEd25519Fixture, DidDocument), + didDocumentMetadata: {}, + didResolutionMetadata: { + contentType: 'application/did+ld+json', + }, + } + mockFunction(didResolverMock.resolve).mockResolvedValue(returnValue) + + const result = await didResolverService.resolve(agentContext, 'did:key:cached', { someKey: 'string' }) + const cachedValue = await cache.get(agentContext, 'did:resolver:did:key:cached') + + expect(result).toEqual({ + ...returnValue, + didResolutionMetadata: { + ...returnValue.didResolutionMetadata, + resolutionTime: expect.any(Number), + servedFromCache: false, + }, + }) + + expect(cachedValue).toEqual({ + ...returnValue, + didDocument: returnValue.didDocument.toJSON(), + didResolutionMetadata: { + ...returnValue.didResolutionMetadata, + resolutionTime: expect.any(Number), + servedFromCache: false, + }, + }) + + expect(didResolverMock.resolve).toHaveBeenCalledTimes(1) + expect(didResolverMock.resolve).toHaveBeenCalledWith(agentContext, 'did:key:cached', parseDid('did:key:cached'), { + someKey: 'string', + }) + + const resultCached = await didResolverService.resolve(agentContext, 'did:key:cached', { someKey: 'string' }) + expect(resultCached).toEqual({ + ...returnValue, + didResolutionMetadata: { + ...returnValue.didResolutionMetadata, + resolutionTime: expect.any(Number), + servedFromCache: true, + }, + }) + + // Still called once because served from cache + expect(didResolverMock.resolve).toHaveBeenCalledTimes(1) + }) + it("should return an error with 'invalidDid' if the did string couldn't be parsed", async () => { const did = 'did:__Asd:asdfa' diff --git a/packages/core/src/modules/dids/types.ts b/packages/core/src/modules/dids/types.ts index 359616a818..ccbc53fa70 100644 --- a/packages/core/src/modules/dids/types.ts +++ b/packages/core/src/modules/dids/types.ts @@ -2,11 +2,34 @@ import type { DidDocument } from './domain' import type { DIDDocumentMetadata, DIDResolutionMetadata, DIDResolutionOptions, ParsedDID } from 'did-resolver' export type ParsedDid = ParsedDID -export type DidResolutionOptions = DIDResolutionOptions export type DidDocumentMetadata = DIDDocumentMetadata +export interface DidResolutionOptions extends DIDResolutionOptions { + /** + * Whether to resolve the did document from the cache. + * + * @default true + */ + useCache?: boolean + + /** + * Whether to persist the did document in the cache. + * + * @default true + */ + persistInCache?: boolean + + /** + * How many seconds to persist the resolved document + * + * @default 3600 + */ + cacheDurationInSeconds?: number +} + export interface DidResolutionMetadata extends DIDResolutionMetadata { message?: string + servedFromCache?: boolean } export interface DidResolutionResult { diff --git a/packages/indy-sdk/src/dids/IndySdkIndyDidResolver.ts b/packages/indy-sdk/src/dids/IndySdkIndyDidResolver.ts index 1c486eb3aa..c84999a8c1 100644 --- a/packages/indy-sdk/src/dids/IndySdkIndyDidResolver.ts +++ b/packages/indy-sdk/src/dids/IndySdkIndyDidResolver.ts @@ -16,6 +16,8 @@ import { addServicesFromEndpointsAttrib } from './didSovUtil' export class IndySdkIndyDidResolver implements DidResolver { public readonly supportedMethods = ['indy'] + public readonly allowsCaching = true + public async resolve(agentContext: AgentContext, did: string): Promise { const didDocumentMetadata = {} diff --git a/packages/indy-sdk/src/dids/IndySdkSovDidResolver.ts b/packages/indy-sdk/src/dids/IndySdkSovDidResolver.ts index ff6afc9571..3a881db50e 100644 --- a/packages/indy-sdk/src/dids/IndySdkSovDidResolver.ts +++ b/packages/indy-sdk/src/dids/IndySdkSovDidResolver.ts @@ -12,6 +12,8 @@ import { addServicesFromEndpointsAttrib, sovDidDocumentFromDid } from './didSovU export class IndySdkSovDidResolver implements DidResolver { public readonly supportedMethods = ['sov'] + public readonly allowsCaching = true + public async resolve(agentContext: AgentContext, did: string, parsed: ParsedDid): Promise { const didDocumentMetadata = {} diff --git a/packages/indy-vdr/src/dids/IndyVdrIndyDidResolver.ts b/packages/indy-vdr/src/dids/IndyVdrIndyDidResolver.ts index 89271f5fdd..7b2dfd768c 100644 --- a/packages/indy-vdr/src/dids/IndyVdrIndyDidResolver.ts +++ b/packages/indy-vdr/src/dids/IndyVdrIndyDidResolver.ts @@ -9,6 +9,8 @@ import { buildDidDocument } from './didIndyUtil' export class IndyVdrIndyDidResolver implements DidResolver { public readonly supportedMethods = ['indy'] + public readonly allowsCaching = true + public async resolve(agentContext: AgentContext, did: string): Promise { const didDocumentMetadata = {} try { diff --git a/packages/indy-vdr/src/dids/IndyVdrSovDidResolver.ts b/packages/indy-vdr/src/dids/IndyVdrSovDidResolver.ts index 4484586078..2563bbbd18 100644 --- a/packages/indy-vdr/src/dids/IndyVdrSovDidResolver.ts +++ b/packages/indy-vdr/src/dids/IndyVdrSovDidResolver.ts @@ -12,6 +12,8 @@ import { addServicesFromEndpointsAttrib, sovDidDocumentFromDid } from './didSovU export class IndyVdrSovDidResolver implements DidResolver { public readonly supportedMethods = ['sov'] + public readonly allowsCaching = true + public async resolve(agentContext: AgentContext, did: string, parsed: ParsedDid): Promise { const didDocumentMetadata = {} diff --git a/packages/tenants/tests/tenants.e2e.test.ts b/packages/tenants/tests/tenants.e2e.test.ts index f37e962449..ffd2518f5b 100644 --- a/packages/tenants/tests/tenants.e2e.test.ts +++ b/packages/tenants/tests/tenants.e2e.test.ts @@ -1,6 +1,6 @@ import type { InitConfig } from '@aries-framework/core' -import { ConnectionsModule, OutOfBandRecord, Agent } from '@aries-framework/core' +import { ConnectionsModule, OutOfBandRecord, Agent, CacheModule, InMemoryLruCache } from '@aries-framework/core' import { agentDependencies } from '@aries-framework/node' import { SubjectInboundTransport } from '../../../tests/transport/SubjectInboundTransport' @@ -39,6 +39,9 @@ const agent1 = new Agent({ connections: new ConnectionsModule({ autoAcceptConnections: true, }), + cache: new CacheModule({ + cache: new InMemoryLruCache({ limit: 500 }), + }), }, dependencies: agentDependencies, }) @@ -51,6 +54,9 @@ const agent2 = new Agent({ connections: new ConnectionsModule({ autoAcceptConnections: true, }), + cache: new CacheModule({ + cache: new InMemoryLruCache({ limit: 500 }), + }), }, dependencies: agentDependencies, })