Migrate to a monorepo structure (#2909)

This commit is contained in:
Charles Bochet
2023-12-10 18:10:54 +01:00
committed by GitHub
parent a70a9281eb
commit 5bdca9de6c
2304 changed files with 37152 additions and 25869 deletions

View File

@ -0,0 +1,66 @@
import { plainToClass } from 'class-transformer';
import { CastToLogLevelArray } from 'src/integrations/environment/decorators/cast-to-log-level-array.decorator';
class TestClass {
@CastToLogLevelArray()
logLevels?: any;
}
describe('CastToLogLevelArray Decorator', () => {
it('should cast "log" to ["log"]', () => {
const transformedClass = plainToClass(TestClass, { logLevels: 'log' });
expect(transformedClass.logLevels).toStrictEqual(['log']);
});
it('should cast "error" to ["error"]', () => {
const transformedClass = plainToClass(TestClass, { logLevels: 'error' });
expect(transformedClass.logLevels).toStrictEqual(['error']);
});
it('should cast "warn" to ["warn"]', () => {
const transformedClass = plainToClass(TestClass, { logLevels: 'warn' });
expect(transformedClass.logLevels).toStrictEqual(['warn']);
});
it('should cast "debug" to ["debug"]', () => {
const transformedClass = plainToClass(TestClass, { logLevels: 'debug' });
expect(transformedClass.logLevels).toStrictEqual(['debug']);
});
it('should cast "verbose" to ["verbose"]', () => {
const transformedClass = plainToClass(TestClass, { logLevels: 'verbose' });
expect(transformedClass.logLevels).toStrictEqual(['verbose']);
});
it('should cast "verbose,error,warn" to ["verbose", "error", "warn"]', () => {
const transformedClass = plainToClass(TestClass, {
logLevels: 'verbose,error,warn',
});
expect(transformedClass.logLevels).toStrictEqual([
'verbose',
'error',
'warn',
]);
});
it('should cast "toto" to undefined', () => {
const transformedClass = plainToClass(TestClass, { logLevels: 'toto' });
expect(transformedClass.logLevels).toBeUndefined();
});
it('should cast "verbose,error,toto" to undefined', () => {
const transformedClass = plainToClass(TestClass, {
logLevels: 'verbose,error,toto',
});
expect(transformedClass.logLevels).toBeUndefined();
});
});

View File

@ -0,0 +1,58 @@
import { plainToClass } from 'class-transformer';
import { CastToPositiveNumber } from 'src/integrations/environment/decorators/cast-to-positive-number.decorator';
class TestClass {
@CastToPositiveNumber()
numberProperty?: any;
}
describe('CastToPositiveNumber Decorator', () => {
it('should cast number to number', () => {
const transformedClass = plainToClass(TestClass, { numberProperty: 123 });
expect(transformedClass.numberProperty).toBe(123);
});
it('should cast string to number', () => {
const transformedClass = plainToClass(TestClass, { numberProperty: '123' });
expect(transformedClass.numberProperty).toBe(123);
});
it('should cast null to undefined', () => {
const transformedClass = plainToClass(TestClass, { numberProperty: null });
expect(transformedClass.numberProperty).toBe(undefined);
});
it('should cast negative number to undefined', () => {
const transformedClass = plainToClass(TestClass, { numberProperty: -12 });
expect(transformedClass.numberProperty).toBe(undefined);
});
it('should cast undefined to undefined', () => {
const transformedClass = plainToClass(TestClass, {
numberProperty: undefined,
});
expect(transformedClass.numberProperty).toBe(undefined);
});
it('should cast NaN string to undefined', () => {
const transformedClass = plainToClass(TestClass, {
numberProperty: 'toto',
});
expect(transformedClass.numberProperty).toBe(undefined);
});
it('should cast a negative string to undefined', () => {
const transformedClass = plainToClass(TestClass, {
numberProperty: '-123',
});
expect(transformedClass.numberProperty).toBe(undefined);
});
});

View File

@ -0,0 +1,18 @@
import { Transform } from 'class-transformer';
export const CastToBoolean = () =>
Transform(({ value }: { value: string }) => toBoolean(value));
const toBoolean = (value: any) => {
if (typeof value === 'boolean') {
return value;
}
if (['true', 'on', 'yes', '1'].includes(value.toLowerCase())) {
return true;
}
if (['false', 'off', 'no', '0'].includes(value.toLowerCase())) {
return false;
}
return undefined;
};

View File

@ -0,0 +1,19 @@
import { Transform } from 'class-transformer';
export const CastToLogLevelArray = () =>
Transform(({ value }: { value: string }) => toLogLevelArray(value));
const toLogLevelArray = (value: any) => {
if (typeof value === 'string') {
const rawLogLevels = value.split(',').map((level) => level.trim());
const isInvalid = rawLogLevels.some(
(level) => !['log', 'error', 'warn', 'debug', 'verbose'].includes(level),
);
if (!isInvalid) {
return rawLogLevels;
}
}
return undefined;
};

View File

@ -0,0 +1,15 @@
import { Transform } from 'class-transformer';
export const CastToPositiveNumber = () =>
Transform(({ value }: { value: string }) => toNumber(value));
const toNumber = (value: any) => {
if (typeof value === 'number') {
return value >= 0 ? value : undefined;
}
if (typeof value === 'string') {
return isNaN(+value) ? undefined : toNumber(+value);
}
return undefined;
};

View File

@ -0,0 +1,12 @@
import { Transform } from 'class-transformer';
export const CastToStringArray = () =>
Transform(({ value }: { value: string }) => toStringArray(value));
const toStringArray = (value: any) => {
if (typeof value === 'string') {
return value.split(',').map((item) => item.trim());
}
return undefined;
};

View File

@ -0,0 +1,27 @@
import {
registerDecorator,
ValidationOptions,
ValidatorConstraint,
ValidatorConstraintInterface,
} from 'class-validator';
@ValidatorConstraint({ async: true })
export class IsAWSRegionConstraint implements ValidatorConstraintInterface {
validate(region: string) {
const regex = /^[a-z]{2}-[a-z]+-\d{1}$/;
return regex.test(region); // Returns true if region matches regex
}
}
export const IsAWSRegion =
(validationOptions?: ValidationOptions) =>
(object: object, propertyName: string) => {
registerDecorator({
target: object.constructor,
propertyName: propertyName,
options: validationOptions,
constraints: [],
validator: IsAWSRegionConstraint,
});
};

View File

@ -0,0 +1,28 @@
import {
registerDecorator,
ValidationOptions,
ValidatorConstraint,
ValidatorConstraintInterface,
} from 'class-validator';
@ValidatorConstraint({ async: true })
export class IsDurationConstraint implements ValidatorConstraintInterface {
validate(duration: string) {
const regex =
/^-?[0-9]+(.[0-9]+)?(m(illiseconds?)?|s(econds?)?|h((ou)?rs?)?|d(ays?)?|w(eeks?)?|M(onths?)?|y(ears?)?)?$/;
return regex.test(duration); // Returns true if duration matches regex
}
}
export const IsDuration =
(validationOptions?: ValidationOptions) =>
(object: object, propertyName: string) => {
registerDecorator({
target: object.constructor,
propertyName: propertyName,
options: validationOptions,
constraints: [],
validator: IsDurationConstraint,
});
};

View File

@ -0,0 +1,8 @@
import { ConfigurableModuleBuilder } from '@nestjs/common';
export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } =
new ConfigurableModuleBuilder({
moduleName: 'Environment',
})
.setClassMethodName('forRoot')
.build();

View File

@ -0,0 +1,20 @@
import { Global, Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { EnvironmentService } from './environment.service';
import { ConfigurableModuleClass } from './environment.module-definition';
import { validate } from './environment.validation';
@Global()
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
expandVariables: true,
validate,
}),
],
providers: [EnvironmentService],
exports: [EnvironmentService],
})
export class EnvironmentModule extends ConfigurableModuleClass {}

View File

@ -0,0 +1,26 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config';
import { EnvironmentService } from './environment.service';
describe('EnvironmentService', () => {
let service: EnvironmentService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
EnvironmentService,
{
provide: ConfigService,
useValue: {},
},
],
}).compile();
service = module.get<EnvironmentService>(EnvironmentService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});

View File

@ -0,0 +1,211 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { Injectable, LogLevel } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { LoggerDriverType } from 'src/integrations/logger/interfaces';
import { ExceptionHandlerDriver } from 'src/integrations/exception-handler/interfaces';
import { StorageDriverType } from 'src/integrations/file-storage/interfaces';
import { MessageQueueDriverType } from 'src/integrations/message-queue/interfaces';
import { AwsRegion } from './interfaces/aws-region.interface';
import { SupportDriver } from './interfaces/support.interface';
@Injectable()
export class EnvironmentService {
constructor(private configService: ConfigService) {}
isDebugMode(): boolean {
return this.configService.get<boolean>('DEBUG_MODE') ?? false;
}
isSignInPrefilled(): boolean {
return this.configService.get<boolean>('SIGN_IN_PREFILLED') ?? false;
}
isTelemetryEnabled(): boolean {
return this.configService.get<boolean>('TELEMETRY_ENABLED') ?? true;
}
isTelemetryAnonymizationEnabled(): boolean {
return (
this.configService.get<boolean>('TELEMETRY_ANONYMIZATION_ENABLED') ?? true
);
}
getPort(): number {
return this.configService.get<number>('PORT') ?? 3000;
}
getPGDatabaseUrl(): string {
return this.configService.get<string>('PG_DATABASE_URL')!;
}
getRedisHost(): string {
return this.configService.get<string>('REDIS_HOST') ?? '127.0.0.1';
}
getRedisPort(): number {
return +(this.configService.get<string>('REDIS_PORT') ?? 6379);
}
getFrontBaseUrl(): string {
return this.configService.get<string>('FRONT_BASE_URL')!;
}
getServerUrl(): string {
return this.configService.get<string>('SERVER_URL')!;
}
getAccessTokenSecret(): string {
return this.configService.get<string>('ACCESS_TOKEN_SECRET')!;
}
getAccessTokenExpiresIn(): string {
return this.configService.get<string>('ACCESS_TOKEN_EXPIRES_IN') ?? '30m';
}
getRefreshTokenSecret(): string {
return this.configService.get<string>('REFRESH_TOKEN_SECRET')!;
}
getRefreshTokenExpiresIn(): string {
return this.configService.get<string>('REFRESH_TOKEN_EXPIRES_IN') ?? '90d';
}
getRefreshTokenCoolDown(): string {
return this.configService.get<string>('REFRESH_TOKEN_COOL_DOWN') ?? '1m';
}
getLoginTokenSecret(): string {
return this.configService.get<string>('LOGIN_TOKEN_SECRET')!;
}
getLoginTokenExpiresIn(): string {
return this.configService.get<string>('LOGIN_TOKEN_EXPIRES_IN') ?? '15m';
}
getTransientTokenExpiresIn(): string {
return (
this.configService.get<string>('SHORT_TERM_TOKEN_EXPIRES_IN') ?? '5m'
);
}
getApiTokenExpiresIn(): string {
return this.configService.get<string>('API_TOKEN_EXPIRES_IN') ?? '1000y';
}
getFrontAuthCallbackUrl(): string {
return (
this.configService.get<string>('FRONT_AUTH_CALLBACK_URL') ??
this.getFrontBaseUrl() + '/verify'
);
}
isMessagingProviderGmailEnabled(): boolean {
return (
this.configService.get<boolean>('MESSAGING_PROVIDER_GMAIL_ENABLED') ??
false
);
}
getMessagingProviderGmailCallbackUrl(): string | undefined {
return this.configService.get<string>(
'MESSAGING_PROVIDER_GMAIL_CALLBACK_URL',
);
}
isAuthGoogleEnabled(): boolean {
return this.configService.get<boolean>('AUTH_GOOGLE_ENABLED') ?? false;
}
getAuthGoogleClientId(): string | undefined {
return this.configService.get<string>('AUTH_GOOGLE_CLIENT_ID');
}
getAuthGoogleClientSecret(): string | undefined {
return this.configService.get<string>('AUTH_GOOGLE_CLIENT_SECRET');
}
getAuthGoogleCallbackUrl(): string | undefined {
return this.configService.get<string>('AUTH_GOOGLE_CALLBACK_URL');
}
getStorageDriverType(): StorageDriverType {
return (
this.configService.get<StorageDriverType>('STORAGE_TYPE') ??
StorageDriverType.Local
);
}
getMessageQueueDriverType(): MessageQueueDriverType {
return (
this.configService.get<MessageQueueDriverType>('MESSAGE_QUEUE_TYPE') ??
MessageQueueDriverType.PgBoss
);
}
getStorageS3Region(): AwsRegion | undefined {
return this.configService.get<AwsRegion>('STORAGE_S3_REGION');
}
getStorageS3Name(): string | undefined {
return this.configService.get<string>('STORAGE_S3_NAME');
}
getStorageS3Endpoint(): string | undefined {
return this.configService.get<string>('STORAGE_S3_ENDPOINT');
}
getStorageLocalPath(): string {
return (
this.configService.get<string>('STORAGE_LOCAL_PATH') ?? '.local-storage'
);
}
getSupportDriver(): string {
return (
this.configService.get<string>('SUPPORT_DRIVER') ?? SupportDriver.None
);
}
getSupportFrontChatId(): string | undefined {
return this.configService.get<string>('SUPPORT_FRONT_CHAT_ID');
}
getSupportFrontHMACKey(): string | undefined {
return this.configService.get<string>('SUPPORT_FRONT_HMAC_KEY');
}
getLoggerDriverType(): LoggerDriverType {
return (
this.configService.get<LoggerDriverType>('LOGGER_DRIVER') ??
LoggerDriverType.Console
);
}
getExceptionHandlerDriverType(): ExceptionHandlerDriver {
return (
this.configService.get<ExceptionHandlerDriver>(
'EXCEPTION_HANDLER_DRIVER',
) ?? ExceptionHandlerDriver.Console
);
}
getLogLevels(): LogLevel[] {
return (
this.configService.get<LogLevel[]>('LOG_LEVELS') ?? [
'log',
'error',
'warn',
]
);
}
getSentryDSN(): string | undefined {
return this.configService.get<string>('SENTRY_DSN');
}
getDemoWorkspaceIds(): string[] {
return this.configService.get<string[]>('DEMO_WORKSPACE_IDS') ?? [];
}
}

View File

@ -0,0 +1,174 @@
import { LogLevel } from '@nestjs/common';
import { plainToClass } from 'class-transformer';
import {
IsEnum,
IsOptional,
IsString,
IsUrl,
ValidateIf,
validateSync,
IsBoolean,
IsNumber,
} from 'class-validator';
import { assert } from 'src/utils/assert';
import { CastToStringArray } from 'src/integrations/environment/decorators/cast-to-string-array.decorator';
import { ExceptionHandlerDriver } from 'src/integrations/exception-handler/interfaces';
import { StorageDriverType } from 'src/integrations/file-storage/interfaces';
import { LoggerDriverType } from 'src/integrations/logger/interfaces';
import { IsDuration } from './decorators/is-duration.decorator';
import { AwsRegion } from './interfaces/aws-region.interface';
import { IsAWSRegion } from './decorators/is-aws-region.decorator';
import { CastToBoolean } from './decorators/cast-to-boolean.decorator';
import { SupportDriver } from './interfaces/support.interface';
import { CastToPositiveNumber } from './decorators/cast-to-positive-number.decorator';
import { CastToLogLevelArray } from './decorators/cast-to-log-level-array.decorator';
export class EnvironmentVariables {
// Misc
@CastToBoolean()
@IsOptional()
@IsBoolean()
DEBUG_MODE?: boolean;
@CastToBoolean()
@IsOptional()
@IsBoolean()
SIGN_IN_PREFILLED?: boolean;
@CastToBoolean()
@IsOptional()
@IsBoolean()
TELEMETRY_ENABLED?: boolean;
@CastToBoolean()
@IsOptional()
@IsBoolean()
TELEMETRY_ANONYMIZATION_ENABLED?: boolean;
@CastToPositiveNumber()
@IsNumber()
@IsOptional()
PORT: number;
// Database
@IsUrl({ protocols: ['postgres'], require_tld: false })
PG_DATABASE_URL: string;
// Frontend URL
@IsUrl({ require_tld: false })
FRONT_BASE_URL: string;
// Server URL
@IsUrl({ require_tld: false })
@IsOptional()
SERVER_URL: string;
// Json Web Token
@IsString()
ACCESS_TOKEN_SECRET: string;
@IsDuration()
@IsOptional()
ACCESS_TOKEN_EXPIRES_IN: string;
@IsString()
REFRESH_TOKEN_SECRET: string;
@IsDuration()
@IsOptional()
REFRESH_TOKEN_EXPIRES_IN: string;
@IsDuration()
@IsOptional()
REFRESH_TOKEN_COOL_DOWN: string;
@IsString()
LOGIN_TOKEN_SECRET: string;
@IsDuration()
@IsOptional()
LOGIN_TOKEN_EXPIRES_IN: string;
// Auth
@IsUrl({ require_tld: false })
@IsOptional()
FRONT_AUTH_CALLBACK_URL: string;
@CastToBoolean()
@IsOptional()
@IsBoolean()
AUTH_GOOGLE_ENABLED?: boolean;
@IsString()
@ValidateIf((env) => env.AUTH_GOOGLE_ENABLED === true)
AUTH_GOOGLE_CLIENT_ID?: string;
@IsString()
@ValidateIf((env) => env.AUTH_GOOGLE_ENABLED === true)
AUTH_GOOGLE_CLIENT_SECRET?: string;
@IsUrl({ require_tld: false })
@ValidateIf((env) => env.AUTH_GOOGLE_ENABLED === true)
AUTH_GOOGLE_CALLBACK_URL?: string;
// Storage
@IsEnum(StorageDriverType)
@IsOptional()
STORAGE_TYPE?: StorageDriverType;
@ValidateIf((env) => env.STORAGE_TYPE === StorageDriverType.S3)
@IsAWSRegion()
STORAGE_S3_REGION?: AwsRegion;
@ValidateIf((env) => env.STORAGE_TYPE === StorageDriverType.S3)
@IsString()
STORAGE_S3_NAME?: string;
@IsString()
@ValidateIf((env) => env.STORAGE_TYPE === StorageDriverType.Local)
STORAGE_LOCAL_PATH?: string;
// Support
@IsEnum(SupportDriver)
@IsOptional()
SUPPORT_DRIVER?: SupportDriver;
@ValidateIf((env) => env.SUPPORT_DRIVER === SupportDriver.Front)
@IsString()
SUPPORT_FRONT_CHAT_ID?: string;
@ValidateIf((env) => env.SUPPORT_DRIVER === SupportDriver.Front)
@IsString()
SUPPORT_FRONT_HMAC_KEY?: string;
@IsEnum(LoggerDriverType)
@IsOptional()
LOGGER_DRIVER?: LoggerDriverType;
@IsEnum(ExceptionHandlerDriver)
@IsOptional()
EXCEPTION_HANDLER_DRIVER?: ExceptionHandlerDriver;
@CastToLogLevelArray()
@IsOptional()
LOG_LEVELS?: LogLevel[];
@CastToStringArray()
@IsOptional()
DEMO_WORKSPACE_IDS?: string[];
@ValidateIf(
(env) => env.EXCEPTION_HANDLER_DRIVER === ExceptionHandlerDriver.Sentry,
)
@IsString()
SENTRY_DSN?: string;
}
export const validate = (config: Record<string, unknown>) => {
const validatedConfig = plainToClass(EnvironmentVariables, config);
const errors = validateSync(validatedConfig);
assert(!errors.length, errors.toString());
return validatedConfig;
};

View File

@ -0,0 +1 @@
export type AwsRegion = `${string}-${string}-${number}`;

View File

@ -0,0 +1,4 @@
export enum SupportDriver {
None = 'none',
Front = 'front',
}

View File

@ -0,0 +1,17 @@
import { ExceptionHandlerDriverInterface } from 'src/integrations/exception-handler/interfaces';
export class ExceptionHandlerConsoleDriver
implements ExceptionHandlerDriverInterface
{
captureException(exception: unknown) {
console.group('Exception Captured');
console.error(exception);
console.groupEnd();
}
captureMessage(message: string): void {
console.group('Message Captured');
console.info(message);
console.groupEnd();
}
}

View File

@ -0,0 +1,40 @@
import * as Sentry from '@sentry/node';
import { ProfilingIntegration } from '@sentry/profiling-node';
import {
ExceptionHandlerDriverInterface,
ExceptionHandlerSentryDriverFactoryOptions,
} from 'src/integrations/exception-handler/interfaces';
export class ExceptionHandlerSentryDriver
implements ExceptionHandlerDriverInterface
{
constructor(options: ExceptionHandlerSentryDriverFactoryOptions['options']) {
Sentry.init({
dsn: options.dns,
integrations: [
// enable HTTP calls tracing
new Sentry.Integrations.Http({ tracing: true }),
// enable Express.js middleware tracing
new Sentry.Integrations.Express({ app: options.serverInstance }),
new Sentry.Integrations.GraphQL(),
new Sentry.Integrations.Postgres({
usePgNative: true,
}),
new ProfilingIntegration(),
],
tracesSampleRate: 1.0,
profilesSampleRate: 1.0,
environment: options.debug ? 'development' : 'production',
debug: options.debug,
});
}
captureException(exception: Error) {
Sentry.captureException(exception);
}
captureMessage(message: string) {
Sentry.captureMessage(message);
}
}

View File

@ -0,0 +1 @@
export const EXCEPTION_HANDLER_DRIVER = Symbol('EXCEPTION_HANDLER_DRIVER');

View File

@ -0,0 +1,25 @@
import {
ConfigurableModuleBuilder,
FactoryProvider,
ModuleMetadata,
} from '@nestjs/common';
import { ExceptionHandlerModuleOptions } from './interfaces';
export const {
ConfigurableModuleClass,
MODULE_OPTIONS_TOKEN,
OPTIONS_TYPE,
ASYNC_OPTIONS_TYPE,
} = new ConfigurableModuleBuilder<ExceptionHandlerModuleOptions>({
moduleName: 'ExceptionHandlerModule',
})
.setClassMethodName('forRoot')
.build();
export type ExceptionHandlerModuleAsyncOptions = {
useFactory: (
...args: any[]
) => ExceptionHandlerModuleOptions | Promise<ExceptionHandlerModuleOptions>;
} & Pick<ModuleMetadata, 'imports'> &
Pick<FactoryProvider, 'inject'>;

View File

@ -0,0 +1,39 @@
import { HttpAdapterHost } from '@nestjs/core';
import { EnvironmentService } from 'src/integrations/environment/environment.service';
import { OPTIONS_TYPE } from 'src/integrations/exception-handler/exception-handler.module-definition';
import { ExceptionHandlerDriver } from 'src/integrations/exception-handler/interfaces';
/**
* ExceptionHandler Module factory
* @param environment
* @returns ExceptionHandlerModuleOptions
*/
export const exceptionHandlerModuleFactory = async (
environmentService: EnvironmentService,
adapterHost: HttpAdapterHost,
): Promise<typeof OPTIONS_TYPE> => {
const driverType = environmentService.getExceptionHandlerDriverType();
switch (driverType) {
case ExceptionHandlerDriver.Console: {
return {
type: ExceptionHandlerDriver.Console,
};
}
case ExceptionHandlerDriver.Sentry: {
return {
type: ExceptionHandlerDriver.Sentry,
options: {
dns: environmentService.getSentryDSN() ?? '',
serverInstance: adapterHost.httpAdapter.getInstance(),
debug: environmentService.isDebugMode(),
},
};
}
default:
throw new Error(
`Invalid exception capturer driver type (${driverType}), check your .env file`,
);
}
};

View File

@ -0,0 +1,60 @@
import { DynamicModule, Global, Module } from '@nestjs/common';
import { ExceptionHandlerSentryDriver } from 'src/integrations/exception-handler/drivers/sentry.driver';
import { ExceptionHandlerConsoleDriver } from 'src/integrations/exception-handler/drivers/console.driver';
import { ExceptionHandlerService } from './exception-handler.service';
import { ExceptionHandlerDriver } from './interfaces';
import { EXCEPTION_HANDLER_DRIVER } from './exception-handler.constants';
import {
ConfigurableModuleClass,
OPTIONS_TYPE,
ASYNC_OPTIONS_TYPE,
} from './exception-handler.module-definition';
@Global()
@Module({
providers: [ExceptionHandlerService],
exports: [ExceptionHandlerService],
})
export class ExceptionHandlerModule extends ConfigurableModuleClass {
static forRoot(options: typeof OPTIONS_TYPE): DynamicModule {
const provider = {
provide: EXCEPTION_HANDLER_DRIVER,
useValue:
options.type === ExceptionHandlerDriver.Console
? new ExceptionHandlerConsoleDriver()
: new ExceptionHandlerSentryDriver(options.options),
};
const dynamicModule = super.forRoot(options);
return {
...dynamicModule,
providers: [...(dynamicModule.providers ?? []), provider],
};
}
static forRootAsync(options: typeof ASYNC_OPTIONS_TYPE): DynamicModule {
const provider = {
provide: EXCEPTION_HANDLER_DRIVER,
useFactory: async (...args: any[]) => {
const config = await options?.useFactory?.(...args);
if (!config) {
return null;
}
return config.type === ExceptionHandlerDriver.Console
? new ExceptionHandlerConsoleDriver()
: new ExceptionHandlerSentryDriver(config.options);
},
inject: options.inject || [],
};
const dynamicModule = super.forRootAsync(options);
return {
...dynamicModule,
providers: [...(dynamicModule.providers ?? []), provider],
};
}
}

View File

@ -0,0 +1,27 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ExceptionHandlerService } from 'src/integrations/exception-handler/exception-handler.service';
import { EXCEPTION_HANDLER_DRIVER } from './exception-handler.constants';
describe('ExceptionHandlerService', () => {
let service: ExceptionHandlerService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
ExceptionHandlerService,
{
provide: EXCEPTION_HANDLER_DRIVER,
useValue: {},
},
],
}).compile();
service = module.get<ExceptionHandlerService>(ExceptionHandlerService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});

View File

@ -0,0 +1,17 @@
import { Inject, Injectable } from '@nestjs/common';
import { ExceptionHandlerDriverInterface } from 'src/integrations/exception-handler/interfaces';
import { EXCEPTION_HANDLER_DRIVER } from './exception-handler.constants';
@Injectable()
export class ExceptionHandlerService {
constructor(
@Inject(EXCEPTION_HANDLER_DRIVER)
private driver: ExceptionHandlerDriverInterface,
) {}
captureException(exception: unknown) {
this.driver.captureException(exception);
}
}

View File

@ -0,0 +1,4 @@
export interface ExceptionHandlerDriverInterface {
captureException(exception: unknown): void;
captureMessage(message: string): void;
}

View File

@ -0,0 +1,23 @@
import { Router } from 'express';
export enum ExceptionHandlerDriver {
Sentry = 'sentry',
Console = 'console',
}
export interface ExceptionHandlerSentryDriverFactoryOptions {
type: ExceptionHandlerDriver.Sentry;
options: {
dns: string;
serverInstance: Router;
debug?: boolean;
};
}
export interface ExceptionHandlerDriverFactoryOptions {
type: ExceptionHandlerDriver.Console;
}
export type ExceptionHandlerModuleOptions =
| ExceptionHandlerSentryDriverFactoryOptions
| ExceptionHandlerDriverFactoryOptions;

View File

@ -0,0 +1,2 @@
export * from './exception-handler.interface';
export * from './exception-handler-driver.interface';

View File

@ -0,0 +1,11 @@
import { Readable } from 'stream';
export interface StorageDriver {
read(params: { folderPath: string; filename: string }): Promise<Readable>;
write(params: {
file: Buffer | Uint8Array | string;
name: string;
folder: string;
mimeType: string | undefined;
}): Promise<void>;
}

View File

@ -0,0 +1,57 @@
import * as fs from 'fs/promises';
import { createReadStream, existsSync } from 'fs';
import { join, dirname } from 'path';
import { Readable } from 'stream';
import { StorageDriver } from './interfaces/storage-driver.interface';
export interface LocalDriverOptions {
storagePath: string;
}
export class LocalDriver implements StorageDriver {
private options: LocalDriverOptions;
constructor(options: LocalDriverOptions) {
this.options = options;
}
async createFolder(path: string) {
if (existsSync(path)) {
return;
}
return fs.mkdir(path, { recursive: true });
}
async write(params: {
file: Buffer | Uint8Array | string;
name: string;
folder: string;
mimeType: string | undefined;
}): Promise<void> {
const filePath = join(
`${this.options.storagePath}/`,
params.folder,
params.name,
);
const folderPath = dirname(filePath);
await this.createFolder(folderPath);
await fs.writeFile(filePath, params.file);
}
async read(params: {
folderPath: string;
filename: string;
}): Promise<Readable> {
const filePath = join(
`${this.options.storagePath}/`,
params.folderPath,
params.filename,
);
return createReadStream(filePath);
}
}

View File

@ -0,0 +1,98 @@
import { Readable } from 'stream';
import {
CreateBucketCommandInput,
GetObjectCommand,
HeadBucketCommandInput,
NotFound,
PutObjectCommand,
S3,
S3ClientConfig,
} from '@aws-sdk/client-s3';
import { StorageDriver } from './interfaces/storage-driver.interface';
export interface S3DriverOptions extends S3ClientConfig {
bucketName: string;
endpoint?: string;
region: string;
}
export class S3Driver implements StorageDriver {
private s3Client: S3;
private bucketName: string;
constructor(options: S3DriverOptions) {
const { bucketName, region, endpoint, ...s3Options } = options;
if (!bucketName || !region) {
return;
}
this.s3Client = new S3({ ...s3Options, region, endpoint });
this.bucketName = bucketName;
}
public get client(): S3 {
return this.s3Client;
}
async write(params: {
file: Buffer | Uint8Array | string;
name: string;
folder: string;
mimeType: string | undefined;
}): Promise<void> {
const command = new PutObjectCommand({
Key: `${params.folder}/${params.name}`,
Body: params.file,
ContentType: params.mimeType,
Bucket: this.bucketName,
});
await this.s3Client.send(command);
}
async read(params: {
folderPath: string;
filename: string;
}): Promise<Readable> {
const command = new GetObjectCommand({
Key: `${params.folderPath}/${params.filename}`,
Bucket: this.bucketName,
});
const file = await this.s3Client.send(command);
if (!file || !file.Body || !(file.Body instanceof Readable)) {
throw new Error('Unable to get file stream');
}
return Readable.from(file.Body);
}
async checkBucketExists(args: HeadBucketCommandInput) {
try {
await this.s3Client.headBucket(args);
return true;
} catch (error) {
if (error instanceof NotFound) {
return false;
}
throw error;
}
}
async createBucket(args: CreateBucketCommandInput) {
const exist = await this.checkBucketExists({
Bucket: args.Bucket,
});
if (exist) {
return;
}
return this.s3Client.createBucket(args);
}
}

View File

@ -0,0 +1 @@
export const STORAGE_DRIVER = Symbol('STORAGE_DRIVER');

View File

@ -0,0 +1,14 @@
import { ConfigurableModuleBuilder } from '@nestjs/common';
import { FileStorageModuleOptions } from './interfaces';
export const {
ConfigurableModuleClass,
MODULE_OPTIONS_TOKEN,
OPTIONS_TYPE,
ASYNC_OPTIONS_TYPE,
} = new ConfigurableModuleBuilder<FileStorageModuleOptions>({
moduleName: 'FileStorage',
})
.setClassMethodName('forRoot')
.build();

View File

@ -0,0 +1,53 @@
import { fromNodeProviderChain } from '@aws-sdk/credential-providers';
import { EnvironmentService } from 'src/integrations/environment/environment.service';
import {
FileStorageModuleOptions,
StorageDriverType,
} from 'src/integrations/file-storage/interfaces';
/**
* FileStorage Module factory
* @param environment
* @returns FileStorageModuleOptions
*/
export const fileStorageModuleFactory = async (
environmentService: EnvironmentService,
): Promise<FileStorageModuleOptions> => {
const driverType = environmentService.getStorageDriverType();
switch (driverType) {
case StorageDriverType.Local: {
const storagePath = environmentService.getStorageLocalPath();
return {
type: StorageDriverType.Local,
options: {
storagePath: process.cwd() + '/' + storagePath,
},
};
}
case StorageDriverType.S3: {
const bucketName = environmentService.getStorageS3Name();
const endpoint = environmentService.getStorageS3Endpoint();
const region = environmentService.getStorageS3Region();
return {
type: StorageDriverType.S3,
options: {
bucketName: bucketName ?? '',
endpoint: endpoint,
credentials: fromNodeProviderChain({
clientConfig: { region },
}),
forcePathStyle: true,
region: region ?? '',
},
};
}
default:
throw new Error(
`Invalid storage driver type (${driverType}), check your .env file`,
);
}
};

View File

@ -0,0 +1,51 @@
import { DynamicModule, Global } from '@nestjs/common';
import { FileStorageService } from './file-storage.service';
import {
FileStorageModuleAsyncOptions,
FileStorageModuleOptions,
} from './interfaces';
import { STORAGE_DRIVER } from './file-storage.constants';
import { LocalDriver } from './drivers/local.driver';
import { S3Driver } from './drivers/s3.driver';
@Global()
export class FileStorageModule {
static forRoot(options: FileStorageModuleOptions): DynamicModule {
const provider = {
provide: STORAGE_DRIVER,
useValue:
options.type === 's3'
? new S3Driver(options.options)
: new LocalDriver(options.options),
};
return {
module: FileStorageModule,
providers: [FileStorageService, provider],
exports: [FileStorageService],
};
}
static forRootAsync(options: FileStorageModuleAsyncOptions): DynamicModule {
const provider = {
provide: STORAGE_DRIVER,
useFactory: async (...args: any[]) => {
const config = await options.useFactory(...args);
return config?.type === 's3'
? new S3Driver(config.options)
: new LocalDriver(config.options);
},
inject: options.inject || [],
};
return {
module: FileStorageModule,
imports: options.imports || [],
providers: [FileStorageService, provider],
exports: [FileStorageService],
};
}
}

View File

@ -0,0 +1,26 @@
import { Test, TestingModule } from '@nestjs/testing';
import { FileStorageService } from './file-storage.service';
import { STORAGE_DRIVER } from './file-storage.constants';
describe('FileStorageService', () => {
let service: FileStorageService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
FileStorageService,
{
provide: STORAGE_DRIVER,
useValue: {},
},
],
}).compile();
service = module.get<FileStorageService>(FileStorageService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});

View File

@ -0,0 +1,25 @@
import { Inject, Injectable } from '@nestjs/common';
import { Readable } from 'stream';
import { STORAGE_DRIVER } from './file-storage.constants';
import { StorageDriver } from './drivers/interfaces/storage-driver.interface';
@Injectable()
export class FileStorageService implements StorageDriver {
constructor(@Inject(STORAGE_DRIVER) private driver: StorageDriver) {}
write(params: {
file: string | Buffer | Uint8Array;
name: string;
folder: string;
mimeType: string | undefined;
}): Promise<void> {
return this.driver.write(params);
}
read(params: { folderPath: string; filename: string }): Promise<Readable> {
return this.driver.read(params);
}
}

View File

@ -0,0 +1,30 @@
import { FactoryProvider, ModuleMetadata } from '@nestjs/common';
import { S3DriverOptions } from 'src/integrations/file-storage/drivers/s3.driver';
import { LocalDriverOptions } from 'src/integrations/file-storage/drivers/local.driver';
export enum StorageDriverType {
S3 = 's3',
Local = 'local',
}
export interface S3DriverFactoryOptions {
type: StorageDriverType.S3;
options: S3DriverOptions;
}
export interface LocalDriverFactoryOptions {
type: StorageDriverType.Local;
options: LocalDriverOptions;
}
export type FileStorageModuleOptions =
| S3DriverFactoryOptions
| LocalDriverFactoryOptions;
export type FileStorageModuleAsyncOptions = {
useFactory: (
...args: any[]
) => FileStorageModuleOptions | Promise<FileStorageModuleOptions>;
} & Pick<ModuleMetadata, 'imports'> &
Pick<FactoryProvider, 'inject'>;

View File

@ -0,0 +1 @@
export * from './file-storage.interface';

View File

@ -0,0 +1,39 @@
import { Module } from '@nestjs/common';
import { HttpAdapterHost } from '@nestjs/core';
import { ExceptionHandlerModule } from 'src/integrations/exception-handler/exception-handler.module';
import { exceptionHandlerModuleFactory } from 'src/integrations/exception-handler/exception-handler.module-factory';
import { fileStorageModuleFactory } from 'src/integrations/file-storage/file-storage.module-factory';
import { loggerModuleFactory } from 'src/integrations/logger/logger.module-factory';
import { messageQueueModuleFactory } from 'src/integrations/message-queue/message-queue.module-factory';
import { EnvironmentModule } from './environment/environment.module';
import { EnvironmentService } from './environment/environment.service';
import { FileStorageModule } from './file-storage/file-storage.module';
import { LoggerModule } from './logger/logger.module';
import { MessageQueueModule } from './message-queue/message-queue.module';
@Module({
imports: [
EnvironmentModule.forRoot({}),
FileStorageModule.forRootAsync({
useFactory: fileStorageModuleFactory,
inject: [EnvironmentService],
}),
LoggerModule.forRootAsync({
useFactory: loggerModuleFactory,
inject: [EnvironmentService],
}),
MessageQueueModule.forRoot({
useFactory: messageQueueModuleFactory,
inject: [EnvironmentService],
}),
ExceptionHandlerModule.forRootAsync({
useFactory: exceptionHandlerModuleFactory,
inject: [EnvironmentService, HttpAdapterHost],
}),
],
exports: [],
providers: [],
})
export class IntegrationsModule {}

View File

@ -0,0 +1 @@
export * from './logger.interface';

View File

@ -0,0 +1,9 @@
export enum LoggerDriverType {
Console = 'console',
}
export interface ConsoleDriverFactoryOptions {
type: LoggerDriverType.Console;
}
export type LoggerModuleOptions = ConsoleDriverFactoryOptions;

View File

@ -0,0 +1 @@
export const LOGGER_DRIVER = Symbol('LOGGER_DRIVER');

View File

@ -0,0 +1,25 @@
import {
ConfigurableModuleBuilder,
FactoryProvider,
ModuleMetadata,
} from '@nestjs/common';
import { LoggerModuleOptions } from './interfaces';
export const {
ConfigurableModuleClass,
MODULE_OPTIONS_TOKEN,
OPTIONS_TYPE,
ASYNC_OPTIONS_TYPE,
} = new ConfigurableModuleBuilder<LoggerModuleOptions>({
moduleName: 'LoggerService',
})
.setClassMethodName('forRoot')
.build();
export type LoggerModuleAsyncOptions = {
useFactory: (
...args: any[]
) => LoggerModuleOptions | Promise<LoggerModuleOptions>;
} & Pick<ModuleMetadata, 'imports'> &
Pick<FactoryProvider, 'inject'>;

View File

@ -0,0 +1,28 @@
import { EnvironmentService } from 'src/integrations/environment/environment.service';
import {
LoggerModuleOptions,
LoggerDriverType,
} from 'src/integrations/logger/interfaces';
/**
* Logger Module factory
* @param environment
* @returns LoggerModuleOptions
*/
export const loggerModuleFactory = async (
environmentService: EnvironmentService,
): Promise<LoggerModuleOptions> => {
const driverType = environmentService.getLoggerDriverType();
switch (driverType) {
case LoggerDriverType.Console: {
return {
type: LoggerDriverType.Console,
};
}
default:
throw new Error(
`Invalid logger driver type (${driverType}), check your .env file`,
);
}
};

View File

@ -0,0 +1,58 @@
import { DynamicModule, Global, ConsoleLogger, Module } from '@nestjs/common';
import { LoggerDriverType } from 'src/integrations/logger/interfaces';
import { LoggerService } from './logger.service';
import { LOGGER_DRIVER } from './logger.constants';
import {
ASYNC_OPTIONS_TYPE,
ConfigurableModuleClass,
OPTIONS_TYPE,
} from './logger.module-definition';
@Global()
@Module({
providers: [LoggerService],
exports: [LoggerService],
})
export class LoggerModule extends ConfigurableModuleClass {
static forRoot(options: typeof OPTIONS_TYPE): DynamicModule {
const provider = {
provide: LOGGER_DRIVER,
useValue:
options.type === LoggerDriverType.Console
? new ConsoleLogger()
: undefined,
};
const dynamicModule = super.forRoot(options);
return {
...dynamicModule,
providers: [...(dynamicModule.providers ?? []), provider],
};
}
static forRootAsync(options: typeof ASYNC_OPTIONS_TYPE): DynamicModule {
const provider = {
provide: LOGGER_DRIVER,
useFactory: async (...args: any[]) => {
const config = await options?.useFactory?.(...args);
if (!config) {
return null;
}
return config?.type === LoggerDriverType.Console
? new ConsoleLogger()
: undefined;
},
inject: options.inject || [],
};
const dynamicModule = super.forRootAsync(options);
return {
...dynamicModule,
providers: [...(dynamicModule.providers ?? []), provider],
};
}
}

View File

@ -0,0 +1,26 @@
import { Test, TestingModule } from '@nestjs/testing';
import { LoggerService } from './logger.service';
import { LOGGER_DRIVER } from './logger.constants';
describe('LoggerService', () => {
let service: LoggerService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
LoggerService,
{
provide: LOGGER_DRIVER,
useValue: {},
},
],
}).compile();
service = module.get<LoggerService>(LoggerService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});

View File

@ -0,0 +1,49 @@
import {
Inject,
Injectable,
LogLevel,
LoggerService as LoggerServiceInterface,
} from '@nestjs/common';
import { LOGGER_DRIVER } from './logger.constants';
@Injectable()
export class LoggerService implements LoggerServiceInterface {
constructor(@Inject(LOGGER_DRIVER) private driver: LoggerServiceInterface) {}
log(message: any, category: string, ...optionalParams: any[]) {
this.driver.log.apply(this.driver, [message, category, ...optionalParams]);
}
error(message: any, category: string, ...optionalParams: any[]) {
this.driver.error.apply(this.driver, [
message,
category,
...optionalParams,
]);
}
warn(message: any, category: string, ...optionalParams: any[]) {
this.driver.warn.apply(this.driver, [message, category, ...optionalParams]);
}
debug?(message: any, category: string, ...optionalParams: any[]) {
this.driver.debug?.apply(this.driver, [
message,
category,
...optionalParams,
]);
}
verbose?(message: any, category: string, ...optionalParams: any[]) {
this.driver.verbose?.apply(this.driver, [
message,
category,
...optionalParams,
]);
}
setLogLevels(levels: LogLevel[]) {
this.driver.setLogLevels?.apply(this.driver, [levels]);
}
}

View File

@ -0,0 +1,9 @@
import { Inject } from '@nestjs/common';
import { createMemoryStorageInjectionToken } from 'src/integrations/memory-storage/memory-storage.util';
export const InjectMemoryStorage = (identifier: string) => {
const injectionToken = createMemoryStorageInjectionToken(identifier);
return Inject(injectionToken);
};

View File

@ -0,0 +1,5 @@
export interface MemoryStorageDriver<T> {
read(params: { key: string }): Promise<T | null>;
write(params: { key: string; data: T }): Promise<void>;
delete(params: { key: string }): Promise<void>;
}

View File

@ -0,0 +1,62 @@
import { MemoryStorageSerializer } from 'src/integrations/memory-storage/serializers/interfaces/memory-storage-serializer.interface';
import { MemoryStorageDriver } from './interfaces/memory-storage-driver.interface';
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface LocalMemoryDriverOptions {}
export class LocalMemoryDriver<T> implements MemoryStorageDriver<T> {
private identifier: string;
private options: LocalMemoryDriverOptions;
private serializer: MemoryStorageSerializer<T>;
private storage: Map<string, string> = new Map();
constructor(
identifier: string,
options: LocalMemoryDriverOptions,
serializer: MemoryStorageSerializer<T>,
) {
this.identifier = identifier;
this.options = options;
this.serializer = serializer;
}
async write(params: { key: string; data: T }): Promise<void> {
const compositeKey = this.generateCompositeKey(params.key);
const serializedData = this.serializer.serialize(params.data);
this.storage.set(compositeKey, serializedData);
}
async read(params: { key: string }): Promise<T | null> {
const compositeKey = this.generateCompositeKey(params.key);
if (!this.storage.has(compositeKey)) {
return null;
}
const data = this.storage.get(compositeKey);
if (!data) {
return null;
}
const deserializeData = this.serializer.deserialize(data);
return deserializeData;
}
async delete(params: { key: string }): Promise<void> {
const compositeKey = this.generateCompositeKey(params.key);
if (!this.storage.has(compositeKey)) {
return;
}
this.storage.delete(compositeKey);
}
private generateCompositeKey(key: string): string {
return `${this.identifier}:${key}`;
}
}

View File

@ -0,0 +1 @@
export * from './memory-storage.interface';

View File

@ -0,0 +1,32 @@
import { FactoryProvider, ModuleMetadata } from '@nestjs/common';
import { MemoryStorageSerializer } from 'src/integrations/memory-storage/serializers/interfaces/memory-storage-serializer.interface';
import { LocalMemoryDriverOptions } from 'src/integrations/memory-storage/drivers/local.driver';
export enum MemoryStorageDriverType {
Local = 'local',
}
export interface LocalMemoryDriverFactoryOptions {
type: MemoryStorageDriverType.Local;
options: LocalMemoryDriverOptions;
}
interface MemoryStorageModuleBaseOptions {
identifier: string;
serializer?: MemoryStorageSerializer<any>;
}
export type MemoryStorageModuleOptions = MemoryStorageModuleBaseOptions &
LocalMemoryDriverFactoryOptions;
export type MemoryStorageModuleAsyncOptions = {
identifier: string;
useFactory: (
...args: any[]
) =>
| Omit<MemoryStorageModuleOptions, 'identifier'>
| Promise<Omit<MemoryStorageModuleOptions, 'identifier'>>;
} & Pick<ModuleMetadata, 'imports'> &
Pick<FactoryProvider, 'inject'>;

View File

@ -0,0 +1 @@
export const MEMORY_STORAGE_SERVICE = 'MEMORY_STORAGE_SERVICE';

View File

@ -0,0 +1,72 @@
import { DynamicModule, Global } from '@nestjs/common';
import { MemoryStorageDefaultSerializer } from 'src/integrations/memory-storage/serializers/default.serializer';
import { createMemoryStorageInjectionToken } from 'src/integrations/memory-storage/memory-storage.util';
import {
MemoryStorageDriverType,
MemoryStorageModuleAsyncOptions,
MemoryStorageModuleOptions,
} from './interfaces';
import { LocalMemoryDriver } from './drivers/local.driver';
@Global()
export class MemoryStorageModule {
static forRoot(options: MemoryStorageModuleOptions): DynamicModule {
// Dynamic injection token to allow multiple instances of the same driver
const injectionToken = createMemoryStorageInjectionToken(
options.identifier,
);
const provider = {
provide: injectionToken,
useValue: this.createStorageDriver(options),
};
return {
module: MemoryStorageModule,
providers: [provider],
exports: [provider],
};
}
static forRootAsync(options: MemoryStorageModuleAsyncOptions): DynamicModule {
// Dynamic injection token to allow multiple instances of the same driver
const injectionToken = createMemoryStorageInjectionToken(
options.identifier,
);
const provider = {
provide: injectionToken,
useFactory: async (...args: any[]) => {
const config = await options.useFactory(...args);
return this.createStorageDriver({
identifier: options.identifier,
...config,
});
},
inject: options.inject || [],
};
return {
module: MemoryStorageModule,
imports: options.imports || [],
providers: [provider],
exports: [provider],
};
}
private static createStorageDriver(options: MemoryStorageModuleOptions) {
switch (options.type) {
case MemoryStorageDriverType.Local:
return new LocalMemoryDriver(
options.identifier,
options.options,
options.serializer ?? new MemoryStorageDefaultSerializer<string>(),
);
// Future case for Redis or other types
default:
throw new Error(`Unsupported storage type: ${options.type}`);
}
}
}

View File

@ -0,0 +1,19 @@
import { Test, TestingModule } from '@nestjs/testing';
import { MemoryStorageService } from './memory-storage.service';
describe('MemoryStorageService', () => {
let service: MemoryStorageService<any>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [MemoryStorageService],
}).compile();
service = module.get<MemoryStorageService<any>>(MemoryStorageService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});

View File

@ -0,0 +1,21 @@
import { MemoryStorageDriver } from 'src/integrations/memory-storage/drivers/interfaces/memory-storage-driver.interface';
export class MemoryStorageService<T> implements MemoryStorageDriver<T> {
private driver: MemoryStorageDriver<T>;
constructor(driver: MemoryStorageDriver<T>) {
this.driver = driver;
}
write(params: { key: string; data: T }): Promise<void> {
return this.driver.write(params);
}
read(params: { key: string }): Promise<T | null> {
return this.driver.read(params);
}
delete(params: { key: string }): Promise<void> {
return this.driver.delete(params);
}
}

View File

@ -0,0 +1,5 @@
import { MEMORY_STORAGE_SERVICE } from 'src/integrations/memory-storage/memory-storage.constants';
export const createMemoryStorageInjectionToken = (identifier: string) => {
return `${MEMORY_STORAGE_SERVICE}_${identifier}`;
};

View File

@ -0,0 +1,17 @@
import { MemoryStorageSerializer } from 'src/integrations/memory-storage/serializers/interfaces/memory-storage-serializer.interface';
export class MemoryStorageDefaultSerializer<T>
implements MemoryStorageSerializer<T>
{
serialize(item: T): string {
if (typeof item !== 'string') {
throw new Error('DefaultSerializer can only serialize strings');
}
return item;
}
deserialize(data: string): T {
return data as unknown as T;
}
}

View File

@ -0,0 +1,4 @@
export interface MemoryStorageSerializer<T> {
serialize(item: T): string;
deserialize(data: string): T;
}

View File

@ -0,0 +1,13 @@
import { MemoryStorageSerializer } from 'src/integrations/memory-storage/serializers/interfaces/memory-storage-serializer.interface';
export class MemoryStorageJsonSerializer<T>
implements MemoryStorageSerializer<T>
{
serialize(item: T): string {
return JSON.stringify(item);
}
deserialize(data: string): T {
return JSON.parse(data) as T;
}
}

View File

@ -0,0 +1,62 @@
import { Queue, QueueOptions, Worker } from 'bullmq';
import { QueueJobOptions } from 'src/integrations/message-queue/drivers/interfaces/job-options.interface';
import { MessageQueues } from 'src/integrations/message-queue/message-queue.constants';
import { MessageQueueDriver } from './interfaces/message-queue-driver.interface';
export type BullMQDriverOptions = QueueOptions;
export class BullMQDriver implements MessageQueueDriver {
private queueMap: Record<MessageQueues, Queue> = {} as Record<
MessageQueues,
Queue
>;
private workerMap: Record<MessageQueues, Worker> = {} as Record<
MessageQueues,
Worker
>;
constructor(private options: BullMQDriverOptions) {}
register(queueName: MessageQueues): void {
this.queueMap[queueName] = new Queue(queueName, this.options);
}
async stop() {
const workers = Object.values(this.workerMap);
const queues = Object.values(this.queueMap);
await Promise.all([
...queues.map((q) => q.close()),
...workers.map((w) => w.close()),
]);
}
async work<T>(
queueName: MessageQueues,
handler: ({ data, id }: { data: T; id: string }) => Promise<void>,
) {
const worker = new Worker(queueName, async (job) => {
await handler(job as { data: T; id: string });
});
this.workerMap[queueName] = worker;
}
async add<T>(
queueName: MessageQueues,
data: T,
options?: QueueJobOptions,
): Promise<void> {
if (!this.queueMap[queueName]) {
throw new Error(
`Queue ${queueName} is not registered, make sure you have added it as a queue provider`,
);
}
await this.queueMap[queueName].add(options?.id || '', data, {
priority: options?.priority,
});
}
}

View File

@ -0,0 +1,4 @@
export interface QueueJobOptions {
id?: string;
priority?: number;
}

View File

@ -0,0 +1,17 @@
import { QueueJobOptions } from 'src/integrations/message-queue/drivers/interfaces/job-options.interface';
import { MessageQueues } from 'src/integrations/message-queue/message-queue.constants';
export interface MessageQueueDriver {
add<T>(
queueName: MessageQueues,
data: T,
options?: QueueJobOptions,
): Promise<void>;
work<T>(
queueName: string,
handler: ({ data, id }: { data: T; id: string }) => Promise<void> | void,
);
stop?(): Promise<void>;
register?(queueName: MessageQueues): void;
}

View File

@ -0,0 +1,38 @@
import PgBoss from 'pg-boss';
import { QueueJobOptions } from 'src/integrations/message-queue/drivers/interfaces/job-options.interface';
import { MessageQueueDriver } from './interfaces/message-queue-driver.interface';
export type PgBossDriverOptions = PgBoss.ConstructorOptions;
export class PgBossDriver implements MessageQueueDriver {
private pgBoss: PgBoss;
constructor(options: PgBossDriverOptions) {
this.pgBoss = new PgBoss(options);
}
async stop() {
await this.pgBoss.stop();
}
async init(): Promise<void> {
await this.pgBoss.start();
}
async work<T>(
queueName: string,
handler: ({ data, id }: { data: T; id: string }) => Promise<void>,
) {
return this.pgBoss.work(queueName, handler);
}
async add<T>(
queueName: string,
data: T,
options?: QueueJobOptions,
): Promise<void> {
await this.pgBoss.send(queueName, data as object, options ? options : {});
}
}

View File

@ -0,0 +1 @@
export * from './message-queue.interface';

View File

@ -0,0 +1,30 @@
import { FactoryProvider, ModuleMetadata } from '@nestjs/common';
import { BullMQDriverOptions } from 'src/integrations/message-queue/drivers/bullmq.driver';
import { PgBossDriverOptions } from 'src/integrations/message-queue/drivers/pg-boss.driver';
export enum MessageQueueDriverType {
PgBoss = 'pg-boss',
BullMQ = 'bull-mq',
}
export interface PgBossDriverFactoryOptions {
type: MessageQueueDriverType.PgBoss;
options: PgBossDriverOptions;
}
export interface BullMQDriverFactoryOptions {
type: MessageQueueDriverType.BullMQ;
options: BullMQDriverOptions;
}
export type MessageQueueModuleOptions =
| PgBossDriverFactoryOptions
| BullMQDriverFactoryOptions;
export type MessageQueueModuleAsyncOptions = {
useFactory: (
...args: any[]
) => MessageQueueModuleOptions | Promise<MessageQueueModuleOptions>;
} & Pick<ModuleMetadata, 'imports'> &
Pick<FactoryProvider, 'inject'>;

View File

@ -0,0 +1,5 @@
export const QUEUE_DRIVER = Symbol('QUEUE_DRIVER');
export enum MessageQueues {
taskAssignedQueue = 'task-assigned-queue',
}

View File

@ -0,0 +1,47 @@
import { EnvironmentService } from 'src/integrations/environment/environment.service';
import {
MessageQueueDriverType,
MessageQueueModuleOptions,
} from 'src/integrations/message-queue/interfaces';
/**
* MessageQueue Module factory
* @param environment
* @returns MessageQueueModuleOptions
*/
export const messageQueueModuleFactory = async (
environmentService: EnvironmentService,
): Promise<MessageQueueModuleOptions> => {
const driverType = environmentService.getMessageQueueDriverType();
switch (driverType) {
case MessageQueueDriverType.PgBoss: {
const connectionString = environmentService.getPGDatabaseUrl();
return {
type: MessageQueueDriverType.PgBoss,
options: {
connectionString,
},
};
}
case MessageQueueDriverType.BullMQ: {
const host = environmentService.getRedisHost();
const port = environmentService.getRedisPort();
return {
type: MessageQueueDriverType.BullMQ,
options: {
connection: {
host,
port,
},
},
};
}
default:
throw new Error(
`Invalid message queue driver type (${driverType}), check your .env file`,
);
}
};

View File

@ -0,0 +1,57 @@
import { DynamicModule, Global } from '@nestjs/common';
import { MessageQueueDriver } from 'src/integrations/message-queue/drivers/interfaces/message-queue-driver.interface';
import {
MessageQueueDriverType,
MessageQueueModuleAsyncOptions,
} from 'src/integrations/message-queue/interfaces';
import {
QUEUE_DRIVER,
MessageQueues,
} from 'src/integrations/message-queue/message-queue.constants';
import { PgBossDriver } from 'src/integrations/message-queue/drivers/pg-boss.driver';
import { MessageQueueService } from 'src/integrations/message-queue/services/message-queue.service';
import { BullMQDriver } from 'src/integrations/message-queue/drivers/bullmq.driver';
@Global()
export class MessageQueueModule {
static forRoot(options: MessageQueueModuleAsyncOptions): DynamicModule {
const providers = [
{
provide: MessageQueues.taskAssignedQueue,
useFactory: (driver: MessageQueueDriver) => {
return new MessageQueueService(
driver,
MessageQueues.taskAssignedQueue,
);
},
inject: [QUEUE_DRIVER],
},
{
provide: QUEUE_DRIVER,
useFactory: async (...args: any[]) => {
const config = await options.useFactory(...args);
if (config.type === MessageQueueDriverType.PgBoss) {
const boss = new PgBossDriver(config.options);
await boss.init();
return boss;
}
return new BullMQDriver(config.options);
},
inject: options.inject || [],
},
];
return {
module: MessageQueueModule,
imports: options.imports || [],
providers,
exports: [MessageQueues.taskAssignedQueue],
};
}
}

View File

@ -0,0 +1,46 @@
import { Test, TestingModule } from '@nestjs/testing';
import { MessageQueueDriver } from 'src/integrations/message-queue/drivers/interfaces/message-queue-driver.interface';
import {
QUEUE_DRIVER,
MessageQueues,
} from 'src/integrations/message-queue/message-queue.constants';
import { MessageQueueService } from 'src/integrations/message-queue/services/message-queue.service';
describe('MessageQueueTaskAssigned queue', () => {
let service: MessageQueueService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: MessageQueues.taskAssignedQueue,
useFactory: (driver: MessageQueueDriver) => {
return new MessageQueueService(
driver,
MessageQueues.taskAssignedQueue,
);
},
inject: [QUEUE_DRIVER],
},
{
provide: QUEUE_DRIVER,
useValue: {},
},
],
}).compile();
service = module.get<MessageQueueService>(MessageQueues.taskAssignedQueue);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
it('should contain the topic and driver', () => {
expect(service).toEqual({
driver: {},
queueName: MessageQueues.taskAssignedQueue,
});
});
});

View File

@ -0,0 +1,37 @@
import { Inject, Injectable, OnModuleDestroy } from '@nestjs/common';
import { QueueJobOptions } from 'src/integrations/message-queue/drivers/interfaces/job-options.interface';
import { MessageQueueDriver } from 'src/integrations/message-queue/drivers/interfaces/message-queue-driver.interface';
import {
MessageQueues,
QUEUE_DRIVER,
} from 'src/integrations/message-queue/message-queue.constants';
@Injectable()
export class MessageQueueService implements OnModuleDestroy {
constructor(
@Inject(QUEUE_DRIVER) protected driver: MessageQueueDriver,
protected queueName: MessageQueues,
) {
if (typeof this.driver.register === 'function') {
this.driver.register(queueName);
}
}
async onModuleDestroy() {
if (typeof this.driver.stop === 'function') {
await this.driver.stop();
}
}
add<T>(data: T, options?: QueueJobOptions): Promise<void> {
return this.driver.add(this.queueName, data, options);
}
work<T>(
handler: ({ data, id }: { data: T; id: string }) => Promise<void> | void,
) {
return this.driver.work(this.queueName, handler);
}
}