@@ -0,0 +1,3 @@
|
||||
import config from "config";
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,50 @@
|
||||
import legacy from "./legacy";
|
||||
import v1 from "./v1";
|
||||
import v2 from "./v2";
|
||||
import { createHash } from "crypto";
|
||||
|
||||
export type CryptoResult = {
|
||||
/**
|
||||
* Encrypted | Decrypted data if done, original data if not done due to error or not encrypted data to decrypt
|
||||
*/
|
||||
data: any;
|
||||
/**
|
||||
* Is there an error while processing the input data?
|
||||
*/
|
||||
err?: Error;
|
||||
/**
|
||||
* Did we encrypted | decrypted the data? true if yes.
|
||||
*/
|
||||
done?: boolean;
|
||||
};
|
||||
|
||||
export function decrypt(data: string, encryptionKey: string): CryptoResult {
|
||||
let result = v2.decrypt(data, encryptionKey);
|
||||
if (result.done) {
|
||||
return result;
|
||||
}
|
||||
|
||||
result = v1.decrypt(data, encryptionKey);
|
||||
if (result.done) {
|
||||
return result;
|
||||
}
|
||||
|
||||
result = legacy.decrypt(data, encryptionKey);
|
||||
if (result.done) {
|
||||
return result;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function md5(value: string): string {
|
||||
return createHash("md5").update(value).digest("hex");
|
||||
}
|
||||
|
||||
export function encrypt(
|
||||
value: any,
|
||||
encryptionKey: any,
|
||||
options: { disableSalts?: boolean } = {},
|
||||
): CryptoResult {
|
||||
return v2.encrypt(value, encryptionKey, options);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { createDecipheriv } from "crypto";
|
||||
import { CryptoResult } from ".";
|
||||
|
||||
const PREFIX = "encrypted_";
|
||||
|
||||
export default {
|
||||
decrypt,
|
||||
encrypt,
|
||||
};
|
||||
|
||||
function decrypt(data: string, encryptionKey: string): CryptoResult {
|
||||
if (!data || !data.startsWith(PREFIX)) {
|
||||
return {
|
||||
data,
|
||||
done: false,
|
||||
};
|
||||
}
|
||||
|
||||
const toDecode = data.substr(PREFIX.length);
|
||||
const encryptedArray = toDecode.split("_");
|
||||
|
||||
// If array length is 1, the value has not been salted and iv is the default one
|
||||
const encrypted = Buffer.from(encryptedArray[0], "base64");
|
||||
const salt = encryptedArray[1]
|
||||
? Buffer.from(encryptedArray[1], "utf-8")
|
||||
: Buffer.from("", "utf-8");
|
||||
const iv =
|
||||
encryptedArray.length >= 3
|
||||
? Buffer.from(encryptedArray[2], "base64")
|
||||
: Buffer.from("twake_constantiv", "utf-8");
|
||||
const password = Buffer.concat([Buffer.from(encryptionKey, "hex"), salt], 32);
|
||||
const decipher = createDecipheriv("AES-256-CBC", password, iv);
|
||||
|
||||
return {
|
||||
data: Buffer.concat([decipher.update(encrypted), decipher.final()]).toString("utf-8"),
|
||||
done: true,
|
||||
};
|
||||
}
|
||||
|
||||
function encrypt(data: any): CryptoResult {
|
||||
return {
|
||||
data,
|
||||
done: false,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "crypto";
|
||||
import { CryptoResult } from ".";
|
||||
|
||||
export default {
|
||||
encrypt,
|
||||
decrypt,
|
||||
};
|
||||
|
||||
function encrypt(
|
||||
data: any,
|
||||
encryptionKey: any,
|
||||
options: { disableSalts?: boolean } = {},
|
||||
): CryptoResult {
|
||||
const key = createHash("sha256").update(String(encryptionKey)).digest("base64").substr(0, 32);
|
||||
try {
|
||||
const iv = options.disableSalts ? "0000000000000000" : randomBytes(16);
|
||||
const cipher = createCipheriv("aes-256-cbc", key, iv);
|
||||
const encrypted = Buffer.concat([cipher.update(JSON.stringify(data)), cipher.final()]).toString(
|
||||
"hex",
|
||||
);
|
||||
return {
|
||||
data: `${iv.toString("hex")}:${encrypted}`,
|
||||
done: true,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
err,
|
||||
data,
|
||||
done: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function decrypt(data: string, encryptionKey: any): CryptoResult {
|
||||
const key = createHash("sha256").update(String(encryptionKey)).digest("base64").substr(0, 32);
|
||||
|
||||
const encryptedArray = data.split(":");
|
||||
|
||||
if (!encryptedArray.length || encryptedArray.length !== 2) {
|
||||
return {
|
||||
data,
|
||||
done: false,
|
||||
};
|
||||
}
|
||||
|
||||
let iv: Buffer | string = Buffer.from(encryptedArray[0], "hex");
|
||||
if (encryptedArray[0] === "0000000000000000") {
|
||||
iv = "0000000000000000";
|
||||
}
|
||||
|
||||
try {
|
||||
const encrypted = Buffer.from(encryptedArray[1], "hex");
|
||||
const decipher = createDecipheriv("aes-256-cbc", key, iv);
|
||||
const decrypt = JSON.parse(
|
||||
Buffer.concat([decipher.update(encrypted), decipher.final()]).toString(),
|
||||
);
|
||||
return {
|
||||
data: decrypt,
|
||||
done: true,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
data,
|
||||
done: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "crypto";
|
||||
import { CryptoResult } from ".";
|
||||
|
||||
export default {
|
||||
encrypt,
|
||||
decrypt,
|
||||
};
|
||||
|
||||
function encrypt(
|
||||
data: any,
|
||||
encryptionKey: any,
|
||||
options: { disableSalts?: boolean } = {},
|
||||
): CryptoResult {
|
||||
const key = createHash("sha256").update(String(encryptionKey)).digest("hex").substr(0, 32);
|
||||
try {
|
||||
const iv = options.disableSalts ? "0000000000000000" : randomBytes(16);
|
||||
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
||||
const encrypted =
|
||||
cipher.update(JSON.stringify(data), "utf8", "base64") + cipher.final("base64");
|
||||
|
||||
return {
|
||||
data: `${iv.toString("base64")}:${cipher.getAuthTag().toString("base64")}:${encrypted}`,
|
||||
done: true,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
err,
|
||||
data,
|
||||
done: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function decrypt(data: string, encryptionKey: any): CryptoResult {
|
||||
const key = createHash("sha256").update(String(encryptionKey)).digest("hex").substr(0, 32);
|
||||
|
||||
const encryptedArray = data.split(":");
|
||||
|
||||
if (!encryptedArray.length || encryptedArray.length !== 3) {
|
||||
return {
|
||||
data,
|
||||
done: false,
|
||||
};
|
||||
}
|
||||
|
||||
let iv: Buffer | string = Buffer.from(encryptedArray[0], "base64");
|
||||
if (encryptedArray[0] === "0000000000000000") {
|
||||
iv = "0000000000000000";
|
||||
}
|
||||
|
||||
try {
|
||||
const decipher = createDecipheriv("aes-256-gcm", key, iv);
|
||||
decipher.setAuthTag(Buffer.from(encryptedArray[1], "base64"));
|
||||
|
||||
const encrypted = encryptedArray[2];
|
||||
let str = decipher.update(encrypted, "base64", "utf8");
|
||||
str += decipher.final("utf8");
|
||||
const decrypt = JSON.parse(str);
|
||||
|
||||
return {
|
||||
data: decrypt,
|
||||
done: true,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
data,
|
||||
done: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { TdriveServiceOptions } from "./service-options";
|
||||
import { TdriveServiceConfiguration } from "./service-configuration";
|
||||
|
||||
export class TdriveAppConfiguration extends TdriveServiceOptions<TdriveServiceConfiguration> {
|
||||
services: Array<string>;
|
||||
servicesPath: string;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type Class = { new (...args: any[]): any };
|
||||
@@ -0,0 +1,67 @@
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
import { TdriveService } from "./service";
|
||||
import { TdriveServiceProvider } from "./service-provider";
|
||||
import { ServiceDefinition } from "./service-definition";
|
||||
import { TdriveServiceState } from "./service-state";
|
||||
import { logger } from "../logger";
|
||||
|
||||
export class TdriveComponent {
|
||||
instance: TdriveService<TdriveServiceProvider>;
|
||||
components: Array<TdriveComponent> = new Array<TdriveComponent>();
|
||||
|
||||
constructor(public name: string, private serviceDefinition: ServiceDefinition) {}
|
||||
|
||||
getServiceDefinition(): ServiceDefinition {
|
||||
return this.serviceDefinition;
|
||||
}
|
||||
|
||||
setServiceInstance(instance: TdriveService<TdriveServiceProvider>): void {
|
||||
this.instance = instance;
|
||||
}
|
||||
|
||||
getServiceInstance(): TdriveService<TdriveServiceProvider> {
|
||||
return this.instance;
|
||||
}
|
||||
|
||||
addDependency(component: TdriveComponent): void {
|
||||
this.components.push(component);
|
||||
}
|
||||
|
||||
getStateTree(): string {
|
||||
return `${this.name}(${this.instance.state.value}) => {${this.components
|
||||
.map(component => component.getStateTree())
|
||||
.join(",")}}`;
|
||||
}
|
||||
|
||||
async switchToState(
|
||||
state: TdriveServiceState.Initialized | TdriveServiceState.Started | TdriveServiceState.Stopped,
|
||||
recursionDepth?: number,
|
||||
): Promise<void> {
|
||||
if (recursionDepth > 10) {
|
||||
logger.error("Maximum recursion depth exceeded (will exit process)");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const states: BehaviorSubject<TdriveServiceState>[] = this.components.map(
|
||||
component => component.instance.state,
|
||||
);
|
||||
|
||||
if (states.length) {
|
||||
for (const component of this.components) {
|
||||
await component.switchToState(state, (recursionDepth || 0) + 1);
|
||||
}
|
||||
logger.info(`Children of ${this.name} are all in ${state} state`);
|
||||
logger.info(this.getStateTree());
|
||||
} else {
|
||||
logger.info(`${this.name} does not have children`);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
async function _switchServiceToState(service: TdriveService<any>) {
|
||||
state === TdriveServiceState.Initialized && (await service.init());
|
||||
state === TdriveServiceState.Started && (await service.start());
|
||||
state === TdriveServiceState.Stopped && (await service.stop());
|
||||
}
|
||||
await _switchServiceToState(this.instance);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export const PREFIX_METADATA = "service:prefix";
|
||||
export const CONSUMES_METADATA = "service:consumes";
|
||||
export const SERVICENAME_METADATA = "service:name";
|
||||
@@ -0,0 +1,73 @@
|
||||
import { TdriveAppConfiguration } from "./application-configuration";
|
||||
import { TdriveComponent } from "./component";
|
||||
import { TdriveContext } from "./context";
|
||||
import { TdriveServiceProvider } from "./service-provider";
|
||||
import { TdriveService } from "./service";
|
||||
import { TdriveServiceState } from "./service-state";
|
||||
import * as ComponentUtils from "../utils/component-utils";
|
||||
|
||||
/**
|
||||
* A container contains components. It provides methods to manage and to retrieve them.
|
||||
*/
|
||||
export abstract class TdriveContainer
|
||||
extends TdriveService<TdriveServiceProvider>
|
||||
implements TdriveContext
|
||||
{
|
||||
private components: Map<string, TdriveComponent>;
|
||||
name = "TdriveContainer";
|
||||
|
||||
constructor(protected options?: TdriveAppConfiguration) {
|
||||
super(options);
|
||||
}
|
||||
|
||||
abstract loadComponents(): Promise<Map<string, TdriveComponent>>;
|
||||
|
||||
abstract loadComponent(name: string): Promise<TdriveComponent>;
|
||||
|
||||
getProvider<T extends TdriveServiceProvider>(name: string): T {
|
||||
const service = this.components.get(name)?.getServiceInstance();
|
||||
|
||||
if (!service) {
|
||||
throw new Error(`Service "${name}" not found`);
|
||||
}
|
||||
|
||||
return service.api() as T;
|
||||
}
|
||||
|
||||
async doInit(): Promise<this> {
|
||||
this.components = await this.loadComponents();
|
||||
await ComponentUtils.buildDependenciesTree(this.components, async (name: string) => {
|
||||
const component = await this.loadComponent(name);
|
||||
if (component) this.components.set(name, component);
|
||||
return component;
|
||||
});
|
||||
|
||||
await this.launchInit();
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
protected async launchInit(): Promise<this> {
|
||||
await this.switchToState(TdriveServiceState.Initialized);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
async doStart(): Promise<this> {
|
||||
await this.switchToState(TdriveServiceState.Started);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
async doStop(): Promise<this> {
|
||||
await this.switchToState(TdriveServiceState.Stopped);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
protected async switchToState(
|
||||
state: TdriveServiceState.Started | TdriveServiceState.Initialized | TdriveServiceState.Stopped,
|
||||
): Promise<void> {
|
||||
await ComponentUtils.switchComponentsToState(this.components, state);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { TdriveServiceProvider } from "./service-provider";
|
||||
|
||||
export interface TdriveContext {
|
||||
getProvider<T extends TdriveServiceProvider>(name: string): T;
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import { User } from "../../../../utils/types";
|
||||
|
||||
export class ContextualizedTarget {
|
||||
context?: ExecutionContext;
|
||||
readonly operation: OperationType;
|
||||
}
|
||||
|
||||
export class EntityTarget<Entity> extends ContextualizedTarget {
|
||||
/**
|
||||
*
|
||||
* @param type type of entity
|
||||
* @param entity the entity itself
|
||||
*/
|
||||
constructor(readonly type: string, readonly entity: Entity) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
export class UpdateResult<Entity> extends EntityTarget<Entity> {
|
||||
readonly operation = OperationType.UPDATE;
|
||||
|
||||
/**
|
||||
* Result sent back by the underlying database
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
raw?: any;
|
||||
|
||||
/**
|
||||
* Number of rows affected by the update.
|
||||
*/
|
||||
affected?: number;
|
||||
}
|
||||
|
||||
export class CreateResult<Entity> extends EntityTarget<Entity> {
|
||||
readonly operation = OperationType.CREATE;
|
||||
|
||||
/**
|
||||
* Result sent back by the underlying database
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
raw?: any;
|
||||
}
|
||||
|
||||
export class SaveResult<Entity> extends EntityTarget<Entity> {
|
||||
/**
|
||||
* Result sent back by the underlying database
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
raw?: any;
|
||||
|
||||
/**
|
||||
*
|
||||
* @param type Type of entity
|
||||
* @param entity The entity itself
|
||||
* @param operation Save can be for a "create", an "update" or "exists" when resource already exists
|
||||
*/
|
||||
constructor(
|
||||
readonly type: string,
|
||||
readonly entity: Entity,
|
||||
readonly operation: OperationType.UPDATE | OperationType.CREATE | OperationType.EXISTS,
|
||||
) {
|
||||
super(type, entity);
|
||||
}
|
||||
}
|
||||
|
||||
export class DeleteResult<Entity> extends EntityTarget<Entity> {
|
||||
readonly operation = OperationType.DELETE;
|
||||
|
||||
/**
|
||||
*
|
||||
* @param type type of entity
|
||||
* @param entity the entity itself
|
||||
* @param deleted the entity has been deleted or not
|
||||
*/
|
||||
constructor(readonly type: string, readonly entity: Entity, readonly deleted: boolean) {
|
||||
super(type, entity);
|
||||
}
|
||||
}
|
||||
|
||||
export class ListResult<Entity> extends ContextualizedTarget implements Paginable {
|
||||
// next page token
|
||||
page_token: string;
|
||||
|
||||
constructor(readonly type: string, protected entities: Entity[], readonly nextPage?: Paginable) {
|
||||
super();
|
||||
this.page_token = nextPage?.page_token;
|
||||
}
|
||||
|
||||
mapEntities(mapper: <T extends Entity>(entity: Entity) => T): void {
|
||||
this.entities = this.entities.map(entity => mapper(entity));
|
||||
}
|
||||
|
||||
filterEntities(filter: (entity: Entity) => boolean): void {
|
||||
this.entities = this.entities.filter(entity => filter(entity));
|
||||
}
|
||||
|
||||
getEntities(): Entity[] {
|
||||
return this.entities || [];
|
||||
}
|
||||
|
||||
isEmpty(): boolean {
|
||||
return this.getEntities().length === 0;
|
||||
}
|
||||
}
|
||||
|
||||
export enum OperationType {
|
||||
CREATE = "create",
|
||||
UPDATE = "update",
|
||||
SAVE = "save",
|
||||
DELETE = "delete",
|
||||
EXISTS = "exists",
|
||||
}
|
||||
|
||||
export declare type EntityOperationResult<Entity> =
|
||||
| CreateResult<Entity>
|
||||
| UpdateResult<Entity>
|
||||
| SaveResult<Entity>
|
||||
| DeleteResult<Entity>;
|
||||
|
||||
export interface ExecutionContext {
|
||||
user: User;
|
||||
reqId?: string;
|
||||
url?: string;
|
||||
method?: string;
|
||||
transport?: "http" | "ws";
|
||||
}
|
||||
|
||||
export class CrudException extends Error {
|
||||
constructor(readonly details: string, readonly status: number) {
|
||||
super();
|
||||
this.message = details;
|
||||
}
|
||||
|
||||
static badRequest(details: string): CrudException {
|
||||
return new CrudException(details, 400);
|
||||
}
|
||||
|
||||
static notFound(details: string): CrudException {
|
||||
return new CrudException(details, 404);
|
||||
}
|
||||
static forbidden(details: string): CrudException {
|
||||
return new CrudException(details, 403);
|
||||
}
|
||||
|
||||
static notImplemented(details: string): CrudException {
|
||||
return new CrudException(details, 501);
|
||||
}
|
||||
|
||||
static badGateway(details: string): CrudException {
|
||||
return new CrudException(details, 502);
|
||||
}
|
||||
}
|
||||
|
||||
export interface Paginable {
|
||||
page_token?: string;
|
||||
limitStr?: string;
|
||||
reversed?: boolean;
|
||||
}
|
||||
|
||||
export class Pagination implements Paginable {
|
||||
reversed?: boolean;
|
||||
constructor(readonly page_token?: string, readonly limitStr = "100", reversed = false) {
|
||||
this.reversed = reversed;
|
||||
}
|
||||
|
||||
public static fromPaginable(p: Paginable) {
|
||||
return new Pagination(p.page_token, p.limitStr, p.reversed);
|
||||
}
|
||||
}
|
||||
|
||||
export interface CRUDService<Entity, PrimaryKey, Context extends ExecutionContext> {
|
||||
/**
|
||||
* Creates a resource
|
||||
*
|
||||
* @param item
|
||||
* @param context
|
||||
*/
|
||||
create?(item: Entity, context?: Context): Promise<CreateResult<Entity>>;
|
||||
|
||||
/**
|
||||
* Get a resource
|
||||
*
|
||||
* @param pk
|
||||
* @param context
|
||||
*/
|
||||
get(pk: PrimaryKey, context?: Context): Promise<Entity>;
|
||||
|
||||
/**
|
||||
* Update a resource
|
||||
*
|
||||
* @param pk
|
||||
* @param item
|
||||
* @param context
|
||||
*/
|
||||
update?(
|
||||
pk: PrimaryKey,
|
||||
item: Entity,
|
||||
context?: Context /* TODO: Options */,
|
||||
): Promise<UpdateResult<Entity>>;
|
||||
|
||||
/**
|
||||
* Save a resource.
|
||||
* If the resource exists, it is updated, if it does not exists, it is created.
|
||||
*
|
||||
* @param itemOrItems
|
||||
* @param options
|
||||
* @param context
|
||||
*/
|
||||
save?<SaveOptions>(
|
||||
item: Entity,
|
||||
options?: SaveOptions,
|
||||
context?: Context,
|
||||
): Promise<SaveResult<Entity>>;
|
||||
|
||||
/**
|
||||
* Delete a resource
|
||||
*
|
||||
* @param pk
|
||||
* @param context
|
||||
*/
|
||||
delete(pk: PrimaryKey, context?: Context): Promise<DeleteResult<Entity>>;
|
||||
|
||||
/**
|
||||
* List a resource
|
||||
*
|
||||
* @param context
|
||||
*/
|
||||
list<ListOptions>(
|
||||
pagination: Paginable,
|
||||
options?: ListOptions,
|
||||
context?: Context /* TODO: Options */,
|
||||
): Promise<ListResult<Entity>>;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export * from "./application-configuration";
|
||||
export * from "./class";
|
||||
export * from "./component";
|
||||
export * from "./constants";
|
||||
export * from "./context";
|
||||
export * from "./container";
|
||||
export * from "./lifecycle";
|
||||
export * from "./service-configuration";
|
||||
export * from "./service-definition";
|
||||
export * from "./service-interface";
|
||||
export * from "./service-options";
|
||||
export * from "./service-provider";
|
||||
export * from "./service-state";
|
||||
export * from "./service";
|
||||
@@ -0,0 +1,6 @@
|
||||
import { TdriveContext } from "./context";
|
||||
|
||||
export interface Initializable {
|
||||
// TODO: Flag to tell if a service which fails to initialize must break all
|
||||
init?(context?: TdriveContext): Promise<this>;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface TdriveServiceConfiguration {
|
||||
get<T>(name?: string, defaultValue?: T): T;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { Class } from "./class";
|
||||
|
||||
export interface ServiceDefinition {
|
||||
name: string;
|
||||
clazz: Class;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { TdriveServiceProvider } from "./service-provider";
|
||||
|
||||
export interface TdriveServiceInterface<T extends TdriveServiceProvider> {
|
||||
doInit(): Promise<this>;
|
||||
doStart(): Promise<this>;
|
||||
doStop(): Promise<this>;
|
||||
api(): T;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export class TdriveServiceOptions<TdriveServiceConfiguration> {
|
||||
name?: string;
|
||||
// TODO: configuration is abstract and comes from all others
|
||||
configuration?: TdriveServiceConfiguration;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface TdriveServiceProvider {
|
||||
version: string;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export enum TdriveServiceState {
|
||||
Ready = "READY",
|
||||
Initializing = "INITIALIZING",
|
||||
Initialized = "INITIALIZED",
|
||||
Starting = "STARTING",
|
||||
Started = "STARTED",
|
||||
Stopping = "STOPPING",
|
||||
Stopped = "STOPPED",
|
||||
Errored = "ERRORED",
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
import { TdriveServiceInterface } from "./service-interface";
|
||||
import { TdriveServiceProvider } from "./service-provider";
|
||||
import { TdriveServiceState } from "./service-state";
|
||||
import { TdriveServiceConfiguration } from "./service-configuration";
|
||||
import { TdriveContext } from "./context";
|
||||
import { TdriveServiceOptions } from "./service-options";
|
||||
import { CONSUMES_METADATA, PREFIX_METADATA } from "./constants";
|
||||
import { getLogger, logger } from "../logger";
|
||||
import { TdriveLogger } from "..";
|
||||
|
||||
const pendingServices: any = {};
|
||||
|
||||
export abstract class TdriveService<T extends TdriveServiceProvider>
|
||||
implements TdriveServiceInterface<TdriveServiceProvider>
|
||||
{
|
||||
state: BehaviorSubject<TdriveServiceState>;
|
||||
readonly name: string;
|
||||
protected readonly configuration: TdriveServiceConfiguration;
|
||||
context: TdriveContext;
|
||||
logger: TdriveLogger;
|
||||
|
||||
constructor(protected options?: TdriveServiceOptions<TdriveServiceConfiguration>) {
|
||||
this.state = new BehaviorSubject<TdriveServiceState>(TdriveServiceState.Ready);
|
||||
// REMOVE ME, we should import config from framework folder instead
|
||||
this.configuration = options?.configuration;
|
||||
this.logger = getLogger(`core.platform.services.${this.name}Service`);
|
||||
}
|
||||
|
||||
abstract api(): T;
|
||||
|
||||
public get prefix(): string {
|
||||
return Reflect.getMetadata(PREFIX_METADATA, this) || "/";
|
||||
}
|
||||
|
||||
getConsumes(): Array<string> {
|
||||
return Reflect.getMetadata(CONSUMES_METADATA, this) || [];
|
||||
}
|
||||
|
||||
async init(): Promise<this> {
|
||||
if (this.state.value !== TdriveServiceState.Ready) {
|
||||
logger.info("Service %s is already initialized", this.name);
|
||||
return this;
|
||||
}
|
||||
|
||||
try {
|
||||
logger.info("Initializing service %s", this.name);
|
||||
pendingServices[this.name] = true;
|
||||
this.state.next(TdriveServiceState.Initializing);
|
||||
await this.doInit();
|
||||
this.state.next(TdriveServiceState.Initialized);
|
||||
logger.info("Service %s is initialized", this.name);
|
||||
delete pendingServices[this.name];
|
||||
logger.info("Pending services: %s", JSON.stringify(Object.keys(pendingServices)));
|
||||
return this;
|
||||
} catch (err) {
|
||||
logger.error("Error while initializing service %s", this.name);
|
||||
logger.error(err);
|
||||
this.state.error(new Error(`Error while initializing service ${this.name}`));
|
||||
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async doInit(): Promise<this> {
|
||||
return this;
|
||||
}
|
||||
|
||||
async doStart(): Promise<this> {
|
||||
return this;
|
||||
}
|
||||
|
||||
async start(): Promise<this> {
|
||||
if (
|
||||
this.state.value === TdriveServiceState.Starting ||
|
||||
this.state.value === TdriveServiceState.Started
|
||||
) {
|
||||
logger.info("Service %s is already started", this.name);
|
||||
return this;
|
||||
}
|
||||
|
||||
try {
|
||||
logger.info("Starting service %s", this.name);
|
||||
this.state.next(TdriveServiceState.Starting);
|
||||
await this.doStart();
|
||||
this.state.next(TdriveServiceState.Started);
|
||||
logger.info("Service %s is started", this.name);
|
||||
|
||||
return this;
|
||||
} catch (err) {
|
||||
logger.error("Error while starting service %s", this.name, err);
|
||||
logger.error(err);
|
||||
this.state.error(new Error(`Error while starting service ${this.name}`));
|
||||
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async stop(): Promise<this> {
|
||||
if (
|
||||
this.state.value === TdriveServiceState.Stopping ||
|
||||
this.state.value === TdriveServiceState.Stopped
|
||||
) {
|
||||
logger.info("Service %s is already stopped", this.name);
|
||||
return this;
|
||||
}
|
||||
|
||||
if (this.state.value !== TdriveServiceState.Started) {
|
||||
logger.info("Service %s can not be stopped until started", this.name);
|
||||
return this;
|
||||
}
|
||||
|
||||
try {
|
||||
logger.info("Stopping service %s", this.name);
|
||||
this.state.next(TdriveServiceState.Stopping);
|
||||
await this.doStop();
|
||||
this.state.next(TdriveServiceState.Stopped);
|
||||
logger.info("Service %s is stopped", this.name);
|
||||
|
||||
return this;
|
||||
} catch (err) {
|
||||
logger.error("Error while stopping service %s", this.name, err);
|
||||
logger.error(err);
|
||||
this.state.error(new Error(`Error while stopping service ${this.name}`));
|
||||
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async doStop(): Promise<this> {
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { PREFIX_METADATA } from "./constants";
|
||||
import { FastifyInstance, FastifyPluginCallback } from "fastify";
|
||||
|
||||
import gr from "../../../../services/global-resolver";
|
||||
|
||||
export abstract class AbstractWebService {
|
||||
abstract routes(fastify: FastifyInstance): void;
|
||||
|
||||
public get prefix(): string {
|
||||
return Reflect.getMetadata(PREFIX_METADATA, this) || "/";
|
||||
}
|
||||
|
||||
public process(): void {
|
||||
const wrapper: FastifyPluginCallback = (fastify: FastifyInstance, options, next) => {
|
||||
this.routes(fastify);
|
||||
next();
|
||||
};
|
||||
|
||||
gr.fastify.register(wrapper, { prefix: this.prefix });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { IConfig } from "config";
|
||||
import configuration from "../../config";
|
||||
import { TdriveServiceConfiguration } from "./api";
|
||||
export class Configuration implements TdriveServiceConfiguration {
|
||||
configuration: IConfig;
|
||||
serviceConfiguration: IConfig;
|
||||
|
||||
constructor(path: string) {
|
||||
try {
|
||||
this.serviceConfiguration = configuration.get(path);
|
||||
} catch {
|
||||
// NOP
|
||||
}
|
||||
}
|
||||
|
||||
get<T>(name?: string, defaultValue?: T): T {
|
||||
let value: T;
|
||||
|
||||
try {
|
||||
value = this.serviceConfiguration as unknown as T;
|
||||
if (name) {
|
||||
value =
|
||||
this.serviceConfiguration &&
|
||||
(this.serviceConfiguration.get(name) || configuration.get(name));
|
||||
}
|
||||
} catch {
|
||||
value = defaultValue || null;
|
||||
} finally {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { CONSUMES_METADATA } from "../api";
|
||||
|
||||
export function Consumes(services: string[] = []): ClassDecorator {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
return function (target: Function): void {
|
||||
Reflect.defineMetadata(CONSUMES_METADATA, services, target.prototype);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./consumes";
|
||||
export * from "./prefix";
|
||||
export * from "./service-name";
|
||||
export * from "./realtime";
|
||||
@@ -0,0 +1,8 @@
|
||||
import { PREFIX_METADATA } from "../api";
|
||||
|
||||
export function Prefix(prefix: string = "/"): ClassDecorator {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
return function (target: Function): void {
|
||||
Reflect.defineMetadata(PREFIX_METADATA, prefix, target.prototype);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { CreateResult } from "../../api/crud-service";
|
||||
import { RealtimeEntityEvent, RealtimeEntityActionType } from "../../../services/realtime/types";
|
||||
import { websocketEventBus } from "../../../services/realtime/bus";
|
||||
import { getRealtimeRecipients, getRoom, RealtimeRecipients } from ".";
|
||||
|
||||
/**
|
||||
*
|
||||
* @param path the path to push the notification to
|
||||
* @param resourcePath the path of the resource itself
|
||||
*/
|
||||
export function RealtimeCreated<T>(recipients: RealtimeRecipients<T>): MethodDecorator {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
return function (target: Object, propertyKey: string, descriptor: PropertyDescriptor): void {
|
||||
const originalMethod = descriptor.value;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
descriptor.value = async function (...args: any[]) {
|
||||
const result: CreateResult<T> = await originalMethod.apply(this, args);
|
||||
// context should always be the last arg
|
||||
const context = args && args[args.length - 1];
|
||||
|
||||
if (!(result instanceof CreateResult)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
getRealtimeRecipients(recipients, result.entity, context).forEach(
|
||||
({ room, path, resource }) => {
|
||||
websocketEventBus.publish<T>(RealtimeEntityActionType.Created, {
|
||||
type: result.type,
|
||||
room: getRoom(room, result, context),
|
||||
resourcePath: path || "/",
|
||||
entity: resource,
|
||||
result,
|
||||
} as RealtimeEntityEvent<T>);
|
||||
},
|
||||
);
|
||||
|
||||
return result;
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { getRealtimeRecipients, getRoom, RealtimeRecipients } from "..";
|
||||
import { DeleteResult } from "../../api/crud-service";
|
||||
import { RealtimeEntityEvent, RealtimeEntityActionType } from "../../../services/realtime/types";
|
||||
import { websocketEventBus } from "../../../services/realtime/bus";
|
||||
|
||||
/**
|
||||
*
|
||||
* @param path the path to push the notification to
|
||||
* @param resourcePath the path of the resource itself
|
||||
*/
|
||||
export function RealtimeDeleted<T>(recipients: RealtimeRecipients<T>): MethodDecorator {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
return function (target: Object, propertyKey: string, descriptor: PropertyDescriptor): void {
|
||||
const originalMethod = descriptor.value;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
descriptor.value = async function (...args: any[]) {
|
||||
const result: DeleteResult<T> = await originalMethod.apply(this, args);
|
||||
const context = args && args[args.length - 1];
|
||||
|
||||
if (!(result instanceof DeleteResult)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
if (result.deleted)
|
||||
getRealtimeRecipients(recipients, result.entity, context).forEach(
|
||||
({ room, path, resource }) => {
|
||||
websocketEventBus.publish<T>(RealtimeEntityActionType.Deleted, {
|
||||
type: result.type,
|
||||
room: getRoom(room, result, context),
|
||||
resourcePath: path,
|
||||
entity: resource,
|
||||
result,
|
||||
} as RealtimeEntityEvent<T>);
|
||||
},
|
||||
);
|
||||
|
||||
return result;
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { ResourcePath } from "../../../../../core/platform/services/realtime/types";
|
||||
import { EntityTarget, ExecutionContext } from "../../api/crud-service";
|
||||
|
||||
export * from "./created";
|
||||
export * from "./deleted";
|
||||
export * from "./updated";
|
||||
export * from "./saved";
|
||||
|
||||
export type RealtimeRecipients<T> =
|
||||
| RealtimeRecipient<T>
|
||||
| RealtimeRecipient<T>[]
|
||||
| ((type: T, context?: ExecutionContext) => RealtimeRecipient<T>[] | RealtimeRecipient<T>);
|
||||
|
||||
export type RealtimeRecipient<T> = {
|
||||
room: RealtimePath<T>;
|
||||
path?: string;
|
||||
resource?: any | T;
|
||||
};
|
||||
|
||||
export function getRealtimeRecipients<T>(
|
||||
recipients: RealtimeRecipients<T>,
|
||||
type: T,
|
||||
context?: ExecutionContext,
|
||||
): RealtimeRecipient<T>[] {
|
||||
if (typeof recipients === "function") recipients = recipients(type, context);
|
||||
if (!recipients) return [];
|
||||
if (!(recipients as RealtimeRecipient<T>[]).length)
|
||||
recipients = [recipients as RealtimeRecipient<T>];
|
||||
|
||||
return (recipients as RealtimeRecipient<T>[]).map(recipient => {
|
||||
return {
|
||||
resource: recipient.resource || type,
|
||||
path: recipient.path || "/",
|
||||
room: recipient.room,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export type RealtimePath<T> = string | ResourcePath | ResourcePathResolver<T>;
|
||||
export interface ResourcePathResolver<T> {
|
||||
(type: T, context?: ExecutionContext): ResourcePath;
|
||||
}
|
||||
export interface PathResolver<T> {
|
||||
(type: T, context?: ExecutionContext): string;
|
||||
}
|
||||
|
||||
export type RealtimeEntity<T> = null | ((type: T, context?: ExecutionContext) => T);
|
||||
|
||||
export function getRoom<T>(
|
||||
path: RealtimePath<T> = ResourcePath.default(),
|
||||
entity: EntityTarget<T>,
|
||||
context: ExecutionContext,
|
||||
): ResourcePath {
|
||||
if (typeof path === "string") {
|
||||
return ResourcePath.get(path);
|
||||
}
|
||||
|
||||
return typeof path === "function" ? path(entity.entity, context) : path;
|
||||
}
|
||||
|
||||
export function getPath<T>(
|
||||
path: string | PathResolver<T> = "/",
|
||||
entity: EntityTarget<T>,
|
||||
context: ExecutionContext,
|
||||
): string {
|
||||
return typeof path === "function" ? path(entity.entity, context) : path;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { getRealtimeRecipients, getRoom, RealtimeRecipients } from "..";
|
||||
import { SaveResult } from "../../api/crud-service";
|
||||
import { RealtimeEntityEvent, RealtimeEntityActionType } from "../../../services/realtime/types";
|
||||
import { websocketEventBus } from "../../../services/realtime/bus";
|
||||
|
||||
/**
|
||||
*
|
||||
* @param path the path to push the notification to
|
||||
* @param resourcePath the path of the resource itself
|
||||
*/
|
||||
export function RealtimeSaved<T>(recipients: RealtimeRecipients<T>): MethodDecorator {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
return function (target: Object, propertyKey: string, descriptor: PropertyDescriptor): void {
|
||||
const originalMethod = descriptor.value;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
descriptor.value = async function (...args: any[]) {
|
||||
const result: SaveResult<T> = await originalMethod.apply(this, args);
|
||||
// context should always be the last arg
|
||||
const context = args && args[args.length - 1];
|
||||
|
||||
if (!(result instanceof SaveResult)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
getRealtimeRecipients(recipients, result.entity, context).forEach(
|
||||
({ room, path, resource }) => {
|
||||
websocketEventBus.publish<T>(RealtimeEntityActionType.Saved, {
|
||||
type: result.type,
|
||||
room: getRoom(room, result, context),
|
||||
resourcePath: path,
|
||||
entity: resource,
|
||||
result,
|
||||
} as RealtimeEntityEvent<T>);
|
||||
},
|
||||
);
|
||||
|
||||
return result;
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { getRealtimeRecipients, getRoom, RealtimeRecipients } from "..";
|
||||
import { UpdateResult } from "../../api/crud-service";
|
||||
import { RealtimeEntityEvent, RealtimeEntityActionType } from "../../../services/realtime/types";
|
||||
import { websocketEventBus } from "../../../services/realtime/bus";
|
||||
|
||||
/**
|
||||
*
|
||||
* @param path the path to push the notification to
|
||||
* @param resourcePath the path of the resource itself
|
||||
*/
|
||||
export function RealtimeUpdated<T>(recipients: RealtimeRecipients<T>): MethodDecorator {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
return function (target: Object, propertyKey: string, descriptor: PropertyDescriptor): void {
|
||||
const originalMethod = descriptor.value;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
descriptor.value = async function (...args: any[]) {
|
||||
const result: UpdateResult<T> = await originalMethod.apply(this, args);
|
||||
const context = args && args[args.length - 1];
|
||||
|
||||
if (!(result instanceof UpdateResult)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
getRealtimeRecipients(recipients, result.entity, context).forEach(
|
||||
({ room, path, resource }) => {
|
||||
websocketEventBus.publish<T>(RealtimeEntityActionType.Updated, {
|
||||
type: result.type,
|
||||
room: getRoom(room, result, context),
|
||||
resourcePath: path,
|
||||
entity: resource,
|
||||
result,
|
||||
} as RealtimeEntityEvent<T>);
|
||||
},
|
||||
);
|
||||
|
||||
return result;
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { SERVICENAME_METADATA } from "../api";
|
||||
|
||||
export function ServiceName(name: string): ClassDecorator {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
return function (target: Function): void {
|
||||
if (name) {
|
||||
Reflect.defineMetadata(SERVICENAME_METADATA, name, target.prototype);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { getLogger } from "../logger";
|
||||
|
||||
const logger = getLogger("core.platform.framework.decorators.Skip");
|
||||
|
||||
type SkipCondition = () => Promise<boolean> | boolean;
|
||||
|
||||
/**
|
||||
* Skip a method call based on some condition
|
||||
*/
|
||||
export function Skip(condition: SkipCondition): MethodDecorator {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
return function (target: Object, _propertyKey: string, descriptor: PropertyDescriptor): void {
|
||||
logger.trace("Skipping method call");
|
||||
|
||||
const originalMethod = descriptor.value;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
descriptor.value = async function (...args: any[]) {
|
||||
const skipIt = await (condition && condition());
|
||||
|
||||
if (skipIt) {
|
||||
return target;
|
||||
}
|
||||
|
||||
return await originalMethod.apply(this, args);
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Skip when process.env.NODE_ENV is set to "cli"
|
||||
*/
|
||||
export function SkipCLI(): MethodDecorator {
|
||||
return Skip(() => process.env.NODE_ENV === "cli");
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { PREFIX_METADATA } from "../api";
|
||||
|
||||
export function WebService(prefix: string = "/"): ClassDecorator {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
return function (target: Function): void {
|
||||
Reflect.defineMetadata(PREFIX_METADATA, prefix, target.prototype);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Subject } from "rxjs";
|
||||
import { logger as rootLogger } from "./logger";
|
||||
import { ExecutionContext } from "./api/crud-service";
|
||||
|
||||
const logger = rootLogger.child({
|
||||
component: "tdrive.core.platform.framework.event-bus",
|
||||
});
|
||||
|
||||
/**
|
||||
* A local event bus in the platform. Used by platform components and services to communicate using publish subscribe.
|
||||
*/
|
||||
class EventBus {
|
||||
private subjects: Map<string, Subject<any>>;
|
||||
constructor() {
|
||||
this.subjects = new Map<string, Subject<any>>();
|
||||
}
|
||||
|
||||
subscribe<T>(name: string, listener: (_data: T) => void): this {
|
||||
if (!this.subjects.has(name)) {
|
||||
this.subjects.set(name, new Subject<T>());
|
||||
}
|
||||
|
||||
this.subjects.get(name).subscribe({
|
||||
next: (value: T) => {
|
||||
try {
|
||||
listener(value);
|
||||
} catch (err) {
|
||||
logger.warn({ err }, "Error while calling listener");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
publish<T>(name: string, data: T, _context?: ExecutionContext): boolean {
|
||||
if (!this.subjects.has(name)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.subjects.get(name)?.next(data);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export const localEventBus = new EventBus();
|
||||
@@ -0,0 +1,32 @@
|
||||
import {
|
||||
TdriveContext,
|
||||
TdriveService,
|
||||
TdriveServiceConfiguration,
|
||||
TdriveServiceOptions,
|
||||
TdriveServiceProvider,
|
||||
} from "./api";
|
||||
import { Configuration } from "./configuration";
|
||||
|
||||
class StaticTdriveServiceFactory {
|
||||
public async create<
|
||||
T extends TdriveService<TdriveServiceProvider> = TdriveService<TdriveServiceProvider>,
|
||||
>(
|
||||
module: { new (options?: TdriveServiceOptions<TdriveServiceConfiguration>): T },
|
||||
context: TdriveContext,
|
||||
configuration?: string,
|
||||
): Promise<T> {
|
||||
let config;
|
||||
|
||||
if (configuration) {
|
||||
config = new Configuration(configuration);
|
||||
}
|
||||
|
||||
const instance = new module({ configuration: config });
|
||||
|
||||
instance.context = context;
|
||||
|
||||
return instance;
|
||||
}
|
||||
}
|
||||
|
||||
export const TdriveServiceFactory = new StaticTdriveServiceFactory();
|
||||
@@ -0,0 +1,8 @@
|
||||
import "reflect-metadata";
|
||||
|
||||
export * from "./api";
|
||||
export * from "./decorators";
|
||||
export * from "./configuration";
|
||||
export * from "./factory";
|
||||
export * from "./logger";
|
||||
export * from "./utils/component-utils";
|
||||
@@ -0,0 +1,15 @@
|
||||
import pino from "pino";
|
||||
import { Configuration } from "./configuration";
|
||||
|
||||
const config = new Configuration("logger");
|
||||
|
||||
export type TdriveLogger = pino.Logger;
|
||||
|
||||
export const logger = pino({
|
||||
name: "TdriveApp",
|
||||
level: config.get("level", "info") || "info",
|
||||
prettyPrint: false,
|
||||
});
|
||||
|
||||
export const getLogger = (name?: string): TdriveLogger =>
|
||||
logger.child({ name: `tdrive${name ? "." + name : ""}` });
|
||||
@@ -0,0 +1,108 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types */
|
||||
import {
|
||||
logger,
|
||||
TdriveComponent,
|
||||
TdriveContext,
|
||||
TdriveServiceFactory,
|
||||
TdriveServiceState,
|
||||
} from "../index";
|
||||
import { Loader } from "./loader";
|
||||
|
||||
export async function buildDependenciesTree(
|
||||
components: Map<string, TdriveComponent>,
|
||||
loadComponent: (name: string) => any,
|
||||
): Promise<void> {
|
||||
for (const [name, component] of components) {
|
||||
const dependencies: string[] = component.getServiceInstance().getConsumes() || [];
|
||||
|
||||
for (const dependencyName of dependencies) {
|
||||
if (name === dependencyName) {
|
||||
throw new Error(`There is a circular dependency for component ${dependencyName}`);
|
||||
}
|
||||
let dependencyComponent = components.get(dependencyName);
|
||||
|
||||
if (!dependencyComponent) {
|
||||
//Except in the tests, we allow this to happen with a warning
|
||||
if (process.env.NODE_ENV === "test") {
|
||||
throw new Error(
|
||||
`The component dependency ${dependencyName} has not been found for component ${name}`,
|
||||
);
|
||||
} else {
|
||||
console.warn(
|
||||
`(warning) The component dependency ${dependencyName} has not been found for component ${name} it will be imported asynchronously`,
|
||||
);
|
||||
|
||||
try {
|
||||
dependencyComponent = await loadComponent(name);
|
||||
if (dependencyComponent) components.set(name, dependencyComponent);
|
||||
} catch (err) {
|
||||
dependencyComponent = null;
|
||||
}
|
||||
|
||||
if (!dependencyComponent) {
|
||||
throw new Error(
|
||||
`The component dependency ${dependencyName} has not been found for component ${name} even with async load`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
component.addDependency(dependencyComponent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load specified components from given list of paths
|
||||
*
|
||||
* @param paths Paths to search components in
|
||||
* @param names Components to load
|
||||
* @param context
|
||||
*/
|
||||
export async function loadComponents(
|
||||
paths: string[],
|
||||
names: string[] = [],
|
||||
context: TdriveContext,
|
||||
): Promise<Map<string, TdriveComponent>> {
|
||||
const result = new Map<string, TdriveComponent>();
|
||||
const loader = new Loader(paths);
|
||||
|
||||
const components: TdriveComponent[] = await Promise.all(
|
||||
names.map(async name => {
|
||||
const clazz = await loader.load(name);
|
||||
const component = new TdriveComponent(name, { clazz, name });
|
||||
result.set(name, component);
|
||||
|
||||
return component;
|
||||
}),
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
components.map(async component => {
|
||||
const service = await TdriveServiceFactory.create(
|
||||
component.getServiceDefinition().clazz,
|
||||
context,
|
||||
component.getServiceDefinition().name,
|
||||
);
|
||||
|
||||
component.setServiceInstance(service);
|
||||
}),
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function switchComponentsToState(
|
||||
components: Map<string, TdriveComponent>,
|
||||
state: TdriveServiceState.Initialized | TdriveServiceState.Started | TdriveServiceState.Stopped,
|
||||
): Promise<void> {
|
||||
const states = [];
|
||||
|
||||
for (const [name, component] of components) {
|
||||
logger.info(`Asking for ${state} on ${name} dependencies`);
|
||||
states.push(component.getServiceInstance().state);
|
||||
await component.switchToState(state);
|
||||
}
|
||||
|
||||
logger.info(`All components are now in ${state} state`);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { logger } from "../logger";
|
||||
import fs from "fs";
|
||||
|
||||
export class Loader {
|
||||
constructor(readonly paths: string[]) {}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
async load(componentName: string): Promise<any> {
|
||||
const modulesPaths = this.paths.map(path => `${path}/${componentName}`);
|
||||
|
||||
let classes = await Promise.all(
|
||||
modulesPaths.map(async modulePath => {
|
||||
if (fs.existsSync(modulePath)) {
|
||||
try {
|
||||
return await import(modulePath);
|
||||
} catch (err) {
|
||||
logger.debug(
|
||||
{ err },
|
||||
`${modulePath} can not be loaded (file was found but we were unable to import the module)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
classes = classes.filter(Boolean);
|
||||
|
||||
if (!classes || !classes.length) {
|
||||
modulesPaths.map(modulePath => {
|
||||
if (fs.existsSync(modulePath)) {
|
||||
logger.debug(`${modulePath} content was: [${fs.readdirSync(modulePath).join(", ")}]`);
|
||||
}
|
||||
});
|
||||
throw new Error(
|
||||
`Can not find or load ${componentName} in any given path [${modulesPaths.join(
|
||||
" - ",
|
||||
)}] see previous logs for more details.`,
|
||||
);
|
||||
}
|
||||
|
||||
logger.debug(`Loaded ${componentName}`);
|
||||
|
||||
return classes[0].default;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { TdriveContainer, TdriveServiceProvider, TdriveComponent } from "./framework";
|
||||
import * as ComponentUtils from "./framework/utils/component-utils";
|
||||
import path from "path";
|
||||
|
||||
export class TdrivePlatform extends TdriveContainer {
|
||||
constructor(protected options: TdrivePlatformConfiguration) {
|
||||
super();
|
||||
}
|
||||
|
||||
api(): TdriveServiceProvider {
|
||||
return null;
|
||||
}
|
||||
|
||||
async loadComponents(): Promise<Map<string, TdriveComponent>> {
|
||||
return await ComponentUtils.loadComponents(
|
||||
[this.options.servicesPath, path.resolve(__dirname, "./services/")],
|
||||
this.options.services,
|
||||
{
|
||||
getProvider: this.getProvider.bind(this),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async loadComponent(name: string): Promise<TdriveComponent> {
|
||||
return (
|
||||
await ComponentUtils.loadComponents(
|
||||
[this.options.servicesPath, path.resolve(__dirname, "./services/")],
|
||||
[name],
|
||||
{
|
||||
getProvider: this.getProvider.bind(this),
|
||||
},
|
||||
)
|
||||
).get(name);
|
||||
}
|
||||
}
|
||||
|
||||
export class TdrivePlatformConfiguration {
|
||||
/**
|
||||
* The services to load in the container
|
||||
*/
|
||||
services: string[];
|
||||
|
||||
/**
|
||||
* The path to load services from
|
||||
*/
|
||||
servicesPath: string;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { TdriveService, Consumes, Prefix, ServiceName } from "../../framework";
|
||||
import web from "./web/index";
|
||||
import AuthServiceAPI, { JwtConfiguration } from "./provider";
|
||||
import { AuthService as AuthServiceImpl } from "./service";
|
||||
import WebServerAPI from "../webserver/provider";
|
||||
|
||||
@Prefix("/api/auth")
|
||||
@Consumes(["webserver"])
|
||||
@ServiceName("auth")
|
||||
export default class AuthService extends TdriveService<AuthServiceAPI> {
|
||||
name = "auth";
|
||||
service: AuthServiceAPI;
|
||||
|
||||
api(): AuthServiceAPI {
|
||||
return this.service;
|
||||
}
|
||||
|
||||
public async doInit(): Promise<this> {
|
||||
this.service = new AuthServiceImpl(this.configuration.get<JwtConfiguration>("jwt"));
|
||||
const fastify = this.context.getProvider<WebServerAPI>("webserver").getServer();
|
||||
|
||||
fastify.register((instance, _opts, next) => {
|
||||
web(instance, { prefix: this.prefix });
|
||||
next();
|
||||
});
|
||||
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { TdriveServiceProvider } from "../../framework/api";
|
||||
import { AccessToken, JWTObject, uuid } from "../../../../utils/types";
|
||||
|
||||
export default interface AuthServiceAPI extends TdriveServiceProvider {
|
||||
/**
|
||||
* Get the authentication types
|
||||
*/
|
||||
getTypes(): Array<string>;
|
||||
|
||||
/**
|
||||
* Sign payload
|
||||
*/
|
||||
sign(payload: any): string;
|
||||
|
||||
/**
|
||||
* Verify token
|
||||
*
|
||||
* @param token
|
||||
*/
|
||||
verifyToken(token: string): JWTObject;
|
||||
|
||||
verifyTokenObject<T>(token: string): T;
|
||||
|
||||
generateJWT(
|
||||
userId: uuid,
|
||||
email: string,
|
||||
options: {
|
||||
track: boolean;
|
||||
provider_id: string;
|
||||
application_id?: string;
|
||||
} & any,
|
||||
): AccessToken;
|
||||
}
|
||||
|
||||
export interface JwtConfiguration {
|
||||
secret: string;
|
||||
expiration: number;
|
||||
refresh_expiration: number;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { JwtConfiguration } from "./provider";
|
||||
import jwt from "jsonwebtoken";
|
||||
import { AccessToken, JWTObject, uuid } from "../../../../utils/types";
|
||||
import assert from "assert";
|
||||
import { JwtType } from "../types";
|
||||
import AuthServiceAPI from "./provider";
|
||||
|
||||
export class AuthService implements AuthServiceAPI {
|
||||
version: "1";
|
||||
|
||||
constructor(private configuration: JwtConfiguration) {}
|
||||
|
||||
getTypes(): string[] {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
sign(payload: string | object | Buffer): string {
|
||||
return jwt.sign(payload, this.configuration.secret);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
verifyToken(token: string): JWTObject {
|
||||
return jwt.verify(token, this.configuration.secret) as JWTObject;
|
||||
}
|
||||
|
||||
verifyTokenObject<T>(token: string): T {
|
||||
return jwt.verify(token, this.configuration.secret) as T;
|
||||
}
|
||||
|
||||
generateJWT(
|
||||
userId: uuid,
|
||||
email: string,
|
||||
options: { track: boolean; provider_id: string; application_id?: string },
|
||||
): AccessToken {
|
||||
const now = Math.round(new Date().getTime() / 1000); // Current time in UTC
|
||||
assert(this.configuration.expiration, "jwt.expiration is missing");
|
||||
assert(this.configuration.refresh_expiration, "jwt.refresh_expiration is missing");
|
||||
|
||||
const jwtExpiration = now + this.configuration.expiration;
|
||||
const jwtRefreshExpiration = now + this.configuration.refresh_expiration;
|
||||
return {
|
||||
time: now,
|
||||
expiration: jwtExpiration,
|
||||
refresh_expiration: jwtRefreshExpiration,
|
||||
value: this.sign({
|
||||
exp: jwtExpiration,
|
||||
type: "access",
|
||||
iat: now - 60 * 10,
|
||||
nbf: now - 60 * 10,
|
||||
sub: userId,
|
||||
email: email,
|
||||
track: !!options.track,
|
||||
provider_id: options.provider_id || "",
|
||||
application_id: options.application_id,
|
||||
} as JwtType),
|
||||
refresh: this.sign({
|
||||
exp: jwtRefreshExpiration,
|
||||
type: "refresh",
|
||||
iat: now - 60 * 10,
|
||||
nbf: now - 60 * 10,
|
||||
sub: userId,
|
||||
email: email,
|
||||
track: !!options.track,
|
||||
provider_id: options.provider_id || "",
|
||||
application_id: options.application_id,
|
||||
} as JwtType),
|
||||
type: "Bearer",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { FastifyInstance, FastifyRegisterOptions } from "fastify";
|
||||
import routes from "./routes";
|
||||
|
||||
export default (
|
||||
fastify: FastifyInstance,
|
||||
opts: FastifyRegisterOptions<{ prefix: string }>,
|
||||
): void => {
|
||||
fastify.log.debug("Configuring /auth");
|
||||
fastify.register(routes, opts);
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
import { FastifyPluginCallback, FastifyRequest } from "fastify";
|
||||
import fastifyJwt from "fastify-jwt";
|
||||
import fp from "fastify-plugin";
|
||||
import config from "../../../../config";
|
||||
import { JwtType } from "../../types";
|
||||
|
||||
const jwtPlugin: FastifyPluginCallback = (fastify, _opts, next) => {
|
||||
fastify.register(fastifyJwt, {
|
||||
secret: config.get("auth.jwt.secret"),
|
||||
});
|
||||
|
||||
const authenticate = async (request: FastifyRequest) => {
|
||||
const jwt: JwtType = await request.jwtVerify();
|
||||
if (jwt.type === "refresh") {
|
||||
// TODO in the future we must invalidate the refresh token (because it should be single use)
|
||||
}
|
||||
request.currentUser = {
|
||||
...{ email: jwt.email },
|
||||
...{ id: jwt.sub },
|
||||
...{ identity_provider_id: jwt.provider_id },
|
||||
...{ application_id: jwt.application_id || null },
|
||||
...{ server_request: jwt.server_request || false },
|
||||
...{ allow_tracking: jwt.track || false },
|
||||
};
|
||||
request.log.debug(`Authenticated as user ${request.currentUser.id}`);
|
||||
};
|
||||
|
||||
fastify.decorate("authenticate", async (request: FastifyRequest) => {
|
||||
try {
|
||||
await authenticate(request);
|
||||
} catch (err) {
|
||||
throw fastify.httpErrors.unauthorized("Bad credentials");
|
||||
}
|
||||
});
|
||||
|
||||
fastify.decorate("authenticateOptional", async (request: FastifyRequest) => {
|
||||
try {
|
||||
await authenticate(request);
|
||||
} catch (err) {}
|
||||
});
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
export default fp(jwtPlugin, {
|
||||
name: "authenticate",
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { FastifyInstance, FastifyPluginCallback } from "fastify";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
const routes: FastifyPluginCallback = (fastify: FastifyInstance, _opts, next) => {
|
||||
fastify.get("/login", async (_request, reply) => {
|
||||
const userId = uuidv4();
|
||||
reply.send({
|
||||
token: fastify.jwt.sign({ sub: userId }),
|
||||
user: {
|
||||
id: userId,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
export default routes;
|
||||
@@ -0,0 +1,21 @@
|
||||
import { TdriveService } from "../../framework";
|
||||
import { CounterProvider } from "./provider";
|
||||
import { CounterAPI } from "./types";
|
||||
import Repository from "../database/services/orm/repository/repository";
|
||||
|
||||
export default class CounterService extends TdriveService<CounterAPI> implements CounterAPI {
|
||||
name = "counter";
|
||||
version = "1";
|
||||
|
||||
api(): CounterAPI {
|
||||
return this;
|
||||
}
|
||||
|
||||
async doInit(): Promise<this> {
|
||||
return Promise.resolve(this);
|
||||
}
|
||||
|
||||
getCounter<T>(repository: Repository<T>): CounterProvider<T> {
|
||||
return new CounterProvider(repository);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import Repository from "../database/services/orm/repository/repository";
|
||||
import { logger } from "../../framework";
|
||||
import { ExecutionContext } from "../../framework/api/crud-service";
|
||||
|
||||
type LastRevised = {
|
||||
calls: number;
|
||||
period: number;
|
||||
};
|
||||
|
||||
export class CounterProvider<T> {
|
||||
private name = "CounterProvider";
|
||||
|
||||
protected readonly repository: Repository<T>;
|
||||
|
||||
private reviseHandler: (pk: Partial<T>) => Promise<number>;
|
||||
private reviseMaxCalls = 0;
|
||||
private reviseMaxPeriod = 0;
|
||||
|
||||
private lastRevisedMap = new Map<string, LastRevised>();
|
||||
|
||||
constructor(repository: Repository<T>) {
|
||||
this.repository = repository;
|
||||
logger.debug(`${this.name} Created counter provider for ${this.repository.table}`);
|
||||
}
|
||||
|
||||
async increase(pk: Partial<T>, value: number, context?: ExecutionContext): Promise<void> {
|
||||
return this.repository.save(this.repository.createEntityFromObject({ value, ...pk }), context);
|
||||
}
|
||||
|
||||
async get(pk: Partial<T>, context?: ExecutionContext): Promise<number> {
|
||||
try {
|
||||
const counter = await this.repository.findOne(pk, {}, context);
|
||||
const val = counter ? (counter as any).value : 0;
|
||||
return this.revise(pk, val);
|
||||
} catch (e) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
setReviseCallback(
|
||||
handler: (pk: Partial<T>) => Promise<number>,
|
||||
maxCalls: number = 10,
|
||||
maxPeriod: number = 24 * 60 * 60 * 1000,
|
||||
): void {
|
||||
logger.debug(`${this.name} Set setReviseCallback for ${this.repository.table}`);
|
||||
this.reviseHandler = handler;
|
||||
this.reviseMaxCalls = maxCalls;
|
||||
this.reviseMaxPeriod = maxPeriod;
|
||||
}
|
||||
|
||||
private async revise(pk: Partial<T>, currentValue: number): Promise<number> {
|
||||
const now = new Date().getTime();
|
||||
const lastRevised: LastRevised = this.lastRevisedMap.get(JSON.stringify(pk)) || {
|
||||
calls: -1,
|
||||
period: now,
|
||||
};
|
||||
|
||||
logger.debug(
|
||||
`${this.name} revision status for ${JSON.stringify(pk)} is ${JSON.stringify(lastRevised)}`,
|
||||
);
|
||||
|
||||
if (
|
||||
lastRevised.calls >= this.reviseMaxCalls ||
|
||||
now > lastRevised.period + this.reviseMaxPeriod ||
|
||||
Math.random() < 1 / Math.max(1, currentValue) //The slowest the number is, the more we update it
|
||||
) {
|
||||
if (!this.reviseHandler) {
|
||||
logger.debug(
|
||||
`${this.name} No setReviseCallback handler found for ${this.repository.table}`,
|
||||
);
|
||||
return currentValue;
|
||||
}
|
||||
|
||||
logger.debug(`${this.name} Execute setReviseCallback handler for ${this.repository.table}`);
|
||||
|
||||
const actual = await this.reviseHandler(pk);
|
||||
|
||||
if (actual !== undefined && actual != currentValue) {
|
||||
await this.increase(pk, actual - currentValue);
|
||||
currentValue = actual;
|
||||
}
|
||||
lastRevised.calls = 0;
|
||||
lastRevised.period = now;
|
||||
} else {
|
||||
lastRevised.calls++;
|
||||
}
|
||||
|
||||
this.lastRevisedMap.set(JSON.stringify(pk), lastRevised);
|
||||
return currentValue;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { TdriveServiceProvider } from "../../framework";
|
||||
import Repository from "../database/services/orm/repository/repository";
|
||||
import { CounterProvider } from "./provider";
|
||||
|
||||
export interface CounterAPI extends TdriveServiceProvider {
|
||||
getCounter<T>(repository: Repository<T>): CounterProvider<T>;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { ScheduledTask } from "node-cron";
|
||||
import { TdriveServiceProvider } from "../../framework";
|
||||
|
||||
export type CronJob = () => void;
|
||||
|
||||
export type CronExpression = string;
|
||||
|
||||
export type CronTask = {
|
||||
id: string;
|
||||
description: string;
|
||||
task: ScheduledTask;
|
||||
startDate: number;
|
||||
lastRun: number;
|
||||
nbRuns: number;
|
||||
nbErrors: number;
|
||||
lastError?: Error;
|
||||
start: () => void;
|
||||
stop: () => void;
|
||||
};
|
||||
|
||||
export interface CronAPI extends TdriveServiceProvider {
|
||||
/**
|
||||
* Schedule a Job
|
||||
*
|
||||
* @param expression
|
||||
* @param job
|
||||
* @param callback
|
||||
*/
|
||||
schedule(expression: CronExpression, job: CronJob, description?: string): CronTask;
|
||||
|
||||
/**
|
||||
* Get the list of current tasks
|
||||
*/
|
||||
getTasks(): CronTask[];
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import * as cron from "node-cron";
|
||||
import uuid from "node-uuid";
|
||||
|
||||
import { TdriveService } from "../../framework";
|
||||
import { CronAPI, CronJob, CronExpression, CronTask } from "./api";
|
||||
|
||||
export default class CronService extends TdriveService<CronAPI> implements CronAPI {
|
||||
name = "cron";
|
||||
version = "1";
|
||||
private tasks = new Array<CronTask>();
|
||||
|
||||
api(): CronAPI {
|
||||
return this;
|
||||
}
|
||||
|
||||
schedule(expression: CronExpression, job: CronJob, description?: string): CronTask {
|
||||
this.logger.debug(`Submit new job with name ${description}`);
|
||||
const task: CronTask = {
|
||||
id: uuid.v4(),
|
||||
description: description || "",
|
||||
startDate: Date.now(),
|
||||
lastRun: 0,
|
||||
nbErrors: 0,
|
||||
nbRuns: 0,
|
||||
task: cron.schedule(expression, () => {
|
||||
task.lastRun = Date.now();
|
||||
task.nbRuns++;
|
||||
this.logger.debug(`Running job ${description || "untitled"}`);
|
||||
try {
|
||||
job();
|
||||
} catch (err) {
|
||||
this.logger.error("Error while running job", err);
|
||||
task.nbErrors++;
|
||||
task.lastError = err;
|
||||
}
|
||||
}),
|
||||
stop: () => {
|
||||
task.task.stop();
|
||||
},
|
||||
start: () => {
|
||||
task.task.start();
|
||||
},
|
||||
};
|
||||
|
||||
this.tasks.push(task);
|
||||
|
||||
return task;
|
||||
}
|
||||
|
||||
getTasks(): CronTask[] {
|
||||
return this.tasks;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { TdriveServiceProvider } from "../../framework";
|
||||
import { Connector } from "./services/orm/connectors";
|
||||
import Manager from "./services/orm/manager";
|
||||
import Repository from "./services/orm/repository/repository";
|
||||
import { EntityTarget } from "./services/orm/types";
|
||||
|
||||
export interface DatabaseServiceAPI extends TdriveServiceProvider {
|
||||
/**
|
||||
* Get the database connector
|
||||
*/
|
||||
getConnector(): Connector;
|
||||
|
||||
/**
|
||||
* Get entities manager (TODO: Find a better name...)
|
||||
*/
|
||||
getManager(): Manager<unknown>;
|
||||
|
||||
/**
|
||||
* Get repository for given entity
|
||||
*
|
||||
* @param table
|
||||
* @param entity
|
||||
*/
|
||||
getRepository<Entity>(table: string, entity: EntityTarget<Entity>): Promise<Repository<Entity>>;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { TdriveService, logger, ServiceName } from "../../framework";
|
||||
import { DatabaseServiceAPI } from "./api";
|
||||
import DatabaseService from "./services";
|
||||
import { DatabaseType } from "./services";
|
||||
import { ConnectionOptions } from "./services/orm/connectors";
|
||||
|
||||
@ServiceName("database")
|
||||
export default class Database extends TdriveService<DatabaseServiceAPI> {
|
||||
version = "1";
|
||||
name = "database";
|
||||
service: DatabaseService;
|
||||
|
||||
public async doInit(): Promise<this> {
|
||||
const driver = this.configuration.get<DatabaseType>("type");
|
||||
const secret = this.configuration.get<string>("secret");
|
||||
|
||||
if (!driver) {
|
||||
throw new Error("Database driver name must be specified in 'database.type' configuration");
|
||||
}
|
||||
|
||||
const configuration: ConnectionOptions = this.configuration.get<ConnectionOptions>(driver);
|
||||
|
||||
this.service = new DatabaseService(driver, configuration, secret);
|
||||
const dbConnector = this.service.getConnector();
|
||||
|
||||
try {
|
||||
logger.info("Connecting to database %o", configuration);
|
||||
await dbConnector.connect();
|
||||
await dbConnector.init();
|
||||
logger.info("Connected to database");
|
||||
} catch (err) {
|
||||
logger.error("Failed to connect to database", err);
|
||||
throw new Error("Failed to connect to db");
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
api(): DatabaseServiceAPI {
|
||||
return this.service;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { DatabaseType } from ".";
|
||||
import { ConnectionOptions, Connector } from "./orm/connectors";
|
||||
import {
|
||||
CassandraConnectionOptions,
|
||||
CassandraConnector,
|
||||
} from "./orm/connectors/cassandra/cassandra";
|
||||
import { MongoConnectionOptions, MongoConnector } from "./orm/connectors/mongodb/mongodb";
|
||||
|
||||
export class ConnectorFactory {
|
||||
public create(type: DatabaseType, options: ConnectionOptions, secret: string): Connector {
|
||||
switch (type) {
|
||||
case "cassandra":
|
||||
return new CassandraConnector(type, options as CassandraConnectionOptions, secret);
|
||||
case "mongodb":
|
||||
return new MongoConnector(type, options as MongoConnectionOptions, secret);
|
||||
default:
|
||||
throw new Error(`${type} is not supported`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { DatabaseServiceAPI } from "../api";
|
||||
import { ConnectorFactory } from "./connector-factory";
|
||||
import { Connector } from "./orm/connectors";
|
||||
import Manager from "./orm/manager";
|
||||
import Repository from "./orm/repository/repository";
|
||||
import { CassandraConnectionOptions } from "./orm/connectors/cassandra/cassandra";
|
||||
import { MongoConnectionOptions } from "./orm/connectors/mongodb/mongodb";
|
||||
import { EntityTarget } from "./orm/types";
|
||||
import { RepositoryManager } from "./orm/repository/manager";
|
||||
|
||||
export default class DatabaseService implements DatabaseServiceAPI {
|
||||
version = "1";
|
||||
private connector: Connector;
|
||||
private entityManager: RepositoryManager;
|
||||
|
||||
constructor(
|
||||
readonly type: DatabaseType,
|
||||
private options: ConnectionOptions,
|
||||
readonly secret: string,
|
||||
) {
|
||||
this.entityManager = new RepositoryManager(this);
|
||||
}
|
||||
|
||||
getConnector(): Connector {
|
||||
if (this.connector) {
|
||||
return this.connector;
|
||||
}
|
||||
|
||||
this.connector = new ConnectorFactory().create(this.type, this.options, this.secret);
|
||||
|
||||
return this.connector;
|
||||
}
|
||||
|
||||
getManager(): Manager<unknown> {
|
||||
return new Manager<unknown>(this.connector);
|
||||
}
|
||||
|
||||
getRepository<Entity>(table: string, entity: EntityTarget<Entity>): Promise<Repository<Entity>> {
|
||||
return this.entityManager.getRepository(table, entity);
|
||||
}
|
||||
}
|
||||
|
||||
export declare type ConnectionOptions = MongoConnectionOptions | CassandraConnectionOptions;
|
||||
|
||||
export declare type DatabaseType = "mongodb" | "cassandra";
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types */
|
||||
import { Connector } from ".";
|
||||
import { ConnectionOptions, DatabaseType } from "../..";
|
||||
import { FindOptions } from "../repository/repository";
|
||||
import { ColumnDefinition, EntityDefinition } from "../types";
|
||||
import { ListResult } from "../../../../../framework/api/crud-service";
|
||||
|
||||
export abstract class AbstractConnector<T extends ConnectionOptions, DatabaseClient>
|
||||
implements Connector
|
||||
{
|
||||
constructor(protected type: DatabaseType, protected options: T, protected secret: string) {}
|
||||
|
||||
abstract connect(): Promise<this>;
|
||||
|
||||
abstract drop(): Promise<this>;
|
||||
|
||||
abstract getClient(): DatabaseClient;
|
||||
|
||||
abstract createTable(
|
||||
entity: EntityDefinition,
|
||||
columns: { [name: string]: ColumnDefinition },
|
||||
): Promise<boolean>;
|
||||
|
||||
abstract upsert(entities: any[]): Promise<boolean[]>;
|
||||
|
||||
abstract remove(entities: any[]): Promise<boolean[]>;
|
||||
|
||||
abstract find<EntityType>(
|
||||
entityType: any,
|
||||
filters: any,
|
||||
options: FindOptions,
|
||||
): Promise<ListResult<EntityType>>;
|
||||
|
||||
getOptions(): T {
|
||||
return this.options;
|
||||
}
|
||||
|
||||
getType(): DatabaseType {
|
||||
return this.type;
|
||||
}
|
||||
|
||||
getSecret(): string {
|
||||
return this.secret;
|
||||
}
|
||||
}
|
||||
+578
@@ -0,0 +1,578 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import cassandra, { types } from "cassandra-driver";
|
||||
import { md5 } from "../../../../../../../crypto";
|
||||
import { defer, Subject, throwError, timer } from "rxjs";
|
||||
import { concat, delayWhen, retryWhen, take, tap } from "rxjs/operators";
|
||||
import { UpsertOptions } from "..";
|
||||
import { logger } from "../../../../../../framework";
|
||||
import { getEntityDefinition, unwrapIndexes, unwrapPrimarykey } from "../../utils";
|
||||
import { EntityDefinition, ColumnDefinition, ObjectType } from "../../types";
|
||||
import { AbstractConnector } from "../abstract-connector";
|
||||
import {
|
||||
transformValueToDbString,
|
||||
cassandraType,
|
||||
transformValueFromDbString,
|
||||
} from "./typeTransforms";
|
||||
import { FindOptions } from "../../repository/repository";
|
||||
import { ListResult, Pagination } from "../../../../../../framework/api/crud-service";
|
||||
import { Paginable } from "../../../../../../framework/api/crud-service";
|
||||
import { buildSelectQuery } from "./query-builder";
|
||||
|
||||
export { CassandraPagination } from "./pagination";
|
||||
|
||||
export interface CassandraConnectionOptions {
|
||||
contactPoints: string[];
|
||||
localDataCenter: string;
|
||||
username: string;
|
||||
password: string;
|
||||
keyspace: string;
|
||||
|
||||
/**
|
||||
* Consistency level
|
||||
*/
|
||||
queryOptions: { consistency: number };
|
||||
|
||||
/**
|
||||
* Wait for keyspace and tables to be created at init
|
||||
*/
|
||||
wait: boolean;
|
||||
|
||||
/**
|
||||
* When wait = true, retry to get the resources N times where N = retries
|
||||
*/
|
||||
retries?: number;
|
||||
|
||||
/**
|
||||
* Delay in ms between the retries. The delay is growing each time a retry fails like delay = retryCount * delay
|
||||
*/
|
||||
delay?: number;
|
||||
}
|
||||
|
||||
export class CassandraConnector extends AbstractConnector<
|
||||
CassandraConnectionOptions,
|
||||
cassandra.Client
|
||||
> {
|
||||
private client: cassandra.Client;
|
||||
private keyspaceExists = false;
|
||||
|
||||
getClient(): cassandra.Client {
|
||||
return this.client;
|
||||
}
|
||||
|
||||
async init(): Promise<this> {
|
||||
if (!this.client) {
|
||||
await this.connect();
|
||||
}
|
||||
|
||||
try {
|
||||
await this.createKeyspace();
|
||||
} catch (err) {
|
||||
logger.warn("services.database.orm.cassandra - Keyspace can not be created", err);
|
||||
}
|
||||
|
||||
if (this.options.wait) {
|
||||
await this.waitForKeyspace(this.options.delay, this.options.retries);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
createKeyspace(): Promise<cassandra.types.ResultSet> {
|
||||
const query = `CREATE KEYSPACE IF NOT EXISTS ${this.options.keyspace} WITH replication = {'class': 'NetworkTopologyStrategy', 'datacenter1': '2'} AND durable_writes = true;`;
|
||||
logger.info(query);
|
||||
return this.client.execute(query);
|
||||
}
|
||||
|
||||
async isKeyspaceCreated(): Promise<boolean> {
|
||||
let result;
|
||||
|
||||
if (this.keyspaceExists) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
result = await this.client.execute(
|
||||
`SELECT * FROM system_schema.keyspaces where keyspace_name = '${this.options.keyspace}'`,
|
||||
);
|
||||
|
||||
if (!result) {
|
||||
throw new Error("No result for keyspace query");
|
||||
}
|
||||
} catch (err) {
|
||||
throw new Error("Keyspace query error");
|
||||
}
|
||||
|
||||
if (result) {
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
this.keyspaceExists = true;
|
||||
logger.info(`Keyspace '${this.options.keyspace}' found.`);
|
||||
}
|
||||
|
||||
return result ? Promise.resolve(true) : Promise.reject(new Error("Keyspace not found"));
|
||||
}
|
||||
|
||||
waitForKeyspace(delay: number = 500, retries: number = 10): Promise<boolean> {
|
||||
const subject = new Subject<boolean>();
|
||||
const obs$ = defer(() => this.isKeyspaceCreated());
|
||||
|
||||
obs$
|
||||
.pipe(
|
||||
retryWhen(errors =>
|
||||
errors.pipe(
|
||||
delayWhen((_, i) => timer(i * delay)),
|
||||
//delay(1000), if we want fixed delay
|
||||
tap(() => logger.debug("services.database.orm.cassandra - Retrying...")),
|
||||
take(retries),
|
||||
concat(throwError("Maximum number of retries reached")),
|
||||
),
|
||||
),
|
||||
)
|
||||
.subscribe(
|
||||
() => logger.debug("services.database.orm.cassandra - Keyspace has been found"),
|
||||
err => {
|
||||
logger.error(
|
||||
{ err },
|
||||
"services.database.orm.cassandra - Error while getting keyspace information",
|
||||
);
|
||||
subject.error(new Error("Can not find keyspace information"));
|
||||
},
|
||||
() => subject.complete(),
|
||||
);
|
||||
|
||||
return subject.toPromise();
|
||||
}
|
||||
|
||||
async drop(): Promise<this> {
|
||||
logger.info("Drop database is not implemented for security reasons.");
|
||||
return this;
|
||||
}
|
||||
|
||||
async connect(): Promise<this> {
|
||||
if (this.client) {
|
||||
return this;
|
||||
}
|
||||
|
||||
// Environment variable format is comma separated string
|
||||
const contactPoints =
|
||||
typeof this.options.contactPoints === "string"
|
||||
? (this.options.contactPoints as string).split(",")
|
||||
: this.options.contactPoints;
|
||||
|
||||
const cassandraOptions: cassandra.DseClientOptions = {
|
||||
contactPoints: contactPoints,
|
||||
localDataCenter: this.options.localDataCenter,
|
||||
queryOptions: {},
|
||||
};
|
||||
|
||||
if (this.options.username && this.options.password) {
|
||||
cassandraOptions.authProvider = new cassandra.auth.PlainTextAuthProvider(
|
||||
this.options.username,
|
||||
this.options.password,
|
||||
);
|
||||
}
|
||||
|
||||
//Set default consistency level to quorum
|
||||
cassandraOptions.queryOptions.consistency =
|
||||
this.options?.queryOptions?.consistency || types.consistencies.quorum;
|
||||
|
||||
this.client = new cassandra.Client(cassandraOptions);
|
||||
await this.client.connect();
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
async getTableDefinition(name: string): Promise<string[]> {
|
||||
let result: string[];
|
||||
|
||||
try {
|
||||
const query = `SELECT column_name from system_schema.columns WHERE keyspace_name = '${this.options.keyspace}' AND table_name='${name}';`;
|
||||
const dbResult = await this.client.execute(query);
|
||||
result = dbResult.rows.map(row => row.values()[0]);
|
||||
} catch (err) {
|
||||
throw "Table query error";
|
||||
}
|
||||
|
||||
return result ? Promise.resolve(result || []) : Promise.resolve([]);
|
||||
}
|
||||
|
||||
async createTable(
|
||||
entity: EntityDefinition,
|
||||
columns: { [name: string]: ColumnDefinition },
|
||||
): Promise<boolean> {
|
||||
await this.waitForKeyspace(this.options.delay, this.options.retries);
|
||||
|
||||
let result = true;
|
||||
|
||||
// --- Generate column and key definition --- //
|
||||
const primaryKey = entity.options.primaryKey || [];
|
||||
|
||||
if (primaryKey.length === 0) {
|
||||
logger.error(
|
||||
`services.database.orm.cassandra - Primary key was not defined for table ${entity.name}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const partitionKeyPart = primaryKey.shift();
|
||||
const partitionKey: string[] =
|
||||
typeof partitionKeyPart === "string" ? [partitionKeyPart] : partitionKeyPart;
|
||||
const clusteringKeys: string[] = primaryKey as string[];
|
||||
if ([...partitionKey, ...clusteringKeys].some(key => columns[key] === undefined)) {
|
||||
logger.error(
|
||||
`services.database.orm.cassandra - One primary key item doesn't exists in entity columns for table ${entity.name}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const clusteringOrderBy = clusteringKeys.map(key => {
|
||||
const direction: "ASC" | "DESC" = columns[key].options.order || "ASC";
|
||||
return `${key} ${direction}`;
|
||||
});
|
||||
const clusteringOrderByString =
|
||||
clusteringOrderBy.length > 0
|
||||
? `WITH CLUSTERING ORDER BY (${clusteringOrderBy.join(", ")})`
|
||||
: "";
|
||||
|
||||
const allKeys = [`(${partitionKey.join(", ")})`, ...clusteringKeys];
|
||||
const primaryKeyString = `(${allKeys.join(", ")})`;
|
||||
|
||||
const columnsString = Object.keys(columns)
|
||||
.map(colName => {
|
||||
const definition = columns[colName];
|
||||
return `${colName} ${cassandraType[definition.type]}`;
|
||||
})
|
||||
.join(",\n");
|
||||
|
||||
// --- Generate final create table query --- //
|
||||
let query = `
|
||||
CREATE TABLE IF NOT EXISTS ${this.options.keyspace}.${entity.name}
|
||||
(
|
||||
${columnsString},
|
||||
PRIMARY KEY ${primaryKeyString}
|
||||
) ${clusteringOrderByString};`;
|
||||
|
||||
// --- Alter table if not up to date --- //
|
||||
const existingColumns = await this.getTableDefinition(entity.name);
|
||||
if (existingColumns.length > 0) {
|
||||
logger.debug(
|
||||
`services.database.orm.cassandra - Existing columns for table ${entity.name}, generating altertable queries`,
|
||||
);
|
||||
const alterQueryColumns = Object.keys(columns)
|
||||
.filter(colName => existingColumns.indexOf(colName) < 0)
|
||||
.map(colName => `${colName} ${cassandraType[columns[colName].type]}`);
|
||||
query = `ALTER TABLE ${this.options.keyspace}.${entity.name} ADD (${alterQueryColumns.join(
|
||||
", ",
|
||||
)})`;
|
||||
|
||||
if (alterQueryColumns.length === 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Write table --- //
|
||||
try {
|
||||
logger.debug(`service.database.orm.createTable - Creating table ${entity.name} : ${query}`);
|
||||
await this.client.execute(query);
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err },
|
||||
`service.database.orm.createTable - creation error for table ${entity.name} : ${err.message}`,
|
||||
);
|
||||
result = false;
|
||||
}
|
||||
|
||||
// --- Create indexes --- //
|
||||
if (entity.options.globalIndexes) {
|
||||
for (const globalIndex of entity.options.globalIndexes) {
|
||||
const indexName = globalIndex.join("_");
|
||||
const indexDbName = `index_${md5(indexName)}`;
|
||||
|
||||
const query = `CREATE INDEX IF NOT EXISTS ${indexDbName} ON ${this.options.keyspace}."${
|
||||
entity.name
|
||||
}" (${
|
||||
globalIndex.length === 1 ? globalIndex[0] : `(${globalIndex[0]}), ${globalIndex[1]}`
|
||||
})`;
|
||||
|
||||
try {
|
||||
logger.debug(
|
||||
`service.database.orm.createTable - Creating index ${indexName} (${indexDbName}) : ${query}`,
|
||||
);
|
||||
await this.client.execute(query);
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err },
|
||||
`service.database.orm.createTable - creation error for index ${indexName} (${indexDbName}) : ${err.message}`,
|
||||
);
|
||||
result = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async upsert(entities: any[], _options: UpsertOptions = {}): Promise<boolean[]> {
|
||||
return new Promise(resolve => {
|
||||
const promises: Promise<boolean>[] = [];
|
||||
|
||||
entities.forEach(entity => {
|
||||
const { columnsDefinition, entityDefinition } = getEntityDefinition(entity);
|
||||
const primaryKey = unwrapPrimarykey(entityDefinition);
|
||||
|
||||
//Set updated content
|
||||
const set = Object.keys(columnsDefinition)
|
||||
.filter(key => primaryKey.indexOf(key) === -1)
|
||||
.filter(key => entity[columnsDefinition[key].nodename] !== undefined)
|
||||
.map(key => [
|
||||
`${key}`,
|
||||
`${transformValueToDbString(
|
||||
entity[columnsDefinition[key].nodename],
|
||||
columnsDefinition[key].type,
|
||||
{
|
||||
columns: columnsDefinition[key].options,
|
||||
secret: this.secret,
|
||||
column: { key },
|
||||
},
|
||||
)}`,
|
||||
]);
|
||||
//Set primary key
|
||||
const where = primaryKey.map(key => [
|
||||
`${key}`,
|
||||
`${transformValueToDbString(
|
||||
entity[columnsDefinition[key].nodename],
|
||||
columnsDefinition[key].type,
|
||||
{
|
||||
columns: columnsDefinition[key].options,
|
||||
secret: this.secret,
|
||||
disableSalts: true,
|
||||
column: { key },
|
||||
},
|
||||
)}`,
|
||||
]);
|
||||
|
||||
// Add time-to-live options
|
||||
let ttlOptions = "";
|
||||
if (entityDefinition.options.ttl && entityDefinition.options.ttl > 0) {
|
||||
ttlOptions = `USING TTL ${entityDefinition.options.ttl}`;
|
||||
}
|
||||
|
||||
// Insert and update are equivalent for most of Cassandra
|
||||
// Update is prefered because the only solution for counters
|
||||
let query = `UPDATE ${this.options.keyspace}.${
|
||||
entityDefinition.name
|
||||
} ${ttlOptions} SET ${set.map(e => `${e[0]} = ${e[1]}`).join(", ")} WHERE ${where
|
||||
.map(e => `${e[0]} = ${e[1]}`)
|
||||
.join(" AND ")}`;
|
||||
|
||||
// If no "set" part, we cannot do an update, so insert
|
||||
if (set.length === 0) {
|
||||
query = `INSERT INTO ${this.options.keyspace}.${
|
||||
entityDefinition.name
|
||||
} ${ttlOptions} (${where.map(e => e[0]).join(", ")}) VALUES (${where
|
||||
.map(e => e[1])
|
||||
.join(", ")})`;
|
||||
}
|
||||
|
||||
logger.debug(`service.database.orm.upsert - Query: "${query}"`);
|
||||
|
||||
promises.push(
|
||||
new Promise(async resolve => {
|
||||
try {
|
||||
await this.getClient().execute(query);
|
||||
resolve(true);
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
{ err },
|
||||
`services.database.orm.cassandra - Error with CQL query: ${query}`,
|
||||
);
|
||||
resolve(false);
|
||||
}
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
Promise.all(promises).then(resolve);
|
||||
});
|
||||
}
|
||||
|
||||
async remove(entities: any[]): Promise<boolean[]> {
|
||||
return new Promise(resolve => {
|
||||
const promises: Promise<boolean>[] = [];
|
||||
|
||||
entities.forEach(entity => {
|
||||
const { columnsDefinition, entityDefinition } = getEntityDefinition(entity);
|
||||
const primaryKey = unwrapPrimarykey(entityDefinition);
|
||||
|
||||
//Set primary key
|
||||
const where = primaryKey.map(
|
||||
key =>
|
||||
`${key} = ${transformValueToDbString(
|
||||
entity[columnsDefinition[key].nodename],
|
||||
columnsDefinition[key].type,
|
||||
{
|
||||
columns: columnsDefinition[key].options,
|
||||
secret: this.secret,
|
||||
disableSalts: true,
|
||||
column: { key },
|
||||
},
|
||||
)}`,
|
||||
);
|
||||
|
||||
const query = `DELETE FROM ${this.options.keyspace}.${
|
||||
entityDefinition.name
|
||||
} WHERE ${where.join(" AND ")}`;
|
||||
logger.debug(`services.database.orm.cassandra.remove - Query: ${query}`);
|
||||
|
||||
promises.push(
|
||||
new Promise(async resolve => {
|
||||
try {
|
||||
await this.getClient().execute(query);
|
||||
resolve(true);
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
{ err },
|
||||
`services.database.orm.cassandra.remove - Error with CQL query: ${query}`,
|
||||
);
|
||||
resolve(false);
|
||||
}
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
Promise.all(promises).then(resolve);
|
||||
});
|
||||
}
|
||||
|
||||
async find<Table>(
|
||||
entityType: Table,
|
||||
filters: any,
|
||||
options: FindOptions = {},
|
||||
): Promise<ListResult<Table>> {
|
||||
const instance = new (entityType as any)();
|
||||
const { columnsDefinition, entityDefinition } = getEntityDefinition(instance);
|
||||
|
||||
const pk = unwrapPrimarykey(entityDefinition);
|
||||
const indexes = unwrapIndexes(entityDefinition);
|
||||
|
||||
if (
|
||||
Object.keys(filters).some(key => pk.indexOf(key) < 0) &&
|
||||
Object.keys(filters).some(key => indexes.indexOf(key) < 0)
|
||||
) {
|
||||
//Filter not in primary key
|
||||
throw new Error(
|
||||
`All filter parameters must be defined in entity primary key,
|
||||
got: ${JSON.stringify(Object.keys(filters))}
|
||||
on table ${entityDefinition.name} but pk is ${JSON.stringify(pk)},
|
||||
instance was ${JSON.stringify(instance)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const query = buildSelectQuery<Table>(
|
||||
entityType as unknown as ObjectType<Table>,
|
||||
filters,
|
||||
options,
|
||||
{
|
||||
keyspace: this.options.keyspace,
|
||||
secret: this.secret,
|
||||
},
|
||||
);
|
||||
|
||||
logger.debug(`services.database.orm.cassandra.find - Query: ${query}`);
|
||||
|
||||
const results = await this.getClient().execute(query, [], {
|
||||
fetchSize: parseInt(options.pagination.limitStr) || 100,
|
||||
pageState: options.pagination.page_token || undefined,
|
||||
prepare: false,
|
||||
});
|
||||
|
||||
const entities: Table[] = [];
|
||||
results.rows.forEach(row => {
|
||||
const entity = new (entityType as any)();
|
||||
Object.keys(row).forEach(key => {
|
||||
if (columnsDefinition[key]) {
|
||||
entity[columnsDefinition[key].nodename] = transformValueFromDbString(
|
||||
row[key],
|
||||
columnsDefinition[key].type,
|
||||
{ column: { key: key, ...columnsDefinition[key].options }, secret: this.secret },
|
||||
);
|
||||
}
|
||||
});
|
||||
entities.push(entity);
|
||||
});
|
||||
|
||||
const nextPage: Paginable = new Pagination(
|
||||
results.pageState,
|
||||
options.pagination.limitStr || "100",
|
||||
);
|
||||
logger.debug(
|
||||
`services.database.orm.cassandra.find - Query Result (items=${entities.length}): ${query}`,
|
||||
);
|
||||
|
||||
return new ListResult<Table>(entityDefinition.type, entities, nextPage);
|
||||
}
|
||||
}
|
||||
|
||||
export function waitForTable(
|
||||
client: cassandra.Client,
|
||||
keyspace: string,
|
||||
table: string,
|
||||
retries: number = 10,
|
||||
delay: number = 500,
|
||||
): Promise<boolean> {
|
||||
const subject = new Subject<boolean>();
|
||||
const obs$ = defer(() => checkForTable(client, keyspace, table));
|
||||
|
||||
obs$
|
||||
.pipe(
|
||||
retryWhen(errors =>
|
||||
errors.pipe(
|
||||
delayWhen((_, i) => timer(i * delay)),
|
||||
//delay(1000),
|
||||
tap(() =>
|
||||
logger.debug("services.database.orm.cassandra - Retrying to get table metadata..."),
|
||||
),
|
||||
take(retries),
|
||||
concat(throwError("Maximum number of retries reached")),
|
||||
),
|
||||
),
|
||||
)
|
||||
.subscribe(
|
||||
() => logger.debug(`services.database.orm.cassandra - Table ${table} has been found`),
|
||||
err => {
|
||||
logger.debug({ err }, `services.database.orm.cassandra - Table ${table} error`);
|
||||
subject.error(new Error("Can not find table"));
|
||||
},
|
||||
() => subject.complete(),
|
||||
);
|
||||
|
||||
return subject.toPromise();
|
||||
}
|
||||
|
||||
async function checkForTable(
|
||||
client: cassandra.Client,
|
||||
keyspace: string,
|
||||
table: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const result: cassandra.types.ResultSet = await client.execute(
|
||||
`SELECT * FROM system_schema.tables WHERE keyspace_name='${keyspace}' AND table_name='${table}'`,
|
||||
);
|
||||
|
||||
const tableMetadata = result.rows[0];
|
||||
|
||||
logger.debug(
|
||||
"services.database.orm.cassandra.checkForTable - Table metadata %o",
|
||||
tableMetadata,
|
||||
);
|
||||
|
||||
if (!tableMetadata) {
|
||||
throw new Error("Can not find table metadata");
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
{ err },
|
||||
"services.database.orm.cassandra.checkForTable - Error while getting table metadata",
|
||||
);
|
||||
throw new Error("Error while getting table metadata");
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
import { Paginable } from "../../../../../../framework/api/crud-service";
|
||||
import { Pagination } from "../../../../../../framework/api/crud-service";
|
||||
|
||||
export class CassandraPagination extends Pagination {
|
||||
limit = 100;
|
||||
|
||||
private constructor(readonly page_token: string, readonly limitStr = "100") {
|
||||
super(page_token, limitStr);
|
||||
this.limit = Number.parseInt(limitStr, 10);
|
||||
}
|
||||
|
||||
static from(pagination: Paginable): CassandraPagination {
|
||||
return new CassandraPagination(pagination.page_token, pagination.limitStr);
|
||||
}
|
||||
|
||||
static next(current: Pagination, pageState: string): Paginable {
|
||||
return new Pagination(pageState, current.limitStr);
|
||||
}
|
||||
}
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
import { FindOptions } from "../../repository/repository";
|
||||
import { ObjectType } from "../../types";
|
||||
import { getEntityDefinition, secureOperators } from "../../utils";
|
||||
import { transformValueToDbString } from "./typeTransforms";
|
||||
|
||||
export function buildSelectQuery<Entity>(
|
||||
entityType: ObjectType<Entity>,
|
||||
filters: Record<string, unknown>,
|
||||
findOptions: FindOptions,
|
||||
options: {
|
||||
secret?: string;
|
||||
keyspace: string;
|
||||
} = {
|
||||
secret: "",
|
||||
keyspace: "tdrive",
|
||||
},
|
||||
): string {
|
||||
const instance = new (entityType as any)();
|
||||
const { columnsDefinition, entityDefinition } = getEntityDefinition(instance);
|
||||
|
||||
const where = Object.keys(filters)
|
||||
.map(key => {
|
||||
let result: string;
|
||||
const filter = filters[key];
|
||||
|
||||
if (!filter) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(filter)) {
|
||||
if (!filter.length) {
|
||||
return;
|
||||
}
|
||||
const inClause: string[] = filter.map(
|
||||
value =>
|
||||
`${transformValueToDbString(value, columnsDefinition[key].type, {
|
||||
columns: columnsDefinition[key].options,
|
||||
secret: options.secret || "",
|
||||
disableSalts: true,
|
||||
})}`,
|
||||
);
|
||||
|
||||
result = `${key} IN (${inClause.join(",")})`;
|
||||
} else {
|
||||
result = `${key} = ${transformValueToDbString(filter, columnsDefinition[key].type, {
|
||||
columns: columnsDefinition[key].options,
|
||||
secret: options.secret || "",
|
||||
disableSalts: true,
|
||||
})}`;
|
||||
}
|
||||
|
||||
return result;
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
secureOperators(transformValueToDbString, findOptions, entityType, options);
|
||||
|
||||
const whereClause = `${[
|
||||
...where,
|
||||
...(buildComparison(findOptions) || []),
|
||||
...(buildIn(findOptions) || []),
|
||||
...(buildLike(findOptions) || []),
|
||||
].join(" AND ")}`.trimEnd();
|
||||
|
||||
let orderByClause = "";
|
||||
if (findOptions.pagination?.reversed) {
|
||||
orderByClause = `${entityDefinition.options.primaryKey
|
||||
.slice(1)
|
||||
.map(
|
||||
(key: string) =>
|
||||
`${key} ${(columnsDefinition[key].options.order || "ASC") === "ASC" ? "DESC" : "ASC"}`,
|
||||
)
|
||||
.join(", ")}`;
|
||||
}
|
||||
|
||||
const query = `SELECT * FROM ${options.keyspace}.${entityDefinition.name} ${
|
||||
whereClause.trim().length ? "WHERE " + whereClause : ""
|
||||
} ${orderByClause.trim().length ? "ORDER BY " + orderByClause : ""}`
|
||||
.trimEnd()
|
||||
.concat(";");
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
export function buildComparison(options: FindOptions = {}): string[] {
|
||||
let lessClause;
|
||||
let lessEqualClause;
|
||||
let greaterClause;
|
||||
let greaterEqualClause;
|
||||
|
||||
if (options.$lt) {
|
||||
lessClause = options.$lt.map(element => `${element[0]} < ${element[1]}`);
|
||||
}
|
||||
|
||||
if (options.$lte) {
|
||||
lessEqualClause = options.$lte.map(element => `${element[0]} <= ${element[1]}`);
|
||||
}
|
||||
|
||||
if (options.$gt) {
|
||||
greaterClause = options.$gt.map(element => `${element[0]} > ${element[1]}`);
|
||||
}
|
||||
|
||||
if (options.$gte) {
|
||||
greaterEqualClause = options.$gte.map(element => `${element[0]} >= ${element[1]}`);
|
||||
}
|
||||
|
||||
return [
|
||||
...(lessClause || []),
|
||||
...(lessEqualClause || []),
|
||||
...(greaterClause || []),
|
||||
...(greaterEqualClause || []),
|
||||
];
|
||||
}
|
||||
|
||||
export function buildIn(options: FindOptions = {}): string[] {
|
||||
let inClauses: string[];
|
||||
if (options.$in) {
|
||||
inClauses = options.$in.map(element => `${element[0]} IN (${element[1].join(",")})`);
|
||||
}
|
||||
|
||||
return inClauses || [];
|
||||
}
|
||||
|
||||
export function buildLike(options: FindOptions = {}): string[] {
|
||||
let likeClauses: string[];
|
||||
if (options.$like) {
|
||||
likeClauses = options.$like.map(element => `${element[0]} LIKE '%${element[1]}%`);
|
||||
}
|
||||
|
||||
return likeClauses || [];
|
||||
}
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
|
||||
import { isBoolean, isInteger, isNull, isUndefined } from "lodash";
|
||||
import { ColumnOptions, ColumnType } from "../../types";
|
||||
import { decrypt, encrypt } from "../../../../../../../crypto";
|
||||
import { logger } from "../../../../../../../../core/platform/framework";
|
||||
|
||||
export const cassandraType = {
|
||||
encoded_string: "TEXT",
|
||||
encoded_json: "TEXT",
|
||||
string: "TEXT",
|
||||
json: "TEXT",
|
||||
number: "BIGINT",
|
||||
timeuuid: "TIMEUUID",
|
||||
uuid: "UUID",
|
||||
counter: "COUNTER",
|
||||
blob: "BLOB",
|
||||
boolean: "BOOLEAN",
|
||||
|
||||
// backward compatibility
|
||||
tdrive_boolean: "TINYINT",
|
||||
tdrive_int: "INT", //Depreciated
|
||||
tdrive_datetime: "TIMESTAMP", //Depreciated
|
||||
};
|
||||
|
||||
type TransformOptions = {
|
||||
secret?: any;
|
||||
disableSalts?: boolean;
|
||||
columns?: ColumnOptions;
|
||||
column?: any;
|
||||
};
|
||||
|
||||
export const transformValueToDbString = (
|
||||
v: any,
|
||||
type: ColumnType,
|
||||
options: TransformOptions = {},
|
||||
): string => {
|
||||
if (type === "tdrive_datetime") {
|
||||
if (isNaN(v) || isNull(v)) {
|
||||
return "null";
|
||||
}
|
||||
return `${v}`;
|
||||
}
|
||||
|
||||
if (type === "number" || type === "tdrive_int") {
|
||||
if (isNull(v)) {
|
||||
return "null";
|
||||
}
|
||||
if (isNaN(v)) {
|
||||
return "null";
|
||||
}
|
||||
return `${parseInt(v)}`;
|
||||
}
|
||||
if (type === "uuid" || type === "timeuuid") {
|
||||
if (isNull(v)) {
|
||||
return "null";
|
||||
}
|
||||
|
||||
v = (v || "").toString().replace(/[^a-zA-Z0-9-]/g, "");
|
||||
return `${v}`;
|
||||
}
|
||||
if (type === "boolean") {
|
||||
//Security to avoid string with "false" in it
|
||||
if (!isInteger(v) && !isBoolean(v) && !isNull(v) && !isUndefined(v)) {
|
||||
throw new Error(`'${v}' is not a ${type}`);
|
||||
}
|
||||
return `${!!v}`;
|
||||
}
|
||||
if (type === "tdrive_boolean") {
|
||||
if (!isBoolean(v)) {
|
||||
throw new Error(`'${v}' is not a ${type}`);
|
||||
}
|
||||
return v ? "1" : "0";
|
||||
}
|
||||
if (type === "encoded_string" || type === "encoded_json") {
|
||||
if (type === "encoded_json") {
|
||||
try {
|
||||
v = JSON.stringify(v);
|
||||
} catch (err) {
|
||||
v = null;
|
||||
}
|
||||
}
|
||||
const encrypted = encrypt(v, options.secret, { disableSalts: options.disableSalts });
|
||||
return `'${(encrypted.data || "").toString().replace(/'/gm, "''")}'`;
|
||||
}
|
||||
if (type === "blob") {
|
||||
return "''"; //Not implemented yet
|
||||
}
|
||||
if (type === "string" || type === "json") {
|
||||
if (type === "json" && v !== null) {
|
||||
try {
|
||||
v = JSON.stringify(v);
|
||||
} catch (err) {
|
||||
v = null;
|
||||
}
|
||||
}
|
||||
return `'${(v || "").toString().replace(/'/gm, "''")}'`;
|
||||
}
|
||||
if (type === "counter") {
|
||||
if (isNaN(v)) throw new Error("Counter value should be a number");
|
||||
return `${options.column.key} + ${v}`;
|
||||
}
|
||||
return `'${(v || "").toString().replace(/'/gm, "''")}'`;
|
||||
};
|
||||
|
||||
export const transformValueFromDbString = (
|
||||
v: any,
|
||||
type: string,
|
||||
options: TransformOptions = {},
|
||||
): any => {
|
||||
logger.trace(`Transform value %o of type ${type}`, v);
|
||||
|
||||
if (type === "tdrive_datetime") {
|
||||
return new Date(`${v}`).getTime();
|
||||
}
|
||||
|
||||
if (v !== null && (type === "encoded_string" || type === "encoded_json")) {
|
||||
let decryptedValue: any;
|
||||
|
||||
if (typeof v === "string" && v.trim() === "") {
|
||||
return v;
|
||||
}
|
||||
|
||||
try {
|
||||
decryptedValue = decrypt(v, options.secret).data;
|
||||
} catch (err) {
|
||||
logger.debug(`Can not decrypt data (${err.message}) %o of type ${type}`, v);
|
||||
|
||||
decryptedValue = v;
|
||||
}
|
||||
|
||||
if (type === "encoded_json") {
|
||||
try {
|
||||
decryptedValue = JSON.parse(decryptedValue);
|
||||
} catch (err) {
|
||||
logger.debug(
|
||||
{ err },
|
||||
`Can not parse JSON from decrypted data %o of type ${type}`,
|
||||
decryptedValue,
|
||||
);
|
||||
decryptedValue = null;
|
||||
}
|
||||
}
|
||||
|
||||
return decryptedValue;
|
||||
}
|
||||
|
||||
if (type === "tdrive_boolean" || type === "boolean") {
|
||||
return Boolean(v).valueOf();
|
||||
}
|
||||
|
||||
if (type === "json") {
|
||||
try {
|
||||
return JSON.parse(v);
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (type === "uuid" || type === "timeuuid") {
|
||||
return v ? String(v).valueOf() : null;
|
||||
}
|
||||
|
||||
if (type === "number") {
|
||||
return new Number(v).valueOf();
|
||||
}
|
||||
|
||||
if (type === "counter") {
|
||||
return new Number(v).valueOf();
|
||||
}
|
||||
|
||||
return v;
|
||||
};
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
import { Initializable } from "../../../../../framework";
|
||||
import { DatabaseType } from "../..";
|
||||
import { CassandraConnectionOptions } from "./cassandra/cassandra";
|
||||
import { MongoConnectionOptions } from "./mongodb/mongodb";
|
||||
import { ColumnDefinition, EntityDefinition } from "../types";
|
||||
import { FindOptions } from "../repository/repository";
|
||||
import { ListResult } from "../../../../../framework/api/crud-service";
|
||||
|
||||
export * from "./mongodb/mongodb";
|
||||
export * from "./cassandra/cassandra";
|
||||
|
||||
export type UpsertOptions = any;
|
||||
|
||||
export type RemoveOptions = any;
|
||||
|
||||
export interface Connector extends Initializable {
|
||||
/**
|
||||
* Connect to the database
|
||||
*/
|
||||
connect(): Promise<this>;
|
||||
|
||||
/**
|
||||
* Get the type of connector
|
||||
*/
|
||||
getType(): DatabaseType;
|
||||
|
||||
/**
|
||||
* Drop data
|
||||
*/
|
||||
drop(): Promise<this>;
|
||||
|
||||
/**
|
||||
* Create table
|
||||
*/
|
||||
createTable(
|
||||
entity: EntityDefinition,
|
||||
columns: { [name: string]: ColumnDefinition },
|
||||
): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Upsert
|
||||
* returns true if the object was created/updated, false otherwise
|
||||
*/
|
||||
upsert(entities: any[]): Promise<boolean[]>;
|
||||
|
||||
/**
|
||||
* Remove
|
||||
* returns true if the object was removed, false otherwise
|
||||
*/
|
||||
remove(entities: any[]): Promise<boolean[]>;
|
||||
|
||||
/**
|
||||
* Find items in database
|
||||
* returns the list of entities matching the filters and options.
|
||||
*/
|
||||
find<EntityType>(
|
||||
entityType: any,
|
||||
filters: any,
|
||||
options: FindOptions,
|
||||
): Promise<ListResult<EntityType>>;
|
||||
}
|
||||
|
||||
export declare type ConnectionOptions = MongoConnectionOptions | CassandraConnectionOptions;
|
||||
+255
@@ -0,0 +1,255 @@
|
||||
import * as mongo from "mongodb";
|
||||
import { UpsertOptions } from "..";
|
||||
import { ListResult, Paginable, Pagination } from "../../../../../../framework/api/crud-service";
|
||||
import { FindOptions } from "../../repository/repository";
|
||||
import { ColumnDefinition, EntityDefinition, ObjectType } from "../../types";
|
||||
import { getEntityDefinition, unwrapIndexes, unwrapPrimarykey } from "../../utils";
|
||||
import { AbstractConnector } from "../abstract-connector";
|
||||
import { buildSelectQuery } from "./query-builder";
|
||||
import { transformValueFromDbString, transformValueToDbString } from "./typeTransforms";
|
||||
import { logger } from "../../../../../../framework";
|
||||
|
||||
export { MongoPagination } from "./pagination";
|
||||
|
||||
export interface MongoConnectionOptions {
|
||||
// TODO: More options
|
||||
uri: string;
|
||||
database: string;
|
||||
}
|
||||
|
||||
export class MongoConnector extends AbstractConnector<MongoConnectionOptions, mongo.MongoClient> {
|
||||
private client: mongo.MongoClient;
|
||||
|
||||
async init(): Promise<this> {
|
||||
if (!this.client) {
|
||||
await this.connect();
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
async connect(): Promise<this> {
|
||||
if (this.client) {
|
||||
return this;
|
||||
}
|
||||
|
||||
this.client = new mongo.MongoClient(this.options.uri);
|
||||
await this.client.connect();
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
getClient(): mongo.MongoClient {
|
||||
return this.client;
|
||||
}
|
||||
|
||||
async getDatabase(): Promise<mongo.Db> {
|
||||
await this.connect();
|
||||
|
||||
return this.client.db(this.options.database);
|
||||
}
|
||||
|
||||
async drop(): Promise<this> {
|
||||
const db = await this.getDatabase();
|
||||
|
||||
db.dropDatabase();
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
async createTable(
|
||||
_entity: EntityDefinition,
|
||||
_columns: { [name: string]: ColumnDefinition },
|
||||
): Promise<boolean> {
|
||||
const db = await this.getDatabase();
|
||||
const collection = db.collection(`${_entity.name}`);
|
||||
|
||||
//Mongo only need to create an index if ttl defined for entity
|
||||
if (_entity.options.ttl && _entity.options.ttl > 0) {
|
||||
const primaryKey = unwrapPrimarykey(_entity);
|
||||
const filter: any = {};
|
||||
primaryKey.forEach(key => {
|
||||
filter[key] = 1;
|
||||
});
|
||||
collection.createIndex(filter, { expireAfterSeconds: _entity.options.ttl });
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
async upsert(entities: any[], _options: UpsertOptions = {}): Promise<boolean[]> {
|
||||
return new Promise(async resolve => {
|
||||
const promises: Promise<mongo.UpdateResult>[] = [];
|
||||
|
||||
const db = await this.getDatabase();
|
||||
|
||||
entities.forEach(entity => {
|
||||
const { columnsDefinition, entityDefinition } = getEntityDefinition(entity);
|
||||
const primaryKey = unwrapPrimarykey(entityDefinition);
|
||||
|
||||
//Set updated content
|
||||
const set: any = {};
|
||||
const inc: any = {};
|
||||
Object.keys(columnsDefinition)
|
||||
.filter(key => primaryKey.indexOf(key) === -1)
|
||||
.filter(key => columnsDefinition[key].nodename !== undefined)
|
||||
.forEach(key => {
|
||||
const value = transformValueToDbString(
|
||||
entity[columnsDefinition[key].nodename],
|
||||
columnsDefinition[key].type,
|
||||
{
|
||||
columns: columnsDefinition[key].options,
|
||||
secret: this.secret,
|
||||
column: { key },
|
||||
},
|
||||
);
|
||||
|
||||
if (columnsDefinition[key].type === "counter") {
|
||||
inc[key] = value;
|
||||
} else {
|
||||
set[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
//Set primary key
|
||||
const where: any = {};
|
||||
primaryKey.forEach(key => {
|
||||
where[key] = transformValueToDbString(
|
||||
entity[columnsDefinition[key].nodename],
|
||||
columnsDefinition[key].type,
|
||||
{
|
||||
columns: columnsDefinition[key].options,
|
||||
secret: this.secret,
|
||||
disableSalts: true,
|
||||
column: { key },
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
const collection = db.collection(`${entityDefinition.name}`);
|
||||
|
||||
const updateObject = { $set: { ...where, ...set } } as any;
|
||||
|
||||
if (Object.keys(inc).length) {
|
||||
updateObject.$inc = inc;
|
||||
}
|
||||
|
||||
promises.push(
|
||||
collection.updateOne(where, updateObject, {
|
||||
upsert: true,
|
||||
}) as Promise<mongo.UpdateResult>,
|
||||
);
|
||||
});
|
||||
|
||||
Promise.all(promises).then(results => {
|
||||
resolve(results.map(result => result.acknowledged));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async remove(entities: any[]): Promise<boolean[]> {
|
||||
return new Promise(async resolve => {
|
||||
const promises: Promise<mongo.DeleteResult>[] = [];
|
||||
const db = await this.getDatabase();
|
||||
|
||||
entities.forEach(entity => {
|
||||
const { columnsDefinition, entityDefinition } = getEntityDefinition(entity);
|
||||
const primaryKey = unwrapPrimarykey(entityDefinition);
|
||||
|
||||
//Set primary key
|
||||
const where: any = {};
|
||||
primaryKey.forEach(key => {
|
||||
where[key] = transformValueToDbString(
|
||||
entity[columnsDefinition[key].nodename],
|
||||
columnsDefinition[key].type,
|
||||
{
|
||||
columns: columnsDefinition[key].options,
|
||||
secret: this.secret,
|
||||
disableSalts: true,
|
||||
column: { key },
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
const collection = db.collection(`${entityDefinition.name}`);
|
||||
promises.push(collection.deleteOne(where));
|
||||
});
|
||||
|
||||
Promise.all(promises).then(results => {
|
||||
resolve(results.map(result => result.acknowledged));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async find<Table>(
|
||||
entityType: Table,
|
||||
filters: any,
|
||||
options: FindOptions = {},
|
||||
): Promise<ListResult<Table>> {
|
||||
const instance = new (entityType as any)();
|
||||
const { columnsDefinition, entityDefinition } = getEntityDefinition(instance);
|
||||
|
||||
const pk = unwrapPrimarykey(entityDefinition);
|
||||
|
||||
const indexes = unwrapIndexes(entityDefinition);
|
||||
if (
|
||||
Object.keys(filters).some(key => pk.indexOf(key) < 0) &&
|
||||
Object.keys(filters).some(key => indexes.indexOf(key) < 0)
|
||||
) {
|
||||
//Filter not in primary key
|
||||
throw Error(
|
||||
"All filter parameters must be defined in entity primary key, got: " +
|
||||
JSON.stringify(Object.keys(filters)) +
|
||||
" on table " +
|
||||
entityDefinition.name +
|
||||
" but pk is " +
|
||||
JSON.stringify(pk),
|
||||
);
|
||||
}
|
||||
|
||||
const db = await this.getDatabase();
|
||||
const collection = db.collection(`${entityDefinition.name}`);
|
||||
|
||||
const query = buildSelectQuery<Table>(
|
||||
entityType as unknown as ObjectType<Table>,
|
||||
filters,
|
||||
options,
|
||||
);
|
||||
|
||||
const sort: any = {};
|
||||
for (const key of entityDefinition.options.primaryKey.slice(1)) {
|
||||
const defaultOrder =
|
||||
(columnsDefinition[key as string].options.order || "ASC") === "ASC" ? 1 : -1;
|
||||
sort[key as string] = (options?.pagination?.reversed ? -1 : 1) * defaultOrder;
|
||||
}
|
||||
|
||||
logger.debug(`services.database.orm.mongodb.find - Query: ${JSON.stringify(query)}`);
|
||||
|
||||
const cursor = collection
|
||||
.find(query)
|
||||
.sort(sort)
|
||||
.skip(Math.max(0, parseInt(options.pagination.page_token || "0")))
|
||||
.limit(Math.max(0, parseInt(options.pagination.limitStr || "100")));
|
||||
|
||||
const entities: Table[] = [];
|
||||
while (await cursor.hasNext()) {
|
||||
let row = await cursor.next();
|
||||
row = { ...row.set, ...row };
|
||||
const entity = new (entityType as any)();
|
||||
Object.keys(row)
|
||||
.filter(key => columnsDefinition[key] !== undefined)
|
||||
.forEach(key => {
|
||||
entity[columnsDefinition[key].nodename] = transformValueFromDbString(
|
||||
row[key],
|
||||
columnsDefinition[key].type,
|
||||
{ columns: columnsDefinition[key].options, secret: this.secret },
|
||||
);
|
||||
});
|
||||
entities.push(entity);
|
||||
}
|
||||
|
||||
const nextPageToken = options.pagination.page_token || "0";
|
||||
const limit = parseInt(options.pagination.limitStr);
|
||||
const nextToken = entities.length === limit && (parseInt(nextPageToken) + limit).toString(10);
|
||||
const nextPage: Paginable = new Pagination(nextToken, options.pagination.limitStr || "100");
|
||||
return new ListResult<Table>(entityDefinition.type, entities, nextPage);
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import { Paginable } from "../../../../../../framework/api/crud-service";
|
||||
import { Pagination } from "../../../../../../framework/api/crud-service";
|
||||
|
||||
export class MongoPagination extends Pagination {
|
||||
limit = 100;
|
||||
skip = 0;
|
||||
page = 1;
|
||||
|
||||
private constructor(readonly page_token = "1", readonly limitStr = "100") {
|
||||
super(page_token, limitStr);
|
||||
this.limit = Number.parseInt(limitStr, 10);
|
||||
this.page = Number.parseInt(page_token, 10);
|
||||
this.skip = (this.page - 1) * this.limit;
|
||||
}
|
||||
|
||||
static from(pagination: Paginable): MongoPagination {
|
||||
return new MongoPagination(pagination.page_token, pagination.limitStr);
|
||||
}
|
||||
|
||||
static next(current: MongoPagination, items: Array<unknown> = []): Paginable {
|
||||
const nextToken = items.length === current.limit && (current.page + 1).toString(10);
|
||||
|
||||
return new Pagination(nextToken, current.limitStr);
|
||||
}
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
import { FindOptions } from "../../repository/repository";
|
||||
import { ObjectType } from "../../types";
|
||||
import { getEntityDefinition, secureOperators } from "../../utils";
|
||||
import { transformValueToDbString } from "./typeTransforms";
|
||||
|
||||
export function buildSelectQuery<Entity>(
|
||||
entityType: ObjectType<Entity>,
|
||||
filters: any,
|
||||
findOptions: FindOptions,
|
||||
options: {
|
||||
secret?: string;
|
||||
keyspace: string;
|
||||
} = {
|
||||
secret: "",
|
||||
keyspace: "tdrive",
|
||||
},
|
||||
): any {
|
||||
const instance = new (entityType as any)();
|
||||
const { columnsDefinition } = getEntityDefinition(instance);
|
||||
|
||||
let where: any = {};
|
||||
Object.keys(filters).forEach((key: string) => {
|
||||
if (Array.isArray(filters[key])) {
|
||||
if (!filters[key] || !filters[key].length) {
|
||||
return;
|
||||
}
|
||||
findOptions.$in = findOptions.$in || [];
|
||||
findOptions.$in.push([key, filters[key]]);
|
||||
} else if (columnsDefinition[key]) {
|
||||
where[key] = transformValueToDbString(filters[key], columnsDefinition[key].type, {
|
||||
columns: columnsDefinition[key].options,
|
||||
secret: options.secret,
|
||||
disableSalts: true,
|
||||
});
|
||||
} else {
|
||||
where[key] = filters[key];
|
||||
}
|
||||
});
|
||||
|
||||
findOptions = secureOperators(transformValueToDbString, findOptions, entityType, options);
|
||||
where = buildComparison(where, findOptions);
|
||||
where = buildIn(where, findOptions);
|
||||
|
||||
return where;
|
||||
}
|
||||
|
||||
export function buildComparison(where: any, options: FindOptions = {}): string[] {
|
||||
Object.keys(options).forEach(operator => {
|
||||
if (operator === "$gt" || operator === "$gte" || operator === "$lt" || operator === "$lte") {
|
||||
(options[operator] || []).forEach(element => {
|
||||
if (!where[element[0]]) where[element[0]] = {};
|
||||
where[element[0]][operator] = element[1];
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return where;
|
||||
}
|
||||
|
||||
export function buildIn(where: any, options: FindOptions = {}): any {
|
||||
if (options.$in) {
|
||||
options.$in.forEach(element => {
|
||||
if (!where[element[0]]) where[element[0]] = {};
|
||||
where[element[0]]["$in"] = element[1];
|
||||
});
|
||||
}
|
||||
|
||||
return where;
|
||||
}
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
import { ColumnOptions, ColumnType } from "../../types";
|
||||
import { decrypt, encrypt } from "../../../../../../../crypto";
|
||||
import { isNull } from "lodash";
|
||||
import { fromMongoDbOrderable, toMongoDbOrderable } from "../../utils";
|
||||
|
||||
type TransformOptions = {
|
||||
secret?: any;
|
||||
disableSalts?: boolean;
|
||||
columns?: ColumnOptions;
|
||||
column?: any;
|
||||
};
|
||||
|
||||
export const transformValueToDbString = (
|
||||
v: any,
|
||||
type: ColumnType,
|
||||
options: TransformOptions = {},
|
||||
): any => {
|
||||
if (type === "timeuuid") {
|
||||
if (isNull(v) || !v) {
|
||||
return null;
|
||||
}
|
||||
//Convert to orderable number on mongodb
|
||||
return toMongoDbOrderable(v);
|
||||
}
|
||||
|
||||
if (type === "uuid") {
|
||||
return `${v}`;
|
||||
}
|
||||
|
||||
if (type === "encoded_string" || type === "encoded_json") {
|
||||
if (type === "encoded_json") {
|
||||
try {
|
||||
v = JSON.stringify(v);
|
||||
} catch (err) {
|
||||
v = null;
|
||||
}
|
||||
}
|
||||
if (v !== undefined) {
|
||||
v = encrypt(v, options.secret, { disableSalts: options.disableSalts }).data;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
if (type === "blob") {
|
||||
return ""; //Not implemented yet
|
||||
}
|
||||
if (type === "string" || type === "json") {
|
||||
if (type === "json") {
|
||||
try {
|
||||
v = JSON.stringify(v);
|
||||
} catch (err) {
|
||||
v = null;
|
||||
}
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
if (type === "tdrive_boolean") {
|
||||
return Boolean(v);
|
||||
}
|
||||
|
||||
if (type === "counter") {
|
||||
if (isNaN(v)) throw new Error("Counter value should be a number");
|
||||
return +v;
|
||||
}
|
||||
|
||||
return v || "";
|
||||
};
|
||||
|
||||
export const transformValueFromDbString = (v: any, type: string, options: any = {}): any => {
|
||||
if (type === "timeuuid") {
|
||||
return fromMongoDbOrderable(v);
|
||||
}
|
||||
if (v !== null && (type === "encoded_string" || type === "encoded_json")) {
|
||||
try {
|
||||
v = decrypt(v, options.secret).data;
|
||||
} catch (err) {
|
||||
v = v;
|
||||
}
|
||||
if (type === "encoded_json") {
|
||||
try {
|
||||
return JSON.parse(v);
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (type === "json") {
|
||||
try {
|
||||
return JSON.parse(v);
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (type === "tdrive_boolean" || type === "boolean") {
|
||||
return Boolean(v).valueOf();
|
||||
}
|
||||
if (type === "number") {
|
||||
return Number(v).valueOf();
|
||||
}
|
||||
|
||||
if (type === "counter") {
|
||||
return new Number(v).valueOf();
|
||||
}
|
||||
|
||||
return v;
|
||||
};
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
import { ColumnDefinition, ColumnType } from "../types";
|
||||
|
||||
export function Column(
|
||||
name: string,
|
||||
type: ColumnType,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
options: ColumnDefinition["options"] = { order: "ASC" },
|
||||
): PropertyDecorator {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
return function (target: Object, key: string | symbol) {
|
||||
target.constructor.prototype._columns = target.constructor.prototype._columns || {};
|
||||
const colDefinition: ColumnDefinition = {
|
||||
type: type,
|
||||
options: options,
|
||||
nodename: key.toString(),
|
||||
};
|
||||
target.constructor.prototype._columns[name] = colDefinition;
|
||||
};
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types */
|
||||
import { EntityDefinition } from "../types";
|
||||
|
||||
type EntityOption = {
|
||||
type?: string;
|
||||
ttl?: number;
|
||||
primaryKey: (string | string[])[];
|
||||
globalIndexes?: string[][];
|
||||
search?: {
|
||||
source: (entity: any) => any; //Should return an object that will be indexed
|
||||
shouldUpdate?: (entity: any) => any; //Should return an object that will be indexed
|
||||
index?: string; //Index name
|
||||
esMapping?: any; //Used for elasticsearch mappings
|
||||
mongoMapping?: any; //Used for elasticsearch mappings
|
||||
};
|
||||
};
|
||||
|
||||
export function Entity(name: string, options: EntityOption): ClassDecorator {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
return function (constructor: Function): void {
|
||||
const entityDefinition: EntityDefinition = {
|
||||
name,
|
||||
type: options.type || name,
|
||||
options,
|
||||
};
|
||||
constructor.prototype._entity = entityDefinition;
|
||||
};
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
export * from "./column";
|
||||
export * from "./entity";
|
||||
@@ -0,0 +1,102 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
|
||||
import _ from "lodash";
|
||||
import { Connector } from "./connectors";
|
||||
import { getEntityDefinition, unwrapPrimarykey } from "./utils";
|
||||
import { v4 as uuidv4, v1 as uuidv1 } from "uuid";
|
||||
import { logger } from "../../../../framework";
|
||||
import { DatabaseEntitiesRemovedEvent, DatabaseEntitiesSavedEvent } from "./types";
|
||||
import { localEventBus } from "../../../../framework/event-bus";
|
||||
|
||||
export default class EntityManager<EntityType extends Record<string, any>> {
|
||||
private toPersist: EntityType[] = [];
|
||||
private toRemove: EntityType[] = [];
|
||||
|
||||
constructor(readonly connector: Connector) {}
|
||||
|
||||
public persist(entity: any): this {
|
||||
if (!entity.constructor.prototype._entity || !entity.constructor.prototype._columns) {
|
||||
logger.error("Can not persist this object %o", entity);
|
||||
throw Error("Cannot persist this object: it is not an entity.");
|
||||
}
|
||||
|
||||
// --- Generate ids on primary keys elements (if not defined) ---
|
||||
const { columnsDefinition, entityDefinition } = getEntityDefinition(entity);
|
||||
const primaryKey: string[] = unwrapPrimarykey(entityDefinition);
|
||||
|
||||
primaryKey.forEach(pk => {
|
||||
if (entity[pk] === undefined) {
|
||||
const definition = columnsDefinition[pk];
|
||||
|
||||
if (!definition) {
|
||||
throw Error(`There is no definition for primary key ${pk}`);
|
||||
}
|
||||
|
||||
//Create default value
|
||||
switch (definition.options.generator || definition.type) {
|
||||
case "uuid":
|
||||
entity[pk] = uuidv4();
|
||||
break;
|
||||
case "timeuuid":
|
||||
entity[pk] = uuidv1();
|
||||
break;
|
||||
case "number":
|
||||
entity[pk] = 0;
|
||||
break;
|
||||
default:
|
||||
entity[pk] = "";
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
//Apply the onUpsert
|
||||
for (const column in columnsDefinition) {
|
||||
const definition = columnsDefinition[column];
|
||||
if (definition.options.onUpsert) {
|
||||
entity[definition.nodename] = definition.options.onUpsert(entity[definition.nodename]);
|
||||
}
|
||||
}
|
||||
|
||||
this.toPersist = this.toPersist.filter(e => e !== entity);
|
||||
this.toPersist.push(_.cloneDeep(entity));
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public remove(entity: EntityType, entityType?: EntityType): this {
|
||||
if (entityType) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
entity = _.merge(new (entityType as any)(), entity);
|
||||
}
|
||||
if (!entity.constructor.prototype._entity || !entity.constructor.prototype._columns) {
|
||||
throw Error("Cannot remove this object: it is not an entity.");
|
||||
}
|
||||
this.toRemove = this.toRemove.filter(e => e !== entity);
|
||||
this.toRemove.push(_.cloneDeep(entity));
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public async flush(): Promise<this> {
|
||||
this.toPersist = _.uniqWith(this.toPersist, _.isEqual);
|
||||
this.toRemove = _.uniqWith(this.toRemove, _.isEqual);
|
||||
|
||||
localEventBus.publish("database:entities:saved", {
|
||||
entities: this.toPersist.map(e => _.cloneDeep(e)),
|
||||
} as DatabaseEntitiesSavedEvent);
|
||||
|
||||
localEventBus.publish("database:entities:saved", {
|
||||
entities: this.toRemove.map(e => _.cloneDeep(e)),
|
||||
} as DatabaseEntitiesRemovedEvent);
|
||||
|
||||
await this.connector.upsert(this.toPersist);
|
||||
await this.connector.remove(this.toRemove);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
this.toPersist = [];
|
||||
this.toRemove = [];
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
import { logger } from "../../../../../../../core/platform/framework";
|
||||
import DatabaseService from "../..";
|
||||
import Repository from "./repository";
|
||||
import { EntityTarget } from "../types";
|
||||
|
||||
export class RepositoryManager {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
private repositories: Map<string, Repository<any>> = new Map<string, Repository<any>>();
|
||||
|
||||
constructor(private databaseService: DatabaseService) {}
|
||||
|
||||
async getRepository<Entity>(
|
||||
table: string,
|
||||
entity: EntityTarget<Entity>,
|
||||
): Promise<Repository<Entity>> {
|
||||
if (!this.repositories.has(table)) {
|
||||
const repository = new Repository<Entity>(this.databaseService.getConnector(), table, entity);
|
||||
|
||||
try {
|
||||
await repository.init();
|
||||
} catch (err) {
|
||||
logger.error({ err }, "Error while initializing repository");
|
||||
throw new Error("Can not initialize repository");
|
||||
}
|
||||
|
||||
this.repositories.set(table, repository);
|
||||
}
|
||||
|
||||
return this.repositories.get(table);
|
||||
}
|
||||
}
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
import { assign } from "lodash";
|
||||
import { logger } from "../../../../../../../core/platform/framework";
|
||||
import {
|
||||
ExecutionContext,
|
||||
ListResult,
|
||||
Pagination,
|
||||
} from "../../../../../framework/api/crud-service";
|
||||
import { Connector } from "../connectors";
|
||||
import Manager from "../manager";
|
||||
import { EntityTarget } from "../types";
|
||||
import { getEntityDefinition } from "../utils";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type FindFilter = { [key: string]: any };
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type RemoveFilter = { [key: string]: any };
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type comparisonType = [string, any];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type inType = [string, Array<any>];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type likeType = [string, any];
|
||||
|
||||
export type FindOptions = {
|
||||
pagination?: Pagination;
|
||||
$lt?: comparisonType[];
|
||||
$lte?: comparisonType[];
|
||||
$gt?: comparisonType[];
|
||||
$gte?: comparisonType[];
|
||||
/**
|
||||
* The $in operator selects the documents where the value of a field equals any value in the specified array
|
||||
*/
|
||||
$in?: inType[];
|
||||
$like?: likeType[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Repository to work with entities. Each entity type has its own repository instance.
|
||||
*/
|
||||
export default class Repository<EntityType> {
|
||||
manager: Manager<EntityType>;
|
||||
|
||||
constructor(
|
||||
readonly connector: Connector,
|
||||
readonly table: string,
|
||||
readonly entityType: EntityTarget<EntityType>,
|
||||
) {
|
||||
this.manager = new Manager<EntityType>(this.connector);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
checkEntityDefinition(): boolean {
|
||||
//TODO, check entity definition make sense from this.entityType
|
||||
return true;
|
||||
}
|
||||
|
||||
async init(): Promise<this> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const instance = new (this.entityType as any)() as EntityType;
|
||||
|
||||
if (this.checkEntityDefinition()) {
|
||||
const { columnsDefinition, entityDefinition } = getEntityDefinition(instance);
|
||||
await this.connector.createTable(entityDefinition, columnsDefinition);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
async find(
|
||||
filters: FindFilter,
|
||||
options: FindOptions = {},
|
||||
_context?: ExecutionContext,
|
||||
): Promise<ListResult<EntityType>> {
|
||||
if (!this.entityType) {
|
||||
throw Error(`Unable to find or findOne: EntityType ${this.table} not initialized`);
|
||||
}
|
||||
|
||||
if (!options.pagination) {
|
||||
options.pagination = new Pagination("", "100");
|
||||
}
|
||||
|
||||
return await this.connector.find(this.entityType, filters, options);
|
||||
}
|
||||
|
||||
async findOne(
|
||||
filters: FindFilter,
|
||||
options: FindOptions = {},
|
||||
context?: ExecutionContext,
|
||||
): Promise<EntityType> {
|
||||
if (!options.pagination) {
|
||||
options.pagination = new Pagination("", "1");
|
||||
}
|
||||
|
||||
return (await this.find(filters, options, context)).getEntities()[0] || null;
|
||||
}
|
||||
|
||||
async save(entity: EntityType, _context?: ExecutionContext): Promise<void> {
|
||||
(await this.manager.persist(entity).flush()).reset();
|
||||
}
|
||||
|
||||
async saveAll(entities: EntityType[] = [], _context?: ExecutionContext): Promise<void> {
|
||||
logger.debug("services.database.repository - Saving entities");
|
||||
|
||||
entities.forEach(entity => this.manager.persist(entity));
|
||||
await (await this.manager.flush()).reset();
|
||||
}
|
||||
|
||||
async remove(entity: EntityType, _context?: ExecutionContext): Promise<void> {
|
||||
await (await this.manager.remove(entity).flush()).reset();
|
||||
}
|
||||
|
||||
//Avoid using this except when no choice
|
||||
createEntityFromObject(object: any): EntityType {
|
||||
const entity = new (this.entityType as any)() as EntityType;
|
||||
return assign(entity, object);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
export type EntityDefinition = {
|
||||
name: string;
|
||||
type: string;
|
||||
options: {
|
||||
primaryKey: (string | string[])[];
|
||||
globalIndexes?: string[][];
|
||||
ttl?: number;
|
||||
search?: {
|
||||
source: <Entity>(entity: Entity) => any; //Should return an object that will be indexed
|
||||
shouldUpdate?: (entity: any) => any; //Should return an object that will be indexed
|
||||
index?: string; //Index name
|
||||
esMapping?: any; //Used for elasticsearch / mongodb mappings
|
||||
mongoMapping?: any; //Used for elasticsearch / mongodb mappings
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export type ColumnDefinition = {
|
||||
type: ColumnType;
|
||||
nodename: string;
|
||||
options: ColumnOptions;
|
||||
};
|
||||
|
||||
export type ColumnOptions = {
|
||||
order?: "ASC" | "DESC";
|
||||
generator?: ColumnType;
|
||||
onUpsert?: (value: any) => any;
|
||||
};
|
||||
|
||||
export type ColumnType =
|
||||
| "encoded_string"
|
||||
| "encoded_json"
|
||||
| "string"
|
||||
| "json"
|
||||
| "number"
|
||||
| "timeuuid"
|
||||
| "uuid"
|
||||
| "counter"
|
||||
| "blob"
|
||||
| "boolean"
|
||||
// backward compatibility
|
||||
| "tdrive_boolean"
|
||||
| "tdrive_int"
|
||||
| "tdrive_datetime";
|
||||
|
||||
export type EntityTarget<Entity> = ObjectType<Entity>;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
export type ObjectType<T> = { new (): T } | Function;
|
||||
|
||||
/** Local Event bus */
|
||||
|
||||
export type DatabaseEntitiesSavedEvent = {
|
||||
entities: any[];
|
||||
};
|
||||
|
||||
export type DatabaseEntitiesRemovedEvent = {
|
||||
entities: any[];
|
||||
};
|
||||
@@ -0,0 +1,108 @@
|
||||
import _, { flatten } from "lodash";
|
||||
import { FindOptions } from "./repository/repository";
|
||||
import { ColumnDefinition, EntityDefinition, ObjectType } from "./types";
|
||||
|
||||
export function getEntityDefinition(instance: any): {
|
||||
entityDefinition: EntityDefinition;
|
||||
columnsDefinition: { [name: string]: ColumnDefinition };
|
||||
} {
|
||||
const entityConfituration = _.cloneDeep(instance.constructor.prototype._entity);
|
||||
const entityColumns = _.cloneDeep(instance.constructor.prototype._columns);
|
||||
return {
|
||||
entityDefinition: entityConfituration,
|
||||
columnsDefinition: entityColumns,
|
||||
};
|
||||
}
|
||||
|
||||
export function unwrapPrimarykey(entityDefinition: EntityDefinition): string[] {
|
||||
const initial = [...entityDefinition.options.primaryKey];
|
||||
const partitionKey = initial.shift();
|
||||
const primaryKey: string[] = [
|
||||
...(typeof partitionKey === "string" ? [partitionKey] : partitionKey),
|
||||
...(initial as string[]),
|
||||
];
|
||||
return primaryKey;
|
||||
}
|
||||
|
||||
export function unwrapIndexes(entityDefinition: EntityDefinition): string[] {
|
||||
const indexes = entityDefinition.options.globalIndexes;
|
||||
if (!indexes) return [];
|
||||
|
||||
return flatten(entityDefinition.options.globalIndexes);
|
||||
}
|
||||
|
||||
export function secureOperators<Entity>(
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
transformValueToDbString: Function,
|
||||
findOptions: FindOptions = {},
|
||||
entityType: ObjectType<Entity>,
|
||||
options: {
|
||||
secret?: string;
|
||||
keyspace: string;
|
||||
} = {
|
||||
secret: "",
|
||||
keyspace: "tdrive",
|
||||
},
|
||||
): FindOptions {
|
||||
const instance = new (entityType as any)();
|
||||
const { columnsDefinition } = getEntityDefinition(instance);
|
||||
|
||||
Object.keys(findOptions).forEach(key => {
|
||||
if (
|
||||
key == "$in" ||
|
||||
key == "$lte" ||
|
||||
key == "$lt" ||
|
||||
key == "$gte" ||
|
||||
key == "$gt" ||
|
||||
key == "$like"
|
||||
) {
|
||||
findOptions[key].forEach(element => {
|
||||
if (columnsDefinition[element[0]]) {
|
||||
if (_.isArray(element[1])) {
|
||||
element[1] = element[1].map((e: any) =>
|
||||
transformValueToDbString(e, columnsDefinition[element[0]].type, {
|
||||
columns: columnsDefinition[element[0]].options,
|
||||
secret: options.secret || "",
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
element[1] = transformValueToDbString(element[1], columnsDefinition[element[0]].type, {
|
||||
columns: columnsDefinition[element[0]].options,
|
||||
secret: options.secret || "",
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return findOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build uuid from nanotime
|
||||
* @param orderable
|
||||
* @returns
|
||||
*/
|
||||
export function fromMongoDbOrderable(orderable: string): string {
|
||||
if (!orderable) {
|
||||
return null;
|
||||
}
|
||||
const uuid_arr = orderable.split("-");
|
||||
const timeuuid = [uuid_arr[2], uuid_arr[1], uuid_arr[0], uuid_arr[3], uuid_arr[4]].join("-");
|
||||
return timeuuid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns orderable string
|
||||
* @param timeuuid
|
||||
* @returns
|
||||
*/
|
||||
export function toMongoDbOrderable(timeuuid?: string): string {
|
||||
if (!timeuuid) {
|
||||
return null;
|
||||
}
|
||||
const uuid_arr = timeuuid.split("-");
|
||||
const time_str = [uuid_arr[2], uuid_arr[1], uuid_arr[0], uuid_arr[3], uuid_arr[4]].join("-");
|
||||
return time_str;
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { getLogger, TdriveLogger, TdriveService } from "../../framework";
|
||||
import EmailPusherAPI from "./provider";
|
||||
import {
|
||||
EmailBuilderDataPayload,
|
||||
EmailBuilderRenderedResult,
|
||||
EmailBuilderTemplateName,
|
||||
EmailPusherEmailType,
|
||||
EmailPusherPayload,
|
||||
EmailPusherResponseType,
|
||||
} from "./types";
|
||||
import * as Eta from "eta";
|
||||
import { convert } from "html-to-text";
|
||||
import path from "path";
|
||||
import { existsSync } from "fs";
|
||||
import axios from "axios";
|
||||
|
||||
export default class EmailPusherClass
|
||||
extends TdriveService<EmailPusherAPI>
|
||||
implements EmailPusherAPI
|
||||
{
|
||||
readonly name = "email-pusher";
|
||||
readonly version: "1.0.0";
|
||||
logger: TdriveLogger = getLogger("email-pusher-service");
|
||||
apiKey: string;
|
||||
apiUrl: string;
|
||||
sender: string;
|
||||
|
||||
api(): EmailPusherAPI {
|
||||
return this;
|
||||
}
|
||||
|
||||
async doInit(): Promise<this> {
|
||||
Eta.configure({
|
||||
views: path.join(__dirname, "templates"),
|
||||
});
|
||||
|
||||
this.apiUrl = this.configuration.get<string>("endpoint", "");
|
||||
this.apiKey = this.configuration.get<string>("api_key", "");
|
||||
this.sender = this.configuration.get<string>("sender", "");
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a rendered HTML and text email
|
||||
*
|
||||
* @param {EmailBuilderTemplateName} template - the Eta template name
|
||||
* @param {String} language - the template language
|
||||
* @param {EmailBuilderDataPayload} data - the data
|
||||
* @returns {EmailBuilderRenderedResult} - the rendered html and text version
|
||||
*/
|
||||
async build(
|
||||
template: EmailBuilderTemplateName,
|
||||
language: string,
|
||||
data: EmailBuilderDataPayload,
|
||||
): Promise<EmailBuilderRenderedResult> {
|
||||
try {
|
||||
language = ["en", "fr"].find(l => language.toLocaleLowerCase().includes(l)) || "en";
|
||||
const templatePath = path.join(__dirname, "templates", language, `${template}.eta`);
|
||||
const subjectPath = path.join(__dirname, "templates", language, `${template}.subject.eta`);
|
||||
|
||||
if (!existsSync(templatePath)) {
|
||||
throw Error(`template not found: ${templatePath}`);
|
||||
}
|
||||
|
||||
if (!existsSync(subjectPath)) {
|
||||
throw Error(`subject template not found: ${subjectPath}`);
|
||||
}
|
||||
|
||||
const html = await Eta.renderFile(templatePath, data);
|
||||
|
||||
if (!html || !html.length) {
|
||||
throw Error("Failed to render template");
|
||||
}
|
||||
|
||||
const text = convert(html);
|
||||
|
||||
const subject = convert((await Eta.renderFile(subjectPath, data)) as string);
|
||||
|
||||
return { html, text, subject };
|
||||
} catch (error) {
|
||||
this.logger.error(`Failure when building email template: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send email
|
||||
*
|
||||
* @param {string} to - the recipient
|
||||
* @param {EmailPusherPayload} email - the email object
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async send(
|
||||
to: string,
|
||||
{ subject, html: html_body, text: text_body }: EmailPusherPayload,
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (!html_body || !text_body || !subject || !to) {
|
||||
throw Error("invalid email");
|
||||
}
|
||||
|
||||
const emailObject = {
|
||||
api_key: this.apiKey,
|
||||
to: [to],
|
||||
subject,
|
||||
text_body,
|
||||
html_body,
|
||||
sender: this.sender,
|
||||
};
|
||||
|
||||
const { data } = await axios.post<EmailPusherEmailType, EmailPusherResponseType>(
|
||||
`${this.apiUrl}`,
|
||||
emailObject,
|
||||
);
|
||||
|
||||
if (data.error && data.error.length) {
|
||||
throw Error(data.error);
|
||||
}
|
||||
|
||||
if (data.failed === 1 && data.failures.length) {
|
||||
throw Error(data.failures.join(""));
|
||||
}
|
||||
|
||||
if (data.succeeded) {
|
||||
this.logger.info("email sent");
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to send email", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { TdriveServiceProvider } from "../../framework";
|
||||
import {
|
||||
EmailBuilderDataPayload,
|
||||
EmailBuilderRenderedResult,
|
||||
EmailBuilderTemplateName,
|
||||
EmailPusherPayload,
|
||||
} from "./types";
|
||||
|
||||
export default interface EmailPusherAPI extends TdriveServiceProvider {
|
||||
build(
|
||||
template: EmailBuilderTemplateName,
|
||||
language: string,
|
||||
data: EmailBuilderDataPayload,
|
||||
): Promise<EmailBuilderRenderedResult>;
|
||||
|
||||
send(to: string, email: EmailPusherPayload): Promise<void>;
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
<!--[if mso |
|
||||
IE]>
|
||||
<table role="presentation" border="0" cellpadding="0"
|
||||
cellspacing="0">
|
||||
<% it.paragraphs.forEach(function(paragraph){ %>
|
||||
<tr>
|
||||
<td class="" width="600px" >
|
||||
<table
|
||||
align="center" border="0" cellpadding="0" cellspacing="0"
|
||||
class="" role="presentation" style="width:600px;" width="600"
|
||||
>
|
||||
<tr>
|
||||
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
|
||||
<![endif]-->
|
||||
<div style="margin:0px auto;max-width:600px;">
|
||||
<table
|
||||
align="center" border="0" cellpadding="0" cellspacing="0"
|
||||
role="presentation" style="width:100%;"
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
style="direction:ltr;font-size:0px;padding:0;text-align:center;"
|
||||
>
|
||||
<!--[if mso | IE]>
|
||||
<table
|
||||
role="presentation" border="0" cellpadding="0"
|
||||
cellspacing="0">
|
||||
<tr>
|
||||
<td class="section-wrapper-outlook"
|
||||
style="vertical-align:top;width:600px;" >
|
||||
<![endif]-->
|
||||
<div
|
||||
class="mj-column-per-100 mj-outlook-group-fix
|
||||
section-wrapper" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;"
|
||||
>
|
||||
<%~ paragraph %>
|
||||
</div>
|
||||
<!--[if mso | IE]>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<!
|
||||
[endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<% }) %>
|
||||
</table>
|
||||
<![endif]-->
|
||||
+929
File diff suppressed because one or more lines are too long
+54
@@ -0,0 +1,54 @@
|
||||
|
||||
<table
|
||||
border="0" cellpadding="0" cellspacing="0"
|
||||
role="presentation" style="vertical-align:top;" width="100%"
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
align="center" style="font-size:0px;padding:0 0 35px;word-break:break-word;"
|
||||
>
|
||||
<div
|
||||
style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:24px;font-weight:800;line-height:29px;text-align:center;color:#000000;"
|
||||
>You have been invited</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td
|
||||
align="left" class="common-text"
|
||||
style="font-size:0px;padding:0 0 20px;word-break:break-word;"
|
||||
>
|
||||
<div
|
||||
style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:13px;line-height:1;text-align:left;color:#000000;"
|
||||
>Hi romaric.mollard@gmail
|
||||
.com,
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td
|
||||
align="left"
|
||||
class="common-text" style="font-size:0px;padding:0 0 20px;word-break:break-word;"
|
||||
>
|
||||
<div
|
||||
style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:13px;line-height:1;text-align:left;color:#000000;"
|
||||
><span>romaric.mollard@gmail.com has invited you to use Tdrive
|
||||
on</span>
|
||||
<span style="color: #007AFF">Crayon Company</span>.
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td
|
||||
align="left"
|
||||
class="common-text" style="font-size:0px;padding:0;word-break:break-word;"
|
||||
>
|
||||
<div
|
||||
style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:13px;line-height:1;text-align:left;color:#000000;"
|
||||
>Since you already have an account, please sign in and switch your
|
||||
application as shown here.
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
<%~ includeFile("../common/_structure.eta", Object.assign(it, {
|
||||
follow_us: "Follow us on social media",
|
||||
sent_by: "Sent by LINAGORA | 100 Terrasse Boieldieu Tour Franklin 92042 Paris La Défense",
|
||||
help: "Help center",
|
||||
privacy: "Privacy Policy",
|
||||
terms: "Terms of Service",
|
||||
links: {
|
||||
help: "https://tdrive.app/",
|
||||
privacy: "https://tdrive.app/en/privacy-policy/",
|
||||
terms: "https://tdrive.app/en/terms-of-service/"
|
||||
}
|
||||
})) %>
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
<% layout('./_structure') %>
|
||||
<% it.title = 'Missed notifications from company ' + it.company.name %>
|
||||
|
||||
<%~ includeFile("../common/_body.eta", {
|
||||
paragraphs: [
|
||||
`
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%" >
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" style="font-size:0px;padding:0 0 8px;word-break:break-word;">
|
||||
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:24px;font-weight:800;line-height:29px;text-align:center;color:#000000;">
|
||||
While you were away
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
`,
|
||||
`
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%" >
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" style="font-size:0px;padding:0 0 16px;word-break:break-word;" >
|
||||
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:16px;line-height:29px;text-align:center;color:#000000;">
|
||||
You have received new messages! 😉
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
`,
|
||||
...it.notifications.map(notification =>
|
||||
includeFile("./notification-digest/notification.eta", notification)
|
||||
),
|
||||
`
|
||||
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:13px;line-height:1;text-align:left;color:#000000;">
|
||||
<a class="main-button" href="https://web.tdrive.app/">
|
||||
${it.notifications.length>1 ? `See all ${it.notifications.length} messages` : `See on Tdrive`}
|
||||
</a>
|
||||
</div>
|
||||
`
|
||||
]
|
||||
}) %>
|
||||
+1
@@ -0,0 +1 @@
|
||||
Missed notifications in your company <%= it.company.name %>
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
<div style="margin-bottom:16px;display:flex;flex-direction:row;">
|
||||
<div style="width:60px;align-items:end;padding:0;display:flex">
|
||||
<img style="width:48px;height:48px;border-radius:24px;" src="<%= it.message.user.picture || 'https://staging-web.tdrive.app/public/identicon/47.png' %>" alt="<%= it.message.user.first_name %>" />
|
||||
</div>
|
||||
<div style="flex:1;position:realtive;border-radius:18px;padding:0 12px;background:#F6F6F6;">
|
||||
<div style="padding:12px 0">
|
||||
<div style="text-transform: capitalize;padding:0 0 12px;font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:14px;line-height:1;text-align:left;color:#0050ff;">
|
||||
<%= [it.message.user.first_name, it.message.user.last_name].join(" ") || it.message.user.username %>
|
||||
</div>
|
||||
<div style="padding:0 0 8px;font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:16px;line-height:1;text-align:left;color:#000000;">
|
||||
<%= it.message.text %>
|
||||
</div>
|
||||
<div style="margin:8px -12px;width:calc(100%-24px);border-top:1px solid #E8E8E8"></div>
|
||||
<div style="padding:0;font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:16px;line-height:1;text-align:left;color:#000000;">
|
||||
<span style="color:#6D7885;font-size:16px;vertical-align:center;display:inline-flex;align-items:center;"><img style="width:20px;height:20px;margin-right:6px;margin-top:-1px;" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACgAAAAoCAYAAACM/rhtAAAACXBIWXMAABYlAAAWJQFJUiTwAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAJ5SURBVHgB7ZhPbtNAFMbfGztqluEE+AjpCfARegPULVTIXaCAoGoqKFW7aYSKxC5wAsoN0hO03CCcgLBr8Z/Hs2O7U9Uez9jKVJXy23hsv8n7PG9mPjsAa9Y8LAgdefXm0z4BeSGFB1+Px3NV7IvXh0NH4D5R/P3sZO8cNHChAzujDz6LG2c/hM5fPgSqeCFgCkhDBOHzqZZAAR0ggcOyneBcFRsE4wEiZPEoQBkr00mgSOBZ0UakK1VstOFIDwO/QTcHdIAAvaLt3sRKgfJoJ0Qz0KS1wDslQ7iaTMYLVbzJaN/pBy0xLZnJaMu0FmhSMtPRltHaB9ME1xswkK/1wD3lbFvLs3g7pGRW198lZ4hC/MhPZ7xnbsv3+zewqBPdKDAYHXkxJJccOYDVMXeuw80qkY0ljjHxVywuxZPntEyjk/CTnUd91+fm0/IiYTmnePYvqGFVIvF8zR+SnWd2L4Do4ux4b1bZF1rw8u3HQBCepu0kod0vJ+8ndbHp/I37vT9ZMl4gn4/ebYIBrVaxDQcpc0ELbDhIgbFAWw7SWqAtBykwFmjLQVoLtLlAsnxgiM0FklK7D+bfGn7FreU13qC5t3pOEZQbdFpiSvvcktrbblPZK51E/taoZZnYb4i51Upwz8rivrjgwzdQUFnivHRzWCU8mqo3oILaEle+YmFvCvmooUNb/6LoV11/jk3jprmYnyGEgXxf9YqlJbCKndHhZbFt8Px5okpg4tcqtFexbQcxFmjbQQq0Bdp2EGOBth2kzKsbaNtBCrQF8nL3sqNGyTAhr2x3WCAp2v9uESX8qYjP4xgPmn/UnUQQD7jTvO5bY82ax8J/crNN34KqW0cAAAAASUVORK5CYII=" /> <%= it.workspace?.name || "Direct chat" %><%= it.workspace?.name ? (" • " + it.channel.name) : "" %></span>
|
||||
<span style="margin-left:16px;color:#6D7885;font-size:16px;vertical-align:center;display:inline-flex;align-items:center;"><img style="width:20px;height:20px;margin-right:6px;margin-top:0px;" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACgAAAAoCAYAAACM/rhtAAAACXBIWXMAABYlAAAWJQFJUiTwAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAANwSURBVHgB7VhLUttAEO0Z2yG7+AY4JwjJCcQJwLlAYJsi4E1iF4RCFD+TDcbxIqtALhCTE0Q3wJwAkQuE7BJjdafHlmShnyXbKlj4Vbk01nTPvOlR90w3wCOHSCNcqehFfPpkg7XM5tHmtzS672oHK0RYyv+zGo2GfptUT0IK9OZyOgHpRHT+9v3BQlK9teqexo8zIeQOj1GBFEhFEIQouooSizAGmOR8KvkkQmpreeULEuQJCVgYTAQdIki0VSxbZFlXDy3cbX3avkikO0pgvXa4w1taYcmxLBYDU4A4b9Y3d+OEIglWqkcllNh2Vp4VlEW7eFf+cqybYf35KEU/OXYOQ5Iouluco+Vur3cFCVAQBY0fZw4hJLpl62n9cXmOJ7LQ5ubLMN1QC/a3lb3VZnZLgOXW8baxVts/54HfDAbGRfUOEkB5MTvHz8FweNqqb1fWPuwt87sz59Ph8XZ5PN2vG+rFTG7FbROuOkTQEg1+mCSo0wPLhITg2NdRemqxPbLUGKCcRC3ckREgN5Qz+nUDFuyvTMq2TdRo1T8uQobgAK4sq/XnC9mVoAWF57tD+AEZAwV559D8/SEERWnYpA5kDIHDOcKCeICgIHjmtBFl4jNzXOQhb8b3++E5znI5OOFvBLIECj4yadDmb77k78+P0NcgYxDF96e7LDwAYi2o3D5NvBuPQK7kBPHw/hgoclFn5LTAZz5YgJH9j36LZwQnxYzgpBgVqKcC+4a0xE0OKXzNT5GyZm5BlQ/b17cV/mkqZd2oHp4k1Q8Q5PPwBqYJoiX/K/RciLs4TF8FiT9+2VgLqigPWcCTId7LrwX89osGCRKZw6aYOKPjHbkKeWe4bSk8iRnejCYIYLidUizBhFCJEJ/pp2An+Ypcj3qr7hx0bw7Drx/ISVTiYs0Vrj3ZVuLsLS3Yu8vsQN/tv+bn+tZzv0zAgqrypFJD5z9nW+31rf2JLRkgV9tb5FvMV+c/zxkaeiIrC+vVg0sS96oKBkm4AAuvVCqA8i51OtB3upx8IRCWwXMZ5mT+snm09SpcJwJcKCojcXVhSFLjgTUQUqUCkIMCjAXfzUqR69Ld6yjxkcUjrgrovBWqmlCC6YH6FQuBTa4y6HGCiSusdhFSlTDm+TQopq12ccnkesAMf/HDyP+1OmkqrTPM8FD4DykBZtaZWmI7AAAAAElFTkSuQmCC" /> <%= (new Date(it.message.created_at)).toLocaleDateString("en-US") %></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
<%~ includeFile("../common/_structure.eta", Object.assign(it, {
|
||||
follow_us: "Retrouvez nous sur les réseaux",
|
||||
sent_by: "Envoyé par LINAGORA | 100 Terrasse Boieldieu Tour Franklin 92042 Paris La Défense",
|
||||
help: "Centre d'aide",
|
||||
privacy: "Confidentialité",
|
||||
terms: "CGU",
|
||||
links: {
|
||||
help: "https://tdrive.app/",
|
||||
privacy: "https://tdrive.app/fr/privacy-policy/",
|
||||
terms: "https://tdrive.app/fr/terms-of-service/"
|
||||
}
|
||||
})) %>
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
<% layout('./_structure') %>
|
||||
<% it.title = 'Notifications manquées de l\'entreprise ' + it.company.name %>
|
||||
|
||||
|
||||
<%~ includeFile("../common/_body.eta", {
|
||||
paragraphs: [
|
||||
`
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%" >
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" style="font-size:0px;padding:0 0 8px;word-break:break-word;">
|
||||
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:24px;font-weight:800;line-height:29px;text-align:center;color:#000000;">
|
||||
Pendant votre absense
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
`,
|
||||
`
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%" >
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" style="font-size:0px;padding:0 0 16px;word-break:break-word;" >
|
||||
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:16px;line-height:29px;text-align:center;color:#000000;">
|
||||
Vous avez reçu de nouveaux messages! 😉
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
`,
|
||||
...it.notifications.map(notification =>
|
||||
includeFile("./notification-digest/notification.eta", notification)
|
||||
)
|
||||
,
|
||||
`
|
||||
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:13px;line-height:1;text-align:left;color:#000000;">
|
||||
<a class="main-button" href="https://web.tdrive.app/">
|
||||
${it.notifications.length>1 ? `Voir les ${it.notifications.length} messages` : `Voir sur Tdrive`}
|
||||
</a>
|
||||
</div>
|
||||
`
|
||||
]
|
||||
}) %>
|
||||
+1
@@ -0,0 +1 @@
|
||||
Notifications manquées dans votre entreprise <%= it.company.name %>
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
<div style="margin-bottom:16px;display:flex;flex-direction:row;">
|
||||
<div style="width:60px;align-items:end;padding:0;display:flex">
|
||||
<img style="width:48px;height:48px;border-radius:24px;" src="<%= it.message.user.picture || 'https://staging-web.tdrive.app/public/identicon/47.png' %>" alt="<%= it.message.user.first_name %>" />
|
||||
</div>
|
||||
<div style="flex:1;position:realtive;border-radius:18px;padding:0 12px;background:#F6F6F6;">
|
||||
<div style="padding:12px 0">
|
||||
<div style="text-transform: capitalize;padding:0 0 12px;font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:14px;line-height:1;text-align:left;color:#0050ff;">
|
||||
<%= [it.message.user.first_name, it.message.user.last_name].join(" ") || it.message.user.username %>
|
||||
</div>
|
||||
<div style="padding:0 0 8px;font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:16px;line-height:1;text-align:left;color:#000000;">
|
||||
<%= it.message.text %>
|
||||
</div>
|
||||
<div style="margin:8px -12px;width:calc(100%-24px);border-top:1px solid #E8E8E8"></div>
|
||||
<div style="padding:0;font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:16px;line-height:1;text-align:left;color:#000000;">
|
||||
<span style="color:#6D7885;font-size:16px;vertical-align:center;display:inline-flex;align-items:center;"><img style="width:20px;height:20px;margin-right:6px;margin-top:-1px;" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACgAAAAoCAYAAACM/rhtAAAACXBIWXMAABYlAAAWJQFJUiTwAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAJ5SURBVHgB7ZhPbtNAFMbfGztqluEE+AjpCfARegPULVTIXaCAoGoqKFW7aYSKxC5wAsoN0hO03CCcgLBr8Z/Hs2O7U9Uez9jKVJXy23hsv8n7PG9mPjsAa9Y8LAgdefXm0z4BeSGFB1+Px3NV7IvXh0NH4D5R/P3sZO8cNHChAzujDz6LG2c/hM5fPgSqeCFgCkhDBOHzqZZAAR0ggcOyneBcFRsE4wEiZPEoQBkr00mgSOBZ0UakK1VstOFIDwO/QTcHdIAAvaLt3sRKgfJoJ0Qz0KS1wDslQ7iaTMYLVbzJaN/pBy0xLZnJaMu0FmhSMtPRltHaB9ME1xswkK/1wD3lbFvLs3g7pGRW198lZ4hC/MhPZ7xnbsv3+zewqBPdKDAYHXkxJJccOYDVMXeuw80qkY0ljjHxVywuxZPntEyjk/CTnUd91+fm0/IiYTmnePYvqGFVIvF8zR+SnWd2L4Do4ux4b1bZF1rw8u3HQBCepu0kod0vJ+8ndbHp/I37vT9ZMl4gn4/ebYIBrVaxDQcpc0ELbDhIgbFAWw7SWqAtBykwFmjLQVoLtLlAsnxgiM0FklK7D+bfGn7FreU13qC5t3pOEZQbdFpiSvvcktrbblPZK51E/taoZZnYb4i51Upwz8rivrjgwzdQUFnivHRzWCU8mqo3oILaEle+YmFvCvmooUNb/6LoV11/jk3jprmYnyGEgXxf9YqlJbCKndHhZbFt8Px5okpg4tcqtFexbQcxFmjbQQq0Bdp2EGOBth2kzKsbaNtBCrQF8nL3sqNGyTAhr2x3WCAp2v9uESX8qYjP4xgPmn/UnUQQD7jTvO5bY82ax8J/crNN34KqW0cAAAAASUVORK5CYII=" /> <%= it.workspace?.name || "Direct chat" %><%= it.workspace?.name ? (" • " + it.channel.name) : "" %></span>
|
||||
<span style="margin-left:16px;color:#6D7885;font-size:16px;vertical-align:center;display:inline-flex;align-items:center;"><img style="width:20px;height:20px;margin-right:6px;margin-top:0px;" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACgAAAAoCAYAAACM/rhtAAAACXBIWXMAABYlAAAWJQFJUiTwAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAANwSURBVHgB7VhLUttAEO0Z2yG7+AY4JwjJCcQJwLlAYJsi4E1iF4RCFD+TDcbxIqtALhCTE0Q3wJwAkQuE7BJjdafHlmShnyXbKlj4Vbk01nTPvOlR90w3wCOHSCNcqehFfPpkg7XM5tHmtzS672oHK0RYyv+zGo2GfptUT0IK9OZyOgHpRHT+9v3BQlK9teqexo8zIeQOj1GBFEhFEIQouooSizAGmOR8KvkkQmpreeULEuQJCVgYTAQdIki0VSxbZFlXDy3cbX3avkikO0pgvXa4w1taYcmxLBYDU4A4b9Y3d+OEIglWqkcllNh2Vp4VlEW7eFf+cqybYf35KEU/OXYOQ5Iouluco+Vur3cFCVAQBY0fZw4hJLpl62n9cXmOJ7LQ5ubLMN1QC/a3lb3VZnZLgOXW8baxVts/54HfDAbGRfUOEkB5MTvHz8FweNqqb1fWPuwt87sz59Ph8XZ5PN2vG+rFTG7FbROuOkTQEg1+mCSo0wPLhITg2NdRemqxPbLUGKCcRC3ckREgN5Qz+nUDFuyvTMq2TdRo1T8uQobgAK4sq/XnC9mVoAWF57tD+AEZAwV559D8/SEERWnYpA5kDIHDOcKCeICgIHjmtBFl4jNzXOQhb8b3++E5znI5OOFvBLIECj4yadDmb77k78+P0NcgYxDF96e7LDwAYi2o3D5NvBuPQK7kBPHw/hgoclFn5LTAZz5YgJH9j36LZwQnxYzgpBgVqKcC+4a0xE0OKXzNT5GyZm5BlQ/b17cV/mkqZd2oHp4k1Q8Q5PPwBqYJoiX/K/RciLs4TF8FiT9+2VgLqigPWcCTId7LrwX89osGCRKZw6aYOKPjHbkKeWe4bSk8iRnejCYIYLidUizBhFCJEJ/pp2An+Ypcj3qr7hx0bw7Drx/ISVTiYs0Vrj3ZVuLsLS3Yu8vsQN/tv+bn+tZzv0zAgqrypFJD5z9nW+31rf2JLRkgV9tb5FvMV+c/zxkaeiIrC+vVg0sS96oKBkm4AAuvVCqA8i51OtB3upx8IRCWwXMZ5mT+snm09SpcJwJcKCojcXVhSFLjgTUQUqUCkIMCjAXfzUqR69Ld6yjxkcUjrgrovBWqmlCC6YH6FQuBTa4y6HGCiSusdhFSlTDm+TQopq12ccnkesAMf/HDyP+1OmkqrTPM8FD4DykBZtaZWmI7AAAAAElFTkSuQmCC" /> <%= (new Date(it.message.created_at)).toLocaleDateString("en-US") %></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Channel } from "../../../../services/channels/entities/channel";
|
||||
import Company from "../../../../services/user/entities/company";
|
||||
import Workspace from "../../../../services/workspaces/entities/workspace";
|
||||
import User from "../../../../services/user/entities/user";
|
||||
|
||||
export type EmailBuilderDataPayload = {
|
||||
user: User;
|
||||
company: Company;
|
||||
notifications: {
|
||||
channel: Channel;
|
||||
workspace: Workspace;
|
||||
}[];
|
||||
};
|
||||
|
||||
export type EmailBuilderRenderedResult = {
|
||||
html: string;
|
||||
text: string;
|
||||
subject: string;
|
||||
};
|
||||
|
||||
export type EmailBuilderTemplateName = "notification-digest";
|
||||
|
||||
export type EmailPusherPayload = {
|
||||
subject: string;
|
||||
html: string;
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type EmailPusherEmailType = {
|
||||
sender: string;
|
||||
html_body: string;
|
||||
text_body: string;
|
||||
to: string[];
|
||||
subject: string;
|
||||
};
|
||||
|
||||
export type EmailPusherResponseType = {
|
||||
data: {
|
||||
succeeded: number;
|
||||
failed: number;
|
||||
failures: string[];
|
||||
error?: string;
|
||||
error_code?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type EmailLanguageType = "en" | "fr";
|
||||
@@ -0,0 +1,151 @@
|
||||
import axios, { AxiosInstance } from "axios";
|
||||
|
||||
import { KnowledgeGraphCreateBodyRequest, KnowledgeGraphCreateMessageObjectData } from "./types";
|
||||
|
||||
import { md5 } from "../../../../core/crypto";
|
||||
import { Channel } from "../../../../services/channels/entities";
|
||||
import gr from "../../../../services/global-resolver";
|
||||
import Company from "../../../../services/user/entities/company";
|
||||
import User from "../../../../services/user/entities/user";
|
||||
import Workspace from "../../../../services/workspaces/entities/workspace";
|
||||
import { getLogger, TdriveLogger } from "../../framework";
|
||||
|
||||
export default class KnowledgeGraphAPIClient {
|
||||
protected readonly version = "1.0.0";
|
||||
protected readonly axiosInstance: AxiosInstance = axios.create();
|
||||
readonly apiUrl: string;
|
||||
readonly logger: TdriveLogger = getLogger("knowledge-graph-api-client");
|
||||
|
||||
constructor(apiUrl: string) {
|
||||
this.apiUrl = apiUrl;
|
||||
}
|
||||
|
||||
private async getUserKGId(id: string, provider_id?: string) {
|
||||
provider_id = provider_id || (await gr.services.users.get({ id }))?.identity_provider_id;
|
||||
return provider_id;
|
||||
}
|
||||
|
||||
private async getUserKGMailId(id: string, email?: string) {
|
||||
email = email || (await gr.services.users.get({ id }))?.email_canonical;
|
||||
return md5(email.trim().toLocaleLowerCase());
|
||||
}
|
||||
|
||||
private async getCompanyKGId(id: string, identity_provider_id?: string) {
|
||||
identity_provider_id =
|
||||
identity_provider_id ||
|
||||
(await gr.services.companies.getCompany({ id }))?.identity_provider_id ||
|
||||
id;
|
||||
return identity_provider_id;
|
||||
}
|
||||
|
||||
public async onCompanyCreated(company: Partial<Company>): Promise<void> {
|
||||
this.send({
|
||||
records: [
|
||||
{
|
||||
key: "null",
|
||||
value: {
|
||||
id: "Company",
|
||||
properties: {
|
||||
_kg_company_id: await this.getCompanyKGId(company.id, company.identity_provider_id),
|
||||
company_id: company.id,
|
||||
company_name: company.displayName || company.name,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
public async onWorkspaceCreated(workspace: Partial<Workspace>): Promise<void> {
|
||||
const response = await this.send({
|
||||
records: [
|
||||
{
|
||||
key: "null",
|
||||
value: {
|
||||
id: "Workspace",
|
||||
properties: {
|
||||
_kg_company_id: await this.getCompanyKGId(workspace.company_id),
|
||||
company_id: workspace.company_id,
|
||||
workspace_name: workspace.name,
|
||||
workspace_id: workspace.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (response.statusText === "OK") {
|
||||
this.logger.info("onWorkspaceCreated %o", response.config.data);
|
||||
}
|
||||
}
|
||||
|
||||
public async onUserCreated(companyId: string, user: Partial<User>): Promise<void> {
|
||||
const response = await this.send({
|
||||
records: [
|
||||
{
|
||||
key: "null",
|
||||
value: {
|
||||
id: "User",
|
||||
properties: {
|
||||
_kg_user_id: await this.getUserKGId(user.id, user.identity_provider_id),
|
||||
_kg_email_id: await this.getUserKGMailId(user.id, user.email_canonical),
|
||||
_kg_company_all_id: await Promise.all(
|
||||
user.cache.companies.map(async c => await this.getCompanyKGId(c)),
|
||||
),
|
||||
user_id: user.id,
|
||||
email: user.email_canonical,
|
||||
username: user.username_canonical,
|
||||
user_last_activity: user.last_activity,
|
||||
first_name: user.first_name,
|
||||
user_created_at: user.creation_date,
|
||||
last_name: user.last_name,
|
||||
company_id: companyId,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (response.statusText === "OK") {
|
||||
this.logger.info("onUserCreated %o", response.config.data);
|
||||
}
|
||||
}
|
||||
|
||||
public async onChannelCreated(channel: Partial<Channel>): Promise<void> {
|
||||
const response = await this.send({
|
||||
records: [
|
||||
{
|
||||
key: "null",
|
||||
value: {
|
||||
id: "Channel",
|
||||
properties: {
|
||||
_kg_user_id: await this.getUserKGId(channel.owner),
|
||||
_kg_email_id: await this.getUserKGMailId(channel.owner),
|
||||
_kg_company_id: await this.getCompanyKGId(channel.company_id),
|
||||
channel_id: channel.id,
|
||||
channel_name: channel.name,
|
||||
channel_owner: channel.owner,
|
||||
workspace_id: channel.workspace_id,
|
||||
company_id: channel.company_id,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (response.statusText === "OK") {
|
||||
this.logger.info("onChannelCreated %o", response.config.data);
|
||||
}
|
||||
}
|
||||
|
||||
private async send(data: any) {
|
||||
return await this.axiosInstance.post<
|
||||
KnowledgeGraphCreateBodyRequest<KnowledgeGraphCreateMessageObjectData[]>
|
||||
>(`${this.apiUrl}/topics/tdrive`, data, {
|
||||
headers: {
|
||||
"Content-Type": "application/vnd.kafka.json.v2+json",
|
||||
Accept: "application/vnd.kafka.v2+json",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { Configuration, Consumes, getLogger, TdriveLogger, TdriveService } from "../../framework";
|
||||
import { localEventBus } from "../../framework/event-bus";
|
||||
import KnowledgeGraphAPI from "./provider";
|
||||
import Workspace from "../../../../services/workspaces/entities/workspace";
|
||||
import Company from "../../../../services/user/entities/company";
|
||||
import User from "../../../../services/user/entities/user";
|
||||
import { Channel } from "../../../../services/channels/entities";
|
||||
import {
|
||||
KnowledgeGraphGenericEventPayload,
|
||||
KnowledgeGraphEvents,
|
||||
KnowledgeGraphCallbackEvent,
|
||||
} from "./types";
|
||||
import KnowledgeGraphAPIClient from "./api-client";
|
||||
import gr from "../../../../services/global-resolver";
|
||||
|
||||
@Consumes([])
|
||||
export default class KnowledgeGraphService
|
||||
extends TdriveService<KnowledgeGraphAPI>
|
||||
implements KnowledgeGraphAPI
|
||||
{
|
||||
readonly name = "knowledge-graph";
|
||||
readonly version = "1.0.0";
|
||||
protected kgAPIClient: KnowledgeGraphAPIClient = this.getKnowledgeGraphApiClient();
|
||||
logger: TdriveLogger = getLogger("knowledge-graph-service");
|
||||
|
||||
async doInit(): Promise<this> {
|
||||
const use = this.getConfigurationEntry<boolean>("use");
|
||||
|
||||
if (!use) {
|
||||
this.logger.warn("Knowledge graph is not used");
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
localEventBus.subscribe<KnowledgeGraphGenericEventPayload<Company>>(
|
||||
KnowledgeGraphEvents.COMPANY_UPSERT,
|
||||
this.onCompanyCreated.bind(this),
|
||||
);
|
||||
|
||||
localEventBus.subscribe<KnowledgeGraphGenericEventPayload<Workspace>>(
|
||||
KnowledgeGraphEvents.WORKSPACE_UPSERT,
|
||||
this.onWorkspaceCreated.bind(this),
|
||||
);
|
||||
|
||||
localEventBus.subscribe<KnowledgeGraphGenericEventPayload<Channel>>(
|
||||
KnowledgeGraphEvents.CHANNEL_UPSERT,
|
||||
this.onChannelCreated.bind(this),
|
||||
);
|
||||
|
||||
localEventBus.subscribe<KnowledgeGraphGenericEventPayload<User>>(
|
||||
KnowledgeGraphEvents.USER_UPSERT,
|
||||
this.onUserCreated.bind(this),
|
||||
);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/** When the KG service send us new events */
|
||||
async onCallbackEvent(token: string, data: KnowledgeGraphCallbackEvent): Promise<void> {
|
||||
if (token === this.getConfigurationEntry<string>("callback_token")) {
|
||||
this.logger.info("Unimplemented: KnowledgeGraph - Callback event", data);
|
||||
} else {
|
||||
throw new Error("Invalid token");
|
||||
}
|
||||
}
|
||||
|
||||
async onCompanyCreated(data: KnowledgeGraphGenericEventPayload<Company>): Promise<void> {
|
||||
this.logger.info(`${KnowledgeGraphEvents.COMPANY_UPSERT} %o`, data);
|
||||
|
||||
if (this.kgAPIClient && (await this.shouldForwardEvent([data.resource.id]))) {
|
||||
this.kgAPIClient.onCompanyCreated(data.resource);
|
||||
}
|
||||
}
|
||||
|
||||
async onWorkspaceCreated(data: KnowledgeGraphGenericEventPayload<Workspace>): Promise<void> {
|
||||
this.logger.info(`${KnowledgeGraphEvents.WORKSPACE_UPSERT} %o`, data);
|
||||
|
||||
if (this.kgAPIClient && (await this.shouldForwardEvent([data.resource.company_id]))) {
|
||||
this.kgAPIClient.onWorkspaceCreated(data.resource);
|
||||
}
|
||||
}
|
||||
|
||||
async onChannelCreated(data: KnowledgeGraphGenericEventPayload<Channel>): Promise<void> {
|
||||
this.logger.info(`${KnowledgeGraphEvents.CHANNEL_UPSERT} %o`, data);
|
||||
|
||||
if (this.kgAPIClient && (await this.shouldForwardEvent([data.resource.company_id]))) {
|
||||
this.kgAPIClient.onChannelCreated(data.resource);
|
||||
}
|
||||
}
|
||||
|
||||
async onUserCreated(data: KnowledgeGraphGenericEventPayload<User>): Promise<void> {
|
||||
this.logger.info(`${KnowledgeGraphEvents.USER_UPSERT} %o`, data);
|
||||
|
||||
if (
|
||||
this.kgAPIClient &&
|
||||
(await this.shouldForwardEvent(data.resource.cache?.companies || [], data.resource.id))
|
||||
) {
|
||||
for (const companyId of data.resource.cache?.companies || []) {
|
||||
this.kgAPIClient.onUserCreated(companyId, data.resource);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private getConfigurationEntry<T>(key: string): T {
|
||||
const configuration = new Configuration("knowledge-graph");
|
||||
return configuration.get(key);
|
||||
}
|
||||
|
||||
private getKnowledgeGraphApiClient(): KnowledgeGraphAPIClient {
|
||||
const endpoint = this.getConfigurationEntry<string>("endpoint");
|
||||
|
||||
if (endpoint && endpoint.length) {
|
||||
this.kgAPIClient = new KnowledgeGraphAPIClient(endpoint);
|
||||
} else {
|
||||
this.logger.info("KnowledgeGraph - No endpoint defined in default.json");
|
||||
}
|
||||
|
||||
return this.kgAPIClient;
|
||||
}
|
||||
|
||||
async shouldForwardEvent(
|
||||
companyIds: string[] | null,
|
||||
userId?: string,
|
||||
): Promise<false | "all" | "metadata"> {
|
||||
const user = userId ? await gr.services.users.get({ id: userId }) : null;
|
||||
const forwardedCompanies = this.getConfigurationEntry<string[]>("forwarded_companies");
|
||||
const isCompanyForwarded = !!(companyIds || []).find(v => forwardedCompanies.includes(v));
|
||||
if (user?.preferences && !user.preferences.knowledge_graph)
|
||||
user.preferences.knowledge_graph = "metadata";
|
||||
return (!userId || (user && user.preferences?.knowledge_graph !== "nothing")) &&
|
||||
(!companyIds ||
|
||||
companyIds.length === 0 ||
|
||||
isCompanyForwarded ||
|
||||
forwardedCompanies.length === 0)
|
||||
? user
|
||||
? (user.preferences.knowledge_graph as "all" | "metadata")
|
||||
: "all"
|
||||
: false;
|
||||
}
|
||||
|
||||
api(): KnowledgeGraphAPI {
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import Company from "../../../../services/user/entities/company";
|
||||
import { Channel } from "../../../../services/channels/entities";
|
||||
import { TdriveServiceProvider } from "../../framework";
|
||||
import { KnowledgeGraphGenericEventPayload } from "./types";
|
||||
import Workspace from "../../../../services/workspaces/entities/workspace";
|
||||
import User from "../../../../services/user/entities/user";
|
||||
|
||||
export default interface KnowledgeGraphAPI extends TdriveServiceProvider {
|
||||
onCompanyCreated(data: KnowledgeGraphGenericEventPayload<Company>): void;
|
||||
onWorkspaceCreated(data: KnowledgeGraphGenericEventPayload<Workspace>): void;
|
||||
onChannelCreated(data: KnowledgeGraphGenericEventPayload<Channel>): void;
|
||||
onUserCreated(data: KnowledgeGraphGenericEventPayload<User>): void;
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
export type KnowledgeGraphCreateCompanyObjectData = {
|
||||
key: string;
|
||||
value: {
|
||||
id: string;
|
||||
properties: {
|
||||
company_id: string;
|
||||
company_name: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export type KnowledgeGraphCreateWorkspaceObjectData = {
|
||||
key: string;
|
||||
value: {
|
||||
id: string;
|
||||
properties: {
|
||||
company_id: string;
|
||||
workspace_name: string;
|
||||
workspace_id: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export type KnowledgeGraphCreateUserObjectData = {
|
||||
key: string;
|
||||
value: {
|
||||
id: string;
|
||||
properties: {
|
||||
user_id: string;
|
||||
email: string;
|
||||
username: string;
|
||||
user_last_activity: string;
|
||||
first_name: string;
|
||||
user_created_at: string;
|
||||
last_name: string;
|
||||
company_id: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export type KnowledgeGraphCreateChannelObjectData = {
|
||||
key: string;
|
||||
value: {
|
||||
id: string;
|
||||
properties: {
|
||||
channel_id: string;
|
||||
channel_name: string;
|
||||
channel_owner: string;
|
||||
workspace_id: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export type KnowledgeGraphCreateMessageObjectData = {
|
||||
key: string;
|
||||
value: {
|
||||
id: string;
|
||||
properties: {
|
||||
message_thread_id: string;
|
||||
message_created_at: string;
|
||||
message_content: string;
|
||||
type_message: string;
|
||||
message_updated_at: string;
|
||||
user_id: string;
|
||||
channel_id: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export type KnowledgeGraphCreateBodyRequest<T> = {
|
||||
records: T;
|
||||
};
|
||||
|
||||
export type KnowledgeGraphRelationLinkObject = {
|
||||
relation: "mention" | "sender" | "parent" | "children" | "owner";
|
||||
type: "user" | "channel" | "workspace" | "company" | "message";
|
||||
id: string;
|
||||
};
|
||||
|
||||
export type KnowledgeGraphGenericEventPayload<T> = {
|
||||
id: string;
|
||||
resource: Partial<T>;
|
||||
links: KnowledgeGraphRelationLinkObject[];
|
||||
};
|
||||
|
||||
export enum KnowledgeGraphEvents {
|
||||
COMPANY_UPSERT = "kg:company:upsert",
|
||||
WORKSPACE_UPSERT = "kg:workspace:upsert",
|
||||
CHANNEL_UPSERT = "kg:channel:upsert",
|
||||
MESSAGE_UPSERT = "kg:message:upsert",
|
||||
USER_UPSERT = "kg:user:upsert",
|
||||
}
|
||||
|
||||
export type KnowledgeGraphCallbackEvent = {
|
||||
recipients: {
|
||||
type: "user";
|
||||
id: string; // KG user id which is a md5 of the email
|
||||
}[];
|
||||
event: {
|
||||
type: "user_tags"; //More events will be added later
|
||||
data: {
|
||||
//For user_tags event only
|
||||
tags?: {
|
||||
value: string;
|
||||
weight: number;
|
||||
}[];
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,143 @@
|
||||
import { logger } from "../../../framework/logger";
|
||||
import { constants as CONSTANTS } from "./constants";
|
||||
import { ConfirmChannel, Replies, Message, Options, ConsumeMessage } from "amqplib";
|
||||
|
||||
const LOG_PREFIX = "service.message-queue.amqp.AmqpClient -";
|
||||
|
||||
export type AmqpCallbackType = (
|
||||
err: Error,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
parsedMessage: any,
|
||||
originalMessage: ConsumeMessage,
|
||||
) => void;
|
||||
|
||||
export type PublishOptions = {
|
||||
/**
|
||||
* Published message TTL in ms.
|
||||
* Up to the underlying layer to deal with it (if supported) and so to not deliver the message if expired.
|
||||
*/
|
||||
ttl?: number | null;
|
||||
};
|
||||
|
||||
export type SubscribeOptions = {
|
||||
/**
|
||||
* Message TTL in ms.
|
||||
* This value is used (if supported) by the underlying layer to not deliver message to the local subscriber if message expired.
|
||||
*/
|
||||
ttl?: number | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Low level AMQP client using AMQP channel the right way.
|
||||
*
|
||||
* see http://www.squaremobius.net/amqp.node/ for the amqp documentation
|
||||
*/
|
||||
export class AmqpClient {
|
||||
protected _subscribeCallbackToConsumerTags: Map<AmqpCallbackType, string[]>;
|
||||
protected _connected = false;
|
||||
|
||||
constructor(protected channel: ConfirmChannel) {
|
||||
this._subscribeCallbackToConsumerTags = new Map();
|
||||
}
|
||||
|
||||
get connected(): boolean {
|
||||
return this._connected;
|
||||
}
|
||||
|
||||
set connected(value: boolean) {
|
||||
this._connected = value;
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
logger.info(`${LOG_PREFIX} Closing the connection`);
|
||||
|
||||
try {
|
||||
this.channel.close();
|
||||
} catch (err) {
|
||||
logger.debug({ err }, `${LOG_PREFIX} Can not close the channel (probably already closed)`);
|
||||
}
|
||||
}
|
||||
|
||||
assertExchange(
|
||||
exchange: string,
|
||||
type = CONSTANTS.EXCHANGE_TYPES.topic,
|
||||
): Promise<Replies.AssertExchange> {
|
||||
logger.debug(`${LOG_PREFIX} Assert exchange ${exchange} of type ${type}`);
|
||||
return new Promise((r, e) => this.channel.assertExchange(exchange, type).then(r).catch(e));
|
||||
}
|
||||
|
||||
ack(message: Message, allUpTo = false): void {
|
||||
logger.debug(`${LOG_PREFIX} Ack message`);
|
||||
this.channel.ack(message, allUpTo);
|
||||
}
|
||||
|
||||
assertQueue(name: string, options: Options.AssertQueue): Promise<Replies.AssertQueue> {
|
||||
logger.debug(`${LOG_PREFIX} Assert queue ${name} with options %o`, options);
|
||||
return new Promise((r, e) =>
|
||||
this.channel
|
||||
.assertQueue(name, options)
|
||||
.then(result => {
|
||||
logger.debug(`${LOG_PREFIX} Queue created %o`, result);
|
||||
|
||||
return result;
|
||||
})
|
||||
.then(r)
|
||||
.catch(e),
|
||||
);
|
||||
}
|
||||
|
||||
assertBinding(queue: string, exchange: string, routingPattern?: string): Promise<Replies.Empty> {
|
||||
logger.debug(
|
||||
`${LOG_PREFIX} Bind queue ${queue} on exchange ${exchange} with pattern ${routingPattern}`,
|
||||
);
|
||||
return new Promise((r, e) =>
|
||||
this.channel.bindQueue(queue, exchange, routingPattern).then(r).catch(e),
|
||||
);
|
||||
}
|
||||
|
||||
send(exchange: string, data: unknown, routingKey = "", options?: Options.Publish): boolean {
|
||||
logger.debug(`${LOG_PREFIX} Publish message to exchange ${exchange} with options %o`, options);
|
||||
return this.channel.publish(exchange, routingKey, dataAsBuffer(data), options);
|
||||
}
|
||||
|
||||
consume(queue: string, options: Options.Consume, callback: AmqpCallbackType): Promise<void> {
|
||||
logger.debug(`${LOG_PREFIX} Consume queue ${queue} with options %o`, options);
|
||||
return new Promise((r, e) =>
|
||||
this.channel
|
||||
.consume(queue, onMessage, options)
|
||||
.then(res => this._registerNewConsumerTag(callback, res.consumerTag, queue))
|
||||
.then(r)
|
||||
.catch(e),
|
||||
);
|
||||
|
||||
function onMessage(originalMessage: ConsumeMessage) {
|
||||
try {
|
||||
const message = JSON.parse(originalMessage.content.toString());
|
||||
callback(null, message, originalMessage);
|
||||
} catch (err) {
|
||||
logger.warn({ err }, `${LOG_PREFIX} Can not parse the incoming message`);
|
||||
callback(err, null, originalMessage);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_registerNewConsumerTag(
|
||||
callback: AmqpCallbackType,
|
||||
consumerTag: string,
|
||||
queueName: string,
|
||||
): void {
|
||||
const sameCallbackTags = this._subscribeCallbackToConsumerTags.get(callback) || [];
|
||||
|
||||
sameCallbackTags.push(consumerTag);
|
||||
this._subscribeCallbackToConsumerTags.set(callback, sameCallbackTags);
|
||||
|
||||
logger.info(
|
||||
`${LOG_PREFIX} A new consumer has been created for queue ${queueName}: ${consumerTag}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function dataAsBuffer(data: any): Buffer {
|
||||
return Buffer.from(JSON.stringify(data));
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
export const constants = {
|
||||
DEFAULT_AMQP_PROTOCOL: "amqp",
|
||||
DEFAULT_AMQP_HOST: "localhost",
|
||||
DEFAULT_AMQP_PORT: "5672",
|
||||
DEFAULT_AMQP_USERNAME: "guest",
|
||||
DEFAULT_AMQP_PASSWORD: "guest",
|
||||
|
||||
EXCHANGE_TYPES: {
|
||||
pubsub: "fanout",
|
||||
topic: "topic",
|
||||
},
|
||||
|
||||
PUBSUB_EXCHANGE: {
|
||||
type: "fanout",
|
||||
routingKey: "", // not considered for 'fanout' exchange
|
||||
encoding: "utf8",
|
||||
},
|
||||
|
||||
SUBSCRIBER: {
|
||||
queueName: "", // This is the message-queue pattern with amqp, the server allocates a free queue name for us
|
||||
queueOptions: {
|
||||
exclusive: true,
|
||||
durable: false,
|
||||
autoDelete: true,
|
||||
},
|
||||
durableQueueOptions: {
|
||||
exclusive: false,
|
||||
durable: true,
|
||||
autoDelete: false,
|
||||
},
|
||||
consumeOptions: { noAck: false },
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Subscription } from "rxjs";
|
||||
import { logger as rootLogger } from "../../../framework/logger";
|
||||
import {
|
||||
MessageQueueAdapter,
|
||||
MessageQueueClient,
|
||||
MessageQueueListener,
|
||||
MessageQueueMessage,
|
||||
MessageQueueSubscriptionOptions,
|
||||
} from "../api";
|
||||
import MessageQueueProxy from "../proxy";
|
||||
import { AMQPMessageQueueManager } from "./manager";
|
||||
import { SkipCLI } from "../../../framework/decorators/skip";
|
||||
|
||||
const logger = rootLogger.child({
|
||||
component: "tdrive.core.platform.services.message-queue.amqp",
|
||||
});
|
||||
|
||||
export class AMQPMessageQueueService implements MessageQueueAdapter {
|
||||
version: "1";
|
||||
clientProxy: MessageQueueProxy;
|
||||
availableSubscription: Subscription;
|
||||
unavailableSubscription: Subscription;
|
||||
manager: AMQPMessageQueueManager;
|
||||
type: "amqp";
|
||||
|
||||
constructor(private urls: string[]) {
|
||||
this.manager = new AMQPMessageQueueManager();
|
||||
this.clientProxy = new MessageQueueProxy();
|
||||
}
|
||||
|
||||
@SkipCLI()
|
||||
async init(): Promise<this> {
|
||||
logger.info("Initializing message-queue service implementation with urls %o", this.urls);
|
||||
await this.manager.createClient(this.urls);
|
||||
|
||||
this.availableSubscription = this.manager.getClientAvailable().subscribe({
|
||||
next: (client: MessageQueueClient) => {
|
||||
logger.info("A new message-queue client is available");
|
||||
this.clientProxy.setClient(client);
|
||||
},
|
||||
error: () => {
|
||||
logger.error("Error while listening to message-queue client availability");
|
||||
},
|
||||
});
|
||||
|
||||
this.unavailableSubscription = this.manager.getClientUnavailable().subscribe({
|
||||
next: (err: Error) => {
|
||||
logger.warn({ err }, "Client is not available anymore");
|
||||
this.clientProxy.unsetClient();
|
||||
},
|
||||
error: () => {
|
||||
logger.error("Error while listening to message-queue client unavailability");
|
||||
},
|
||||
});
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
async stop(): Promise<MessageQueueAdapter> {
|
||||
logger.info("Closing message-queue service");
|
||||
await this.clientProxy.close();
|
||||
return this;
|
||||
}
|
||||
|
||||
publish<T>(topic: string, message: MessageQueueMessage<T>): Promise<void> {
|
||||
return this.clientProxy.publish(topic, message);
|
||||
}
|
||||
|
||||
subscribe<T>(
|
||||
topic: string,
|
||||
listener: MessageQueueListener<T>,
|
||||
options?: MessageQueueSubscriptionOptions,
|
||||
): Promise<void> {
|
||||
return this.clientProxy.subscribe(topic, listener, options);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { ConfirmChannel } from "amqplib";
|
||||
import { AmqpConnectionManager, connect } from "amqp-connection-manager";
|
||||
import { ReplaySubject, Subject } from "rxjs";
|
||||
import { logger } from "../../../framework/logger";
|
||||
import { AmqpMessageQueueClient } from "./pubsubclient";
|
||||
import { MessageQueueClientManager, MessageQueueClient } from "../api";
|
||||
import { AMQPPubSub } from "./pubsub";
|
||||
|
||||
const LOG_PREFIX = "service.pubsub.amqp.AMQPMessageQueueManager -";
|
||||
|
||||
export class AMQPMessageQueueManager implements MessageQueueClientManager {
|
||||
// using ReplaySubjects will allow to get the data which has been published before the subscriptions
|
||||
private clientAvailable: Subject<MessageQueueClient> = new ReplaySubject<MessageQueueClient>(1);
|
||||
private clientUnavailable: Subject<Error> = new ReplaySubject<Error>(1);
|
||||
private connected = false;
|
||||
|
||||
getClientAvailable(): Subject<MessageQueueClient> {
|
||||
return this.clientAvailable;
|
||||
}
|
||||
|
||||
getClientUnavailable(): Subject<Error> {
|
||||
return this.clientUnavailable;
|
||||
}
|
||||
|
||||
async createClient(urls: string[]): Promise<Subject<MessageQueueClient>> {
|
||||
logger.info(`${LOG_PREFIX} Creating AMQP client %o`, urls);
|
||||
const connection = connect(urls);
|
||||
|
||||
// disconnect is called when going from "connected" to "disconnected",
|
||||
// and also at every unsuccessfull connection attempt
|
||||
connection.on("disconnect", (err: Error) => {
|
||||
logger.warn({ err }, `${LOG_PREFIX} RabbitMQ connection lost`);
|
||||
this.connected = false;
|
||||
this.clientUnavailable.next(err);
|
||||
});
|
||||
|
||||
const client = await this.create(connection);
|
||||
const pubsub = new AMQPPubSub(client);
|
||||
|
||||
// For the first connection
|
||||
this.clientAvailable.next(pubsub);
|
||||
|
||||
// This event listener must be after the first connection occurs
|
||||
// Connect event is not sent on first connection
|
||||
// so we have to deal with the `connected` flag
|
||||
connection.on("connect", ({ url }) => {
|
||||
logger.info(`${LOG_PREFIX} Connected to RabbitMQ on ${url}`);
|
||||
if (this.connected) {
|
||||
return;
|
||||
}
|
||||
this.connected = true;
|
||||
});
|
||||
|
||||
return this.clientAvailable;
|
||||
}
|
||||
|
||||
private create(connection: AmqpConnectionManager): Promise<AmqpMessageQueueClient> {
|
||||
logger.info(`${LOG_PREFIX} Creating AMQP Channel`);
|
||||
const channel = connection.createChannel({ name: "Tdrive" });
|
||||
|
||||
channel.on("close", () => {
|
||||
logger.info(`${LOG_PREFIX} Channel is closed`);
|
||||
});
|
||||
|
||||
channel.on("connect", () => {
|
||||
logger.info(`${LOG_PREFIX} Channel is connected`);
|
||||
});
|
||||
|
||||
channel.on("error", () => {
|
||||
logger.info(`${LOG_PREFIX} Channel creation error`);
|
||||
});
|
||||
|
||||
return new Promise<AmqpMessageQueueClient>(resolve => {
|
||||
channel.addSetup((channel: ConfirmChannel) => {
|
||||
logger.info(`${LOG_PREFIX} Channel setup is complete`);
|
||||
const client = new AmqpMessageQueueClient(channel);
|
||||
|
||||
// First attempt
|
||||
if (!this.connected) {
|
||||
this.connected = true;
|
||||
resolve(client);
|
||||
} else {
|
||||
this.clientAvailable.next(new AMQPPubSub(client));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { logger } from "../../../framework/logger";
|
||||
import { AmqpMessageQueueClient } from "./pubsubclient";
|
||||
import {
|
||||
MessageQueueMessage,
|
||||
MessageQueueListener,
|
||||
MessageQueueClient,
|
||||
MessageQueueSubscriptionOptions,
|
||||
} from "../api";
|
||||
import { AmqpCallbackType, SubscribeOptions } from "./client";
|
||||
|
||||
const LOG_PREFIX = "service.message-queue.amqp.AMQPPubSub -";
|
||||
|
||||
/**
|
||||
* Implementation of MessageQueueClient based on AMQP
|
||||
*/
|
||||
export class AMQPPubSub implements MessageQueueClient {
|
||||
constructor(private client: AmqpMessageQueueClient) {}
|
||||
|
||||
close(): Promise<void> {
|
||||
return this.client.dispose();
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
async publish(topic: string, message: MessageQueueMessage<any>): Promise<void> {
|
||||
logger.debug(`${LOG_PREFIX} Publishing message to topic ${topic}`);
|
||||
await this.client.publish(topic, message.data, { ttl: message.ttl });
|
||||
}
|
||||
|
||||
subscribe(
|
||||
topic: string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
listener: MessageQueueListener<any>,
|
||||
options: MessageQueueSubscriptionOptions = { unique: false, queue: null, ttl: -1 },
|
||||
): Promise<void> {
|
||||
const subscribeOptions: SubscribeOptions = {};
|
||||
logger.debug(`${LOG_PREFIX} Subscribing to topic ${topic} with options %o`, options);
|
||||
|
||||
const callback: AmqpCallbackType = (err, message, originalMessage) => {
|
||||
const data = err ? originalMessage : message;
|
||||
|
||||
if (err) {
|
||||
logger.error(
|
||||
`${LOG_PREFIX} Received a message which can not be parsed on topic ${topic}: %o`,
|
||||
originalMessage?.content.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
listener({
|
||||
data,
|
||||
ack: (): void => {
|
||||
logger.debug(`${LOG_PREFIX} Ack message on topic ${topic}`);
|
||||
this.client.ack(originalMessage);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
if (options.ttl && options.ttl > 0) {
|
||||
subscribeOptions.ttl = options.ttl;
|
||||
}
|
||||
|
||||
return options?.unique
|
||||
? this.client.subscribeToDurableQueue(
|
||||
topic,
|
||||
options?.queue || topic,
|
||||
subscribeOptions,
|
||||
callback,
|
||||
)
|
||||
: this.client.subscribe(topic, subscribeOptions, callback);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { logger } from "../../../framework/logger";
|
||||
import { constants as CONSTANTS } from "./constants";
|
||||
import { AmqpClient, AmqpCallbackType, PublishOptions, SubscribeOptions } from "./client";
|
||||
import { Options } from "amqplib";
|
||||
import _ from "lodash";
|
||||
|
||||
const LOG_PREFIX = "service.message-queue.amqp.AmqpMessageQueueClient -";
|
||||
|
||||
/**
|
||||
* AMQP client abstracting low level channel methods to create a message-queue-like implementation
|
||||
*/
|
||||
export class AmqpMessageQueueClient extends AmqpClient {
|
||||
publish(topic: string, data: unknown, options?: PublishOptions): Promise<boolean> {
|
||||
data = _.cloneDeep(data);
|
||||
|
||||
let publishOptions: Options.Publish;
|
||||
|
||||
logger.debug(`${LOG_PREFIX} Publishing message to topic "${topic}" with options %o`, options);
|
||||
|
||||
if (options?.ttl) {
|
||||
publishOptions = {
|
||||
expiration: options.ttl,
|
||||
};
|
||||
}
|
||||
|
||||
return this.assertExchange(topic, CONSTANTS.PUBSUB_EXCHANGE.type).then(() =>
|
||||
this.send(topic, data, CONSTANTS.PUBSUB_EXCHANGE.routingKey, publishOptions),
|
||||
);
|
||||
}
|
||||
|
||||
subscribe(topic: string, options: SubscribeOptions, callback: AmqpCallbackType): Promise<void> {
|
||||
const queueOptions: Options.AssertQueue = {
|
||||
...CONSTANTS.SUBSCRIBER.queueOptions,
|
||||
...(options?.ttl ? { messageTtl: options.ttl } : {}),
|
||||
};
|
||||
|
||||
return this.assertExchange(topic, CONSTANTS.PUBSUB_EXCHANGE.type)
|
||||
.then(() => this.assertQueue(CONSTANTS.SUBSCRIBER.queueName, queueOptions))
|
||||
.then(res => this.assertBinding(res.queue, topic).then(() => res))
|
||||
.then(res => this.consume(res.queue, CONSTANTS.SUBSCRIBER.consumeOptions, callback));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new consumer which asserts that it will listen to messages in a durable queue.
|
||||
* The durable queue goal is to be able to receive events 'from the past' ie events which has been push to the queue while subscribers were not bound.
|
||||
* This is quite useful in case of restart to not miss anything between stop and start.
|
||||
* Important: If multiple consumers are connected to the same durable queue, AMQP will deliver message to one and only one consumer with a Round-robin behavior by default.
|
||||
*
|
||||
* @param {String} exchangeName - The exchange name
|
||||
* @param {String} queueName - The queue name
|
||||
* @param {Function} callback - The callback to call once there is a message in the queue
|
||||
*/
|
||||
subscribeToDurableQueue(
|
||||
exchangeName: string,
|
||||
queueName: string,
|
||||
options: SubscribeOptions,
|
||||
callback: AmqpCallbackType,
|
||||
): Promise<void> {
|
||||
const durableQueueOptions: Options.AssertQueue = {
|
||||
...CONSTANTS.SUBSCRIBER.durableQueueOptions,
|
||||
...(options?.ttl ? { messageTtl: options.ttl } : {}),
|
||||
};
|
||||
|
||||
logger.debug(
|
||||
`${LOG_PREFIX} Subscribing to durable queue ${queueName} with options %o`,
|
||||
durableQueueOptions,
|
||||
);
|
||||
|
||||
return this.assertExchange(exchangeName, CONSTANTS.PUBSUB_EXCHANGE.type)
|
||||
.then(() => this.assertQueue(queueName, durableQueueOptions))
|
||||
.then(() => this.assertBinding(queueName, exchangeName))
|
||||
.then(() => this.consume(queueName, CONSTANTS.SUBSCRIBER.consumeOptions, callback));
|
||||
}
|
||||
|
||||
async unsubscribe(topic: string, callback: AmqpCallbackType): Promise<void> {
|
||||
const consumerTags = this._subscribeCallbackToConsumerTags.get(callback);
|
||||
|
||||
if (Array.isArray(consumerTags)) {
|
||||
logger.info(`${LOG_PREFIX} About to remove the consumer(s): ${consumerTags}`);
|
||||
|
||||
await Promise.all(consumerTags.map(c => this.channel.cancel(c)));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
logger.warn(`${LOG_PREFIX} No consumerTag found to unsubscribe a consumer from: ${topic}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
import { Subject } from "rxjs";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { Initializable, logger, TdriveServiceProvider } from "../../framework";
|
||||
import { Processor } from "./processor";
|
||||
import { ExecutionContext } from "../../framework/api/crud-service";
|
||||
|
||||
export type MessageQueueType = "local" | "amqp";
|
||||
|
||||
export interface MessageQueueMessage<T> {
|
||||
/**
|
||||
* Optional message id, mainly used for logs
|
||||
*/
|
||||
id?: string;
|
||||
|
||||
/**
|
||||
* The message TTL period in milliseconds
|
||||
*/
|
||||
ttl?: number;
|
||||
|
||||
/**
|
||||
* The message payload to process
|
||||
*/
|
||||
data: T;
|
||||
}
|
||||
|
||||
export interface MessageQueueEventMessage<T> extends MessageQueueMessage<T> {
|
||||
topic: string;
|
||||
}
|
||||
|
||||
export interface IncomingMessageQueueMessage<T> extends MessageQueueMessage<T> {
|
||||
ack: () => void;
|
||||
}
|
||||
|
||||
export type MessageQueueSubscriptionOptions = {
|
||||
/**
|
||||
* A unique subscription guaranties that there will be only one listener consuming the message on the subscription topic even if many are subscribing to the same topic from several places (not only from the current instance/host).
|
||||
* Also, it will guaranties that messages which were published before any subscriber subscribes will be consumed.
|
||||
*/
|
||||
unique?: boolean;
|
||||
|
||||
/**
|
||||
* Automatically acknowledge the incoming message when the processing is complete
|
||||
*/
|
||||
ack?: boolean;
|
||||
|
||||
/**
|
||||
* Use custom named queue instead of using same name as exchange. Can be useful when we have multiple subsciptions on the same topic.
|
||||
*/
|
||||
queue?: string | null;
|
||||
|
||||
/**
|
||||
* Configures the message TTL (in ms) in the underlying messaging system.
|
||||
* Negative or undefined means no TTL.
|
||||
* Notes:
|
||||
* - If supported by the messaging system, messages will not be delivered to the application.
|
||||
* - If not supported, this may be up to the subscriber itself to filter messages at the application level based on some timestamp if available.
|
||||
*/
|
||||
ttl?: number | null;
|
||||
};
|
||||
|
||||
export type MessageQueueListener<T> = (message: IncomingMessageQueueMessage<T>) => void;
|
||||
|
||||
export interface MessageQueueServiceAPI extends TdriveServiceProvider {
|
||||
/**
|
||||
* Publish a message to a given topic
|
||||
* @param topic The topic to publish the message to
|
||||
* @param message The message to publish to the topic
|
||||
* @param context
|
||||
*/
|
||||
publish<T>(
|
||||
topic: string,
|
||||
message: MessageQueueMessage<T>,
|
||||
context?: ExecutionContext,
|
||||
): Promise<void>;
|
||||
|
||||
/**
|
||||
* Subscribe the a topic. The listener will be called when a new message is published in the topic (this may not be true based on the options parameters).
|
||||
* @param topic The topic to subsribe to
|
||||
* @param listener The function which will process the message published in the topic
|
||||
* @param options The subscription options. If not defined, the subscriber will be called for all messages available in the topic.
|
||||
*/
|
||||
subscribe<T>(
|
||||
topic: string,
|
||||
listener: MessageQueueListener<T>,
|
||||
options?: MessageQueueSubscriptionOptions,
|
||||
): Promise<void>;
|
||||
|
||||
/**
|
||||
* The messages processor instance
|
||||
*/
|
||||
processor: Processor;
|
||||
|
||||
stop(): Promise<this>;
|
||||
start(): Promise<this>;
|
||||
}
|
||||
|
||||
export type MessageQueueAdapter = Pick<MessageQueueServiceAPI, "publish" | "subscribe"> & {
|
||||
type: string;
|
||||
init?(): Promise<MessageQueueAdapter>;
|
||||
start?(): Promise<MessageQueueAdapter>;
|
||||
stop?(): Promise<MessageQueueAdapter>;
|
||||
};
|
||||
|
||||
export type MessageQueueClient = Pick<MessageQueueServiceAPI, "publish" | "subscribe"> & {
|
||||
/**
|
||||
* Close the client
|
||||
*/
|
||||
close?(): Promise<void>;
|
||||
};
|
||||
|
||||
/**
|
||||
* The client manager allows to get notified when a client is available and then becomes unavailable.
|
||||
*/
|
||||
export interface MessageQueueClientManager {
|
||||
/**
|
||||
* Ask for a new client to be created
|
||||
*/
|
||||
createClient(config: string[]): Promise<Subject<MessageQueueClient>>;
|
||||
|
||||
/**
|
||||
* Subject when a client is available
|
||||
*/
|
||||
getClientAvailable(): Subject<MessageQueueClient>;
|
||||
|
||||
/**
|
||||
* Subject when a client becomes unavailable
|
||||
*/
|
||||
getClientUnavailable(): Subject<Error>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages the message-queue client adding mechanisms to cache subscriptions and messages to publish.
|
||||
*/
|
||||
export interface MessageQueueProxy extends MessageQueueClient {
|
||||
/**
|
||||
* Set a new client to use replacing any existing one.
|
||||
* The new client will reuse all the subscriptions automatically.
|
||||
* @param client
|
||||
*/
|
||||
setClient(client: MessageQueueClient): Promise<void>;
|
||||
|
||||
/**
|
||||
* Remove the client. This will close any underlying connection but will not remove any subscription.
|
||||
*/
|
||||
unsetClient(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface MessageQueueEventBus {
|
||||
/**
|
||||
* Subscribes to events
|
||||
*/
|
||||
subscribe<T>(listener: (message: MessageQueueEventMessage<T>) => void): this;
|
||||
|
||||
/**
|
||||
* Publish message in event bus
|
||||
*/
|
||||
publish<T>(message: MessageQueueEventMessage<T>): boolean;
|
||||
}
|
||||
|
||||
export abstract class MessageQueueServiceSubscription {
|
||||
protected messageQueue: MessageQueueServiceAPI;
|
||||
|
||||
async subscribe(messageQueue: MessageQueueServiceAPI): Promise<void> {
|
||||
if (!messageQueue) {
|
||||
throw new Error("message-queue service it not defined");
|
||||
}
|
||||
this.messageQueue = messageQueue;
|
||||
|
||||
return this.doSubscribe();
|
||||
}
|
||||
|
||||
abstract doSubscribe(): Promise<void>;
|
||||
}
|
||||
|
||||
export class MessageQueueServiceProcessor<In, Out>
|
||||
extends MessageQueueServiceSubscription
|
||||
implements Initializable
|
||||
{
|
||||
constructor(
|
||||
protected handler: MessageQueueHandler<In, Out>,
|
||||
protected messageQueue: MessageQueueServiceAPI,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async init(): Promise<this> {
|
||||
try {
|
||||
await this.subscribe(this.messageQueue);
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err },
|
||||
`MessageQueueServiceProcessor.handler.${this.handler.name} - Not able to start handler`,
|
||||
);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
async stop(): Promise<this> {
|
||||
// TODO
|
||||
return this;
|
||||
}
|
||||
|
||||
async process(message: IncomingMessageQueueMessage<In>): Promise<Out> {
|
||||
logger.info(
|
||||
`MessageQueueServiceProcessor.handler.${this.handler.name}:${message.id} - Processing message`,
|
||||
);
|
||||
return this.handler.process(message.data);
|
||||
}
|
||||
|
||||
async doSubscribe(): Promise<void> {
|
||||
//TODO this is where we do not receive the call
|
||||
|
||||
if (this.handler.topics && this.handler.topics.in) {
|
||||
logger.info(
|
||||
`MessageQueueServiceProcessor.handler.${this.handler.name} - Subscribing to topic ${this.handler?.topics?.in} with options %o`,
|
||||
this.handler.options,
|
||||
);
|
||||
await this.messageQueue.subscribe(
|
||||
this.handler.topics.in,
|
||||
this.processMessage.bind(this),
|
||||
this.handler.options,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async processMessage(message: IncomingMessageQueueMessage<In>): Promise<void> {
|
||||
if (!message.id) {
|
||||
message.id = uuidv4();
|
||||
}
|
||||
|
||||
if (this.handler.validate) {
|
||||
const isValid = this.handler.validate(message.data);
|
||||
|
||||
if (!isValid) {
|
||||
logger.error(
|
||||
`MessageQueueServiceProcessor.handler.${this.handler.name}:${message.id} - Message is invalid`,
|
||||
);
|
||||
|
||||
//Validate rabbitmq message, we will not process it again
|
||||
if (this.handler?.options?.ack && message?.ack) message?.ack();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.process(message);
|
||||
if (this.handler?.options?.ack && message?.ack) {
|
||||
logger.debug(
|
||||
`MessageQueueServiceProcessor.handler.${this.handler.name}:${message.id} - Acknowledging message %o`,
|
||||
message,
|
||||
);
|
||||
message?.ack();
|
||||
}
|
||||
|
||||
if (result) {
|
||||
await this.sendResult(message, result);
|
||||
}
|
||||
} catch (error) {
|
||||
this.handleError(message, error);
|
||||
|
||||
//Fixme Validate message because we don't have max retry handler
|
||||
if (this.handler?.options?.ack && message?.ack) message?.ack();
|
||||
}
|
||||
}
|
||||
|
||||
private async sendResult(message: IncomingMessageQueueMessage<In>, result: Out): Promise<void> {
|
||||
if (!this.handler.topics.out) {
|
||||
logger.info(
|
||||
`MessageQueueServiceProcessor.handler.${this.handler.name}:${message.id} - Message processing result is skipped`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`MessageQueueServiceProcessor.handler.${this.handler.name}:${message.id} - Sending processing result to ${this.handler.topics.out}`,
|
||||
);
|
||||
|
||||
return this.messageQueue.publish(this.handler.topics.out, {
|
||||
id: uuidv4(),
|
||||
data: result,
|
||||
});
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
private async handleError(message: IncomingMessageQueueMessage<In>, err: any) {
|
||||
logger.error(
|
||||
{ err },
|
||||
`MessageQueueServiceProcessor.handler.${this.handler.name}:${message.id} - Error while processing message`,
|
||||
);
|
||||
if (this.handler.topics.error) {
|
||||
this.messageQueue.publish(this.handler.topics.error, {
|
||||
data: {
|
||||
type: "error",
|
||||
id: message.id,
|
||||
message: err instanceof Error ? (err as Error).message : String(err),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A message-queue handler is in charge of processing message from a topic and publishing the processing result to another topic
|
||||
*/
|
||||
export interface MessageQueueHandler<InputMessage, OutputMessage> extends Initializable {
|
||||
readonly topics: {
|
||||
// The topic to subscribe to
|
||||
in: string;
|
||||
// The topic to push process result to if defined
|
||||
out?: string;
|
||||
// The topic to push error to. When topic is undefined, do not push the error
|
||||
error?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Options subscriber options
|
||||
*/
|
||||
readonly options?: MessageQueueSubscriptionOptions;
|
||||
|
||||
/**
|
||||
* The handler name
|
||||
*/
|
||||
readonly name: string;
|
||||
|
||||
/**
|
||||
* Validate the input message
|
||||
*
|
||||
* @param message message to validate
|
||||
*/
|
||||
validate?(message: InputMessage): boolean;
|
||||
|
||||
/**
|
||||
* Process the message and potentially produces result which will be published elsewhere
|
||||
* @param message
|
||||
*/
|
||||
process(message: InputMessage): Promise<OutputMessage>;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { EventEmitter } from "events";
|
||||
import { MessageQueueEventBus, MessageQueueEventMessage } from "./api";
|
||||
const EVENT = Symbol("event");
|
||||
|
||||
/**
|
||||
* Event bus is used by the MessageQueue decorators to publish data to be send to message-queue service.
|
||||
* The message-queue service is the only subscriber of the event bus.
|
||||
* By decoupling decorator and service, events will not be published when the service is not enabled.
|
||||
*/
|
||||
class EventBus extends EventEmitter implements MessageQueueEventBus {
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe is MUST only be used by the message-queue service
|
||||
*
|
||||
* @param listener
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
subscribe(listener: (message: MessageQueueEventMessage<any>) => void): this {
|
||||
return this.on(EVENT, listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish must only be used by the MessageQueue decorators
|
||||
*
|
||||
* @param message
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
publish(message: MessageQueueEventMessage<any>): boolean {
|
||||
return this.emit(EVENT, message);
|
||||
}
|
||||
}
|
||||
|
||||
export const eventBus = new EventBus();
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user