feat(core): new #event builder
MODIFY
packages/core/package.json
packages/core/package.json
"@types/json-schema": "^7.0.3",
"@types/qs": "^6.9.0",
"@types/uuid": "^7.0.0",
+ "io-ts": "^2.2.4",
"chalk": "^3.0.0",
"file-type": "^8.0.0",
"mime": "^2.4.4",

MODIFY
packages/core/src/event/event.interface.ts
packages/core/src/event/event.interface.ts
@@ -13,12 +13,21 @@ export interface Event<P = unknown, E = any, T extends EventType = EventType> {
metadata?: EventMetadata;
}
-export interface ValidatedEvent<P = unknown, T extends EventType = EventType> {
+
+export interface EventWithoutPayload<T extends EventType = EventType> {
+ type: T;
+ metadata?: EventMetadata;
+}
+
+export interface EventWithPayload<P = unknown, T extends EventType = EventType> {
type: T;
payload: P;
metadata?: EventMetadata;
}
+export type ValidatedEvent<P = unknown, T extends EventType = EventType> =
+ EventWithPayload<P, T>
+
export type EventsUnion<A extends {
[eventCreator: string]: (...args: any[]) => any & { type?: string };
}> = ReturnType<A[keyof A]>;

ADD
packages/core/src/event/event.spec.ts
+import * as t from 'io-ts';
+import * as E from 'fp-ts/lib/Either';
+import { pipe } from 'fp-ts/lib/pipeable';
+import { identity } from 'fp-ts/lib/function';
+import { event } from './event';
+
+describe('#event', () => {
+ test('creates event schema with object payload', () => {
+ const Foo = event('FOO')(t.type({
+ bar: t.string,
+ baz: t.boolean,
+ }));
+
+ const foo = Foo.create({
+ bar: 'test',
+ baz: false,
+ });
+
+ expect(foo).toEqual({
+ type: 'FOO',
+ payload: {
+ bar: 'test',
+ baz: false,
+ },
+ })
+ });
+
+ test('creates event schema with array of objects payload', () => {
+ const Foo = event('FOO')(t.array(t.type({
+ bar: t.string,
+ baz: t.boolean,
+ })));
+
+ const foo = Foo.create([{
+ bar: 'test',
+ baz: false,
+ }]);
+
+ expect(foo).toEqual({
+ type: 'FOO',
+ payload: [{ bar: 'test', baz: false }],
+ })
+ });
+
+ test('creates event schema with tuple payload', () => {
+ const Foo = event('FOO')(t.tuple([
+ t.string,
+ t.string,
+ ]));
+
+ const foo = Foo.create([
+ 'test_1',
+ 'test_2',
+ ]);
+
+ expect(foo).toEqual({
+ type: 'FOO',
+ payload: ['test_1', 'test_2'],
+ })
+ });
+
+
+ test('creates event schema with string payload', () => {
+ const Foo = event('FOO')(t.string);
+
+ const foo = Foo.create('test');
+
+ expect(foo).toEqual({
+ type: 'FOO',
+ payload: 'test',
+ })
+ });
+
+ test('creates event schema with boolean payload', () => {
+ const Foo = event('FOO')(t.boolean);
+
+ const foo = Foo.create(true);
+
+ expect(foo).toEqual({
+ type: 'FOO',
+ payload: true,
+ })
+ });
+
+ test('creates event schema without payload', () => {
+ const Foo = event('FOO')();
+
+ const foo = Foo.create();
+
+ expect(foo).toEqual({
+ type: 'FOO',
+ })
+ });
+
+ test('creates event schema for enum type', () => {
+ enum FooType { FOO = 'FOO' }
+
+ const Foo = event(FooType.FOO)();
+
+ const foo = Foo.create();
+
+ expect(foo).toEqual({
+ type: 'FOO',
+ })
+ });
+
+ test('successfuly decodes event schema with object payload and metadata', () => {
+ const Foo = event('FOO')(t.type({
+ bar: t.string,
+ baz: t.boolean,
+ }));
+
+ const input = {
+ type: 'FOO',
+ payload: {
+ bar: 'test',
+ baz: false,
+ },
+ metadata: {
+ correlationId: 'some_uuid',
+ replyTo: 'some_channel',
+ raw: { test: true },
+ },
+ };
+
+ const output = pipe(
+ Foo.decode(input),
+ E.fold(fail, identity),
+ );
+
+ expect(output).toEqual(input);
+ });
+
+ test('successfuly decodes event schema with object payload and undefined metadata', () => {
+ const Foo = event('FOO')(t.type({
+ bar: t.string,
+ baz: t.boolean,
+ }));
+
+ const input = {
+ type: 'FOO',
+ payload: {
+ bar: 'test',
+ baz: false,
+ },
+ };
+
+ const output = pipe(
+ Foo.decode(input),
+ E.fold(fail, identity),
+ );
+
+ expect(output).toEqual(input);
+ });
+
+ test('fails to decode event schema with invalid object payload', () => {
+ const Foo = event('FOO')(t.type({
+ bar: t.string,
+ baz: t.boolean,
+ }));
+
+ const input = {
+ type: 'FOO',
+ payload: {
+ bar: 'test',
+ },
+ };
+
+ const output = pipe(
+ Foo.decode(input),
+ E.fold(identity, fail),
+ );
+
+ expect(output).toBeDefined();
+ });
+});

ADD
packages/core/src/event/event.ts
+import * as t from 'io-ts';
+import { PathReporter } from 'io-ts/lib/PathReporter';
+import * as E from 'fp-ts/lib/Either';
+import { pipe } from 'fp-ts/lib/pipeable';
+import { identity, flow } from 'fp-ts/lib/function';
+import { Event, EventWithPayload, EventWithoutPayload } from './event.interface';
+
+type Payload =
+ | t.TypeC<any>
+ | t.TupleC<any>
+ | t.ReadonlyArrayC<any>
+ | t.ArrayC<any>
+ | t.StringC
+ | t.BooleanC
+ | t.NumberC
+ | t.UndefinedC
+ ;
+
+export type EventSchema<T extends string> =
+ t.TypeC<{ type: t.LiteralC<T>; payload: t.UndefinedC }>
+
+export type EventSchemaWithPayload<T extends string, P extends Payload> =
+ t.TypeC<{ type: t.LiteralC<T>; payload: P }>
+
+type EventCreator<T extends string> =
+ () => EventWithoutPayload<T>
+
+type EventCreatorWithPayload<T extends string, P extends Payload> =
+ (payload: t.TypeOf<P>) => EventWithPayload<t.TypeOf<P>, T>
+
+type EventBuilderOutput<T extends string, P extends Payload | undefined> =
+ undefined extends P
+ ? EventSchema<T> & { create: EventCreator<T> }
+ : EventSchemaWithPayload<T, NonNullable<P>> & { create: EventCreatorWithPayload<T, NonNullable<P>> }
+
+/**
+ * Creates an Event codec for decoding and creating I/O events.
+ * @since v3.3.0
+ * @example
+ *
+ * const CreateUser = event('CREATE_USER')(t.type({
+ * name: t.string,
+ * age: t.number,
+ * }));
+ *
+ * CreateUser.create({
+ * name: 'Bob',
+ * age: 21,
+ * });
+ */
+export const event = <T extends string>(type: T) => <P extends Payload | undefined>(payload?: P): EventBuilderOutput<T, P> => {
+ const schema = t.type({
+ type: t.literal(type),
+ payload: payload ?? t.undefined,
+ }, 'EventSchema');
+
+ const create = (payload?: any): Event => {
+ const result = schema.decode({ type, payload });
+ const reportError = flow(
+ PathReporter.report,
+ report => report.join(', '),
+ report => new Error(report),
+ );
+
+ return pipe(
+ schema.decode({ type, payload }),
+ flow(E.fold(_ => { throw reportError(result) }, identity)),
+ );
+ }
+
+ return { ...schema, create } as EventBuilderOutput<T, P>;
+};

MODIFY
packages/core/src/index.spec.ts
packages/core/src/index.spec.ts
@@ -8,5 +8,6 @@ describe('@marblejs/core public API', () => {
expect(API.defaultError$).toBeDefined();
expect(API.EffectFactory).toBeDefined();
expect(API.combineRoutes).toBeDefined();
+ expect(API.event).toBeDefined();
});
});

MODIFY
packages/core/src/index.ts
packages/core/src/index.ts
@@ -29,6 +29,7 @@ export * from './operators';
export * from './logger';
// core - event
+export * from './event/event';
export * from './event/event.factory';
export * from './event/event.interface';

MODIFY
packages/core/src/operators/matchEvent/matchEvent.operator.spec.ts
packages/core/src/operators/matchEvent/matchEvent.operator.spec.ts
import { Observable } from 'rxjs';
import { mapTo, map } from 'rxjs/operators';
+import * as t from 'io-ts';
import { Marbles } from '../../+internal/testing';
+import { event } from '../../event/event';
import { Event } from '../../event/event.interface';
import { ServerEventType, ServerEvent, AllServerEvents, } from '../../http/server/http.server.event';
import { matchEvent } from './matchEvent.operator';
describe('#matchEvent operator', () => {
- test(`matches string Event`, () => {
+ test('matches string Event', () => {
// given
const event1: Event = { type: 'TEST_EVENT_1', payload: 1 };
const event2: Event = { type: 'TEST_EVENT_2', payload: 2 };
@@ -28,7 +30,7 @@ describe('#matchEvent operator', () => {
]);
});
- test(`matches object Event`, () => {
+ test('matches object Event', () => {
// given
const event1: Event = { type: 'TEST_EVENT_1', payload: 1 };
const event2: Event = { type: 'TEST_EVENT_2', payload: 2 };
@@ -50,7 +52,7 @@ describe('#matchEvent operator', () => {
]);
});
- test(`matches EventCreator`, () => {
+ test('matches EventCreator', () => {
// given
const listenEvent: Event = { type: ServerEventType.LISTENING, payload: { port: 80, host: 'localhost' } };
const closeEvent: Event = { type: ServerEventType.CLOSE, payload: {} };
@@ -70,7 +72,7 @@ describe('#matchEvent operator', () => {
]);
});
- test(`matches EventCreator collection`, () => {
+ test('matches EventCreator collection', () => {
// given
const listenEvent: Event = { type: ServerEventType.LISTENING, payload: { port: 80, host: 'localhost' } };
const closeEvent: Event = { type: ServerEventType.CLOSE, payload: {} };
@@ -89,4 +91,64 @@ describe('#matchEvent operator', () => {
['---b-c---', { b: listenEvent.payload, c: errorEvent.payload }],
]);
});
+
+ test('matches EventSchema', () => {
+ // given
+ const FooEvent = event('FOO')();
+ const BarEvent = event('BAR')();
+ const BazEvent = event('BAZ')(t.type({
+ test1: t.number,
+ test2: t.string,
+ }));
+
+ const fooEvent = FooEvent.create();
+ const barEvent = BarEvent.create();
+ const bazEvent = BazEvent.create({ test1: 100, test2: '100' });
+
+ const EventUnion = t.union([FooEvent, BarEvent, BazEvent]);
+ type EventUnion = t.TypeOf<typeof EventUnion>
+
+ // when
+ const listen$ = (event$: Observable<EventUnion>) =>
+ event$.pipe(
+ matchEvent(BazEvent),
+ map(event => event.payload),
+ );
+
+ // then
+ Marbles.assertEffect(listen$, [
+ ['-a-b-c---', { a: fooEvent, b: barEvent, c: bazEvent }],
+ ['-----c---', { c: bazEvent.payload }],
+ ]);
+ });
+
+ test('matches EventSchema collection', () => {
+ // given
+ const FooEvent = event('FOO')();
+ const BarEvent = event('BAR')();
+ const BazEvent = event('BAZ')(t.type({
+ test1: t.number,
+ test2: t.string,
+ }));
+
+ const fooEvent = FooEvent.create();
+ const barEvent = BarEvent.create();
+ const bazEvent = BazEvent.create({ test1: 100, test2: '100' });
+
+ const EventUnion = t.union([FooEvent, BarEvent, BazEvent]);
+ type EventUnion = t.TypeOf<typeof EventUnion>
+
+ // when
+ const listen$ = (event$: Observable<Event>) =>
+ event$.pipe(
+ matchEvent(FooEvent, BarEvent),
+ map(event => event.payload),
+ );
+
+ // then
+ Marbles.assertEffect(listen$, [
+ ['-a-b-c---', { a: fooEvent, b: barEvent, c: bazEvent }],
+ ['-a-b-----', { a: undefined, b: undefined }],
+ ]);
+ });
});

MODIFY
packages/core/src/operators/matchEvent/matchEvent.operator.ts
packages/core/src/operators/matchEvent/matchEvent.operator.ts
+import * as t from 'io-ts';
import { Observable } from 'rxjs';
import { filter } from 'rxjs/operators';
import { isString } from '../../+internal/utils';
-import { Event } from '../../event/event.interface';
+import { Event, EventMetadata } from '../../event/event.interface';
import { EventCreator } from '../../event/event.factory';
-type EventLike = { type: string } | string;
+type EventSchema = t.Type<any, any, any>;
+
+type EventLike =
+ | { type: string }
+ | string
+ ;
+
+type MachingEvent =
+ | EventLike
+ | EventSchema
+ | EventCreator<string, any>
+ ;
+
+// 3 arguments
+export function matchEvent<
+ E1 extends EventSchema,
+ E2 extends EventSchema,
+ E3 extends EventSchema,
+>(e1: E1, e2: E2, e3: E3): (o: Observable<Event>) => Observable<
+ | t.TypeOf<E1> & { metadata: EventMetadata }
+ | t.TypeOf<E2> & { metadata: EventMetadata }
+ | t.TypeOf<E3> & { metadata: EventMetadata }
+>;
export function matchEvent<
E1 extends EventCreator<string, any>,
@@ -21,7 +44,16 @@ export function matchEvent<
E2 extends EventLike,
E3 extends EventLike,
>(e1: E1, e2: E2, e3: E3): (o: Observable<Event>) => Observable<
- Event<unknown, any>
+ | Event<unknown, any>
+>;
+
+// 2 arguments
+export function matchEvent<
+ E1 extends EventSchema,
+ E2 extends EventSchema,
+>(e1: E1, e2: E2): (o: Observable<Event>) => Observable<
+ | t.TypeOf<E1> & { metadata: EventMetadata }
+ | t.TypeOf<E2> & { metadata: EventMetadata }
>;
export function matchEvent<
@@ -32,42 +64,46 @@ export function matchEvent<
| ReturnType<E2>
>;
-// 2 arguments
export function matchEvent<
E1 extends EventLike,
E2 extends EventLike,
>(e1: E1, e2: E2): (o: Observable<Event>) => Observable<
- Event<unknown, any>
+ | Event<unknown, any>
>;
+// 1 argument
export function matchEvent<
- E1 extends EventCreator<string, any>,
- E2 extends EventCreator<string, any>,
->(e1: E1, e2: E2): (o: Observable<Event>) => Observable<
- | ReturnType<E1>
- | ReturnType<E2>
+ E1 extends EventSchema,
+>(e1: E1): (o: Observable<Event>) => Observable<
+ | t.TypeOf<E1> & { metadata: EventMetadata }
>;
-// 1 argument
export function matchEvent<
- E1 extends EventCreator<string, any>
+ E1 extends EventCreator<string, any>,
>(e1: E1): (o: Observable<Event>) => Observable<
- ReturnType<E1>
+ | ReturnType<E1>
>;
export function matchEvent<
- E1 extends EventLike
+ E1 extends EventLike,
>(e1: E1): (o: Observable<Event>) => Observable<
| Event<unknown, any>
>;
// any arguments
-export function matchEvent(...events: any[]) {
+export function matchEvent(...events: MachingEvent[]) {
+ const isEventSchema = (event: any): event is EventSchema =>
+ event.name === 'EventSchema';
+
+ const match = (incomingEvent: Event) => (matchingEvent: MachingEvent): boolean =>
+ isEventSchema(matchingEvent)
+ ? matchingEvent.is(incomingEvent)
+ : isString(matchingEvent)
+ ? matchingEvent === incomingEvent.type
+ : matchingEvent.type === incomingEvent.type;
+
return (source$: Observable<Event>) =>
source$.pipe(
- filter(event => {
- const types = events.map(event => !isString(event) ? event.type : event);
- return types.includes(event.type);
- }),
+ filter(incomingEvent => events.some(match(incomingEvent))),
);
}

MODIFY
yarn.lock
yarn.lock
@@ -4474,6 +4474,11 @@ inquirer@^7.0.0:
strip-ansi "^5.1.0"
through "^2.3.6"
+io-ts@^2.2.4:
+ version "2.2.4"
+ resolved "https://registry.yarnpkg.com/io-ts/-/io-ts-2.2.4.tgz#c9b5390b8c60b9976ad03f670a6b976fe50d4083"
+ integrity sha512-a8A/3jekTgSPfQ+OG40sTiX2gohXZz89X1+TfKsWJ/vpA2dBilBkrGYPiDE8OaKMMG7xm5ma3SAZL5eNr4Bi2w==
+
io-ts@~2.2.1:
version "2.2.1"
resolved "https://registry.yarnpkg.com/io-ts/-/io-ts-2.2.1.tgz#d8cb21a23cf8fbe697d78445a65bab42c0eb467f"

feat(core): new #event builder