create module

This commit is contained in:
sbriat 2023-04-06 14:21:43 +02:00
parent d7b1c54b45
commit afca685e3d
30 changed files with 653 additions and 36 deletions

12
package-lock.json generated
View File

@ -16,6 +16,7 @@
"@grpc/grpc-js": "^1.8.13",
"@grpc/proto-loader": "^0.7.6",
"@liaoliaots/nestjs-redis": "^9.0.5",
"@nestjs/cache-manager": "^1.0.0",
"@nestjs/common": "^9.0.0",
"@nestjs/config": "^2.3.1",
"@nestjs/core": "^9.0.0",
@ -1569,6 +1570,17 @@
"node": ">=8"
}
},
"node_modules/@nestjs/cache-manager": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@nestjs/cache-manager/-/cache-manager-1.0.0.tgz",
"integrity": "sha512-XMNdgsj3H+Ng/SYwFl13vRGNFA3e5Obk8LNwIuHLVSocnK2exReAWtscxEjQhoBc4FW4jAYOgU/U+mt18Q9T0g==",
"peerDependencies": {
"@nestjs/common": "^9.0.0",
"cache-manager": "<=5",
"reflect-metadata": "^0.1.12",
"rxjs": "^7.0.0"
}
},
"node_modules/@nestjs/cli": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-9.3.0.tgz",

View File

@ -38,6 +38,7 @@
"@grpc/grpc-js": "^1.8.13",
"@grpc/proto-loader": "^0.7.6",
"@liaoliaots/nestjs-redis": "^9.0.5",
"@nestjs/cache-manager": "^1.0.0",
"@nestjs/common": "^9.0.0",
"@nestjs/config": "^2.3.1",
"@nestjs/core": "^9.0.0",

View File

@ -0,0 +1,65 @@
-- CreateExtension
CREATE EXTENSION IF NOT EXISTS "postgis";
-- Required to use postgis extension :
-- set the search_path to both public and territory (where is postgis) AND the current schema
SET search_path TO matcher, territory, public;
-- CreateTable
CREATE TABLE "ad" (
"uuid" UUID NOT NULL,
"driver" BOOLEAN NOT NULL,
"passenger" BOOLEAN NOT NULL,
"frequency" INTEGER NOT NULL,
"from_date" DATE NOT NULL,
"to_date" DATE NOT NULL,
"mon_time" TIMESTAMPTZ NOT NULL,
"tue_time" TIMESTAMPTZ NOT NULL,
"wed_time" TIMESTAMPTZ NOT NULL,
"thu_time" TIMESTAMPTZ NOT NULL,
"fri_time" TIMESTAMPTZ NOT NULL,
"sat_time" TIMESTAMPTZ NOT NULL,
"sun_time" TIMESTAMPTZ NOT NULL,
"mon_margin" INTEGER NOT NULL,
"tue_margin" INTEGER NOT NULL,
"wed_margin" INTEGER NOT NULL,
"thu_margin" INTEGER NOT NULL,
"fri_margin" INTEGER NOT NULL,
"sat_margin" INTEGER NOT NULL,
"sun_margin" INTEGER NOT NULL,
"driver_duration" INTEGER NOT NULL,
"driver_distance" INTEGER NOT NULL,
"passenger_duration" INTEGER NOT NULL,
"passenger_distance" INTEGER NOT NULL,
"origin_type" SMALLINT NOT NULL,
"destination_type" SMALLINT NOT NULL,
"waypoints" geography(LINESTRING) NOT NULL,
"direction" geography(LINESTRING) NOT NULL,
"fwd_azimuth" INTEGER NOT NULL,
"back_azimuth" INTEGER NOT NULL,
"seats_driver" SMALLINT NOT NULL,
"seats_passenger" SMALLINT NOT NULL,
"seats_used" SMALLINT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ad_pkey" PRIMARY KEY ("uuid")
);
-- CreateIndex
CREATE INDEX "ad_driver_idx" ON "ad"("driver");
-- CreateIndex
CREATE INDEX "ad_passenger_idx" ON "ad"("passenger");
-- CreateIndex
CREATE INDEX "ad_from_date_idx" ON "ad"("from_date");
-- CreateIndex
CREATE INDEX "ad_to_date_idx" ON "ad"("to_date");
-- CreateIndex
CREATE INDEX "ad_fwd_azimuth_idx" ON "ad"("fwd_azimuth");
-- CreateIndex
CREATE INDEX "direction_idx" ON "ad" USING GIST ("direction");

View File

@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"

View File

@ -3,12 +3,14 @@ import { AutomapperModule } from '@automapper/nestjs';
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { ConfigurationModule } from './modules/configuration/configuration.module';
import { HealthModule } from './modules/health/health.module';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
AutomapperModule.forRoot({ strategyInitializer: classes() }),
ConfigurationModule,
HealthModule,
],
controllers: [],
providers: [],

View File

@ -1,6 +1,6 @@
import { NestFactory } from '@nestjs/core';
import { MicroserviceOptions, Transport } from '@nestjs/microservices';
import { join } from 'path';
// import { join } from 'path';
import { AppModule } from './app.module';
async function bootstrap() {
@ -8,19 +8,19 @@ async function bootstrap() {
app.connectMicroservice<MicroserviceOptions>({
transport: Transport.TCP,
});
app.connectMicroservice<MicroserviceOptions>({
transport: Transport.GRPC,
options: {
// package: ['matcher', 'health'],
package: ['health'],
protoPath: [
// join(__dirname, 'modules/matcher/adapters/primaries/matcher.proto'),
join(__dirname, 'modules/health/adapters/primaries/health.proto'),
],
url: process.env.SERVICE_URL + ':' + process.env.SERVICE_PORT,
loader: { keepCase: true },
},
});
// app.connectMicroservice<MicroserviceOptions>({
// transport: Transport.GRPC,
// options: {
// // package: ['matcher', 'health'],
// package: ['health'],
// protoPath: [
// // join(__dirname, 'modules/matcher/adapters/primaries/matcher.proto'),
// join(__dirname, 'modules/health/adapters/primaries/health.proto'),
// ],
// url: process.env.SERVICE_URL + ':' + process.env.SERVICE_PORT,
// loader: { keepCase: true },
// },
// });
await app.startAllMicroservices();
await app.listen(process.env.HEALTH_SERVICE_PORT);

View File

@ -0,0 +1,42 @@
import { Controller } from '@nestjs/common';
import { GrpcMethod } from '@nestjs/microservices';
import { PrismaHealthIndicatorUseCase } from '../../domain/usecases/prisma.health-indicator.usecase';
enum ServingStatus {
UNKNOWN = 0,
SERVING = 1,
NOT_SERVING = 2,
}
interface HealthCheckRequest {
service: string;
}
interface HealthCheckResponse {
status: ServingStatus;
}
@Controller()
export class HealthServerController {
constructor(
private readonly _prismaHealthIndicatorUseCase: PrismaHealthIndicatorUseCase,
) {}
@GrpcMethod('Health', 'Check')
async check(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
data: HealthCheckRequest,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
metadata: any,
): Promise<HealthCheckResponse> {
const healthCheck = await this._prismaHealthIndicatorUseCase.isHealthy(
'prisma',
);
return {
status:
healthCheck['prisma'].status == 'up'
? ServingStatus.SERVING
: ServingStatus.NOT_SERVING,
};
}
}

View File

@ -0,0 +1,34 @@
import { Controller, Get } from '@nestjs/common';
import {
HealthCheckService,
HealthCheck,
HealthCheckResult,
} from '@nestjs/terminus';
import { Messager } from '../secondaries/messager';
import { PrismaHealthIndicatorUseCase } from '../../domain/usecases/prisma.health-indicator.usecase';
@Controller('health')
export class HealthController {
constructor(
private readonly _prismaHealthIndicatorUseCase: PrismaHealthIndicatorUseCase,
private _healthCheckService: HealthCheckService,
private _messager: Messager,
) {}
@Get()
@HealthCheck()
async check() {
try {
return await this._healthCheckService.check([
async () => this._prismaHealthIndicatorUseCase.isHealthy('prisma'),
]);
} catch (error) {
const healthCheckResult: HealthCheckResult = error.response;
this._messager.publish(
'logging.matcher.health.crit',
JSON.stringify(healthCheckResult.error),
);
throw error;
}
}
}

View File

@ -0,0 +1,21 @@
syntax = "proto3";
package health;
service Health {
rpc Check(HealthCheckRequest) returns (HealthCheckResponse);
}
message HealthCheckRequest {
string service = 1;
}
message HealthCheckResponse {
enum ServingStatus {
UNKNOWN = 0;
SERVING = 1;
NOT_SERVING = 2;
}
ServingStatus status = 1;
}

View File

@ -0,0 +1,12 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export abstract class IMessageBroker {
exchange: string;
constructor(exchange: string) {
this.exchange = exchange;
}
abstract publish(routingKey: string, message: string): void;
}

View File

@ -0,0 +1,18 @@
import { AmqpConnection } from '@golevelup/nestjs-rabbitmq';
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { IMessageBroker } from './message-broker';
@Injectable()
export class Messager extends IMessageBroker {
constructor(
private readonly _amqpConnection: AmqpConnection,
configService: ConfigService,
) {
super(configService.get<string>('RMQ_EXCHANGE'));
}
publish(routingKey: string, message: string): void {
this._amqpConnection.publish(this.exchange, routingKey, message);
}
}

View File

@ -0,0 +1,25 @@
import { Injectable } from '@nestjs/common';
import {
HealthCheckError,
HealthIndicator,
HealthIndicatorResult,
} from '@nestjs/terminus';
import { AdRepository } from '../../../matcher/adapters/secondaries/ad.repository';
@Injectable()
export class PrismaHealthIndicatorUseCase extends HealthIndicator {
constructor(private readonly _repository: AdRepository) {
super();
}
async isHealthy(key: string): Promise<HealthIndicatorResult> {
try {
await this._repository.healthCheck();
return this.getStatus(key, true);
} catch (e) {
throw new HealthCheckError('Prisma', {
prisma: e.message,
});
}
}
}

View File

@ -0,0 +1,34 @@
import { Module } from '@nestjs/common';
import { HealthServerController } from './adapters/primaries/health-server.controller';
import { PrismaHealthIndicatorUseCase } from './domain/usecases/prisma.health-indicator.usecase';
import { DatabaseModule } from '../database/database.module';
import { HealthController } from './adapters/primaries/health.controller';
import { TerminusModule } from '@nestjs/terminus';
import { RabbitMQModule } from '@golevelup/nestjs-rabbitmq';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { Messager } from './adapters/secondaries/messager';
import { AdRepository } from '../matcher/adapters/secondaries/ad.repository';
@Module({
imports: [
TerminusModule,
RabbitMQModule.forRootAsync(RabbitMQModule, {
imports: [ConfigModule],
useFactory: async (configService: ConfigService) => ({
exchanges: [
{
name: configService.get<string>('RMQ_EXCHANGE'),
type: 'topic',
},
],
uri: configService.get<string>('RMQ_URI'),
connectionInitOptions: { wait: false },
}),
inject: [ConfigService],
}),
DatabaseModule,
],
controllers: [HealthServerController, HealthController],
providers: [PrismaHealthIndicatorUseCase, AdRepository, Messager],
})
export class HealthModule {}

View File

@ -0,0 +1,47 @@
import { AmqpConnection } from '@golevelup/nestjs-rabbitmq';
import { ConfigService } from '@nestjs/config';
import { Test, TestingModule } from '@nestjs/testing';
import { Messager } from '../../adapters/secondaries/messager';
const mockAmqpConnection = {
publish: jest.fn().mockImplementation(),
};
const mockConfigService = {
get: jest.fn().mockResolvedValue({
RMQ_EXCHANGE: 'mobicoop',
}),
};
describe('Messager', () => {
let messager: Messager;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [],
providers: [
Messager,
{
provide: AmqpConnection,
useValue: mockAmqpConnection,
},
{
provide: ConfigService,
useValue: mockConfigService,
},
],
}).compile();
messager = module.get<Messager>(Messager);
});
it('should be defined', () => {
expect(messager).toBeDefined();
});
it('should publish a message', async () => {
jest.spyOn(mockAmqpConnection, 'publish');
messager.publish('test.create.info', 'my-test');
expect(mockAmqpConnection.publish).toHaveBeenCalledTimes(1);
});
});

View File

@ -0,0 +1,58 @@
import { Test, TestingModule } from '@nestjs/testing';
import { PrismaHealthIndicatorUseCase } from '../../domain/usecases/prisma.health-indicator.usecase';
import { TerritoriesRepository } from '../../../territory/adapters/secondaries/territories.repository';
import { PrismaClientKnownRequestError } from '@prisma/client/runtime';
import { HealthCheckError, HealthIndicatorResult } from '@nestjs/terminus';
const mockTerritoriesRepository = {
healthCheck: jest
.fn()
.mockImplementationOnce(() => {
return Promise.resolve(true);
})
.mockImplementation(() => {
throw new PrismaClientKnownRequestError('Service unavailable', {
code: 'code',
clientVersion: 'version',
});
}),
};
describe('PrismaHealthIndicatorUseCase', () => {
let prismaHealthIndicatorUseCase: PrismaHealthIndicatorUseCase;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: TerritoriesRepository,
useValue: mockTerritoriesRepository,
},
PrismaHealthIndicatorUseCase,
],
}).compile();
prismaHealthIndicatorUseCase = module.get<PrismaHealthIndicatorUseCase>(
PrismaHealthIndicatorUseCase,
);
});
it('should be defined', () => {
expect(prismaHealthIndicatorUseCase).toBeDefined();
});
describe('execute', () => {
it('should check health successfully', async () => {
const healthIndicatorResult: HealthIndicatorResult =
await prismaHealthIndicatorUseCase.isHealthy('prisma');
expect(healthIndicatorResult['prisma'].status).toBe('up');
});
it('should throw an error if database is unavailable', async () => {
await expect(
prismaHealthIndicatorUseCase.isHealthy('prisma'),
).rejects.toBeInstanceOf(HealthCheckError);
});
});
});

View File

@ -0,0 +1,37 @@
import { Mapper } from '@automapper/core';
import { InjectMapper } from '@automapper/nestjs';
import { Controller, UsePipes } from '@nestjs/common';
import { CommandBus, QueryBus } from '@nestjs/cqrs';
import { GrpcMethod } from '@nestjs/microservices';
import { RpcValidationPipe } from 'src/modules/utils/pipes/rpc.validation-pipe';
import { MatchRequest } from '../../domain/dtos/match.request';
import { ICollection } from 'src/modules/database/src/interfaces/collection.interface';
import { Match } from '../../domain/entities/match';
import { MatchQuery } from '../../queries/match.query';
import { MatchPresenter } from '../secondaries/match.presenter';
@UsePipes(
new RpcValidationPipe({
whitelist: true,
forbidUnknownValues: false,
}),
)
@Controller()
export class MatcherController {
constructor(
private readonly _commandBus: CommandBus,
private readonly _queryBus: QueryBus,
@InjectMapper() private readonly _mapper: Mapper,
) {}
@GrpcMethod('MatcherService', 'Match')
async match(data: MatchRequest): Promise<ICollection<Match>> {
const matchCollection = await this._queryBus.execute(new MatchQuery(data));
return Promise.resolve({
data: matchCollection.data.map((match: Match) =>
this._mapper.map(match, Match, MatchPresenter),
),
total: matchCollection.total,
});
}
}

View File

@ -0,0 +1,20 @@
syntax = "proto3";
package matcher;
service MatcherService {
rpc Match(MatchRequest) returns (Matches);
}
message MatchRequest {
string uuid = 1;
}
message Match {
string uuid = 1;
}
message Matches {
repeated Match data = 1;
int32 total = 2;
}

View File

@ -0,0 +1,8 @@
import { Injectable } from '@nestjs/common';
import { MatcherRepository } from '../../../database/src/domain/matcher-repository';
import { Ad } from '../../domain/entities/ad';
@Injectable()
export class AdRepository extends MatcherRepository<Ad> {
protected _model = 'ad';
}

View File

@ -0,0 +1,6 @@
import { AutoMap } from '@automapper/classes';
export class MatchPresenter {
@AutoMap()
uuid: string;
}

View File

@ -0,0 +1,3 @@
export enum Algorithm {
CLASSIC = 'CLASSIC',
}

View File

@ -0,0 +1,126 @@
import {
IsArray,
IsBoolean,
IsDate,
IsEnum,
IsInt,
IsNumber,
IsOptional,
Max,
Min,
} from 'class-validator';
import { AutoMap } from '@automapper/classes';
import { Point } from '../entities/point.type';
import { Schedule } from '../entities/schedule.type';
import { MarginDurations } from '../entities/margin_durations.type';
import { Algorithm } from './algorithm.enum';
export class MatchRequest {
@IsArray()
@AutoMap()
waypoints: Array<Point>;
@IsDate()
@IsOptional()
@AutoMap()
departure: Date;
@IsDate()
@IsOptional()
@AutoMap()
fromDate: Date;
@IsOptional()
@AutoMap()
schedule: Schedule;
@IsOptional()
@IsBoolean()
@AutoMap()
driver: boolean;
@IsOptional()
@IsBoolean()
@AutoMap()
passenger: boolean;
@IsOptional()
@IsDate()
@AutoMap()
toDate: Date;
@IsOptional()
@IsInt()
@AutoMap()
marginDuration: number;
@IsOptional()
@AutoMap()
marginDurations: MarginDurations;
@IsOptional()
@IsNumber()
@AutoMap()
seatsPassenger: number;
@IsOptional()
@IsNumber()
@AutoMap()
seatsDriver: number;
@IsOptional()
@AutoMap()
strict: boolean;
@IsOptional()
@IsEnum(Algorithm)
@AutoMap()
algorithm: Algorithm;
@IsOptional()
@IsNumber()
@AutoMap()
remoteness: number;
@IsOptional()
@IsBoolean()
@AutoMap()
useProportion: boolean;
@IsOptional()
@IsNumber()
@Min(0)
@Max(1)
@AutoMap()
proportion: number;
@IsOptional()
@IsBoolean()
@AutoMap()
useAzimuth: boolean;
@IsOptional()
@IsInt()
@Min(0)
@Max(359)
@AutoMap()
azimuthMargin: number;
@IsOptional()
@IsNumber()
@Min(0)
@Max(1)
@AutoMap()
maxDetourDistanceRatio: number;
@IsOptional()
@IsNumber()
@Min(0)
@Max(1)
@AutoMap()
maxDetourDurationRatio: number;
@IsOptional()
@IsArray()
exclusions: Array<number>;
}

View File

@ -0,0 +1,4 @@
export enum Role {
DRIVER = 'DRIVER',
PASSENGER = 'PASSENGER',
}

View File

@ -0,0 +1,6 @@
import { AutoMap } from '@automapper/classes';
export class Ad {
@AutoMap()
uuid: string;
}

View File

@ -0,0 +1,9 @@
export type MarginDurations = {
mon: number;
tue: number;
wed: number;
thu: number;
fri: number;
sat: number;
sun: number;
};

View File

@ -0,0 +1,6 @@
import { AutoMap } from '@automapper/classes';
export class Match {
@AutoMap()
uuid: string;
}

View File

@ -0,0 +1,4 @@
export type Point = {
lon: number;
lat: number;
};

View File

@ -0,0 +1,9 @@
export type Schedule = {
mon: string;
tue: string;
wed: string;
thu: string;
fri: string;
sat: string;
sun: string;
};

View File

@ -0,0 +1,18 @@
import { createMap, Mapper } from '@automapper/core';
import { AutomapperProfile, InjectMapper } from '@automapper/nestjs';
import { Injectable } from '@nestjs/common';
import { MatchPresenter } from '../adapters/secondaries/match.presenter';
import { Match } from '../domain/entities/match';
@Injectable()
export class MatchProfile extends AutomapperProfile {
constructor(@InjectMapper() mapper: Mapper) {
super(mapper);
}
override get profile() {
return (mapper) => {
createMap(mapper, Match, MatchPresenter);
};
}
}

View File

@ -0,0 +1,9 @@
import { MatchRequest } from '../domain/dtos/match.request';
export class MatchQuery {
matchRequest: MatchRequest;
constructor(matchRequest?: MatchRequest) {
this.matchRequest = matchRequest;
}
}

View File

@ -1,22 +0,0 @@
import { ArgumentMetadata } from '@nestjs/common';
import { UpdateTerritoryRequest } from '../../../modules/territory/domain/dtos/update-territory.request';
import { RpcValidationPipe } from '../../pipes/rpc.validation-pipe';
describe('RpcValidationPipe', () => {
it('should not validate request', async () => {
const target: RpcValidationPipe = new RpcValidationPipe({
whitelist: true,
forbidUnknownValues: false,
});
const metadata: ArgumentMetadata = {
type: 'body',
metatype: UpdateTerritoryRequest,
data: '',
};
await target
.transform(<UpdateTerritoryRequest>{}, metadata)
.catch((err) => {
expect(err.message).toEqual('Rpc Exception');
});
});
});