@getanthill/datastore
Version:
Event-Sourced Datastore
169 lines (143 loc) • 3.78 kB
text/typescript
import type { JSONSchemaType, ValidateFunction } from 'ajv';
import type { AnyObject, Telemetry } from '../typings';
import assert from 'node:assert';
import { EventEmitter } from 'events';
import Ajv from 'ajv';
import addFormats from 'ajv-formats';
import cloneDeep from 'lodash/cloneDeep';
import merge from 'lodash/merge';
export interface Route {
original: string;
topic: string;
regexp: RegExp;
paramNames: string[];
params: { [key: string]: string };
validate: ValidateFunction;
}
export interface MessageOptions {
source: 'amqp' | 'mqtt';
original: any;
delivery: number;
ack: () => Promise<void>;
nack: () => Promise<void>;
}
const DEFAULT_SPEC = {
asyncapi: '2.2.0',
info: {
title: 'MQTT API',
version: '0.1.0',
// termsOfService: '',
contact: {},
license: {
name: 'MIT',
url: 'https://opensource.org/licenses/MIT',
},
},
tags: [],
channels: {},
components: {
schemas: {
authorization: {
type: 'string',
description: 'Authorization token',
},
},
},
};
export default class BrokerClient extends EventEmitter {
static REGEXP_MATCH_PATH_PARAMETERS = /\{([a-z_]+)\}/g;
protected config: any;
protected telemetry?: Telemetry;
protected ajv: Ajv;
protected spec: any;
protected topics: Map<string, any> = new Map();
constructor(config: any, telemetry?: Telemetry) {
super();
this.config = config;
this.telemetry = telemetry;
this.ajv = new Ajv({
useDefaults: true,
coerceTypes: false,
strict: false,
});
addFormats(this.ajv);
this.spec = merge(cloneDeep(DEFAULT_SPEC), config.spec);
}
logOnInvalid(event: any, route: any) {
const err = new assert.AssertionError({
message: 'Event schema validation error',
expected: null,
actual: {
event,
errors: route.validate.errors,
},
});
/* @ts-ignore */
this.telemetry?.logger[this.config.logLevelOnInvalidMessage ?? 'error'](
'[services#broker] Message is invalid',
{
err,
event,
schema: route.validate.schema,
errors: route.validate.errors,
acked: true,
},
);
}
mapTopic(topic: string, schema: JSONSchemaType<AnyObject>): Route {
const topicWithNamespace = this.topicWithNamespace(topic);
return {
original: topic,
topic: topicWithNamespace.replace(
BrokerClient.REGEXP_MATCH_PATH_PARAMETERS,
'+',
),
regexp: new RegExp(
topicWithNamespace.replace(
BrokerClient.REGEXP_MATCH_PATH_PARAMETERS,
'([^\\/]+)',
),
),
paramNames: (
topic.match(BrokerClient.REGEXP_MATCH_PATH_PARAMETERS) ?? []
).map((p) => p.slice(1, -1)),
params: {},
validate: this.ajv.compile(schema),
};
}
parseTopic(topic: string, route: Route): Route {
const values = route.regexp.exec(topic)!.slice(1);
const params: { [key: string]: string } = {};
values.forEach((v, i) => (params[route.paramNames[i]] = v));
return {
...route,
params,
};
}
getRoute(topic: string): Route | null {
for (const entry of this.topics.values()) {
if (entry.regexp.test(topic)) {
return entry;
}
}
return null;
}
topicWithNamespace(topic: string): string {
return (
(this.config.namespace !== '' ? this.config.namespace + '/' : '') + topic
);
}
addChannelToSpec(topic: string, schema: any) {
this.spec.channels[this.topicWithNamespace(topic)] = {
publish: {
message: {
payload: {
type: 'object',
additionalProperties: false,
properties: schema,
},
},
},
};
}
}