@sphereon/ssi-sdk.resource-resolver
Version:
201 lines (174 loc) • 6.78 kB
text/typescript
import { IKeyValueStore, IValueData, KeyValueStore, ValueStoreType } from '@sphereon/ssi-sdk.kv-store-temp'
import { IAgentPlugin } from '@veramo/core'
import fetch, { Response, Headers } from 'cross-fetch'
import { schema } from '../index'
import { deserializeResponse, getResourceIdentifier, isCacheWithinMaxAge, serializeResponse } from '../utils/ResourceResolverUtils'
import {
ClearAllResourcesArgs,
ResolveArgs,
GetResourceArgs,
IResourceResolver,
NamespaceStrArgs,
PersistResourceArgs,
PrefixArgs,
RequiredContext,
ResourceResolverOptions,
StoreArgs,
StoreIdStrArgs,
Resource,
} from '../types/IResourceResolver'
/**
* {@inheritDoc IResourceResolver}
*/
export class ResourceResolver implements IAgentPlugin {
readonly schema = schema.IResourceResolver
readonly methods: IResourceResolver = {
resourceResolve: this.resourceResolve.bind(this),
resourceClearAllResources: this.resourceClearAllResources.bind(this),
resourceDefaultStoreId: this.resourceDefaultStoreId.bind(this),
resourceDefaultNamespace: this.resourceDefaultNamespace.bind(this),
resourceDefaultTtl: this.resourceDefaultTtl.bind(this),
}
private readonly defaultStoreId: string
private readonly defaultNamespace: string
private readonly defaultTtl: number
private readonly detectLocation: boolean
private readonly _resourceStores: Map<string, IKeyValueStore<Resource>>
constructor(options?: ResourceResolverOptions) {
const { defaultStore, defaultNamespace, resourceStores, ttl, detectLocation } = options ?? {}
this.defaultStoreId = defaultStore ?? '_default'
this.defaultNamespace = defaultNamespace ?? 'resources'
this.defaultTtl = ttl ?? 3600
this.detectLocation = detectLocation ?? false
if (resourceStores && resourceStores instanceof Map) {
this._resourceStores = resourceStores
} else if (resourceStores) {
this._resourceStores = new Map().set(this.defaultStoreId, resourceStores)
} else {
this._resourceStores = new Map().set(
this.defaultStoreId,
new KeyValueStore({
namespace: this.defaultNamespace,
store: new Map<string, Resource>(),
ttl: this.defaultTtl,
}),
)
}
}
/** {@inheritDoc IResourceResolver.resourceResolve} */
private async resourceResolve(args: ResolveArgs, context: RequiredContext): Promise<Response> {
const { input, init, resourceType, resolveOpts, partyCorrelationId, storeId, namespace } = args
const resourceIdentifier = getResourceIdentifier(input)
const cachedResource = await this.getResource({ resourceIdentifier, storeId, namespace })
if (cachedResource.value && isCacheWithinMaxAge(cachedResource.value, resolveOpts)) {
return deserializeResponse(cachedResource.value.response)
}
if (resolveOpts?.onlyCache) {
return new Response(JSON.stringify({ error: 'Resource not found' }), {
status: 404,
statusText: 'Not Found',
headers: new Headers({ 'Content-Type': 'application/json' }),
})
}
let location
if (this.detectLocation) {
location = await this.retrieveLocation(input, context)
}
const response = await fetch(input, init)
if (!resolveOpts?.skipPersistence && response.status >= 200 && response.status < 300) {
const serializedResponse = await serializeResponse(response)
const resource: Resource = {
location,
response: serializedResponse,
resourceType,
insertedAt: Date.now(),
partyCorrelationId,
}
const cachedResource = await this.persistResource({
resource,
resourceIdentifier,
namespace,
storeId,
})
if (!cachedResource.value) {
return Promise.reject(Error('Resource not present in persistence result'))
}
return deserializeResponse(cachedResource.value.response)
}
return response
}
private async retrieveLocation(input: RequestInfo | URL, context: RequiredContext) {
let url: URL
if (input instanceof Request && input.url !== undefined && input.url !== null) {
url = new URL(input.url)
} else if (input instanceof URL) {
url = input
} else {
throw Error(`input type is required to be RequestInfo | URL`)
}
return await context.agent.anomalyDetectionLookupLocation({
ipOrHostname: url.hostname,
})
}
/** {@inheritDoc IResourceResolver.resourceClearAllResources} */
private async resourceClearAllResources(args: ClearAllResourcesArgs, context: RequiredContext): Promise<boolean> {
const { storeId } = args
return await this.store({ stores: this._resourceStores, storeId })
.clear()
.then(() => true)
}
/** {@inheritDoc IResourceResolver.resourceDefaultStoreId} */
private async resourceDefaultStoreId(context: RequiredContext): Promise<string> {
return this.defaultStoreId
}
/** {@inheritDoc IResourceResolver.resourceDefaultNamespace} */
private async resourceDefaultNamespace(context: RequiredContext): Promise<string> {
return this.defaultNamespace
}
/** {@inheritDoc IResourceResolver.resourceDefaultTtl} */
private async resourceDefaultTtl(context: RequiredContext): Promise<number> {
return this.defaultTtl
}
private async getResource(args: GetResourceArgs): Promise<IValueData<Resource>> {
const { resourceIdentifier, storeId, namespace } = args
return this.store({ stores: this._resourceStores, storeId }).getAsValueData(
this.prefix({
namespace,
resourceIdentifier,
}),
)
}
private async persistResource(args: PersistResourceArgs): Promise<IValueData<Resource>> {
const { resource, resourceIdentifier, ttl } = args
const namespace = this.namespaceStr(args)
const storeId = this.storeIdStr(args)
return await this.store({ stores: this._resourceStores, storeId }).set(
this.prefix({
namespace,
resourceIdentifier,
}),
resource,
ttl ?? this.defaultTtl,
)
}
private store<T extends ValueStoreType>(args: StoreArgs<T>): IKeyValueStore<T> {
const storeId = this.storeIdStr({ storeId: args.storeId })
const store = args.stores.get(storeId)
if (!store) {
throw Error(`Could not get resource store: ${storeId}`)
}
return store
}
private storeIdStr(args: StoreIdStrArgs): string {
const { storeId } = args
return storeId ?? this.defaultStoreId
}
private namespaceStr(args: NamespaceStrArgs): string {
const { namespace } = args
return namespace ?? this.defaultNamespace
}
private prefix(args: PrefixArgs): string {
const { namespace, resourceIdentifier } = args
return `${this.namespaceStr({ namespace })}:${resourceIdentifier}`
}
}