Skip to content
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

Merged
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions packages/core/src/storage/__tests__/DidCommMessageRecord.test.ts
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',
})
)
})
})
})
99 changes: 99 additions & 0 deletions packages/core/src/storage/didcomm/DidCommMessageRecord.ts
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()
Copy link
Contributor

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.

Copy link
Contributor Author

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.

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>
}
}
51 changes: 51 additions & 0 deletions packages/core/src/storage/didcomm/DidCommMessageRepository.ts
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
}
4 changes: 4 additions & 0 deletions packages/core/src/storage/didcomm/DidCommMessageRole.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export enum DidCommMessageRole {
Sender = 'sender',
Receiver = 'receiver',
}
3 changes: 3 additions & 0 deletions packages/core/src/storage/didcomm/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from './DidCommMessageRecord'
export * from './DidCommMessageRepository'
export * from './DidCommMessageRole'
1 change: 1 addition & 0 deletions packages/core/src/storage/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './didcomm'
6 changes: 6 additions & 0 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Copy link
Contributor

@jakubkoci jakubkoci Jan 19, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is good. I was confused a bit with Map in JsonMap,

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

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes! This was just a copy paste from somewhere :)

11 changes: 11 additions & 0 deletions packages/core/src/utils/__tests__/string.test.ts
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'])
})
})
})
4 changes: 4 additions & 0 deletions packages/core/src/utils/string.ts
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
}
6 changes: 6 additions & 0 deletions packages/core/src/utils/type.ts
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)
}