-
Notifications
You must be signed in to change notification settings - Fork 204
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: add didcomm message record #593
Changes from 4 commits
83f9996
7ecb376
27da1c8
608efdb
bfe1651
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,55 @@ | ||
import { ConnectionInvitationMessage } from '../../modules/connections' | ||
import { DidCommMessageRecord, DidCommMessageRole } from '../didcomm' | ||
|
||
describe('DidCommMessageRecord', () => { | ||
it('correctly computes message type tags', () => { | ||
const didCommMessage = { | ||
'@id': '7eb74118-7f91-4ba9-9960-c709b036aa86', | ||
'@type': 'https://didcomm.org/test-protocol/1.0/send-test', | ||
some: { other: 'property' }, | ||
'~thread': { | ||
thid: 'ea24e14a-4fc4-40f4-85a0-f6fcf02bfc1c', | ||
}, | ||
} | ||
|
||
const didCommeMessageRecord = new DidCommMessageRecord({ | ||
message: didCommMessage, | ||
role: DidCommMessageRole.Receiver, | ||
associatedRecordId: '16ca6665-29f6-4333-a80e-d34db6bfe0b0', | ||
}) | ||
|
||
expect(didCommeMessageRecord.getTags()).toEqual({ | ||
role: DidCommMessageRole.Receiver, | ||
associatedRecordId: '16ca6665-29f6-4333-a80e-d34db6bfe0b0', | ||
|
||
// Computed properties based on message id and type | ||
threadId: 'ea24e14a-4fc4-40f4-85a0-f6fcf02bfc1c', | ||
protocolName: 'test-protocol', | ||
messageName: 'send-test', | ||
versionMajor: '1', | ||
versionMinor: '0', | ||
messageType: 'https://didcomm.org/test-protocol/1.0/send-test', | ||
messageId: '7eb74118-7f91-4ba9-9960-c709b036aa86', | ||
}) | ||
}) | ||
|
||
it('correctly returns a message class instance', () => { | ||
const invitationJson = { | ||
'@type': 'https://didcomm.org/connections/1.0/invitation', | ||
'@id': '04a2c382-999e-4de9-a1d2-9dec0b2fa5e4', | ||
recipientKeys: ['recipientKeyOne', 'recipientKeyTwo'], | ||
serviceEndpoint: 'https://example.com', | ||
label: 'test', | ||
} | ||
|
||
const didCommeMessageRecord = new DidCommMessageRecord({ | ||
message: invitationJson, | ||
role: DidCommMessageRole.Receiver, | ||
associatedRecordId: '16ca6665-29f6-4333-a80e-d34db6bfe0b0', | ||
}) | ||
|
||
const invitation = didCommeMessageRecord.getMessageInstance(ConnectionInvitationMessage) | ||
|
||
expect(invitation).toBeInstanceOf(ConnectionInvitationMessage) | ||
}) | ||
}) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,72 @@ | ||
import { mockFunction } from '../../../tests/helpers' | ||
import { ConnectionInvitationMessage } from '../../modules/connections' | ||
import { JsonTransformer } from '../../utils/JsonTransformer' | ||
import { IndyStorageService } from '../IndyStorageService' | ||
import { DidCommMessageRecord, DidCommMessageRepository, DidCommMessageRole } from '../didcomm' | ||
|
||
jest.mock('../IndyStorageService') | ||
|
||
const StorageMock = IndyStorageService as unknown as jest.Mock<IndyStorageService<DidCommMessageRecord>> | ||
|
||
const invitationJson = { | ||
'@type': 'https://didcomm.org/connections/1.0/invitation', | ||
'@id': '04a2c382-999e-4de9-a1d2-9dec0b2fa5e4', | ||
recipientKeys: ['recipientKeyOne', 'recipientKeyTwo'], | ||
serviceEndpoint: 'https://example.com', | ||
label: 'test', | ||
} | ||
|
||
describe('Repository', () => { | ||
let repository: DidCommMessageRepository | ||
let storageMock: IndyStorageService<DidCommMessageRecord> | ||
|
||
beforeEach(async () => { | ||
storageMock = new StorageMock() | ||
repository = new DidCommMessageRepository(storageMock) | ||
}) | ||
|
||
const getRecord = ({ id }: { id?: string } = {}) => { | ||
return new DidCommMessageRecord({ | ||
id, | ||
message: invitationJson, | ||
role: DidCommMessageRole.Receiver, | ||
associatedRecordId: '16ca6665-29f6-4333-a80e-d34db6bfe0b0', | ||
}) | ||
} | ||
|
||
describe('getAgentMessage()', () => { | ||
it('should get the record using the storage service', async () => { | ||
const record = getRecord({ id: 'test-id' }) | ||
mockFunction(storageMock.findByQuery).mockReturnValue(Promise.resolve([record])) | ||
|
||
const invitation = await repository.getAgentMessage({ | ||
messageClass: ConnectionInvitationMessage, | ||
associatedRecordId: '04a2c382-999e-4de9-a1d2-9dec0b2fa5e4', | ||
}) | ||
|
||
expect(storageMock.findByQuery).toBeCalledWith(DidCommMessageRecord, { | ||
associatedRecordId: '04a2c382-999e-4de9-a1d2-9dec0b2fa5e4', | ||
messageType: 'https://didcomm.org/connections/1.0/invitation', | ||
}) | ||
expect(invitation).toBeInstanceOf(ConnectionInvitationMessage) | ||
}) | ||
}) | ||
|
||
describe('saveAgentMessage()', () => { | ||
it('should transform and save the agent message', async () => { | ||
await repository.saveAgentMessage({ | ||
role: DidCommMessageRole.Receiver, | ||
agentMessage: JsonTransformer.fromJSON(invitationJson, ConnectionInvitationMessage), | ||
associatedRecordId: '04a2c382-999e-4de9-a1d2-9dec0b2fa5e4', | ||
}) | ||
|
||
expect(storageMock.save).toBeCalledWith( | ||
expect.objectContaining({ | ||
role: DidCommMessageRole.Receiver, | ||
message: invitationJson, | ||
associatedRecordId: '04a2c382-999e-4de9-a1d2-9dec0b2fa5e4', | ||
}) | ||
) | ||
}) | ||
}) | ||
}) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,99 @@ | ||
import type { AgentMessage } from '../../agent/AgentMessage' | ||
import type { JsonMap } from '../../types' | ||
import type { DidCommMessageRole } from './DidCommMessageRole' | ||
|
||
import { AriesFrameworkError } from '../../error' | ||
import { JsonTransformer } from '../../utils/JsonTransformer' | ||
import { rightSplit } from '../../utils/string' | ||
import { isJsonMap } from '../../utils/type' | ||
import { uuid } from '../../utils/uuid' | ||
import { BaseRecord } from '../BaseRecord' | ||
|
||
export type DefaultDidCommMessageTags = { | ||
role: DidCommMessageRole | ||
associatedRecordId?: string | ||
|
||
// Computed | ||
protocolName: string | ||
messageName: string | ||
versionMajor: string | ||
versionMinor: string | ||
messageType: string | ||
messageId: string | ||
threadId: string | ||
} | ||
|
||
export interface DidCommMessageRecordProps { | ||
role: DidCommMessageRole | ||
message: JsonMap | ||
id?: string | ||
createdAt?: Date | ||
associatedRecordId?: string | ||
} | ||
|
||
export class DidCommMessageRecord extends BaseRecord<DefaultDidCommMessageTags> { | ||
public message!: JsonMap | ||
public role!: DidCommMessageRole | ||
|
||
/** | ||
* The id of the record that is associated with this message record. | ||
* | ||
* E.g. if the connection record wants to store an invitation message | ||
* the associatedRecordId will be the id of the connection record. | ||
*/ | ||
public associatedRecordId?: string | ||
|
||
public static readonly type = 'DidCommMessageRecord' | ||
public readonly type = DidCommMessageRecord.type | ||
|
||
public constructor(props: DidCommMessageRecordProps) { | ||
super() | ||
|
||
if (props) { | ||
this.id = props.id ?? uuid() | ||
this.createdAt = props.createdAt ?? new Date() | ||
this.associatedRecordId = props.associatedRecordId | ||
this.role = props.role | ||
this.message = props.message | ||
} | ||
} | ||
|
||
public getTags() { | ||
const messageId = this.message['@id'] as string | ||
const messageType = this.message['@type'] as string | ||
const [, protocolName, protocolVersion, messageName] = rightSplit(messageType, '/', 3) | ||
const [versionMajor, versionMinor] = protocolVersion.split('.') | ||
|
||
const thread = this.message['~thread'] | ||
let threadId = messageId | ||
|
||
if (isJsonMap(thread) && typeof thread.thid === 'string') { | ||
threadId = thread.thid | ||
} | ||
|
||
return { | ||
...this._tags, | ||
role: this.role, | ||
associatedRecordId: this.associatedRecordId, | ||
|
||
// Computed properties based on message id and type | ||
threadId, | ||
protocolName, | ||
messageName, | ||
versionMajor, | ||
versionMinor, | ||
messageType, | ||
messageId, | ||
} | ||
} | ||
|
||
public getMessageInstance<MessageClass extends typeof AgentMessage = typeof AgentMessage>( | ||
messageClass: MessageClass | ||
): InstanceType<MessageClass> { | ||
if (messageClass.type !== this.message['@type']) { | ||
throw new AriesFrameworkError('Provided message class type does not match type of stored message') | ||
} | ||
|
||
return JsonTransformer.fromJSON(this.message, messageClass) as InstanceType<MessageClass> | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,51 @@ | ||
import type { AgentMessage } from '../../agent/AgentMessage' | ||
import type { JsonMap } from '../../types' | ||
import type { DidCommMessageRole } from './DidCommMessageRole' | ||
|
||
import { inject, scoped, Lifecycle } from 'tsyringe' | ||
|
||
import { InjectionSymbols } from '../../constants' | ||
import { Repository } from '../Repository' | ||
import { StorageService } from '../StorageService' | ||
|
||
import { DidCommMessageRecord } from './DidCommMessageRecord' | ||
|
||
@scoped(Lifecycle.ContainerScoped) | ||
export class DidCommMessageRepository extends Repository<DidCommMessageRecord> { | ||
public constructor(@inject(InjectionSymbols.StorageService) storageService: StorageService<DidCommMessageRecord>) { | ||
super(DidCommMessageRecord, storageService) | ||
} | ||
|
||
public async saveAgentMessage({ role, agentMessage, associatedRecordId }: SaveAgentMessageOptions) { | ||
const didCommMessageRecord = new DidCommMessageRecord({ | ||
message: agentMessage.toJSON() as JsonMap, | ||
role, | ||
associatedRecordId, | ||
}) | ||
|
||
await this.save(didCommMessageRecord) | ||
} | ||
|
||
public async getAgentMessage<MessageClass extends typeof AgentMessage = typeof AgentMessage>({ | ||
associatedRecordId, | ||
messageClass, | ||
}: GetAgentMessageOptions<MessageClass>): Promise<InstanceType<MessageClass>> { | ||
const record = await this.getSingleByQuery({ | ||
associatedRecordId, | ||
messageType: messageClass.type, | ||
}) | ||
|
||
return record.getMessageInstance(messageClass) | ||
} | ||
} | ||
|
||
export interface SaveAgentMessageOptions { | ||
role: DidCommMessageRole | ||
agentMessage: AgentMessage | ||
associatedRecordId: string | ||
} | ||
|
||
export interface GetAgentMessageOptions<MessageClass extends typeof AgentMessage> { | ||
associatedRecordId: string | ||
messageClass: MessageClass | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
export enum DidCommMessageRole { | ||
Sender = 'sender', | ||
Receiver = 'receiver', | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
export * from './DidCommMessageRecord' | ||
export * from './DidCommMessageRepository' | ||
export * from './DidCommMessageRole' |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export * from './didcomm' |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -78,3 +78,9 @@ export interface OutboundPackage { | |
endpoint?: string | ||
connectionId?: string | ||
} | ||
|
||
export type AnyJson = boolean | number | string | null | JsonArray | JsonMap | ||
export interface JsonMap { | ||
[key: string]: AnyJson | ||
} | ||
export type JsonArray = Array<AnyJson> | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is good. I was confused a bit with Could we use the following naming: type JsonValue = string | number | boolean | JsonObject | JsonArray;
interface JsonObject {
[property: string]: JsonValue;
}
interface JsonArray extends Array<JsonValue> { } It's actually according to the standard https://www.json.org/json-en.html where you also have object, array and value There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes! This was just a copy paste from somewhere :) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,11 @@ | ||
import { rightSplit } from '../string' | ||
|
||
describe('string', () => { | ||
describe('rightSplit', () => { | ||
it('correctly splits a string starting from the right', () => { | ||
const messageType = 'https://didcomm.org/connections/1.0/invitation' | ||
|
||
expect(rightSplit(messageType, '/', 3)).toEqual(['https://didcomm.org', 'connections', '1.0', 'invitation']) | ||
}) | ||
}) | ||
}) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
export function rightSplit(string: string, sep: string, limit: number) { | ||
const split = string.split(sep) | ||
return limit ? [split.slice(0, -limit).join(sep)].concat(split.slice(-limit)) : split | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,5 +1,11 @@ | ||
import type { JsonMap } from '../types' | ||
|
||
export type Optional<T, K extends keyof T> = Pick<Partial<T>, K> & Omit<T, K> | ||
|
||
export const isString = (value: unknown): value is string => typeof value === 'string' | ||
export const isNumber = (value: unknown): value is number => typeof value === 'number' | ||
export const isBoolean = (value: unknown): value is boolean => typeof value === 'boolean' | ||
|
||
export const isJsonMap = (value: unknown): value is JsonMap => { | ||
return value !== undefined && typeof value === 'object' && value !== null && !Array.isArray(value) | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm thinking if we shouldn't use message ID instead of creating an artificial one. But I guess it's actually a record ID and not a message ID in this case.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Problem with message id is that it can be provided by other agents, meaning it could be non-unique.... That's why I went with a new, generated, id.