diff --git a/example/src/test_utils.ts b/example/src/test_utils.ts new file mode 100644 index 000000000..40abc8129 --- /dev/null +++ b/example/src/test_utils.ts @@ -0,0 +1,42 @@ +import { EncodedContent } from "../../src/XMTP.types"; +import { CodecError } from "../../src/lib/CodecError"; +import { ContentTypeID } from "../../src/lib/ContentTypeID"; + +export class NumberCodec { + contentType: { + id(): string; + authorityID: string; + typeID: string; + versionMajor: number; + versionMinor: number; + }; + + constructor() { + this.contentType = new ContentTypeID({ + authorityID: "example.com", + typeID: "number", + versionMajor: 1, + versionMinor: 1, + }); + } + + encode(content: Uint8Array) { + const encodedContent = { + type: this.contentType, + content: Buffer.from(JSON.stringify(content)), + fallback: "fallbackText", + }; + + return encodedContent; + } + + decode(encodedContent: EncodedContent) { + try { + const contentToDecode = encodedContent.content.toString(); + const decodedContent = JSON.parse(contentToDecode); + return decodedContent; + } catch { + throw new CodecError("invalidContent"); + } + } +} diff --git a/example/src/tests.ts b/example/src/tests.ts index 24b268eb0..b5128eb4c 100644 --- a/example/src/tests.ts +++ b/example/src/tests.ts @@ -1,6 +1,11 @@ -import { Wallet } from "ethers"; -import { Client } from "xmtp-react-native-sdk"; import * as XMTP from "../../src/index"; +import { + CodecRegistry, + ContentCodecInterface, +} from "../../src/lib/CodecRegistry"; +import { CodecError } from "../../src/lib/CodecError"; + +import { NumberCodec } from "./test_utils"; function sleep(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)); @@ -63,3 +68,54 @@ test("canMessage", async () => { const canMessage = await bob.canMessage(alice.address); return canMessage; }); + +test("can register, encode, and decode a number codec", async () => { + const numberCodec = new NumberCodec(); + + const registry = new CodecRegistry(); + registry.register(numberCodec as ContentCodecInterface); + + const id = numberCodec.contentType.id(); + const codec = registry.find(id); + + const encodedContent = codec.encode(3.14); + const decodedContent = codec.decode(encodedContent); + + return decodedContent === 3.14; +}); + +test("throws an error if codec is not found in registry", async () => { + const numberCodec = new NumberCodec(); + const registry = new CodecRegistry(); + registry.register(numberCodec as ContentCodecInterface); + + try { + const id = "invalidId"; + registry.find(id); + } catch (e) { + return (e as CodecError).message === "codecNotFound"; + } + return false; +}); + +test("throws an error if codec is invalid when decoding", async () => { + const numberCodec = new NumberCodec(); + const registry = new CodecRegistry(); + registry.register(numberCodec as ContentCodecInterface); + + try { + const id = numberCodec.contentType.id(); + const codec = registry.find(id); + + const encodedContent = codec.encode(3.14); + const invalidContentToDecode = { + ...encodedContent, + content: { key1: "This cannot be parsed" }, + }; + // @ts-ignore + codec.decode(invalidContentToDecode); + } catch (e) { + return (e as CodecError).message === "invalidContent"; + } + return false; +}); diff --git a/src/XMTP.types.ts b/src/XMTP.types.ts index 9562227ea..3b1312587 100644 --- a/src/XMTP.types.ts +++ b/src/XMTP.types.ts @@ -1,3 +1,5 @@ +import { ContentTypeID } from "./lib/ContentTypeID"; + export type ChangeEventPayload = { value: string; }; @@ -5,3 +7,19 @@ export type ChangeEventPayload = { export type XMTPViewProps = { name: string; }; + +/* + * + * Represents encoded content and its metadata. + * + * @param {ContentTypeID} type - The content type ID for this content. + * @param {Uint8Array} content - The encoded content data. + * @param {string} fallback - A fallback representation of the content, if any. + */ + +export type EncodedContent = { + type: ContentTypeID; + parameters: { [key: string]: [value: string] }? , + content: Uint8Array; + fallback?: string; +}; diff --git a/src/lib/CodecError.ts b/src/lib/CodecError.ts new file mode 100644 index 000000000..c7e1cfbf7 --- /dev/null +++ b/src/lib/CodecError.ts @@ -0,0 +1,13 @@ +/* + * + * An error class for codec-related errors when searching the registry. + * + * @param {string} message - The error message. + */ + +export class CodecError extends Error { + constructor(message: "invalidContent" | "codecNotFound") { + super(message); + this.name = message; + } +} diff --git a/src/lib/CodecRegistry.ts b/src/lib/CodecRegistry.ts new file mode 100644 index 000000000..d7e95823f --- /dev/null +++ b/src/lib/CodecRegistry.ts @@ -0,0 +1,62 @@ +import { EncodedContent } from "../XMTP.types"; +import { CodecError } from "./CodecError"; +import { ContentTypeID } from "./ContentTypeID"; + +/* + * + * Codecs encode and decode content, and this is the interface for ContentCodecs within this app. + * + * @param {ContentTypeID} contentType - The content type this codec handles. + * @param {} encode(content) - Encodes the given content and returns an EncodedContent. + * @param {} decode(content) - Decodes the given EncodedContent and returns the original content. + */ + +export interface ContentCodecInterface { + contentType: ContentTypeID; + encode(content: T): EncodedContent; + decode(content: EncodedContent): T; +} + +/* + * CodecRegistry + * + * A registry for content codecs. Allows registering codecs by id + * and looking them up. + * + */ +export class CodecRegistry { + codecs: { [key: string]: ContentCodecInterface }; + + constructor() { + /* + * An object mapping content type IDs to + * codec instances. + */ + this.codecs = {}; + } + + /* + * Registers a content codec. + * + * @param {ContentCodecInterface} codec - This is the codec instance to register. + */ + register(codec: ContentCodecInterface) { + const contentType = codec.contentType.id(); + this.codecs[contentType] = codec; + } + + /* + * Finds a registered codec by content type ID. + * + * @param {string} contentTypeID - The id to look up. + * @returns {ContentCodecInterface} The found codec, or an error is thrown if not found. + */ + find(contentTypeID: string) { + const codec = this.codecs[contentTypeID]; + if (!codec) { + throw new CodecError("codecNotFound"); + } else { + return codec; + } + } +} diff --git a/src/lib/ContentTypeID.ts b/src/lib/ContentTypeID.ts new file mode 100644 index 000000000..651e6af5d --- /dev/null +++ b/src/lib/ContentTypeID.ts @@ -0,0 +1,29 @@ +/* + * + * This ContentTypeID class represents a content type identifier. + * + * @param {string} authorityID - The authority that defined the content type. + * @param {string} typeID - The unique ID for the content type within the authority, e.g. "number" + * @param {number} versionMajor - The major version number of the content type. + * @param {number} versionMinor - The minor version number of the content type. + * + * @returns {string} The full content type ID in the format: + * : + */ + +export class ContentTypeID { + authorityID: string; + typeID: string; + versionMajor: number; + versionMinor: number; + constructor({ authorityID, typeID, versionMajor, versionMinor }) { + this.authorityID = authorityID; + this.typeID = typeID; + this.versionMajor = versionMajor; + this.versionMinor = versionMinor; + } + + id() { + return `${this.authorityID}:${this.typeID}`; + } +}