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

Rewrite to support content blocks & improved parser #22

Merged
merged 22 commits into from
Jan 13, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
6617400
Move old extensible events structure to v1-old holding dir
turt2live Dec 9, 2022
bf1b8c0
Add initial content blocks system
turt2live Dec 9, 2022
952fe79
Use an automated solution for index files
turt2live Dec 9, 2022
c929cbe
Reinstate NamespacedValue
turt2live Dec 9, 2022
02f99ba
Define an m.notice content block type
turt2live Dec 9, 2022
ee48139
Add an m.emote block
turt2live Dec 10, 2022
737ff22
Add a RoomEvent base type
turt2live Dec 13, 2022
cefa00e
Consolidate repetitive block testing
turt2live Dec 13, 2022
126b824
Optimize test imports
turt2live Dec 13, 2022
13bf609
Flag the AjvContainer class just in case
turt2live Dec 13, 2022
9a81188
Flip naming of wire modules
turt2live Dec 13, 2022
db812d9
Format jest config because why not
turt2live Dec 13, 2022
da95c26
Remove v1-old as it gets in the way of refactoring
turt2live Dec 13, 2022
643b7d5
Add support for message events
turt2live Dec 13, 2022
c7e1467
Use lazily loaded values for performance
turt2live Dec 13, 2022
e3df70f
Add notice and emote events
turt2live Dec 13, 2022
11b8a8b
Test to ensure larger/more verbose content bodies work
turt2live Dec 14, 2022
e98e244
Add an event parser
turt2live Dec 16, 2022
d63b976
Expose wire types in index by not using d.ts files
turt2live Dec 16, 2022
aa1c45b
Test for events being state events when they shouldn't be (and inverse)
turt2live Dec 16, 2022
b0ce205
Update the README to match new layout; expose AjvContainer
turt2live Dec 16, 2022
f5502a6
De-flag the AjvContainer in docs
turt2live Dec 16, 2022
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
5 changes: 5 additions & 0 deletions .ctiignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"src/events/EventParser.ts": ["addInternalKnownEventParser", "addInternalUnknownEventParser", "InternalOrderCategorization"],
"**/*.d.ts": "*",
"test/**": "*"
}
2 changes: 1 addition & 1 deletion .husky/pre-commit
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"

yarn format
yarn idx && yarn format
170 changes: 142 additions & 28 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,56 +14,170 @@ architecture is up to the task of handling proper extensible events.
## Usage: Parsing events

```typescript
const parsed = ExtensibleEvents.parse({
type: "m.room.message",
const parser = new EventParser();
const parsed = parser.parse({
type: "org.matrix.msc1767.message",
content: {
"msgtype": "m.text",
"body": "Hello world!"
"org.matrix.msc1767.markup": [
{ "body": "this is my message text" },
],
},
// and other fields
}) as MessageEvent;
// and other required fields
});

// Using instanceof can be unsafe in some cases, but casting the
// response in TypeScript (as above) should be safe given this
// if statement will block non-message types anyhow.
if (parsed?.isEquivalentTo(M_MESSAGE)) {
if (parsed instanceof MessageEvent) {
console.log(parsed.text);
}
```

*Note*: `instanceof` isn't considered safe for projects which might be running multiple copies
of the library, such as in clients which have layers needing access to the events-sdk individually.
It is recommended to cache your `EventParser` instance for performance reasons, and for ease of use
when adding custom events.

If you would like to register your own handling of events, use the following:
Registering your own events is easy, and we recommend creating your own block objects for handling the
contents of events:

```typescript
type MyContent = M_MESSAGE_EVENT_CONTENT & {
field: string;
};
// There are a number of built-in block types for simple primitives
// BooleanBlock, IntegerBlock, StringBlock

class MyEvent extends MessageEvent {
public readonly field: string;
// For object-based blocks, the following can be used:
type MyObjectBlockWireType = {
my_property: string; // or whatever your block's properties are on the wire
};

constructor(wireFormat: IPartialEvent<MyContent>) {
// Parse the text bit of the event
super(wireFormat);
class MyObjectBlock extends ObjectBlock<MyObjectBlockWireType> {
public static readonly schema: Schema = {
// This is a JSON Schema
type: "object",
properties: {
my_property: {
type: "string",
nullable: false,
},
},
required: ["my_property"],
errorMessage: {
properties: {
my_property: "my_property should be a non-null string and is required",
},
},
};

public static readonly validateFn = AjvContainer.ajv.compile(MyObjectBlock.schema);

public static readonly type = new UnstableValue(null, "org.example.my_custom_block");

public constructor(raw: MyObjectBlockWireType) {
super(MyObjectBlock.type.name, raw);
if (!MyObjectBlock.validateFn(raw)) {
throw new InvalidBlockError(this.name, MyObjectBlock.validateFn.errors);
}
}
}

this.field = wireFormat.content?.field;
// For array-based blocks, we define the contents (items) slightly differently:
type MyArrayItemWireType = {
my_property: string; // or whatever
}; // your item type can also be a primitive, like integers, booleans, and strings.

class MyArrayBlock extends ArrayBlock<MyArrayItemWireType> {
public static readonly schema = ArrayBlock.schema;
public static readonly validateFn = ArrayBlock.validateFn;

public static readonly itemSchema: Schema = {
// This is a JSON Schema
type: "object",
properties: {
my_property: {
type: "string",
nullable: false,
},
},
required: ["my_property"],
errorMessage: {
properties: {
my_property: "my_property should be a non-null string and is required",
},
},
};
public static readonly itemValidateFn = AjvContainer.ajv.compile(MyArrayBlock.itemSchema);

public static readonly type = new UnstableValue(null, "org.example.my_custom_block");

public constructor(raw: MyArrayItemWireType[]) {
super(MyArrayBlock.type.name, raw);
this.raw = raw.filter(x => {
const bool = MyArrayBlock.itemValidateFn(x);
if (!bool) {
// Do something with the error. It might be valid to throw, as we do here, or
// use `.filter()`'s ability to exclude items from the final array.
throw new InvalidBlockError(this.name, MyArrayBlock.itemValidateFn.errors);
}
return bool;
});
}
}
```

function parseMyEvent(wireEvent: IPartialEvent<MyContent>): Optional<MyEvent> {
// If you need to convert a legacy format, this is where you'd do it. Your
// event class should be able to be instatiated outside of this parse function.
return new MyEvent(wireEvent);
Then, we can define a custom event:

```typescript
type MyWireContent = EitherAnd<
{ [MyObjectBlock.type.name]: MyObjectBlockWireType },
{ [MyObjectBlock.type.altName]: MyObjectBlockWireType }
>;

class MyCustomEvent extends RoomEvent<MyWireContent> {
public static readonly contentSchema: Schema = AjvContainer.eitherAnd(MyObjectBlock.type, MyObjectBlock.schema);
public static readonly contentValidateFn = AjvContainer.ajv.compile(MyCustomEvent.contentSchema);

public static readonly type = new UnstableValue(null, "org.example.my_custom_event");

public constructor(raw: WireEvent.RoomEvent<MyWireContent>) {
super(MyCustomEvent.type.name, raw, false); // see docs
if (!MyCustomEvent.contentValidateFn(this.content)) {
throw new InvalidEventError(this.name, MyCustomEvent.contentValidateFn.errors);
}
}
}
```

and finally we can register it in a parser instance:

ExtensibleEvents.registerInterpreter("org.example.my_event_type", parseMyEvent);
ExtensibleEvents.unknownInterpretOrder.push("org.example.my_event_type");
```typescript
const parser = new EventParser();
parser.addKnownType(MyCustomEvent.type, x => new MyCustomEvent(x));
```

If you'd also like to register an "unknown event type" handler, that can be done like so:

```typescript
const myParser: UnknownEventParser<MyWireContent> = x => {
const possibleBlock = MyObjectBlock.type.findIn(x.content);
if (!!possibleBlock) {
const block = new MyObjectBlock(possibleBlock as MyObjectBlockWireType);
return new MyCustomEvent({
...x,
type: MyCustomEvent.type.name, // required - override the event type
content: {
[MyObjectBlock.name]: block.raw,
}, // technically optional, but good practice: clean up the event's content for handling.
});
}
return undefined; // else, we don't care about it
};
parser.setUnknownParsers([myParser, ...parser.defaultUnknownEventParsers]);
```

Putting your parser at the start of the array will ensure it gets called first. Including the default parsers
is also optional, though recommended.

## Usage: Making events

<!-- ------------------------- -->
***TODO: This needs refactoring***
<!-- ------------------------- -->

Most event objects have a `from` static function which takes common details of an event
and returns an instance of that event for later serialization.

Expand Down
22 changes: 11 additions & 11 deletions jest.config.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
collectCoverage: true,
collectCoverageFrom: ["./src/**"],
coverageThreshold: {
global: {
lines: 100,
branches: 100,
functions: 100,
statements: -1,
preset: "ts-jest",
testEnvironment: "node",
collectCoverage: true,
collectCoverageFrom: ["./src/**"],
coverageThreshold: {
global: {
lines: 100,
branches: 100,
functions: 100,
statements: -1,
},
},
},
};
8 changes: 7 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@
"prepare": "husky install",
"prepublishOnly": "yarn build",
"clean": "rimraf lib",
"build": "yarn clean && tsc -p tsconfig.build.json",
"idx": "ctix single -p tsconfig.build.json --startAt src --output src/index.ts --overwrite --noBackup --useComment && yarn format",
"build": "yarn clean && yarn idx && tsc -p tsconfig.build.json",
"start": "tsc -p tsconfig.build.json -w",
"test": "jest",
"format": "prettier --config .prettierrc \"{src,test}/**/*.ts\" --write",
Expand All @@ -27,11 +28,16 @@
"devDependencies": {
"@types/jest": "^29.2.3",
"@types/node": "^16",
"ctix": "^1.7.0",
"husky": "^8.0.2",
"jest": "^29.3.1",
"prettier": "^2.8.0",
"rimraf": "^3.0.2",
"ts-jest": "^29.0.3",
"typescript": "^4.9.3"
},
"dependencies": {
"ajv": "^8.11.2",
"ajv-errors": "^3.0.0"
}
}
97 changes: 97 additions & 0 deletions src/AjvContainer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

import Ajv, {Schema, SchemaObject} from "ajv";
import AjvErrors from "ajv-errors";
import {NamespacedValue} from "./NamespacedValue";

/**
* Container for the ajv instance, the SDK's schema validator of choice.
*/
export class AjvContainer {
public static readonly ajv = new Ajv({
allErrors: true,
});

static {
AjvErrors(AjvContainer.ajv);
}

/* istanbul ignore next */
// noinspection JSUnusedLocalSymbols
private constructor() {}

/**
* Creates a JSON Schema representation of the EitherAnd<> TypeScript type.
* @param ns The namespace to use in the EitherAnd<> type.
* @param schema The schema to use as a value type for the namespace options.
* @returns The EitherAnd<> type as a JSON Schema.
*/
public static eitherAnd<S extends string = string, U extends string = string>(
ns: NamespacedValue<S, U>,
schema: Schema,
): {anyOf: SchemaObject[]; errorMessage: string} {
// Dev note: ajv currently doesn't have a useful type for this stuff, but ideally it'd be smart enough to
// have an "anyOf" type we can return.
// Also note that we don't use oneOf: we manually construct it through a Type A, or Type B, or Type A+B list.
if (!ns.altName) {
throw new Error("Cannot create an EitherAnd<> JSON schema type without both stable and unstable values");
}
return {
errorMessage: `schema does not apply to ${ns.stable} or ${ns.unstable}`,
anyOf: [
{
type: "object",
properties: {
[ns.name]: schema,
},
required: [ns.name],
errorMessage: {
properties: {
[ns.name]: `${ns.name} is required`,
},
},
},
{
type: "object",
properties: {
[ns.altName]: schema,
},
required: [ns.altName],
errorMessage: {
properties: {
[ns.altName]: `${ns.altName} is required`,
},
},
},
{
type: "object",
properties: {
[ns.name]: schema,
[ns.altName]: schema,
},
required: [ns.name, ns.altName],
errorMessage: {
properties: {
[ns.name]: `${ns.name} is required`,
[ns.altName]: `${ns.altName} is required`,
},
},
},
],
};
}
}
Loading