Skip to content

Commit

Permalink
feat!: Discover Features V2 (#991)
Browse files Browse the repository at this point in the history
* feat: expose featureRegistry & add some e2e tests
* feat: add synchronous feature query

Signed-off-by: Ariel Gentile <[email protected]>

BREAKING CHANGE: 
- `queryFeatures` method parameters have been unified to a single `QueryFeaturesOptions` object that requires specification of Discover Features protocol to be used. 
- `isProtocolSupported` has been replaced by the more general synchronous mode of `queryFeatures`, which works when `awaitDisclosures` in options is set. Instead of returning a boolean, it returns an object with matching features
- Custom modules implementing protocols must register them in Feature Registry in order to let them be discovered by other agents (this can be done in module `register(dependencyManager, featureRegistry)` method)
  • Loading branch information
genaris authored Sep 9, 2022
1 parent 5cdcfa2 commit 273e353
Show file tree
Hide file tree
Showing 70 changed files with 2,213 additions and 247 deletions.
9 changes: 9 additions & 0 deletions packages/core/src/agent/Agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import { Dispatcher } from './Dispatcher'
import { EnvelopeService } from './EnvelopeService'
import { EventEmitter } from './EventEmitter'
import { AgentEventTypes } from './Events'
import { FeatureRegistry } from './FeatureRegistry'
import { MessageReceiver } from './MessageReceiver'
import { MessageSender } from './MessageSender'
import { TransportService } from './TransportService'
Expand Down Expand Up @@ -92,6 +93,13 @@ export class Agent extends BaseAgent {
return this.eventEmitter
}

/**
* Agent's feature registry
*/
public get features() {
return this.featureRegistry
}

public async initialize() {
await super.initialize()

Expand Down Expand Up @@ -156,6 +164,7 @@ export class Agent extends BaseAgent {
dependencyManager.registerSingleton(TransportService)
dependencyManager.registerSingleton(Dispatcher)
dependencyManager.registerSingleton(EnvelopeService)
dependencyManager.registerSingleton(FeatureRegistry)
dependencyManager.registerSingleton(JwsService)
dependencyManager.registerSingleton(CacheRepository)
dependencyManager.registerSingleton(DidCommMessageRepository)
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/agent/BaseAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { WalletApi } from '../wallet/WalletApi'
import { WalletError } from '../wallet/error'

import { EventEmitter } from './EventEmitter'
import { FeatureRegistry } from './FeatureRegistry'
import { MessageReceiver } from './MessageReceiver'
import { MessageSender } from './MessageSender'
import { TransportService } from './TransportService'
Expand All @@ -33,6 +34,7 @@ export abstract class BaseAgent {
protected logger: Logger
public readonly dependencyManager: DependencyManager
protected eventEmitter: EventEmitter
protected featureRegistry: FeatureRegistry
protected messageReceiver: MessageReceiver
protected transportService: TransportService
protected messageSender: MessageSender
Expand Down Expand Up @@ -75,6 +77,7 @@ export abstract class BaseAgent {

// Resolve instances after everything is registered
this.eventEmitter = this.dependencyManager.resolve(EventEmitter)
this.featureRegistry = this.dependencyManager.resolve(FeatureRegistry)
this.messageSender = this.dependencyManager.resolve(MessageSender)
this.messageReceiver = this.dependencyManager.resolve(MessageReceiver)
this.transportService = this.dependencyManager.resolve(TransportService)
Expand Down
56 changes: 56 additions & 0 deletions packages/core/src/agent/FeatureRegistry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import type { FeatureQuery, Feature } from './models'

import { injectable } from 'tsyringe'

@injectable()
class FeatureRegistry {
private features: Feature[] = []

/**
* Register a single or set of Features on the registry
*
* @param features set of {Feature} objects or any inherited class
*/
public register(...features: Feature[]) {
for (const feature of features) {
const index = this.features.findIndex((item) => item.type === feature.type && item.id === feature.id)

if (index > -1) {
this.features[index] = this.features[index].combine(feature)
} else {
this.features.push(feature)
}
}
}

/**
* Perform a set of queries in the registry, supporting wildcards (*) as
* expressed in Aries RFC 0557.
*
* @see https://github.com/hyperledger/aries-rfcs/blob/560ffd23361f16a01e34ccb7dcc908ec28c5ddb1/features/0557-discover-features-v2/README.md
*
* @param queries set of {FeatureQuery} objects to query features
* @returns array containing all matching features (can be empty)
*/
public query(...queries: FeatureQuery[]) {
const output = []
for (const query of queries) {
const items = this.features.filter((item) => item.type === query.featureType)
// An * will return all features of a given type (e.g. all protocols, all goal codes, all AIP configs)
if (query.match === '*') {
output.push(...items)
// An string ending with * will return a family of features of a certain type
// (e.g. all versions of a given protocol, all subsets of an AIP, etc.)
} else if (query.match.endsWith('*')) {
const match = query.match.slice(0, -1)
output.push(...items.filter((m) => m.id.startsWith(match)))
// Exact matching (single feature)
} else {
output.push(...items.filter((m) => m.id === query.match))
}
}
return output
}
}

export { FeatureRegistry }
30 changes: 30 additions & 0 deletions packages/core/src/agent/__tests__/Agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { WalletError } from '../../wallet/error'
import { Agent } from '../Agent'
import { Dispatcher } from '../Dispatcher'
import { EnvelopeService } from '../EnvelopeService'
import { FeatureRegistry } from '../FeatureRegistry'
import { MessageReceiver } from '../MessageReceiver'
import { MessageSender } from '../MessageSender'

Expand Down Expand Up @@ -194,7 +195,36 @@ describe('Agent', () => {
expect(container.resolve(MessageSender)).toBe(container.resolve(MessageSender))
expect(container.resolve(MessageReceiver)).toBe(container.resolve(MessageReceiver))
expect(container.resolve(Dispatcher)).toBe(container.resolve(Dispatcher))
expect(container.resolve(FeatureRegistry)).toBe(container.resolve(FeatureRegistry))
expect(container.resolve(EnvelopeService)).toBe(container.resolve(EnvelopeService))
})
})

it('all core features are properly registered', () => {
const agent = new Agent(config, dependencies)
const registry = agent.dependencyManager.resolve(FeatureRegistry)

const protocols = registry.query({ featureType: 'protocol', match: '*' }).map((p) => p.id)

expect(protocols).toEqual(
expect.arrayContaining([
'https://didcomm.org/basicmessage/1.0',
'https://didcomm.org/connections/1.0',
'https://didcomm.org/coordinate-mediation/1.0',
'https://didcomm.org/didexchange/1.0',
'https://didcomm.org/discover-features/1.0',
'https://didcomm.org/discover-features/2.0',
'https://didcomm.org/issue-credential/1.0',
'https://didcomm.org/issue-credential/2.0',
'https://didcomm.org/messagepickup/1.0',
'https://didcomm.org/messagepickup/2.0',
'https://didcomm.org/out-of-band/1.1',
'https://didcomm.org/present-proof/1.0',
'https://didcomm.org/revocation_notification/1.0',
'https://didcomm.org/revocation_notification/2.0',
'https://didcomm.org/questionanswer/1.0',
])
)
expect(protocols.length).toEqual(15)
})
})
58 changes: 58 additions & 0 deletions packages/core/src/agent/models/features/Feature.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { Expose } from 'class-transformer'
import { IsString } from 'class-validator'

import { AriesFrameworkError } from '../../../error'
import { JsonTransformer } from '../../../utils/JsonTransformer'

export interface FeatureOptions {
id: string
type: string
}

export class Feature {
public id!: string

public constructor(props: FeatureOptions) {
if (props) {
this.id = props.id
this.type = props.type
}
}

@IsString()
@Expose({ name: 'feature-type' })
public readonly type!: string

/**
* Combine this feature with another one, provided both are from the same type
* and have the same id
*
* @param feature object to combine with this one
* @returns a new object resulting from the combination between this and feature
*/
public combine(feature: this) {
if (feature.id !== this.id) {
throw new AriesFrameworkError('Can only combine with a feature with the same id')
}

const obj1 = JsonTransformer.toJSON(this)
const obj2 = JsonTransformer.toJSON(feature)

for (const key in obj2) {
try {
if (Array.isArray(obj2[key])) {
obj1[key] = [...new Set([...obj1[key], ...obj2[key]])]
} else {
obj1[key] = obj2[key]
}
} catch (e) {
obj1[key] = obj2[key]
}
}
return JsonTransformer.fromJSON(obj1, Feature)
}

public toJSON(): Record<string, unknown> {
return JsonTransformer.toJSON(this)
}
}
23 changes: 23 additions & 0 deletions packages/core/src/agent/models/features/FeatureQuery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { Expose } from 'class-transformer'
import { IsString } from 'class-validator'

export interface FeatureQueryOptions {
featureType: string
match: string
}

export class FeatureQuery {
public constructor(options: FeatureQueryOptions) {
if (options) {
this.featureType = options.featureType
this.match = options.match
}
}

@Expose({ name: 'feature-type' })
@IsString()
public featureType!: string

@IsString()
public match!: string
}
13 changes: 13 additions & 0 deletions packages/core/src/agent/models/features/GoalCode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type { FeatureOptions } from './Feature'

import { Feature } from './Feature'

export type GoalCodeOptions = Omit<FeatureOptions, 'type'>

export class GoalCode extends Feature {
public constructor(props: GoalCodeOptions) {
super({ ...props, type: GoalCode.type })
}

public static readonly type = 'goal-code'
}
13 changes: 13 additions & 0 deletions packages/core/src/agent/models/features/GovernanceFramework.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type { FeatureOptions } from './Feature'

import { Feature } from './Feature'

export type GovernanceFrameworkOptions = Omit<FeatureOptions, 'type'>

export class GovernanceFramework extends Feature {
public constructor(props: GovernanceFrameworkOptions) {
super({ ...props, type: GovernanceFramework.type })
}

public static readonly type = 'gov-fw'
}
25 changes: 25 additions & 0 deletions packages/core/src/agent/models/features/Protocol.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { FeatureOptions } from './Feature'

import { IsOptional, IsString } from 'class-validator'

import { Feature } from './Feature'

export interface ProtocolOptions extends Omit<FeatureOptions, 'type'> {
roles?: string[]
}

export class Protocol extends Feature {
public constructor(props: ProtocolOptions) {
super({ ...props, type: Protocol.type })

if (props) {
this.roles = props.roles
}
}

public static readonly type = 'protocol'

@IsString({ each: true })
@IsOptional()
public roles?: string[]
}
5 changes: 5 additions & 0 deletions packages/core/src/agent/models/features/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export * from './Feature'
export * from './FeatureQuery'
export * from './GoalCode'
export * from './GovernanceFramework'
export * from './Protocol'
2 changes: 2 additions & 0 deletions packages/core/src/agent/models/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './features'
export * from './InboundMessageContext'
4 changes: 3 additions & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@ export { Agent } from './agent/Agent'
export { BaseAgent } from './agent/BaseAgent'
export * from './agent'
export { EventEmitter } from './agent/EventEmitter'
export { FeatureRegistry } from './agent/FeatureRegistry'
export { Handler, HandlerInboundMessage } from './agent/Handler'
export { InboundMessageContext } from './agent/models/InboundMessageContext'
export * from './agent/models'
export { AgentConfig } from './agent/AgentConfig'
export { AgentMessage } from './agent/AgentMessage'
export { Dispatcher } from './agent/Dispatcher'
Expand Down Expand Up @@ -36,6 +37,7 @@ export * from './transport'
export * from './modules/basic-messages'
export * from './modules/common'
export * from './modules/credentials'
export * from './modules/discover-features'
export * from './modules/proofs'
export * from './modules/connections'
export * from './modules/ledger'
Expand Down
14 changes: 13 additions & 1 deletion packages/core/src/modules/basic-messages/BasicMessagesModule.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import type { FeatureRegistry } from '../../agent/FeatureRegistry'
import type { DependencyManager, Module } from '../../plugins'

import { Protocol } from '../../agent/models'

import { BasicMessageRole } from './BasicMessageRole'
import { BasicMessagesApi } from './BasicMessagesApi'
import { BasicMessageRepository } from './repository'
import { BasicMessageService } from './services'
Expand All @@ -8,7 +12,7 @@ export class BasicMessagesModule implements Module {
/**
* Registers the dependencies of the basic message module on the dependency manager.
*/
public register(dependencyManager: DependencyManager) {
public register(dependencyManager: DependencyManager, featureRegistry: FeatureRegistry) {
// Api
dependencyManager.registerContextScoped(BasicMessagesApi)

Expand All @@ -17,5 +21,13 @@ export class BasicMessagesModule implements Module {

// Repositories
dependencyManager.registerSingleton(BasicMessageRepository)

// Features
featureRegistry.register(
new Protocol({
id: 'https://didcomm.org/basicmessage/1.0',
roles: [BasicMessageRole.Sender, BasicMessageRole.Receiver],
})
)
}
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { FeatureRegistry } from '../../../agent/FeatureRegistry'
import { DependencyManager } from '../../../plugins/DependencyManager'
import { BasicMessagesApi } from '../BasicMessagesApi'
import { BasicMessagesModule } from '../BasicMessagesModule'
Expand All @@ -9,9 +10,14 @@ const DependencyManagerMock = DependencyManager as jest.Mock<DependencyManager>

const dependencyManager = new DependencyManagerMock()

jest.mock('../../../agent/FeatureRegistry')
const FeatureRegistryMock = FeatureRegistry as jest.Mock<FeatureRegistry>

const featureRegistry = new FeatureRegistryMock()

describe('BasicMessagesModule', () => {
test('registers dependencies on the dependency manager', () => {
new BasicMessagesModule().register(dependencyManager)
new BasicMessagesModule().register(dependencyManager, featureRegistry)

expect(dependencyManager.registerContextScoped).toHaveBeenCalledTimes(1)
expect(dependencyManager.registerContextScoped).toHaveBeenCalledWith(BasicMessagesApi)
Expand Down
Loading

0 comments on commit 273e353

Please sign in to comment.