├── tests ├── mocks │ ├── plugins │ │ ├── index.ts │ │ └── openapi │ │ │ └── index.ts │ ├── index.ts │ ├── appApiService.ts │ ├── appAction.ts │ └── grpcObject.ts ├── integration │ ├── interfaces │ │ ├── config.ts │ │ ├── actions │ │ │ └── v1 │ │ │ │ ├── throwError.ts │ │ │ │ ├── getTest.ts │ │ │ │ ├── getTestPrivate.ts │ │ │ │ └── lockResource.ts │ │ └── deps.ts │ ├── setup.ts │ ├── specs │ │ ├── application.spec.ts │ │ ├── grpcMiddlewares.spec.ts │ │ └── grpcService.spec.ts │ ├── actions │ │ └── v1 │ │ │ ├── getTestPrivate.ts │ │ │ ├── lockResource.ts │ │ │ ├── getTest.ts │ │ │ └── throwError.ts │ ├── proto │ │ └── test-service.proto │ ├── getApp.ts │ ├── deps.ts │ ├── .env.test │ └── config.ts ├── unit │ ├── interfaces │ │ └── index.ts │ ├── moleculer │ │ ├── moleculerLogger.spec.ts │ │ └── moleculerWrapper.spec.ts │ ├── plugins │ │ └── openapi │ │ │ ├── index.spec.ts │ │ │ ├── actionVisitor.spec.ts │ │ │ └── parser.spec.ts │ ├── config.ts │ ├── tracing │ │ └── index.spec.ts │ ├── grpc │ │ ├── grpcClient.spec.ts │ │ └── grpcService.spec.ts │ └── application.spec.ts └── tsconfig.json ├── .prettierignore ├── .eslintignore ├── src ├── interfaces │ ├── moleculer.ts │ ├── errorCode.ts │ ├── apiService.ts │ ├── index.ts │ ├── tracing.ts │ ├── actionExecutor.ts │ ├── grpc.ts │ ├── application.ts │ ├── deps.ts │ ├── config.ts │ └── action.ts ├── plugins │ ├── pluginConstants.ts │ └── openapi │ │ ├── index.ts │ │ └── actionVisitor.ts ├── grpc │ ├── index.ts │ ├── wrappers.ts │ ├── server.ts │ └── grpcClient.ts ├── index.ts ├── moleculer │ ├── moleculerLogger.ts │ └── moleculerWrapper.ts ├── tracing │ └── index.ts ├── baseDeps.ts ├── actionExecutor.ts └── application.ts ├── tsconfig.json ├── CONTRIBUTING.md ├── README.md ├── package.json ├── .gitignore └── LICENCE.md /tests/mocks/plugins/index.ts: -------------------------------------------------------------------------------- 1 | export * from './openapi' 2 | -------------------------------------------------------------------------------- /tests/mocks/plugins/openapi/index.ts: -------------------------------------------------------------------------------- 1 | export * from './parser' 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | coverage 3 | tests/integration/generated/ 4 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | coverage 4 | tests/integration/generated/ 5 | -------------------------------------------------------------------------------- /src/interfaces/moleculer.ts: -------------------------------------------------------------------------------- 1 | export interface ContextMeta { 2 | tracing: object 3 | } 4 | -------------------------------------------------------------------------------- /src/interfaces/errorCode.ts: -------------------------------------------------------------------------------- 1 | export enum ErrorCode { 2 | SubscriptionsExists = 9000, 3 | } 4 | -------------------------------------------------------------------------------- /src/plugins/pluginConstants.ts: -------------------------------------------------------------------------------- 1 | export const ACTION_PARAMS = '__actionParams' 2 | 3 | export const ACTION_RESPONSE = '__actionResponse' 4 | -------------------------------------------------------------------------------- /tests/integration/interfaces/config.ts: -------------------------------------------------------------------------------- 1 | import { configFactory } from '../config' 2 | 3 | export type AppConfig = Awaited> 4 | -------------------------------------------------------------------------------- /tests/mocks/index.ts: -------------------------------------------------------------------------------- 1 | export * from './appAction' 2 | 3 | export * from './appApiService' 4 | 5 | export * from './grpcObject' 6 | 7 | export * from './plugins' 8 | -------------------------------------------------------------------------------- /src/interfaces/apiService.ts: -------------------------------------------------------------------------------- 1 | export interface AppApiService { 2 | port: number 3 | ip: string 4 | routes: unknown[] 5 | methods: Record 6 | } 7 | -------------------------------------------------------------------------------- /tests/integration/setup.ts: -------------------------------------------------------------------------------- 1 | jest.setTimeout(3000000) 2 | 3 | const appDir = 'tests/integration' 4 | 5 | if (!process.cwd().endsWith(appDir)) { 6 | process.chdir(appDir) 7 | } 8 | -------------------------------------------------------------------------------- /tests/unit/interfaces/index.ts: -------------------------------------------------------------------------------- 1 | import { MetricsService } from '@diia-inhouse/diia-metrics' 2 | 3 | export interface AppDeps { 4 | metrics: MetricsService 5 | test?: string 6 | } 7 | -------------------------------------------------------------------------------- /tests/mocks/appApiService.ts: -------------------------------------------------------------------------------- 1 | import { AppApiService } from '../../src' 2 | 3 | export const appApiService: AppApiService = { 4 | ip: '0.0.0.0', 5 | methods: {}, 6 | port: 3000, 7 | routes: [], 8 | } 9 | -------------------------------------------------------------------------------- /tests/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@diia-inhouse/configs/tsconfig/tsconfig.esm", 3 | "compilerOptions": { 4 | "baseUrl": "../", 5 | "noEmit": true, 6 | "strict": true 7 | }, 8 | "include": ["./**/*"] 9 | } 10 | -------------------------------------------------------------------------------- /src/interfaces/index.ts: -------------------------------------------------------------------------------- 1 | export * from './action' 2 | 3 | export * from './config' 4 | 5 | export * from './apiService' 6 | 7 | export * from './tracing' 8 | 9 | export * from './grpc' 10 | 11 | export * from './actionExecutor' 12 | 13 | export * from './errorCode' 14 | -------------------------------------------------------------------------------- /tests/integration/interfaces/actions/v1/throwError.ts: -------------------------------------------------------------------------------- 1 | import { ServiceActionArguments } from '@diia-inhouse/types' 2 | 3 | import { ThrowErrorReq } from '../../../generated/test-service' 4 | 5 | export interface CustomActionArguments extends ServiceActionArguments { 6 | params: ThrowErrorReq 7 | } 8 | 9 | export type ActionResult = never 10 | -------------------------------------------------------------------------------- /tests/integration/interfaces/actions/v1/getTest.ts: -------------------------------------------------------------------------------- 1 | import { ServiceActionArguments } from '@diia-inhouse/types' 2 | 3 | import { GetTestReq, GetTestRes } from '../../../generated/test-service' 4 | 5 | export interface CustomActionArguments extends ServiceActionArguments { 6 | params: GetTestReq 7 | } 8 | 9 | export type ActionResult = GetTestRes 10 | -------------------------------------------------------------------------------- /tests/mocks/appAction.ts: -------------------------------------------------------------------------------- 1 | import { ActionVersion, SessionType } from '@diia-inhouse/types' 2 | 3 | import { AppAction } from '../../src' 4 | 5 | export const appAction: AppAction = { 6 | name: 'userActionName', 7 | sessionType: SessionType.User, 8 | handler() {}, 9 | actionVersion: ActionVersion.V1, 10 | validationRules: {}, 11 | } 12 | -------------------------------------------------------------------------------- /tests/integration/interfaces/actions/v1/getTestPrivate.ts: -------------------------------------------------------------------------------- 1 | import { ServiceActionArguments } from '@diia-inhouse/types' 2 | 3 | import { GetTestReq, GetTestRes } from '../../../generated/test-service' 4 | 5 | export interface CustomActionArguments extends ServiceActionArguments { 6 | params: GetTestReq 7 | } 8 | 9 | export type ActionResult = GetTestRes 10 | -------------------------------------------------------------------------------- /tests/integration/interfaces/actions/v1/lockResource.ts: -------------------------------------------------------------------------------- 1 | import { ServiceActionArguments } from '@diia-inhouse/types' 2 | 3 | import { GetTestRes, LockResourceReq } from '../../../generated/test-service' 4 | 5 | export interface CustomActionArguments extends ServiceActionArguments { 6 | params: LockResourceReq 7 | } 8 | 9 | export type ActionResult = GetTestRes 10 | -------------------------------------------------------------------------------- /tests/integration/interfaces/deps.ts: -------------------------------------------------------------------------------- 1 | import { HashService } from '@diia-inhouse/crypto' 2 | 3 | import { CallOptions } from '../../../src' 4 | import { TestClient, TestPrivateClient } from '../generated/test-service' 5 | 6 | export type AppDeps = { 7 | hash: HashService 8 | testServiceClient: TestClient 9 | testPrivateServiceClient: TestPrivateClient 10 | } 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@diia-inhouse/configs/tsconfig/tsconfig.esm", 3 | "compilerOptions": { 4 | "outDir": "dist", 5 | "declaration": true, 6 | "declarationDir": "dist/types", 7 | "baseUrl": ".", 8 | "noUnusedLocals": false, 9 | "noUnusedParameters": false, 10 | "strict": true 11 | }, 12 | "include": ["src/**/*"], 13 | "exclude": ["node_modules", "tests"] 14 | } 15 | -------------------------------------------------------------------------------- /tests/integration/specs/application.spec.ts: -------------------------------------------------------------------------------- 1 | import { getApp } from '../getApp' 2 | 3 | describe('Application', () => { 4 | it('should start', async () => { 5 | const app = await getApp() 6 | 7 | const services = ['healthCheck', 'identifier'] 8 | 9 | for (const service of services) { 10 | expect(app.container.resolve(service)).toBeDefined() 11 | } 12 | 13 | await app.stop() 14 | }) 15 | }) 16 | -------------------------------------------------------------------------------- /src/interfaces/tracing.ts: -------------------------------------------------------------------------------- 1 | import { InstrumentationConfigMap } from '@opentelemetry/auto-instrumentations-node' 2 | import type { OTLPGRPCExporterConfigNode } from '@opentelemetry/otlp-grpc-exporter-base' 3 | 4 | export interface OpentelemetryTracingConfig { 5 | enabled?: boolean 6 | instrumentations?: InstrumentationConfigMap 7 | exporter?: OTLPGRPCExporterConfigNode 8 | debug?: boolean 9 | } 10 | 11 | export const SEMATTRS_MESSAGING_RABBITMQ_ATTRIBUTES = 'messaging.rabbitmq.attributes' 12 | -------------------------------------------------------------------------------- /src/interfaces/actionExecutor.ts: -------------------------------------------------------------------------------- 1 | import { SpanKind } from '@opentelemetry/api' 2 | 3 | import { RequestMechanism } from '@diia-inhouse/diia-metrics' 4 | import { ActionArguments, ActionSession } from '@diia-inhouse/types' 5 | 6 | import { AppAction } from './action' 7 | 8 | export interface MetaTracing { 9 | traceparent?: string 10 | tracestate?: string 11 | } 12 | 13 | export interface ExecuteActionParams { 14 | action: AppAction 15 | transport: RequestMechanism 16 | caller?: string 17 | tracingMetadata?: unknown 18 | spanKind: SpanKind 19 | actionArguments: ActionArguments & { session?: ActionSession } 20 | serviceName?: string 21 | } 22 | -------------------------------------------------------------------------------- /src/grpc/index.ts: -------------------------------------------------------------------------------- 1 | import { Metadata } from 'nice-grpc' 2 | 3 | import { CallOptions, GrpcClientMetadata } from '../interfaces' 4 | 5 | export * from './grpcService' 6 | 7 | export * from './grpcClient' 8 | 9 | export function clientCallOptions(grpcMetadata: GrpcClientMetadata): CallOptions { 10 | const metadata = new Metadata() 11 | 12 | const { session, version, deadline } = grpcMetadata 13 | 14 | if (session) { 15 | metadata.set('session', Buffer.from(JSON.stringify(session)).toString('base64')) 16 | } 17 | 18 | if (version) { 19 | metadata.set('actionversion', version) 20 | } 21 | 22 | return { 23 | metadata, 24 | deadline, 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { config } from 'dotenv-flow' 2 | 3 | config({ silent: true }) 4 | 5 | export { AwilixContainer, Constructor, Lifetime, asClass, asFunction, asValue, NameAndRegistrationPair, listModules } from 'awilix' 6 | 7 | export * from '@opentelemetry/api' 8 | 9 | export * from '@opentelemetry/semantic-conventions' 10 | 11 | export * from './application' 12 | 13 | export * from './interfaces' 14 | 15 | export * from './interfaces/deps' 16 | 17 | export * from './interfaces/application' 18 | 19 | export * from './grpc' 20 | 21 | export * from './plugins/pluginConstants' 22 | 23 | export * from './tracing' 24 | 25 | export * from './actionExecutor' 26 | 27 | export { default as MoleculerService } from './moleculer/moleculerWrapper' 28 | -------------------------------------------------------------------------------- /tests/integration/actions/v1/getTestPrivate.ts: -------------------------------------------------------------------------------- 1 | import { SessionType } from '@diia-inhouse/types' 2 | import { ValidationSchema } from '@diia-inhouse/validators' 3 | 4 | import { GrpcAppAction } from '../../../../src' 5 | import { ActionResult, CustomActionArguments } from '../../interfaces/actions/v1/getTestPrivate' 6 | 7 | export default class GetTestPrivateAction implements GrpcAppAction { 8 | readonly sessionType: SessionType = SessionType.User 9 | 10 | readonly name = 'getTestPrivate' 11 | 12 | readonly validationRules: ValidationSchema = { 13 | timeoutMs: { type: 'number' }, 14 | } 15 | 16 | async handler(): Promise { 17 | return { status: 'ok' } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /tests/integration/proto/test-service.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | // do not import google protobufs due to extension conflict in the test mode 4 | 5 | package ua.gov.diia.test; 6 | 7 | option java_multiple_files = true; 8 | option java_package = "ua.gov.diia.test"; 9 | 10 | service Test { 11 | rpc GetTest(GetTestReq) returns (GetTestRes) {}; 12 | rpc ThrowError(ThrowErrorReq) returns (GetTestRes) {}; 13 | rpc LockResource(LockResourceReq) returns (GetTestRes) {}; 14 | } 15 | 16 | service TestPrivate { 17 | rpc GetTestPrivate(GetTestReq) returns (GetTestRes) {}; 18 | } 19 | 20 | message GetTestReq { int32 timeoutMs = 1; } 21 | message GetTestRes { string status = 1; } 22 | 23 | message ThrowErrorReq { int32 httpStatus = 1; optional int32 processCode = 2; } 24 | 25 | message LockResourceReq { string id = 1; } 26 | -------------------------------------------------------------------------------- /tests/integration/actions/v1/lockResource.ts: -------------------------------------------------------------------------------- 1 | import { SessionType } from '@diia-inhouse/types' 2 | import { ValidationSchema } from '@diia-inhouse/validators' 3 | 4 | import { GrpcAppAction } from '../../../../src' 5 | import { ActionResult, CustomActionArguments } from '../../interfaces/actions/v1/lockResource' 6 | 7 | export default class LockResourceAction implements GrpcAppAction { 8 | readonly sessionType: SessionType = SessionType.User 9 | 10 | readonly name = 'lockResource' 11 | 12 | readonly validationRules: ValidationSchema = { 13 | id: { type: 'string' }, 14 | } 15 | 16 | getLockResource(args: CustomActionArguments): string { 17 | return args.params.id 18 | } 19 | 20 | async handler(): Promise { 21 | return { status: 'ok' } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /tests/integration/getApp.ts: -------------------------------------------------------------------------------- 1 | import { randomInt } from 'node:crypto' 2 | 3 | import { Application, ServiceContext, ServiceOperator } from '../../src' 4 | 5 | import { configFactory } from './config' 6 | import deps from './deps' 7 | import { AppConfig } from './interfaces/config' 8 | import { AppDeps } from './interfaces/deps' 9 | 10 | export async function getApp(): Promise> { 11 | const app = new Application>('Auth') 12 | 13 | await app.setConfig(configFactory) 14 | const config = app.getConfig() 15 | const grpcPort = randomInt(1000, 9999) 16 | 17 | app.patchConfig({ grpc: { testServiceAddress: `localhost:${grpcPort}` }, grpcServer: { ...config.grpcServer, port: grpcPort } }) 18 | await app.setDeps(deps) 19 | const appOperator = await app.initialize() 20 | 21 | await appOperator.start() 22 | 23 | return appOperator 24 | } 25 | -------------------------------------------------------------------------------- /tests/integration/actions/v1/getTest.ts: -------------------------------------------------------------------------------- 1 | import { setTimeout } from 'node:timers/promises' 2 | 3 | import { SessionType } from '@diia-inhouse/types' 4 | import { ValidationSchema } from '@diia-inhouse/validators' 5 | 6 | import { GrpcAppAction } from '../../../../src' 7 | import { ActionResult, CustomActionArguments } from '../../interfaces/actions/v1/getTest' 8 | 9 | export default class GetTestAction implements GrpcAppAction { 10 | readonly sessionType: SessionType = SessionType.User 11 | 12 | readonly name = 'getTest' 13 | 14 | readonly validationRules: ValidationSchema = { 15 | timeoutMs: { type: 'number' }, 16 | } 17 | 18 | async handler(args: CustomActionArguments): Promise { 19 | const { 20 | params: { timeoutMs }, 21 | } = args 22 | 23 | await setTimeout(timeoutMs) 24 | 25 | return { status: 'ok' } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /tests/integration/actions/v1/throwError.ts: -------------------------------------------------------------------------------- 1 | import { ApiError } from '@diia-inhouse/errors' 2 | import { SessionType } from '@diia-inhouse/types' 3 | import { ValidationSchema } from '@diia-inhouse/validators' 4 | 5 | import { GrpcAppAction } from '../../../../src' 6 | import { ActionResult, CustomActionArguments } from '../../interfaces/actions/v1/throwError' 7 | 8 | export default class ThrowErrorAction implements GrpcAppAction { 9 | readonly sessionType: SessionType = SessionType.User 10 | 11 | readonly name = 'throwError' 12 | 13 | readonly validationRules: ValidationSchema = { 14 | httpStatus: { type: 'number' }, 15 | processCode: { type: 'number', optional: true }, 16 | } 17 | 18 | async handler(args: CustomActionArguments): Promise { 19 | const { 20 | params: { httpStatus, processCode }, 21 | } = args 22 | 23 | throw new ApiError('error message', httpStatus, {}, processCode) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /tests/integration/deps.ts: -------------------------------------------------------------------------------- 1 | import { asClass, asFunction } from 'awilix' 2 | 3 | import { HashService } from '@diia-inhouse/crypto' 4 | 5 | import { DepsFactoryFn, GrpcClientFactory } from '../../src' 6 | 7 | import { TestDefinition, TestPrivateDefinition } from './generated/test-service' 8 | import { AppConfig } from './interfaces/config' 9 | import { AppDeps } from './interfaces/deps' 10 | 11 | export default async (config: AppConfig): ReturnType> => { 12 | const { grpc } = config 13 | 14 | return { 15 | testServiceClient: asFunction((grpcClientFactory: GrpcClientFactory) => 16 | grpcClientFactory.createGrpcClient(TestDefinition, grpc.testServiceAddress), 17 | ).singleton(), 18 | testPrivateServiceClient: asFunction((grpcClientFactory: GrpcClientFactory) => 19 | grpcClientFactory.createGrpcClient(TestPrivateDefinition, grpc.testServiceAddress), 20 | ).singleton(), 21 | 22 | hash: asClass(HashService).singleton(), 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/plugins/openapi/index.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import * as dotenv from 'dotenv-flow' 3 | // eslint-disable-next-line import/no-extraneous-dependencies, n/no-unpublished-import 4 | import * as ts from 'typescript' 5 | 6 | import { Env } from '@diia-inhouse/env' 7 | 8 | import ActionVisitor from './actionVisitor' 9 | 10 | dotenv.config({ silent: true }) 11 | 12 | const defaultActionsDir = 'src/actions/' 13 | 14 | function before(program: ts.Program): ts.Transformer { 15 | return (ctx: ts.TransformationContext): ts.Transformer => { 16 | return (sf: ts.SourceFile): ReturnType => { 17 | const isAction: boolean = sf.fileName.includes(defaultActionsDir) 18 | if (isAction) { 19 | return ActionVisitor.visit(sf, ctx, program) 20 | } 21 | 22 | return sf 23 | } 24 | } 25 | } 26 | 27 | function transformer() { 28 | return (sf: ts.SourceFile): ts.SourceFile => sf 29 | } 30 | 31 | export default function (program: ts.Program): ts.Transformer { 32 | return process.env.NODE_ENV === Env.Prod ? transformer : before(program) 33 | } 34 | -------------------------------------------------------------------------------- /src/grpc/wrappers.ts: -------------------------------------------------------------------------------- 1 | import { IWrapper, Message, Type } from 'protobufjs' 2 | 3 | import { GenericObject } from '@diia-inhouse/types/dist/types/common' 4 | 5 | export default { 6 | '.google.protobuf.Timestamp': { 7 | fromObject(object: { [k: string]: unknown }): Message { 8 | if (typeof object !== 'string') { 9 | if (object instanceof Date) { 10 | return this.fromObject({ 11 | seconds: Math.floor(object.getTime() / 1000), 12 | nanos: (object.getTime() % 1000) * 1000000, 13 | }) 14 | } 15 | 16 | return this.fromObject(object) 17 | } 18 | 19 | const dt = Date.parse(object) 20 | 21 | if (Number.isNaN(dt)) { 22 | return this.fromObject(object) 23 | } 24 | 25 | return (this).create({ 26 | seconds: Math.floor(dt / 1000), 27 | nanos: (dt % 1000) * 1000000, 28 | }) 29 | }, 30 | toObject(message: GenericObject): GenericObject { 31 | return new Date(message.seconds * 1000 + message.nanos / 1000000) 32 | }, 33 | }, 34 | } 35 | -------------------------------------------------------------------------------- /src/moleculer/moleculerLogger.ts: -------------------------------------------------------------------------------- 1 | import { LogLevels, Loggers } from 'moleculer' 2 | 3 | import { LogLevel, Logger } from '@diia-inhouse/types' 4 | 5 | export default class MoleculerLogger extends Loggers.Base { 6 | private readonly brokerLogLevelsMap: Record = { 7 | fatal: LogLevel.FATAL, 8 | error: LogLevel.ERROR, 9 | warn: LogLevel.WARN, 10 | info: LogLevel.INFO, 11 | debug: LogLevel.DEBUG, 12 | trace: LogLevel.TRACE, 13 | } 14 | 15 | constructor(private logger: Logger) { 16 | super() 17 | } 18 | 19 | getLogHandler(): Loggers.LogHandler | null { 20 | return (type: LogLevels, argParams: unknown[]): void => { 21 | const level: LogLevel = this.brokerLogLevelsMap[type] 22 | const [msg, ...args] = argParams 23 | if (!msg || typeof msg !== 'string') { 24 | return 25 | } 26 | 27 | if (args.length > 0) { 28 | let data: unknown = args 29 | if (args.length === 1) { 30 | data = args[0] 31 | } 32 | 33 | this.logger[level](msg, { data }) 34 | 35 | return 36 | } 37 | 38 | this.logger[level](msg) 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /tests/integration/.env.test: -------------------------------------------------------------------------------- 1 | BALANCING_STRATEGY_NAME=RoundRobin 2 | BALANCING_STRATEGY_OPTIONS= 3 | 4 | TRANSPORT_TYPE=Redis 5 | TRANSPORT_OPTIONS={"host":"localhost","port":"6379"} 6 | 7 | MONGO_HOST=mongo1 8 | MONGO_HOSTS=mongo1,mongo2,mongo3 9 | MONGO_PORT=27017 10 | MONGO_DATABASE=diia-auth-service 11 | MONGO_USER= 12 | MONGO_PASSWORD= 13 | MONGO_REPLICA_SET=diia 14 | MONGO_READ_PREFERENCE=primary 15 | MONGO_INDEXES_SYNC=true 16 | MONGO_INDEXES_EXIT_AFTER_SYNC=false 17 | 18 | RABBIT_HOST=127.0.0.1 19 | RABBIT_PORT=5672 20 | RABBIT_USERNAME=guest 21 | RABBIT_PASSWORD=guest 22 | RABBIT_HEARTBEAT=60 23 | RABBIT_QUEUE_PREFETCH_COUNT=1 24 | 25 | EXTERNAL_RABBIT_HOST=127.0.0.1 26 | EXTERNAL_RABBIT_PORT=5672 27 | EXTERNAL_RABBIT_USERNAME=guest 28 | EXTERNAL_RABBIT_PASSWORD=guest 29 | EXTERNAL_RABBIT_HEARTBEAT=60 30 | EXTERNAL_RABBIT_QUEUE_PREFETCH_COUNT=1 31 | EXTERNAL_RABBIT_ASSERT_EXCHANGES=true 32 | 33 | REDIS_READ_WRITE_OPTIONS='{"port":6379}' 34 | REDIS_READ_ONLY_OPTIONS='{"port":6379}' 35 | STORE_READ_WRITE_OPTIONS='{"port":6379}' 36 | STORE_READ_ONLY_OPTIONS='{"port":6379}' 37 | 38 | HEALTH_CHECK_IS_ENABLED=false 39 | HEALTH_CHECK_IS_PORT=4545 40 | 41 | METRICS_MOLECULER_PROMETHEUS_IS_ENABLED=false 42 | METRICS_CUSTOM_DISABLED=true 43 | 44 | GRPC_SERVER_ENABLED=true 45 | GRPC_SERVER_PORT=5001 46 | GRPC_SERVICES='["ua.gov.diia.test.Test", "ua.gov.diia.test.TestPrivate"]' 47 | GRPC_TEST_SERVICE_ADDRESS=localhost:5001 48 | -------------------------------------------------------------------------------- /tests/unit/moleculer/moleculerLogger.spec.ts: -------------------------------------------------------------------------------- 1 | import { Loggers } from 'moleculer' 2 | 3 | import Logger from '@diia-inhouse/diia-logger' 4 | import { mockInstance } from '@diia-inhouse/test' 5 | 6 | import MoleculerLogger from '../../../src/moleculer/moleculerLogger' 7 | 8 | describe(`${MoleculerLogger.constructor.name}`, () => { 9 | const logger = mockInstance(Logger) 10 | 11 | const moleculerLogger = new MoleculerLogger(logger) 12 | 13 | describe(`method ${moleculerLogger.getLogHandler.name}`, () => { 14 | const logHandler = moleculerLogger.getLogHandler() 15 | 16 | it('should successfully get log handler and invoke log method when no message', () => { 17 | logHandler('trace', ['', undefined]) 18 | 19 | expect(logger.trace).not.toHaveBeenCalled() 20 | }) 21 | 22 | it('should successfully get log handler and invoke log method when only message', () => { 23 | logHandler('trace', <[string, unknown]>(['trace message'])) 24 | 25 | expect(logger.trace).toHaveBeenCalledWith('trace message') 26 | }) 27 | 28 | it('should successfully get log handler and invoke log method when message with data', () => { 29 | logHandler('trace', ['trace message', {}]) 30 | 31 | expect(logger.trace).toHaveBeenCalledWith('trace message', { data: {} }) 32 | }) 33 | }) 34 | }) 35 | -------------------------------------------------------------------------------- /src/interfaces/grpc.ts: -------------------------------------------------------------------------------- 1 | import { ServiceDefinition, UntypedServiceImplementation } from '@grpc/grpc-js' 2 | import { CallOptions as DefaultGrpcCallOptions } from 'nice-grpc' 3 | 4 | import { ActionVersion } from '@diia-inhouse/types' 5 | import { ActionSession } from '@diia-inhouse/types/dist/types/session/session' 6 | 7 | export interface GrpcServerConfig { 8 | isEnabled: boolean 9 | port: number 10 | services: string[] 11 | isReflectionEnabled: boolean 12 | maxReceiveMessageLength: number 13 | } 14 | 15 | export interface GrpcClientMetadata { 16 | /** @deprecated params should be provided explicitly without a session as sessions are not a part of proto contracts */ 17 | session?: ActionSession 18 | /** @deprecated a method that contains a version in his name should be used */ 19 | version?: ActionVersion 20 | deadline?: number 21 | } 22 | 23 | export interface CallOptions extends DefaultGrpcCallOptions { 24 | deadline?: number 25 | } 26 | 27 | export interface GrpcServiceStatus { 28 | grpcServer: 'UNKNOWN' | 'SERVING' | 'NOT_SERVING' | 'DISABLED' 29 | } 30 | 31 | export enum GrpcMethodType { 32 | UNARY, 33 | CLIENT_STREAM, 34 | SERVER_STREAM, 35 | BIDI_STREAM, 36 | } 37 | 38 | export interface StreamKey { 39 | streamId: string 40 | mobileUid: string 41 | } 42 | 43 | export enum DeviceMultipleConnectionPolicy { 44 | ALLOW_MULTIPLE_CONNECTIONS, 45 | FORBID_REJECT_NEW_CONNECTION, 46 | FORBID_CLOSE_PREVIOUS_CONNECTION, 47 | } 48 | 49 | export type GrpcServiceImplementationProvider = (serviceName: string, service: ServiceDefinition) => UntypedServiceImplementation 50 | -------------------------------------------------------------------------------- /tests/integration/specs/grpcMiddlewares.spec.ts: -------------------------------------------------------------------------------- 1 | import { DurationMs } from '@diia-inhouse/types' 2 | 3 | import { clientCallOptions } from '../../../src' 4 | import { GetTestRes } from '../generated/test-service' 5 | import { getApp } from '../getApp' 6 | 7 | describe('grpc-middlewares', () => { 8 | let app: Awaited> 9 | 10 | beforeAll(async () => { 11 | app = await getApp() 12 | }) 13 | 14 | afterAll(async () => { 15 | await app.stop() 16 | }) 17 | 18 | describe('deadline', () => { 19 | it('should return response', async () => { 20 | const result = await app.container 21 | .resolve('testServiceClient') 22 | .getTest({ timeoutMs: DurationMs.Second }, clientCallOptions({ deadline: DurationMs.Second * 10 })) 23 | 24 | expect(result).toEqual({ status: 'ok' }) 25 | }) 26 | 27 | it('should return deadline error via clientCallOptions', async () => { 28 | await expect( 29 | app.container 30 | .resolve('testServiceClient') 31 | .getTest({ timeoutMs: DurationMs.Second * 20 }, clientCallOptions({ deadline: DurationMs.Second })), 32 | ).rejects.toThrow('/ua.gov.diia.test.Test/GetTest DEADLINE_EXCEEDED: Deadline exceeded') 33 | }) 34 | 35 | it('should return deadline error via direct options', async () => { 36 | await expect( 37 | app.container.resolve('testServiceClient').getTest({ timeoutMs: DurationMs.Second * 20 }, { deadline: DurationMs.Second }), 38 | ).rejects.toThrow('/ua.gov.diia.test.Test/GetTest DEADLINE_EXCEEDED: Deadline exceeded') 39 | }) 40 | }) 41 | }) 42 | -------------------------------------------------------------------------------- /src/interfaces/application.ts: -------------------------------------------------------------------------------- 1 | import { AwilixContainer, NameAndRegistrationPair } from 'awilix' 2 | import { NameFormatter } from 'awilix/lib/load-modules' 3 | 4 | import { EnvService } from '@diia-inhouse/env' 5 | import { GenericObject } from '@diia-inhouse/types' 6 | 7 | import { BaseConfig } from './config' 8 | import { BaseDeps } from './deps' 9 | 10 | export interface ServiceContext { 11 | config: TConfig 12 | container: AwilixContainer 13 | } 14 | 15 | export type AppConfigType = TContext extends ServiceContext ? T : never 16 | 17 | export type AppDepsType = TContext extends ServiceContext ? T : never 18 | 19 | export type DepsType = AppDepsType & BaseDeps> 20 | 21 | export type ConfigFactoryFn = ( 22 | envService: EnvService, 23 | serviceName: string, 24 | ) => Promise 25 | 26 | export type DepsFactory = Partial & Omit 27 | 28 | export type DepsFactoryFn = ( 29 | config: TConfig, 30 | baseDeps: AwilixContainer>, 31 | ) => Promise>> 32 | 33 | export interface ServiceOperator 34 | extends ServiceContext { 35 | start(): Promise 36 | stop(): Promise 37 | } 38 | 39 | export interface LoadDepsFromFolderOptions { 40 | folderName: string 41 | fileMask?: string 42 | nameFormatter?: NameFormatter 43 | groupName?: string 44 | } 45 | -------------------------------------------------------------------------------- /tests/unit/plugins/openapi/index.spec.ts: -------------------------------------------------------------------------------- 1 | import ts from 'typescript' 2 | 3 | import { Env } from '@diia-inhouse/env' 4 | 5 | import openApiPlugin from '../../../../src/plugins/openapi' 6 | import ActionVisitor from '../../../../src/plugins/openapi/actionVisitor' 7 | 8 | jest.mock('../../../../src/plugins/openapi/actionVisitor') 9 | 10 | describe('OpenApi plugin', () => { 11 | it('should call action visitor', () => { 12 | process.env.NODE_ENV = Env.Stage 13 | 14 | const sourceFile = { fileName: 'dist/src/actions/action.js' } 15 | const ctx = {} 16 | const program = {} 17 | 18 | jest.spyOn(ActionVisitor, 'visit').mockReturnValueOnce(>(sourceFile)) 19 | 20 | const result = openApiPlugin(program)(ctx)(sourceFile) 21 | 22 | expect(ActionVisitor.visit).toHaveBeenCalledWith(sourceFile, ctx, program) 23 | expect(result).toBe(sourceFile) 24 | }) 25 | 26 | it('should not call action visitor if source file is not an action', () => { 27 | process.env.NODE_ENV = Env.Stage 28 | 29 | const sourceFile = { fileName: 'dist/src/providers/action.js' } 30 | const ctx = {} 31 | const program = {} 32 | 33 | const result = openApiPlugin(program)(ctx)(sourceFile) 34 | 35 | expect(ActionVisitor.visit).toHaveBeenCalledTimes(0) 36 | expect(result).toBe(sourceFile) 37 | }) 38 | 39 | it('should not call action visitor if env is prod', () => { 40 | process.env.NODE_ENV = Env.Prod 41 | 42 | const sourceFile = { fileName: 'dist/src/actions/action.js' } 43 | const ctx = {} 44 | const program = {} 45 | 46 | const result = openApiPlugin(program)(ctx)(sourceFile) 47 | 48 | expect(ActionVisitor.visit).toHaveBeenCalledTimes(0) 49 | expect(result).toBe(sourceFile) 50 | }) 51 | }) 52 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to the Diia project 2 | 3 | We're pleased that you're interested in contributing to the Diia project. At the moment we're welcoming contributions in various forms and we want to make contributing as easy and transparent as possible. You're welcome to contribute in any of the following ways: 4 | 5 | - Reporting a bug 6 | - Discussing the current state of the code 7 | - Proposing new features or ideas 8 | 9 | In the future we'll be considering welcoming code contributions and expanding our contributor community. 10 | 11 | ## Report using Issues 12 | 13 | We use GitHub issues to track public bugs. Report a bug, feature, idea or open a discussion point by [opening a new issue](../../issues/new); it's that easy! 14 | 15 | For bugs related to vulnerabilities or security concerns please feel free to contact us directly at [modt.opensource@thedigital.gov.ua](mailto:modt.opensource@thedigital.gov.ua). 16 | 17 | We'd also request that you detail bug reports with detail, background and sample code. Typically a great bug report includes: 18 | 19 | - A quick summary and/or background 20 | - Steps to reproduce 21 | - Be specific and provide sample code if you can. 22 | - What you expected would happen 23 | - What actually happens 24 | - Notes (possibly including why you think this might be happening, or stuff you tried that didn't work) 25 | 26 | For ideas, suggestions and discussion you're free to use any format that you find suitable. 27 | 28 | ## Licensing 29 | 30 | By contributing, you agree that your contributions will be licensed under the EUPL. 31 | 32 | You may obtain a copy of the License at [https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12](https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12). 33 | 34 | Questions regarding the Diia project, the License and any re-use should be directed to [modt.opensource@thedigital.gov.ua](mailto:modt.opensource@thedigital.gov.ua). 35 | -------------------------------------------------------------------------------- /src/interfaces/deps.ts: -------------------------------------------------------------------------------- 1 | import { AsyncLocalStorage } from 'node:async_hooks' 2 | 3 | import type { AuthService, IdentifierService } from '@diia-inhouse/crypto' 4 | import type { DatabaseService } from '@diia-inhouse/db' 5 | import type { MetricsService } from '@diia-inhouse/diia-metrics' 6 | import type { 7 | EventBus, 8 | EventMessageHandler, 9 | EventMessageValidator, 10 | ExternalCommunicator, 11 | ExternalCommunicatorChannel, 12 | ExternalEventBus, 13 | Queue, 14 | ScheduledTask, 15 | Task, 16 | } from '@diia-inhouse/diia-queue' 17 | import type { EnvService } from '@diia-inhouse/env' 18 | import type { FeatureService } from '@diia-inhouse/features' 19 | import type { HealthCheck } from '@diia-inhouse/healthcheck' 20 | import type { CacheService, PubSubService, RedlockService, StoreService } from '@diia-inhouse/redis' 21 | import type { AlsData, Logger } from '@diia-inhouse/types' 22 | import type { AppValidator } from '@diia-inhouse/validators' 23 | 24 | import { ActionExecutor } from '../actionExecutor' 25 | import { GrpcService } from '../grpc' 26 | import { GrpcClientFactory } from '../grpc/grpcClient' 27 | import MoleculerService from '../moleculer/moleculerWrapper' 28 | 29 | import { BaseConfig } from './config' 30 | 31 | export interface BaseDeps { 32 | serviceName: string 33 | config: TConfig 34 | logger: Logger 35 | envService: EnvService 36 | asyncLocalStorage: AsyncLocalStorage 37 | validator: AppValidator 38 | actionExecutor: ActionExecutor 39 | metrics: MetricsService 40 | grpcService: GrpcService 41 | grpcClientFactory: GrpcClientFactory 42 | moleculer?: MoleculerService 43 | store?: StoreService 44 | redlock?: RedlockService 45 | cache?: CacheService 46 | pubsub?: PubSubService 47 | queue?: Queue 48 | eventMessageHandler?: EventMessageHandler 49 | eventMessageValidator?: EventMessageValidator 50 | externalChannel?: ExternalCommunicatorChannel 51 | task?: Task 52 | scheduledTask?: ScheduledTask 53 | eventBus?: EventBus 54 | externalEventBus?: ExternalEventBus 55 | external?: ExternalCommunicator 56 | healthCheck?: HealthCheck 57 | database?: DatabaseService 58 | auth?: AuthService 59 | identifier?: IdentifierService 60 | featureFlag?: FeatureService 61 | } 62 | -------------------------------------------------------------------------------- /src/interfaces/config.ts: -------------------------------------------------------------------------------- 1 | import type { AuthConfig, IdentifierConfig } from '@diia-inhouse/crypto' 2 | import type { AppDbConfig } from '@diia-inhouse/db' 3 | import type { MetricsConfig as CustomMetricsConfig } from '@diia-inhouse/diia-metrics' 4 | import type { QueueConnectionConfig } from '@diia-inhouse/diia-queue' 5 | import type { FeatureConfig } from '@diia-inhouse/features' 6 | import type { HealthCheckConfig } from '@diia-inhouse/healthcheck' 7 | import type { RedisConfig } from '@diia-inhouse/redis' 8 | import type { GenericObject, HttpMethod } from '@diia-inhouse/types' 9 | 10 | import { GrpcServerConfig } from './grpc' 11 | 12 | export interface CorsConfig { 13 | // Configures the Access-Control-Allow-Origin CORS header. 14 | origins: string[] 15 | // Configures the Access-Control-Allow-Methods CORS header. 16 | methods: HttpMethod[] 17 | // Configures the Access-Control-Allow-Headers CORS header. 18 | allowedHeaders: string[] 19 | // Configures the Access-Control-Expose-Headers CORS header. 20 | exposedHeaders: string[] 21 | // Configures the Access-Control-Allow-Credentials CORS header. 22 | credentials: boolean 23 | // Configures the Access-Control-Max-Age CORS header. 24 | maxAge: number 25 | } 26 | 27 | export interface TransporterConfig { 28 | type: string 29 | options: Record 30 | } 31 | 32 | export interface BalancingStrategy { 33 | strategy: string 34 | strategyOptions: GenericObject 35 | } 36 | 37 | export interface TracingConfig { 38 | zipkin: { 39 | isEnabled: boolean 40 | baseURL: string 41 | sendIntervalSec: number 42 | } 43 | } 44 | 45 | export interface MetricsConfig { 46 | moleculer?: { 47 | prometheus: { 48 | isEnabled: boolean 49 | port?: number 50 | path: string 51 | } 52 | } 53 | custom?: CustomMetricsConfig 54 | } 55 | 56 | export interface BaseConfig { 57 | listenTerminationSignals?: boolean 58 | depsDir?: string 59 | transporter?: TransporterConfig 60 | app?: { 61 | externalBusTimeout?: number 62 | [key: string]: unknown 63 | } 64 | cors?: CorsConfig 65 | balancing?: BalancingStrategy 66 | tracing?: TracingConfig 67 | metrics?: MetricsConfig 68 | isMoleculerEnabled?: boolean 69 | store?: RedisConfig 70 | redis?: RedisConfig 71 | rabbit?: QueueConnectionConfig 72 | healthCheck?: HealthCheckConfig 73 | db?: AppDbConfig 74 | auth?: AuthConfig 75 | identifier?: IdentifierConfig 76 | grpcServer?: GrpcServerConfig 77 | featureFlags?: FeatureConfig 78 | } 79 | -------------------------------------------------------------------------------- /src/plugins/openapi/actionVisitor.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/no-extraneous-dependencies, n/no-unpublished-import 2 | import ts from 'typescript' 3 | 4 | import { ACTION_RESPONSE } from '../pluginConstants' 5 | 6 | import parser from './parser' 7 | 8 | const ActionVisitor = { 9 | visit(sourceFile: ts.SourceFile, ctx: ts.TransformationContext, program: ts.Program): ReturnType { 10 | const typeChecker = program.getTypeChecker() 11 | 12 | const visitClassNode = (node: ts.Node): ts.Node => { 13 | if (ts.isClassDeclaration(node)) { 14 | const classMethods = node.members.filter((member) => ts.isMethodDeclaration(member)) 15 | 16 | const handlerMethod = classMethods.find((classMethod) => classMethod.name.getText() === 'handler') 17 | 18 | if (!handlerMethod) { 19 | return ts.visitEachChild(node, visitClassNode, ctx) 20 | } 21 | 22 | const signature = typeChecker.getSignatureFromDeclaration(handlerMethod) 23 | 24 | if (!signature) { 25 | return ts.visitEachChild(node, visitClassNode, ctx) 26 | } 27 | 28 | const type = typeChecker.getReturnTypeOfSignature(signature) 29 | 30 | const responseType = type.symbol.getName() === 'Promise' ? (type)?.typeArguments?.[0] : type 31 | 32 | if (!responseType) { 33 | return ts.visitEachChild(node, visitClassNode, ctx) 34 | } 35 | 36 | const handlerResponse: ts.ObjectLiteralExpression = parser.parseType(responseType, program) 37 | 38 | return this.addResponseFactory(ctx.factory, node, handlerResponse) 39 | } 40 | 41 | return ts.visitEachChild(node, visitClassNode, ctx) 42 | } 43 | 44 | return ts.visitNode(sourceFile, visitClassNode) 45 | }, 46 | 47 | addResponseFactory( 48 | factory: ts.NodeFactory, 49 | node: ts.ClassDeclaration, 50 | handlerResponse: ts.ObjectLiteralExpression, 51 | ): ts.ClassDeclaration { 52 | const property = factory.createPropertyDeclaration( 53 | undefined, 54 | factory.createIdentifier(ACTION_RESPONSE), 55 | undefined, 56 | undefined, 57 | handlerResponse, 58 | ) 59 | 60 | return factory.updateClassDeclaration(node, ts.getModifiers(node), node.name, node.typeParameters, node.heritageClauses, [ 61 | ...node.members, 62 | property, 63 | ]) 64 | }, 65 | } 66 | 67 | export default ActionVisitor 68 | -------------------------------------------------------------------------------- /tests/integration/specs/grpcService.spec.ts: -------------------------------------------------------------------------------- 1 | import TestKit from '@diia-inhouse/test' 2 | import { GrpcStatusCode, HttpStatusCode, SessionType } from '@diia-inhouse/types' 3 | 4 | import { clientCallOptions } from '../../../src' 5 | import { getApp } from '../getApp' 6 | 7 | describe('grpcService', () => { 8 | let app: Awaited> 9 | const testKit = new TestKit() 10 | 11 | beforeAll(async () => { 12 | app = await getApp() 13 | }) 14 | 15 | afterAll(async () => { 16 | await app.stop() 17 | }) 18 | 19 | it('should start gRPC server with declared services', async () => { 20 | const result = await Promise.all([ 21 | app.container.resolve('testServiceClient').getTest({ timeoutMs: 1 }), 22 | app.container.resolve('testPrivateServiceClient').getTestPrivate({ timeoutMs: 1 }), 23 | ]) 24 | 25 | expect(result).toEqual([{ status: 'ok' }, { status: 'ok' }]) 26 | }) 27 | 28 | describe('errors', () => { 29 | it('should throw an grpc error when action throws an ApiError', async () => { 30 | await expect(app.container.resolve('testServiceClient').throwError({ httpStatus: HttpStatusCode.NOT_FOUND })).rejects.toThrow( 31 | expect.objectContaining({ 32 | message: '/ua.gov.diia.test.Test/ThrowError NOT_FOUND: error message', 33 | code: GrpcStatusCode.NOT_FOUND, 34 | }), 35 | ) 36 | }) 37 | 38 | it('should throw an grpc error when action throws an ApiError with processCode', async () => { 39 | const processCode = 11111 40 | 41 | await expect( 42 | app.container.resolve('testServiceClient').throwError({ httpStatus: HttpStatusCode.NOT_FOUND, processCode }), 43 | ).rejects.toThrow( 44 | expect.objectContaining({ 45 | message: '/ua.gov.diia.test.Test/ThrowError NOT_FOUND: error message', 46 | code: processCode, 47 | data: { processCode }, 48 | }), 49 | ) 50 | }) 51 | }) 52 | 53 | it.each(Object.values(SessionType))('should handle %s session', async (sessionType: SessionType) => { 54 | const session = testKit.session.getSessionBySessionType(sessionType) 55 | 56 | const { status } = await app.container.resolve('testServiceClient').getTest({ timeoutMs: 1 }, clientCallOptions({ session })) 57 | 58 | expect(status).toBe('ok') 59 | }) 60 | 61 | it('should lock resource', async () => { 62 | const redlockSpy = jest.spyOn(app.container.resolve('redlock')!, 'lock') 63 | const { status } = await app.container.resolve('testServiceClient').lockResource({ id: '123' }) 64 | 65 | expect(status).toBe('ok') 66 | expect(redlockSpy).toHaveBeenCalledWith('lockResource.123', 30000) 67 | }) 68 | }) 69 | -------------------------------------------------------------------------------- /tests/unit/config.ts: -------------------------------------------------------------------------------- 1 | import { IdentifierConfig } from '@diia-inhouse/crypto' 2 | import { EnvService } from '@diia-inhouse/env' 3 | import { HealthCheckConfig } from '@diia-inhouse/healthcheck' 4 | 5 | import { BalancingStrategy, BaseConfig, MetricsConfig, TransporterConfig } from '../../src/interfaces' 6 | 7 | // eslint-disable-next-line @typescript-eslint/explicit-function-return-type 8 | export const configFactory = async (envService: EnvService, serviceName: string) => 9 | ({ 10 | isMoleculerEnabled: false, 11 | serviceName, 12 | depsDir: './tests/unit/dist', 13 | 14 | transporter: { 15 | type: envService.getVar('TRANSPORT_TYPE', 'string', 'Redis'), 16 | options: envService.getVar('TRANSPORT_OPTIONS', 'object', {}), 17 | }, 18 | 19 | balancing: { 20 | strategy: process.env.BALANCING_STRATEGY_NAME, 21 | strategyOptions: process.env.BALANCING_STRATEGY_OPTIONS ? JSON.parse(process.env.BALANCING_STRATEGY_OPTIONS) : {}, 22 | }, 23 | 24 | healthCheck: { 25 | isEnabled: envService.getVar('METRICS_MOLECULER_PROMETHEUS_IS_ENABLED', 'boolean', false), 26 | port: envService.getVar('HEALTH_CHECK_IS_PORT', 'number', 3000), 27 | }, 28 | 29 | metrics: { 30 | moleculer: { 31 | prometheus: { 32 | isEnabled: envService.getVar('METRICS_MOLECULER_PROMETHEUS_IS_ENABLED', 'boolean', false), 33 | port: envService.getVar('METRICS_MOLECULER_PROMETHEUS_PORT', 'number', 3031), 34 | path: envService.getVar('METRICS_MOLECULER_PROMETHEUS_PATH', 'string', '/metrics'), 35 | }, 36 | }, 37 | custom: { 38 | disabled: envService.getVar('METRICS_CUSTOM_DISABLED', 'boolean', false), 39 | port: envService.getVar('METRICS_CUSTOM_PORT', 'number', 3030), 40 | moleculer: { 41 | disabled: envService.getVar('METRICS_CUSTOM_MOLECULER_DISABLED', 'boolean', false), 42 | port: envService.getVar('METRICS_CUSTOM_MOLECULER_PORT', 'number', 3031), 43 | path: envService.getVar('METRICS_CUSTOM_MOLECULER_PATH', 'string', '/metrics'), 44 | }, 45 | disableDefaultMetrics: envService.getVar('METRICS_CUSTOM_DISABLE_DEFAULT_METRICS', 'boolean', false), 46 | defaultLabels: envService.getVar('METRICS_CUSTOM_DEFAULT_LABELS', 'object', {}), 47 | }, 48 | }, 49 | 50 | app: { 51 | integrationPointsTimeout: process.env.INTEGRATION_TIMEOUT_IN_MSEC 52 | ? Number.parseInt(process.env.INTEGRATION_TIMEOUT_IN_MSEC, 10) 53 | : 10 * 1000, 54 | externalBusTimeout: process.env.EXTERNAL_BUS_TIMEOUT ? Number.parseInt(process.env.EXTERNAL_BUS_TIMEOUT, 10) : 5 * 1000, 55 | }, 56 | 57 | identifier: { 58 | salt: process.env.SALT, 59 | }, 60 | 61 | cors: { 62 | allowedHeaders: [], 63 | credentials: false, 64 | exposedHeaders: [], 65 | maxAge: 1800, 66 | methods: [], 67 | origins: [], 68 | }, 69 | 70 | grpc: { 71 | testServiceAddress: envService.getVar('GRPC_TEST_SERVICE_ADDRESS', 'string', null), 72 | }, 73 | }) satisfies BaseConfig & Record 74 | -------------------------------------------------------------------------------- /tests/mocks/grpcObject.ts: -------------------------------------------------------------------------------- 1 | import { GrpcObject, ServiceClientConstructor, ServiceDefinition } from '@grpc/grpc-js' 2 | import { AppAction } from 'src' 3 | 4 | import { ApiError } from '@diia-inhouse/errors' 5 | import { ActionVersion, AppUserActionHeaders, HttpStatusCode, ServiceActionArguments, SessionType } from '@diia-inhouse/types' 6 | import { ValidationSchema } from '@diia-inhouse/validators' 7 | 8 | interface GrpcActionArguments extends ServiceActionArguments { 9 | params: { param: string } 10 | } 11 | 12 | interface GrpcActionErrorArguments extends ServiceActionArguments { 13 | params: { param: string; processCode?: number } 14 | } 15 | 16 | export const grpcObjectWithAction: GrpcObject = { 17 | 'service-with-action': { 18 | service: ({ 19 | action: { 20 | originalName: 'action', 21 | path: '/action', 22 | }, 23 | }), 24 | serviceName: 'action', 25 | }, 26 | } 27 | 28 | export const grpcObjectWithActionError: GrpcObject = { 29 | 'service-with-action-error': { 30 | service: ({ 31 | 'action-error': { 32 | originalName: 'action-error', 33 | path: '/action-error', 34 | }, 35 | }), 36 | serviceName: 'action-error', 37 | }, 38 | } 39 | 40 | export const grpcObjectActionRedlock: GrpcObject = { 41 | 'service-with-action-redlock': { 42 | service: ({ 43 | 'action-redlock': { 44 | originalName: 'action-redlock', 45 | path: '/action-redlock', 46 | }, 47 | }), 48 | serviceName: 'action-redlock', 49 | }, 50 | } 51 | 52 | export class GrpcAction implements AppAction { 53 | readonly name: string = 'action' 54 | 55 | readonly actionVersion: ActionVersion = ActionVersion.V1 56 | 57 | readonly validationRules: ValidationSchema = { 58 | param: { type: 'string' }, 59 | } 60 | 61 | readonly sessionType: SessionType = SessionType.User 62 | 63 | async handler(args: GrpcActionArguments): Promise { 64 | return args.params.param 65 | } 66 | } 67 | 68 | export class GrpcActionError implements AppAction { 69 | readonly name: string = 'action-error' 70 | 71 | readonly actionVersion: ActionVersion = ActionVersion.V1 72 | 73 | readonly validationRules: ValidationSchema = { 74 | param: { type: 'string', enum: Object.values(HttpStatusCode).map(String) }, 75 | processCode: { type: 'number', optional: true }, 76 | } 77 | 78 | readonly sessionType: SessionType = SessionType.User 79 | 80 | async handler(args: GrpcActionErrorArguments): Promise { 81 | throw new ApiError('Mocked error', Number.parseInt(args.params.param), {}, args.params.processCode) 82 | } 83 | } 84 | 85 | export class GrpcActionRedlock implements AppAction { 86 | readonly name: string = 'action-redlock' 87 | 88 | readonly actionVersion: ActionVersion = ActionVersion.V1 89 | 90 | readonly sessionType: SessionType = SessionType.User 91 | 92 | getLockResource(args: ServiceActionArguments): string { 93 | const { 94 | headers: { mobileUid }, 95 | } = args 96 | 97 | return `action-redlock-${mobileUid}` 98 | } 99 | 100 | async handler(): Promise { 101 | return true 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /tests/unit/tracing/index.spec.ts: -------------------------------------------------------------------------------- 1 | import { IncomingMessage } from 'node:http' 2 | 3 | import { DiagConsoleLogger, DiagLogLevel, diag } from '@opentelemetry/api' 4 | import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-grpc' 5 | import { registerInstrumentations } from '@opentelemetry/instrumentation' 6 | import { ConsoleSpanExporter, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base' 7 | import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node' 8 | 9 | import { getIgnoreIncomingRequestHook, initTracing } from '../../../src' 10 | 11 | jest.mock('@opentelemetry/resources', () => { 12 | const { Resource, ...rest } = jest.requireActual('@opentelemetry/resources') 13 | 14 | return { 15 | ...rest, 16 | // eslint-disable-next-line unicorn/no-static-only-class 17 | Resource: class ResourceMock { 18 | static default(): { merge: () => void } { 19 | return { 20 | merge: (): void => {}, 21 | } 22 | } 23 | }, 24 | } 25 | }) 26 | 27 | jest.mock('@opentelemetry/sdk-trace-node') 28 | jest.mock('@opentelemetry/api') 29 | jest.mock('@opentelemetry/exporter-trace-otlp-grpc') 30 | jest.mock('@opentelemetry/instrumentation') 31 | jest.mock('@opentelemetry/sdk-trace-base') 32 | 33 | const defaultConfig = { 34 | instrumentations: { 35 | '@opentelemetry/instrumentation-fs': { enabled: false }, 36 | '@opentelemetry/instrumentation-http': { ignoreIncomingRequestHook: getIgnoreIncomingRequestHook() }, 37 | }, 38 | exporter: { 39 | url: 'http://opentelemetry-collector.tracing.svc.cluster.local:4317', 40 | }, 41 | } 42 | 43 | describe(`initTracing`, () => { 44 | it('should not register node tracer provider if tracing is disabled', () => { 45 | initTracing('Documents') 46 | 47 | const { instances: providerInstances } = (>NodeTracerProvider).mock 48 | 49 | expect(providerInstances).toHaveLength(1) 50 | }) 51 | 52 | it('should register node tracer provider', () => { 53 | initTracing('Documents', { enabled: true }) 54 | 55 | const [provider] = (>NodeTracerProvider).mock.instances 56 | 57 | expect(registerInstrumentations).toHaveBeenCalled() 58 | expect(OTLPTraceExporter).toHaveBeenCalledWith(defaultConfig.exporter) 59 | 60 | expect(provider.register).toHaveBeenCalled() 61 | }) 62 | 63 | it('should add debug logging', () => { 64 | initTracing('Documents', { enabled: true, debug: true }) 65 | 66 | const [diagConsoleLogger] = (>DiagConsoleLogger).mock.instances 67 | const [provider] = (>NodeTracerProvider).mock.instances 68 | const [simpleSpanProcessor] = (>SimpleSpanProcessor).mock.instances 69 | const [consoleSpanExporter] = (>ConsoleSpanExporter).mock.instances 70 | 71 | expect(jest.spyOn(diag, 'setLogger')).toHaveBeenCalledWith(diagConsoleLogger, DiagLogLevel.VERBOSE) 72 | expect(provider.addSpanProcessor).toHaveBeenCalledWith(simpleSpanProcessor) 73 | expect(SimpleSpanProcessor).toHaveBeenCalledWith(consoleSpanExporter) 74 | expect(provider.register).toHaveBeenCalled() 75 | }) 76 | }) 77 | 78 | describe(`getIgnoreIncomingRequestHook`, () => { 79 | const paths = ['/path1', '/path2'] 80 | const hook = getIgnoreIncomingRequestHook(paths) 81 | 82 | it('should return true if passed url in ignored paths list', () => { 83 | expect(hook({ url: paths[0] })).toBe(true) 84 | }) 85 | 86 | it('should return true if passed url in the list of hardcoded ignored paths', () => { 87 | expect(hook({ url: '/ready' })).toBe(true) 88 | }) 89 | 90 | it('should return false if passed url not in ignored paths list', () => { 91 | expect(hook({ url: '/not0in-list' })).toBe(false) 92 | }) 93 | 94 | it('should return false if url is not passed', () => { 95 | expect(hook({})).toBe(false) 96 | }) 97 | }) 98 | -------------------------------------------------------------------------------- /tests/integration/config.ts: -------------------------------------------------------------------------------- 1 | import { IdentifierConfig } from '@diia-inhouse/crypto' 2 | import { EnvService } from '@diia-inhouse/env' 3 | import { HealthCheckConfig } from '@diia-inhouse/healthcheck' 4 | import { RedisConfig } from '@diia-inhouse/redis' 5 | 6 | import { BalancingStrategy, BaseConfig, MetricsConfig, TransporterConfig } from '../../src/interfaces' 7 | 8 | // eslint-disable-next-line @typescript-eslint/explicit-function-return-type 9 | export const configFactory = async (envService: EnvService, serviceName: string) => 10 | ({ 11 | isMoleculerEnabled: true, 12 | serviceName, 13 | depsDir: '../dist/tests/integration', 14 | 15 | transporter: { 16 | type: envService.getVar('TRANSPORT_TYPE', 'string'), 17 | options: envService.getVar('TRANSPORT_OPTIONS', 'object', {}), 18 | }, 19 | 20 | balancing: { 21 | strategy: process.env.BALANCING_STRATEGY_NAME, 22 | strategyOptions: process.env.BALANCING_STRATEGY_OPTIONS ? JSON.parse(process.env.BALANCING_STRATEGY_OPTIONS) : {}, 23 | }, 24 | 25 | healthCheck: { 26 | isEnabled: envService.getVar('METRICS_MOLECULER_PROMETHEUS_IS_ENABLED', 'boolean', false), 27 | port: envService.getVar('HEALTH_CHECK_IS_PORT', 'number', 3000), 28 | }, 29 | 30 | store: { 31 | readWrite: envService.getVar('STORE_READ_WRITE_OPTIONS', 'object'), 32 | 33 | readOnly: envService.getVar('STORE_READ_ONLY_OPTIONS', 'object'), 34 | }, 35 | 36 | metrics: { 37 | moleculer: { 38 | prometheus: { 39 | isEnabled: envService.getVar('METRICS_MOLECULER_PROMETHEUS_IS_ENABLED', 'boolean', true), 40 | port: envService.getVar('METRICS_MOLECULER_PROMETHEUS_PORT', 'number', 3031), 41 | path: envService.getVar('METRICS_MOLECULER_PROMETHEUS_PATH', 'string', '/metrics'), 42 | }, 43 | }, 44 | custom: { 45 | disabled: envService.getVar('METRICS_CUSTOM_DISABLED', 'boolean', false), 46 | port: envService.getVar('METRICS_CUSTOM_PORT', 'number', 3030), 47 | moleculer: { 48 | disabled: envService.getVar('METRICS_CUSTOM_MOLECULER_DISABLED', 'boolean', false), 49 | port: envService.getVar('METRICS_CUSTOM_MOLECULER_PORT', 'number', 3031), 50 | path: envService.getVar('METRICS_CUSTOM_MOLECULER_PATH', 'string', '/metrics'), 51 | }, 52 | disableDefaultMetrics: envService.getVar('METRICS_CUSTOM_DISABLE_DEFAULT_METRICS', 'boolean', false), 53 | defaultLabels: envService.getVar('METRICS_CUSTOM_DEFAULT_LABELS', 'object', {}), 54 | }, 55 | }, 56 | 57 | app: { 58 | integrationPointsTimeout: process.env.INTEGRATION_TIMEOUT_IN_MSEC 59 | ? Number.parseInt(process.env.INTEGRATION_TIMEOUT_IN_MSEC, 10) 60 | : 10 * 1000, 61 | externalBusTimeout: process.env.EXTERNAL_BUS_TIMEOUT ? Number.parseInt(process.env.EXTERNAL_BUS_TIMEOUT, 10) : 5 * 1000, 62 | }, 63 | 64 | identifier: { 65 | salt: process.env.SALT, 66 | }, 67 | 68 | cors: { 69 | allowedHeaders: [], 70 | credentials: false, 71 | exposedHeaders: [], 72 | maxAge: 1800, 73 | methods: [], 74 | origins: [], 75 | }, 76 | 77 | grpc: { 78 | testServiceAddress: envService.getVar('GRPC_TEST_SERVICE_ADDRESS', 'string', null), 79 | }, 80 | 81 | grpcServer: { 82 | isEnabled: envService.getVar('GRPC_SERVER_ENABLED', 'boolean', false), 83 | port: envService.getVar('GRPC_SERVER_PORT', 'number', 5000), 84 | services: envService.getVar('GRPC_SERVICES', 'object'), 85 | isReflectionEnabled: envService.getVar('GRPC_REFLECTION_ENABLED', 'boolean', false), 86 | maxReceiveMessageLength: envService.getVar('GRPC_SERVER_MAX_RECEIVE_MESSAGE_LENGTH', 'number', 1024 * 1024 * 4), 87 | }, 88 | }) satisfies BaseConfig & Record 89 | -------------------------------------------------------------------------------- /src/grpc/server.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path' 2 | 3 | import { 4 | GrpcObject, 5 | ProtobufTypeDefinition, 6 | Server, 7 | ServerCredentials, 8 | ServiceClientConstructor, 9 | loadPackageDefinition, 10 | } from '@grpc/grpc-js' 11 | import { load } from '@grpc/proto-loader' 12 | import { ReflectionService } from '@grpc/reflection' 13 | import { glob } from 'glob' 14 | 15 | import type { Logger } from '@diia-inhouse/types' 16 | 17 | import { GrpcServerConfig, GrpcServiceImplementationProvider, GrpcServiceStatus } from '../interfaces/grpc' 18 | 19 | export class GrpcServer { 20 | constructor( 21 | private readonly config: GrpcServerConfig, 22 | private readonly logger: Logger, 23 | ) { 24 | this.server = new Server({ 'grpc.max_receive_message_length': this.config.maxReceiveMessageLength }) 25 | } 26 | 27 | private status: GrpcServiceStatus['grpcServer'] = 'UNKNOWN' 28 | 29 | private readonly server: Server 30 | 31 | getStatus(): GrpcServiceStatus['grpcServer'] { 32 | return this.status 33 | } 34 | 35 | async start(grpcServiceImplementationProvider: GrpcServiceImplementationProvider): Promise { 36 | const externalProtos = await glob('node_modules/@diia-inhouse/**/proto/**/*.proto') 37 | const externalProtosDirnames = new Set(externalProtos.map((value) => path.dirname(value))) 38 | 39 | const internalProtosDirname = 'proto' 40 | const internalProtosPaths = await glob(`${internalProtosDirname}/**/*.proto`) 41 | const internalProtos = internalProtosPaths.map((protoPath) => path.relative(internalProtosDirname, protoPath)) 42 | const pkgDefs = await load(internalProtos, { 43 | keepCase: true, 44 | longs: String, 45 | enums: String, 46 | defaults: true, 47 | oneofs: true, 48 | includeDirs: [...externalProtosDirnames, internalProtosDirname], 49 | }) 50 | 51 | this.logger.debug('grpc server proto loaded', { pkgDefs }) 52 | const serviceProto = loadPackageDefinition(pkgDefs) 53 | if (this.config.isReflectionEnabled) { 54 | const reflection = new ReflectionService(pkgDefs) 55 | 56 | reflection.addToServer(this.server) 57 | } 58 | 59 | for (const service of this.config.services) { 60 | const subpath = service.split('.') 61 | let serviceDefinition: GrpcObject | ServiceClientConstructor | ProtobufTypeDefinition | undefined 62 | for (const p of subpath) { 63 | serviceDefinition = serviceDefinition ? (serviceDefinition)[p] : serviceProto[p] 64 | } 65 | 66 | if (!this.isServiceDefinition(serviceDefinition)) { 67 | throw new Error(`Unable to find service definition for ${service}`) 68 | } 69 | 70 | this.logger.debug('grpc server service definition', { 71 | service: serviceDefinition.service, 72 | serviceName: serviceDefinition.serviceName, 73 | }) 74 | this.server.addService( 75 | serviceDefinition.service, 76 | grpcServiceImplementationProvider(serviceDefinition.serviceName, serviceDefinition.service), 77 | ) 78 | } 79 | 80 | return await new Promise((resolve, reject) => { 81 | this.server.bindAsync(`0.0.0.0:${this.config.port}`, ServerCredentials.createInsecure(), (error, port) => { 82 | if (error) { 83 | this.logger.error(`grpc service failed to start on port ${port}`) 84 | 85 | return reject(error) 86 | } 87 | 88 | this.status = 'SERVING' 89 | this.logger.info(`grpc service is listening on port ${port}`) 90 | resolve() 91 | }) 92 | }) 93 | } 94 | 95 | async stop(): Promise { 96 | this.status = 'NOT_SERVING' 97 | 98 | return await new Promise((resolve, reject) => { 99 | this.server.tryShutdown((err) => (err ? reject(err) : resolve(this.server.forceShutdown()))) 100 | }) 101 | } 102 | 103 | private isServiceDefinition( 104 | param: GrpcObject | ServiceClientConstructor | ProtobufTypeDefinition | undefined, 105 | ): param is ServiceClientConstructor { 106 | if (param && 'service' in param) { 107 | return true 108 | } 109 | 110 | return false 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Diia 2 | 3 | This repository provides an overview over the flagship product [**Diia**](https://diia.gov.ua/) developed by the [**Ministry of Digital Transformation of Ukraine**](https://thedigital.gov.ua/). 4 | 5 | **Diia** is an app with access to citizen’s digital documents and government services. 6 | 7 | The application was created so that Ukrainians could interact with the state in a few clicks, without spending their time on queues and paperwork - **Diia** open source application will help countries, companies and communities build a foundation for long-term relationships. At the heart of these relations are openness, efficiency and humanity. 8 | 9 | We're pleased to share the **Diia** project with you. 10 | 11 | ## Useful Links 12 | 13 | | Topic | Link | Description | 14 | | --------------------------------------------- | -------------------------- | -------------------------------------------------------------------------- | 15 | | Ministry of Digital Transformation of Ukraine | https://thedigital.gov.ua/ | The Official homepage of the Ministry of Digital Transformation of Ukraine | 16 | | Diia App | https://diia.gov.ua/ | The Official website for the Diia application | 17 | 18 | ## Getting Started 19 | 20 | This repository contains the package which provides service bootstrap functionality with all required dependencies. 21 | 22 | ## Build Process 23 | 24 | ### **1. Clone codebase via `git clone` command** 25 | 26 | Example: 27 | 28 | ``` 29 | git clone https://github.com/diia-open-source/be-diia-app.git diia-app 30 | ``` 31 | 32 | --- 33 | 34 | ### **2. Go to code base root directory** 35 | 36 | ``` 37 | cd ./diia-app 38 | ``` 39 | 40 | --- 41 | 42 | ### **3. Install npm dependencies** 43 | 44 | The installation of dependencies consists of the following 2 steps: 45 | 46 | #### **1. Manually clone, build and link dependencies from `@diia-inhouse` scope** 47 | 48 | Each Diia service depends on dependencies from `@diia-inhouse/` scope which are distributed across different repositories, are built separately, and aren't published into public npm registry. 49 | 50 | The full list of such dependencies can be found in the target service `package.json` file in `dependencies` and `devDependencies` sections respectively. 51 | 52 | Detailed instructions on how to link dependencies from `@diia-inhouse/` scope are described in `LINKDEPS.md` which can be found here 53 | https://github.com/diia-open-source/diia-setup-howto/tree/main/backend 54 | 55 | #### **2. Install public npm dependencies and use those linked from `@diia-inhouse` scope** 56 | 57 | In order to install and use the linked dependencies for `diia-app` the following command can be used: 58 | 59 | ``` 60 | $ cd ./diia-app 61 | $ npm link @diia-inhouse/db @diia-inhouse/redis ... @diia-inhouse/ 62 | ``` 63 | 64 | In case all dependencies from `@diia-inhouse` scope are linked, and can be resolved, you will then have a complete list of dependencies installed for the service code base. 65 | 66 | --- 67 | 68 | ### **4. Build package** 69 | 70 | In order to build the service you have to run the command `npm run build` inside the root directory of service code base as per: 71 | 72 | ``` 73 | $ cd ./diia-app 74 | $ npm run build 75 | ``` 76 | 77 | --- 78 | 79 | ## How to contribute 80 | 81 | The Diia project welcomes contributions into this solution; please refer to the CONTRIBUTING.md file for details 82 | 83 | ## Licensing 84 | 85 | Copyright (C) Diia and all other contributors. 86 | 87 | Licensed under the **EUPL** (the "License"); you may not use this file except in compliance with the License. Re-use is permitted, although not encouraged, under the EUPL, with the exception of source files that contain a different license. 88 | 89 | You may obtain a copy of the License at [https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12](https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12). 90 | 91 | Questions regarding the Diia project, the License and any re-use should be directed to [modt.opensource@thedigital.gov.ua](mailto:modt.opensource@thedigital.gov.ua). 92 | 93 | This project incorporates third party material. In all cases the original copyright notices and the license under which these third party dependencies were provided remains as so. In relation to the Typescript dependency you should also review the [Typescript Third Party Notices](https://github.com/microsoft/TypeScript/blob/9684ba6b0d73c37546ada901e5d0a5324de7fc1d/ThirdPartyNoticeText.txt). 94 | -------------------------------------------------------------------------------- /tests/unit/grpc/grpcClient.spec.ts: -------------------------------------------------------------------------------- 1 | import { CallOptions, ClientMiddleware, ClientMiddlewareCall, TsProtoServiceDefinition } from 'nice-grpc' 2 | 3 | import DiiaLogger from '@diia-inhouse/diia-logger' 4 | import { MetricsService } from '@diia-inhouse/diia-metrics' 5 | import TestKit, { mockInstance } from '@diia-inhouse/test' 6 | import { ActionVersion } from '@diia-inhouse/types' 7 | 8 | import { GrpcClientFactory, clientCallOptions } from '../../../src/grpc' 9 | 10 | const generatorValue = 'generatorResult' 11 | 12 | // eslint-disable-next-line n/no-unsupported-features/node-builtins 13 | const call = >({ 14 | method: { 15 | path: '/test/', 16 | }, 17 | next: function* () { 18 | yield generatorValue 19 | }, 20 | request: '', 21 | }) 22 | 23 | const options = ({}) 24 | 25 | const client = {} 26 | 27 | jest.mock('nice-grpc', () => { 28 | const originalModule = jest.requireActual('nice-grpc') 29 | 30 | return { 31 | __esModule: true, 32 | ...originalModule, 33 | createChannel: jest.fn(), 34 | ChannelCredentials: { 35 | createInsecure: jest.fn(), 36 | }, 37 | createClientFactory: (): unknown => ({ 38 | use: (loggingMiddleware: ClientMiddleware) => ({ 39 | use: (metadataMiddleware: ClientMiddleware): object => ({ 40 | use: (errorHandlerMiddleware: ClientMiddleware): object => ({ 41 | use: (deadlineMiddleware: ClientMiddleware): object => ({ 42 | create: async (): Promise => { 43 | let result = await loggingMiddleware(call, options) 44 | 45 | let generatorResult = await result.next() 46 | 47 | expect(generatorResult).toStrictEqual({ 48 | value: generatorValue, 49 | done: false, 50 | }) 51 | 52 | result = await metadataMiddleware(call, options) 53 | generatorResult = await result.next() 54 | 55 | expect(generatorResult).toStrictEqual({ 56 | value: generatorValue, 57 | done: false, 58 | }) 59 | 60 | result = await errorHandlerMiddleware(call, options) 61 | generatorResult = await result.next() 62 | 63 | expect(generatorResult).toStrictEqual({ 64 | value: generatorValue, 65 | done: false, 66 | }) 67 | 68 | result = await deadlineMiddleware(call, options) 69 | generatorResult = await result.next() 70 | 71 | expect(generatorResult).toStrictEqual({ 72 | value: generatorValue, 73 | done: false, 74 | }) 75 | 76 | return client 77 | }, 78 | }), 79 | }), 80 | }), 81 | }), 82 | }), 83 | } 84 | }) 85 | 86 | describe('grpcClientFactory', () => { 87 | const serviceName = 'Auth' 88 | const logger = mockInstance(DiiaLogger) 89 | const metrics = mockInstance(MetricsService) 90 | 91 | const grpcClientFactory = new GrpcClientFactory(serviceName, logger, metrics) 92 | 93 | it('should create client', async () => { 94 | const definition: TsProtoServiceDefinition = { name: 'Test', fullName: 'ua.Test', methods: {} } 95 | const serviceAddress = 'ua.gov.diia.publicservice.service-with-action' 96 | 97 | await expect(grpcClientFactory.createGrpcClient(definition, serviceAddress)).resolves.toStrictEqual(client) 98 | }) 99 | }) 100 | 101 | describe('function clientCallOptions', () => { 102 | const testKit = new TestKit() 103 | 104 | it('should create metadata', () => { 105 | const grpcMetadata = { 106 | session: testKit.session.getUserSession(), 107 | version: ActionVersion.V0, 108 | deadline: 0, 109 | } 110 | 111 | const { metadata, deadline } = clientCallOptions(grpcMetadata) 112 | 113 | expect(deadline).toBe(0) 114 | expect(metadata?.get('actionversion')).toBe(grpcMetadata.version) 115 | const sessionBase64Decoded = metadata?.get('session') 116 | 117 | expect(sessionBase64Decoded).toBeDefined() 118 | }) 119 | }) 120 | -------------------------------------------------------------------------------- /tests/unit/application.spec.ts: -------------------------------------------------------------------------------- 1 | import { asClass, asValue } from 'awilix' 2 | 3 | import { MetricsService } from '@diia-inhouse/diia-metrics' 4 | import { mockClass } from '@diia-inhouse/test' 5 | 6 | import { Application } from '../../src' 7 | 8 | import { configFactory } from './config' 9 | 10 | jest.mock('@diia-inhouse/redis', () => { 11 | const { CacheService, PubSubService, RedlockService, StoreService, ...rest } = jest.requireActual('@diia-inhouse/redis') 12 | 13 | return { 14 | ...rest, 15 | CacheService: mockClass(CacheService), 16 | PubSubService: mockClass(PubSubService), 17 | RedlockService: mockClass(RedlockService), 18 | StoreService: mockClass(StoreService), 19 | } 20 | }) 21 | 22 | jest.mock('@diia-inhouse/diia-queue', () => { 23 | const { 24 | ExternalCommunicator, 25 | ExternalCommunicatorChannel, 26 | ExternalEventBus, 27 | ScheduledTask, 28 | Queue, 29 | EventMessageHandler, 30 | EventMessageValidator, 31 | EventBus, 32 | Task, 33 | QueueConnectionType, 34 | ...rest 35 | } = jest.requireActual('@diia-inhouse/diia-queue') 36 | 37 | return { 38 | ...rest, 39 | ExternalCommunicator: mockClass(ExternalCommunicator), 40 | ExternalCommunicatorChannel: mockClass(ExternalCommunicatorChannel), 41 | ExternalEventBus: mockClass(ExternalEventBus), 42 | ScheduledTask: mockClass(ScheduledTask), 43 | Queue: mockClass(Queue), 44 | EventMessageHandler: mockClass(EventMessageHandler), 45 | EventMessageValidator: mockClass(EventMessageValidator), 46 | EventBus: mockClass(EventBus), 47 | Task: mockClass(Task), 48 | QueueConnectionType: mockClass(QueueConnectionType), 49 | } 50 | }) 51 | 52 | jest.mock('awilix', () => { 53 | const original = jest.requireActual('awilix') 54 | let alreadyCalled = false 55 | 56 | return { 57 | ...original, 58 | listModules: (): unknown => { 59 | if (alreadyCalled) { 60 | return [] 61 | } 62 | 63 | alreadyCalled = true 64 | 65 | return [] 66 | }, 67 | } 68 | }) 69 | 70 | describe(`${Application.constructor.name}`, () => { 71 | const serviceName = 'Auth' 72 | const MockedMetricsService = mockClass(MetricsService) 73 | 74 | describe(`method ${Application.prototype.initialize.name}`, () => { 75 | it('should successfully start application', async () => { 76 | const app = new Application(serviceName) 77 | 78 | await app.setConfig(configFactory) 79 | await app.setDeps(async () => ({ metrics: asClass(MockedMetricsService).singleton() })) 80 | const appOperator = await app.initialize() 81 | 82 | await expect(appOperator.start()).resolves.not.toThrow() 83 | }) 84 | 85 | it('should successfully stop application', async () => { 86 | const app = new Application(serviceName) 87 | 88 | await app.setConfig(configFactory) 89 | await app.setDeps(async () => ({ metrics: asClass(MockedMetricsService).singleton() })) 90 | const appOperator = await app.initialize() 91 | 92 | await expect(appOperator.stop()).resolves.not.toThrow() 93 | }) 94 | 95 | it('should throw error if config is not set', async () => { 96 | const app = new Application(serviceName) 97 | 98 | await expect(app.initialize()).rejects.toThrow(new Error('Container and config should be initialized before creating context')) 99 | }) 100 | 101 | it('should throw error if has no config when do patch', () => { 102 | const app = new Application(serviceName) 103 | 104 | expect(() => { 105 | app.patchConfig({}) 106 | }).toThrow(new Error('Config should be set before patch')) 107 | }) 108 | 109 | it('should throw error when update existed config', async () => { 110 | const app = new Application(serviceName) 111 | 112 | await app.setConfig(configFactory) 113 | await app.setDeps(async () => ({ metrics: asClass(MockedMetricsService).singleton() })) 114 | 115 | expect(() => { 116 | app.patchConfig({}) 117 | }).not.toThrow() 118 | }) 119 | }) 120 | 121 | describe(`method ${Application.prototype.setDeps.name}`, () => { 122 | it('should throw error if config was not set', async () => { 123 | const app = new Application(serviceName) 124 | 125 | await expect(app.setDeps(async () => ({ metrics: asClass(MockedMetricsService).singleton() }))).rejects.toThrow( 126 | new Error('Config should be set before deps'), 127 | ) 128 | }) 129 | 130 | it('should set deps', async () => { 131 | const app = new Application(serviceName) 132 | 133 | await app.setConfig(configFactory) 134 | await app.setDeps(async () => ({ 135 | metrics: asClass(MockedMetricsService).singleton(), 136 | test: asValue('testValue'), 137 | })) 138 | const appOperator = await app.initialize() 139 | 140 | expect(appOperator.container.resolve('test')).toBe('testValue') 141 | }) 142 | }) 143 | }) 144 | -------------------------------------------------------------------------------- /src/interfaces/action.ts: -------------------------------------------------------------------------------- 1 | import { MethodDefinition } from '@grpc/grpc-js' 2 | 3 | import { BadRequestError } from '@diia-inhouse/errors' 4 | import { ActionArguments, ActionVersion, GenericObject, SessionType } from '@diia-inhouse/types' 5 | import { ActHeaders } from '@diia-inhouse/types/dist/types/common' 6 | import { ValidationSchema } from '@diia-inhouse/validators' 7 | 8 | import { ErrorCode } from './errorCode' 9 | import { DeviceMultipleConnectionPolicy, StreamKey } from './grpc' 10 | 11 | export interface AppAction { 12 | /** @deprecated sessionType doesn't have any sense as it doesn't impact logic */ 13 | sessionType?: SessionType 14 | /** @deprecated separate action with another name that explicitly contains the version should be created: getToken -> getTokenV3 */ 15 | actionVersion?: ActionVersion 16 | name: string 17 | validationRules?: ValidationSchema 18 | getLockResource?(args: T): string 19 | getServiceCode?(args: T): string 20 | handler(args: T): unknown 21 | 22 | /** @info use only for development! */ 23 | __actionResponse?: GenericObject 24 | } 25 | 26 | /** 27 | * marker interface indicates that action supports communication via grpc transport 28 | */ 29 | export interface GrpcAppAction extends AppAction { 30 | grpcMethod?: MethodDefinition 31 | } 32 | 33 | export interface GrpcStreamAction extends GrpcAppAction { 34 | onConnectionClosed(metadata: ActHeaders, request: GenericObject): void 35 | 36 | onConnectionOpened(metadata: ActHeaders, request: GenericObject): void 37 | } 38 | 39 | type SubscriptionHandler = (data: GenericObject) => void 40 | 41 | export interface Subscription { 42 | streamId: string 43 | handler: SubscriptionHandler 44 | } 45 | 46 | export abstract class GrpcServerStreamAction implements GrpcStreamAction { 47 | private deviceSubscriptions = new Map() 48 | 49 | abstract name: string 50 | 51 | /** @deprecated sessionType doesn't have any sense as it doesn't impact logic */ 52 | abstract sessionType?: SessionType 53 | 54 | abstract handler(args: ActionArguments): unknown 55 | 56 | abstract onConnectionClosed(metadata: ActHeaders, request: GenericObject): void 57 | 58 | abstract onConnectionOpened(metadata: ActHeaders, request: GenericObject): void 59 | 60 | protected deviceMultipleConnectionPolicy: DeviceMultipleConnectionPolicy = 61 | DeviceMultipleConnectionPolicy.FORBID_CLOSE_PREVIOUS_CONNECTION 62 | 63 | subscribeChannel(streamKey: StreamKey, handler: SubscriptionHandler): void | never { 64 | const { mobileUid, streamId } = streamKey 65 | const subscriptions = this.deviceSubscriptions.get(mobileUid) || [] 66 | 67 | switch (this.deviceMultipleConnectionPolicy) { 68 | case DeviceMultipleConnectionPolicy.FORBID_REJECT_NEW_CONNECTION: { 69 | if (subscriptions.length > 0) { 70 | throw new Error(`Unable to open new connection for ${mobileUid}, please close previous connection`) 71 | } 72 | 73 | break 74 | } 75 | case DeviceMultipleConnectionPolicy.FORBID_CLOSE_PREVIOUS_CONNECTION: { 76 | if (subscriptions.length > 0) { 77 | throw new BadRequestError( 78 | 'Unable to open new connection as existed conne', 79 | { subscriptions: subscriptions.map((sub) => sub.streamId) }, 80 | ErrorCode.SubscriptionsExists, 81 | ) 82 | } 83 | 84 | break 85 | } 86 | case DeviceMultipleConnectionPolicy.ALLOW_MULTIPLE_CONNECTIONS: { 87 | break 88 | } 89 | default: { 90 | const unhandledPolicy: never = this.deviceMultipleConnectionPolicy 91 | 92 | throw new TypeError(`Unhandled deviceMultipleConnectionPolicy: ${unhandledPolicy}`) 93 | } 94 | } 95 | 96 | subscriptions.push({ handler, streamId }) 97 | 98 | this.deviceSubscriptions.set(mobileUid, subscriptions) 99 | } 100 | 101 | unsubscribeChannel(streamKey: StreamKey): void { 102 | const { mobileUid, streamId } = streamKey 103 | 104 | const subscriptions = this.deviceSubscriptions.get(mobileUid) 105 | 106 | if (subscriptions) { 107 | const indexOfStreamToRemove = subscriptions.findIndex((sub) => sub.streamId === streamId) 108 | if (indexOfStreamToRemove !== -1) { 109 | subscriptions.splice(indexOfStreamToRemove, 1) 110 | this.deviceSubscriptions.set(mobileUid, subscriptions) 111 | } 112 | } 113 | } 114 | 115 | protected publishToChannel(mobileUid: string, data: GenericObject): void { 116 | const subscriptions = this.deviceSubscriptions.get(mobileUid) 117 | 118 | if (subscriptions) { 119 | for (const subscription of subscriptions) { 120 | subscription.handler(data) 121 | } 122 | } 123 | } 124 | 125 | protected broadcast(data: GenericObject): void { 126 | for (const [, subscriptions] of this.deviceSubscriptions) { 127 | for (const subscription of subscriptions) { 128 | subscription.handler(data) 129 | } 130 | } 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /src/tracing/index.ts: -------------------------------------------------------------------------------- 1 | import { DiagConsoleLogger, DiagLogLevel, Span, diag } from '@opentelemetry/api' 2 | import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node' 3 | import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-grpc' 4 | import { registerInstrumentations } from '@opentelemetry/instrumentation' 5 | import type { ConsumeEndInfo, ConsumeInfo, PublishConfirmedInfo, PublishInfo } from '@opentelemetry/instrumentation-amqplib' 6 | import type { IgnoreIncomingRequestFunction } from '@opentelemetry/instrumentation-http' 7 | import { Resource } from '@opentelemetry/resources' 8 | import { BatchSpanProcessor, ConsoleSpanExporter, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base' 9 | import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node' 10 | import { SEMRESATTRS_SERVICE_NAME } from '@opentelemetry/semantic-conventions' 11 | import { merge } from 'lodash' 12 | 13 | import { OpentelemetryTracingConfig, SEMATTRS_MESSAGING_RABBITMQ_ATTRIBUTES } from '../interfaces/tracing' 14 | 15 | export function getIgnoreIncomingRequestHook(paths: string[] = []): IgnoreIncomingRequestFunction { 16 | const ignoreIncomingPaths = new Set(['/metrics', '/ready', '/start', '/live'].concat(paths)) 17 | 18 | return ({ url }) => { 19 | if (!url) { 20 | return false 21 | } 22 | 23 | return ignoreIncomingPaths.has(url) 24 | } 25 | } 26 | 27 | const defaultConfig: OpentelemetryTracingConfig = { 28 | enabled: process.env.TRACING_ENABLED ? process.env.TRACING_ENABLED === 'true' : false, 29 | instrumentations: { 30 | '@opentelemetry/instrumentation-fs': { enabled: false }, 31 | '@opentelemetry/instrumentation-http': { ignoreIncomingRequestHook: getIgnoreIncomingRequestHook() }, 32 | '@opentelemetry/instrumentation-amqplib': { 33 | publishHook: (span: Span, publishInfo: PublishInfo) => { 34 | if (publishInfo.exchange.includes('reply-to') || publishInfo.routingKey.includes('reply-to')) { 35 | span.updateName('amq.rabbitmq.reply-to') 36 | span.setAttributes({ 37 | originalExchange: publishInfo.exchange, 38 | originalRoutingKey: publishInfo.routingKey, 39 | }) 40 | } 41 | }, 42 | publishConfirmHook: (span: Span, publishConfirmedInto: PublishConfirmedInfo) => { 43 | if (publishConfirmedInto.exchange.includes('reply-to') || publishConfirmedInto.routingKey.includes('reply-to')) { 44 | span.updateName('amq.rabbitmq.reply-to') 45 | span.setAttributes({ 46 | originalExchange: publishConfirmedInto.exchange, 47 | originalRoutingKey: publishConfirmedInto.routingKey, 48 | }) 49 | } 50 | }, 51 | consumeHook: (span: Span, consumeInfo: ConsumeInfo) => { 52 | for (const [key, value] of Object.entries(consumeInfo.msg.properties)) { 53 | span.setAttribute(`${SEMATTRS_MESSAGING_RABBITMQ_ATTRIBUTES}.${key}`, value) 54 | } 55 | 56 | if (consumeInfo.msg.fields.exchange.includes('reply-to') || consumeInfo.msg.fields.routingKey.includes('reply-to')) { 57 | span.updateName('amq.rabbitmq.reply-to') 58 | span.setAttributes({ 59 | originalExchange: consumeInfo.msg.fields.exchange, 60 | originalRoutingKey: consumeInfo.msg.fields.routingKey, 61 | }) 62 | } 63 | }, 64 | consumeEndHook: (span: Span, consumeEndInfo: ConsumeEndInfo) => { 65 | if (consumeEndInfo.msg.fields.exchange.includes('reply-to') || consumeEndInfo.msg.fields.routingKey.includes('reply-to')) { 66 | span.updateName('amq.rabbitmq.reply-to') 67 | span.setAttributes({ 68 | originalExchange: consumeEndInfo.msg.fields.exchange, 69 | originalRoutingKey: consumeEndInfo.msg.fields.routingKey, 70 | }) 71 | } 72 | }, 73 | }, 74 | }, 75 | debug: process.env.TRACING_DEBUG_ENABLED ? process.env.TRACING_DEBUG_ENABLED === 'true' : false, 76 | exporter: { 77 | url: process.env.TRACING_EXPORTER_URL || 'http://opentelemetry-collector.tracing.svc.cluster.local:4317', 78 | }, 79 | } 80 | 81 | export function initTracing(serviceName: string, override?: OpentelemetryTracingConfig): void { 82 | const config = { 83 | ...defaultConfig, 84 | ...override, 85 | instrumentations: merge(defaultConfig.instrumentations, override?.instrumentations), 86 | } 87 | 88 | const instrumentations = getNodeAutoInstrumentations(config.instrumentations) 89 | 90 | registerInstrumentations({ 91 | instrumentations: [instrumentations], 92 | }) 93 | 94 | if (config.debug) { 95 | diag.setLogger(new DiagConsoleLogger(), DiagLogLevel.VERBOSE) 96 | } 97 | 98 | const resource = Resource.default().merge( 99 | new Resource({ 100 | [SEMRESATTRS_SERVICE_NAME]: serviceName, 101 | }), 102 | ) 103 | 104 | const provider = new NodeTracerProvider({ resource }) 105 | 106 | if (config.enabled) { 107 | const exporter = new OTLPTraceExporter(config.exporter) 108 | 109 | provider.addSpanProcessor(new BatchSpanProcessor(exporter)) 110 | } 111 | 112 | if (config.debug) { 113 | provider.addSpanProcessor(new SimpleSpanProcessor(new ConsoleSpanExporter())) 114 | } 115 | 116 | provider.register() 117 | } 118 | -------------------------------------------------------------------------------- /src/baseDeps.ts: -------------------------------------------------------------------------------- 1 | import { NameAndRegistrationPair, asClass, asValue } from 'awilix' 2 | 3 | import { MetricsService } from '@diia-inhouse/diia-metrics' 4 | import type { Queue as QueueType } from '@diia-inhouse/diia-queue' 5 | import { AppValidator } from '@diia-inhouse/validators' 6 | 7 | import { ActionExecutor } from './actionExecutor' 8 | import { GrpcClientFactory, GrpcService } from './grpc' 9 | import { BaseConfig } from './interfaces' 10 | import { BaseDeps } from './interfaces/deps' 11 | import MoleculerService from './moleculer/moleculerWrapper' 12 | 13 | export async function getBaseDeps( 14 | config: TConfig, 15 | ): Promise>> { 16 | const { isMoleculerEnabled, healthCheck: healthCheckConfig, store, redis, rabbit, db, auth, identifier, metrics, featureFlags } = config 17 | const baseDeps: NameAndRegistrationPair> = { 18 | config: asValue(config), 19 | validator: asClass(AppValidator).singleton(), 20 | actionExecutor: asClass(ActionExecutor).singleton(), 21 | metrics: asClass(MetricsService, { 22 | injector: () => ({ metricsConfig: metrics?.custom || {}, isMoleculerEnabled }), 23 | }).singleton(), 24 | grpcClientFactory: asClass(GrpcClientFactory).singleton(), 25 | grpcService: asClass(GrpcService).singleton(), 26 | } 27 | if (healthCheckConfig) { 28 | const { HealthCheck } = await import('@diia-inhouse/healthcheck') 29 | 30 | baseDeps.healthCheck = asClass(HealthCheck, { 31 | injector: (c) => ({ container: c.cradle, healthCheckConfig }), 32 | }).singleton() 33 | } 34 | 35 | if (isMoleculerEnabled) { 36 | baseDeps.moleculer = asClass(MoleculerService).singleton() 37 | } 38 | 39 | if (store) { 40 | const { StoreService, RedlockService } = await import('@diia-inhouse/redis') 41 | 42 | baseDeps.store = asClass(StoreService, { injector: () => ({ storeConfig: store }) }).singleton() 43 | baseDeps.redlock = asClass(RedlockService, { 44 | injector: () => ({ storeConfig: store }), 45 | }).singleton() 46 | } 47 | 48 | if (redis) { 49 | const { CacheService, PubSubService } = await import('@diia-inhouse/redis') 50 | 51 | baseDeps.cache = asClass(CacheService, { injector: () => ({ redisConfig: redis }) }).singleton() 52 | baseDeps.pubsub = asClass(PubSubService, { injector: () => ({ redisConfig: redis }) }).singleton() 53 | } 54 | 55 | if (rabbit) { 56 | const { 57 | ExternalCommunicator, 58 | ExternalCommunicatorChannel, 59 | ExternalEventBus, 60 | ScheduledTask, 61 | Queue, 62 | EventMessageHandler, 63 | EventMessageValidator, 64 | EventBus, 65 | Task, 66 | } = await import('@diia-inhouse/diia-queue') 67 | 68 | baseDeps.queue = asClass(Queue, { injector: () => ({ connectionConfig: rabbit }) }).singleton() 69 | baseDeps.eventMessageHandler = asClass(EventMessageHandler).singleton() 70 | baseDeps.eventMessageValidator = asClass(EventMessageValidator).singleton() 71 | baseDeps.externalChannel = asClass(ExternalCommunicatorChannel).singleton() 72 | const { internal: internalQueueConfig, external: externalQueueConfig } = rabbit 73 | if (internalQueueConfig) { 74 | baseDeps.task = asClass(Task, { 75 | injector: (c) => ({ queueProvider: c.resolve('queue').getInternalQueue() }), 76 | }).singleton() 77 | 78 | if (internalQueueConfig.scheduledTaskQueueName) { 79 | baseDeps.scheduledTask = asClass(ScheduledTask, { 80 | injector: (c) => ({ 81 | queueProvider: c.resolve('queue').getInternalQueue(), 82 | queueName: internalQueueConfig.scheduledTaskQueueName, 83 | }), 84 | }).singleton() 85 | } 86 | 87 | if (internalQueueConfig.queueName) { 88 | baseDeps.eventBus = asClass(EventBus, { 89 | injector: (c) => ({ 90 | queueProvider: c.resolve('queue').getInternalQueue(), 91 | queueName: internalQueueConfig.queueName, 92 | }), 93 | }).singleton() 94 | } 95 | } 96 | 97 | if (externalQueueConfig) { 98 | baseDeps.externalEventBus = asClass(ExternalEventBus, { 99 | injector: (c) => ({ queueProvider: c.resolve('queue').getExternalQueue() }), 100 | }).singleton() 101 | baseDeps.external = asClass(ExternalCommunicator).singleton() 102 | } 103 | } 104 | 105 | if (db) { 106 | const { DatabaseService, DbType } = await import('@diia-inhouse/db') 107 | 108 | baseDeps.database = asClass(DatabaseService, { 109 | injector: () => ({ dbConfigs: { [DbType.Main]: db } }), 110 | }).singleton() 111 | } 112 | 113 | if (auth) { 114 | const { AuthService } = await import('@diia-inhouse/crypto') 115 | 116 | baseDeps.auth = asClass(AuthService, { injector: () => ({ authConfig: auth }) }).singleton() 117 | } 118 | 119 | if (identifier) { 120 | const { IdentifierService } = await import('@diia-inhouse/crypto') 121 | 122 | baseDeps.identifier = asClass(IdentifierService, { 123 | injector: () => ({ identifierConfig: identifier }), 124 | }).singleton() 125 | } 126 | 127 | if (featureFlags) { 128 | const { FeatureService } = await import('@diia-inhouse/features') 129 | 130 | baseDeps.featureFlag = asClass(FeatureService, { 131 | injector: () => ({ featureConfig: featureFlags }), 132 | }).singleton() 133 | } 134 | 135 | return baseDeps 136 | } 137 | -------------------------------------------------------------------------------- /tests/unit/plugins/openapi/actionVisitor.spec.ts: -------------------------------------------------------------------------------- 1 | import ts from 'typescript' 2 | 3 | import { ACTION_RESPONSE } from '../../../../src' 4 | import ActionVisitor from '../../../../src/plugins/openapi/actionVisitor' 5 | 6 | jest.mock('../../../../src/plugins/openapi/parser') 7 | jest.mock('typescript', () => { 8 | const mocked = >jest.createMockFromModule('typescript') 9 | 10 | return { 11 | ...mocked, 12 | visitEachChild: jest.fn(), 13 | getModifiers: jest.fn(), 14 | isClassDeclaration: (): boolean => true, 15 | isMethodDeclaration: (): boolean => true, 16 | visitNode: (node: unknown, visitClassNode: (node: unknown) => void): void => { 17 | visitClassNode(node) 18 | }, 19 | } 20 | }) 21 | 22 | const programMock = { 23 | getTypeChecker(): { 24 | getSignatureFromDeclaration: () => boolean 25 | getReturnTypeOfSignature: () => { symbol: { getName: () => string } } 26 | } { 27 | return { 28 | getSignatureFromDeclaration(): boolean { 29 | return true 30 | }, 31 | getReturnTypeOfSignature(): { symbol: { getName: () => string } } { 32 | return { 33 | symbol: { 34 | getName(): string { 35 | return 'name' 36 | }, 37 | }, 38 | } 39 | }, 40 | } 41 | }, 42 | } 43 | const transformationContextMock = { 44 | factory: { 45 | createIdentifier: jest.fn(), 46 | createPropertyDeclaration: jest.fn(), 47 | updateClassDeclaration: jest.fn(), 48 | }, 49 | } 50 | 51 | describe(`OpenApi ActionVisitor`, () => { 52 | describe(`method ${ActionVisitor.visit.name}`, () => { 53 | const nodeWithHandler = { 54 | members: [ 55 | { 56 | name: { 57 | getText(): string { 58 | return 'handler' 59 | }, 60 | }, 61 | }, 62 | ], 63 | } 64 | 65 | it('should update class declaration', () => { 66 | ActionVisitor.visit( 67 | (nodeWithHandler), 68 | (transformationContextMock), 69 | (programMock), 70 | ) 71 | 72 | expect(transformationContextMock.factory.createIdentifier).toHaveBeenCalledWith(ACTION_RESPONSE) 73 | expect(transformationContextMock.factory.createPropertyDeclaration).toHaveBeenCalled() 74 | expect(transformationContextMock.factory.updateClassDeclaration).toHaveBeenCalled() 75 | }) 76 | 77 | it('should visit each child if handler method was not found', () => { 78 | const node = { 79 | members: [ 80 | { 81 | name: { 82 | getText(): string { 83 | return 'name' 84 | }, 85 | }, 86 | }, 87 | ], 88 | } 89 | 90 | ActionVisitor.visit( 91 | (node), 92 | (transformationContextMock), 93 | (programMock), 94 | ) 95 | 96 | expect(ts.visitEachChild).toHaveBeenCalled() 97 | }) 98 | 99 | it('should visit each child if signature was not found', () => { 100 | jest.spyOn(programMock, 'getTypeChecker').mockReturnValueOnce({ 101 | getSignatureFromDeclaration(): boolean { 102 | return false 103 | }, 104 | getReturnTypeOfSignature(): { symbol: { getName: () => string } } { 105 | return { 106 | symbol: { 107 | getName(): string { 108 | return 'name' 109 | }, 110 | }, 111 | } 112 | }, 113 | }) 114 | 115 | ActionVisitor.visit( 116 | (nodeWithHandler), 117 | (transformationContextMock), 118 | (programMock), 119 | ) 120 | 121 | expect(ts.visitEachChild).toHaveBeenCalled() 122 | }) 123 | 124 | it('should visit each child if response type arguments not found', () => { 125 | jest.spyOn(programMock, 'getTypeChecker').mockReturnValueOnce({ 126 | getSignatureFromDeclaration(): boolean { 127 | return true 128 | }, 129 | getReturnTypeOfSignature(): { symbol: { getName: () => string }; typeArguments: unknown[] } { 130 | return { 131 | symbol: { 132 | getName(): string { 133 | return 'Promise' 134 | }, 135 | }, 136 | typeArguments: [], 137 | } 138 | }, 139 | }) 140 | 141 | ActionVisitor.visit( 142 | (nodeWithHandler), 143 | (transformationContextMock), 144 | (programMock), 145 | ) 146 | 147 | expect(ts.visitEachChild).toHaveBeenCalled() 148 | }) 149 | 150 | it('should visit each child if node is not a class declaration', () => { 151 | jest.spyOn(ts, 'isClassDeclaration').mockReturnValueOnce(false) 152 | 153 | ActionVisitor.visit( 154 | (nodeWithHandler), 155 | (transformationContextMock), 156 | (programMock), 157 | ) 158 | 159 | expect(ts.visitEachChild).toHaveBeenCalled() 160 | }) 161 | }) 162 | }) 163 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@diia-inhouse/diia-app", 3 | "version": "18.2.0", 4 | "description": "Application package with IoC container", 5 | "main": "dist/index.js", 6 | "types": "dist/types/index.d.ts", 7 | "repository": "https://github.com/diia-open-source/diia-app.git", 8 | "author": "Diia", 9 | "license": "SEE LICENSE IN LICENSE.md", 10 | "files": [ 11 | "dist", 12 | "src" 13 | ], 14 | "engines": { 15 | "node": ">=20" 16 | }, 17 | "scripts": { 18 | "prebuild": "rimraf dist", 19 | "build": "tsc", 20 | "prepare": "ts-patch install -s && npm run build", 21 | "genproto": "npm run genproto-test", 22 | "semantic-release": "semantic-release", 23 | "start": "npm run build && node dist/index.js", 24 | "lint": "eslint --ext .ts . && prettier --check .", 25 | "lint-fix": "eslint '*/**/*.{js,ts}' --fix && prettier --write .", 26 | "lint:lockfile": "lockfile-lint --path package-lock.json --allowed-hosts registry.npmjs.org --validate-https", 27 | "pretest": "npm run genproto-test && npm run build-test", 28 | "test": "NODE_OPTIONS=\"$NODE_OPTIONS --experimental-vm-modules\" jest", 29 | "test:unit": "npm run test --selectProjects unit --", 30 | "test:integration": "npm run test --selectProjects integration --", 31 | "test:coverage": "npm run test --coverage", 32 | "find-circulars": "madge --circular --extensions ts ./", 33 | "genproto-test": "genproto --rootDir tests/integration/proto --outputDir tests/integration/generated --generateClient=true", 34 | "build-test": "rimraf tests/dist && npm run build -- --project tests/tsconfig.json --noEmit false --outDir tests/dist" 35 | }, 36 | "dependencies": { 37 | "@grpc/grpc-js": "1.10.7", 38 | "@grpc/proto-loader": "0.7.13", 39 | "@grpc/reflection": "1.0.4", 40 | "@opentelemetry/api": "1.9.0", 41 | "@opentelemetry/auto-instrumentations-node": "0.47.1", 42 | "@opentelemetry/core": "1.25.0", 43 | "@opentelemetry/exporter-trace-otlp-grpc": "0.52.0", 44 | "@opentelemetry/instrumentation": "0.52.0", 45 | "@opentelemetry/resources": "1.25.0", 46 | "@opentelemetry/sdk-trace-base": "1.25.0", 47 | "@opentelemetry/sdk-trace-node": "1.25.0", 48 | "@opentelemetry/semantic-conventions": "1.25.0", 49 | "@types/dotenv-flow": "3.3.3", 50 | "awilix": "10.0.2", 51 | "cookie-parser": "1.4.6", 52 | "dotenv-flow": "3.3.0", 53 | "glob": "11.0.0", 54 | "lodash": "4.17.21", 55 | "moleculer": "0.14.33", 56 | "moleculer-web": "0.10.7", 57 | "nats": "2.26.0", 58 | "nice-grpc": "2.1.8", 59 | "nice-grpc-client-middleware-deadline": "2.0.11", 60 | "pluralize": "8.0.0", 61 | "protobufjs": "^7.2.5", 62 | "ts-patch": "2.1.0" 63 | }, 64 | "peerDependencies": { 65 | "@diia-inhouse/crypto": ">=1.8.1", 66 | "@diia-inhouse/db": ">=4.8.0", 67 | "@diia-inhouse/diia-logger": ">=3.0.0", 68 | "@diia-inhouse/diia-metrics": ">=3.12.0", 69 | "@diia-inhouse/diia-queue": ">=6.3.2", 70 | "@diia-inhouse/env": ">=1.10.2", 71 | "@diia-inhouse/errors": ">=1.7.0", 72 | "@diia-inhouse/features": ">=2.1.0", 73 | "@diia-inhouse/healthcheck": ">=1.7.0", 74 | "@diia-inhouse/redis": ">=2.2.1", 75 | "@diia-inhouse/types": ">=6.30.0", 76 | "@diia-inhouse/utils": ">=2.29.0", 77 | "@diia-inhouse/validators": ">=1.5.0" 78 | }, 79 | "peerDependenciesMeta": { 80 | "@diia-inhouse/diia-logger": { 81 | "optional": true 82 | }, 83 | "@diia-inhouse/redis": { 84 | "optional": true 85 | }, 86 | "@diia-inhouse/diia-queue": { 87 | "optional": true 88 | }, 89 | "@diia-inhouse/healthcheck": { 90 | "optional": true 91 | }, 92 | "@diia-inhouse/db": { 93 | "optional": true 94 | }, 95 | "@diia-inhouse/crypto": { 96 | "optional": true 97 | }, 98 | "@diia-inhouse/features": { 99 | "optional": true 100 | } 101 | }, 102 | "devDependencies": { 103 | "@diia-inhouse/configs": "2.2.0", 104 | "@diia-inhouse/crypto": "1.11.1", 105 | "@diia-inhouse/db": "4.13.1", 106 | "@diia-inhouse/diia-logger": "3.3.0", 107 | "@diia-inhouse/diia-metrics": "3.12.0", 108 | "@diia-inhouse/diia-queue": "8.0.0", 109 | "@diia-inhouse/env": "1.16.0", 110 | "@diia-inhouse/errors": "1.10.0", 111 | "@diia-inhouse/eslint-config": "5.1.0", 112 | "@diia-inhouse/eslint-plugin": "1.6.0", 113 | "@diia-inhouse/features": "2.3.1", 114 | "@diia-inhouse/genproto": "2.0.1", 115 | "@diia-inhouse/healthcheck": "1.12.0", 116 | "@diia-inhouse/redis": "2.13.1", 117 | "@diia-inhouse/test": "6.4.0", 118 | "@diia-inhouse/types": "6.51.0", 119 | "@diia-inhouse/utils": "4.0.0", 120 | "@diia-inhouse/validators": "1.17.0", 121 | "@opentelemetry/instrumentation-amqplib": "0.38.0", 122 | "@opentelemetry/instrumentation-http": "0.52.0", 123 | "@opentelemetry/otlp-grpc-exporter-base": "0.52.0", 124 | "@types/cookie-parser": "1.4.7", 125 | "@types/lodash": "4.17.7", 126 | "@types/node": "22.0.3", 127 | "@types/pluralize": "0.0.33", 128 | "eslint": "8.57.0", 129 | "lockfile-lint": "4.14.0", 130 | "madge": "7.0.0", 131 | "prettier": "3.3.3", 132 | "type-fest": "4.8.2", 133 | "typescript": "5.4.5" 134 | }, 135 | "jest": { 136 | "preset": "@diia-inhouse/configs/dist/jest" 137 | }, 138 | "release": { 139 | "extends": "@diia-inhouse/configs/dist/semantic-release/package" 140 | }, 141 | "commitlint": { 142 | "extends": "@diia-inhouse/configs/dist/commitlint" 143 | }, 144 | "eslintConfig": { 145 | "extends": "@diia-inhouse/eslint-config", 146 | "overrides": [ 147 | { 148 | "files": [ 149 | "*.ts" 150 | ], 151 | "parserOptions": { 152 | "project": [ 153 | "./tsconfig.json", 154 | "./tests/tsconfig.json" 155 | ] 156 | } 157 | } 158 | ] 159 | }, 160 | "prettier": "@diia-inhouse/eslint-config/prettier", 161 | "madge": { 162 | "tsConfig": "./tsconfig.json" 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /tests/unit/moleculer/moleculerWrapper.spec.ts: -------------------------------------------------------------------------------- 1 | import { AsyncLocalStorage } from 'node:async_hooks' 2 | 3 | import { Service } from 'moleculer' 4 | 5 | import Logger from '@diia-inhouse/diia-logger' 6 | import { MetricsService } from '@diia-inhouse/diia-metrics' 7 | import { EnvService } from '@diia-inhouse/env' 8 | import { mockInstance } from '@diia-inhouse/test' 9 | import { ActionVersion, AlsData } from '@diia-inhouse/types' 10 | 11 | import { ActionExecutor, AppApiService, ConfigFactoryFn, MoleculerService } from '../../../src' 12 | import { appAction, appApiService } from '../../mocks' 13 | import { configFactory } from '../config' 14 | 15 | describe(`${MoleculerService.name}`, () => { 16 | const serviceName = 'Auth' 17 | const logger = mockInstance(Logger) 18 | const envService = new EnvService(logger) 19 | const actionExecutor = mockInstance(ActionExecutor) 20 | const asyncLocalStorage = mockInstance(AsyncLocalStorage) 21 | const metrics = mockInstance(MetricsService, { 22 | totalRequestMetric: { 23 | increment: jest.fn(), 24 | }, 25 | totalTimerMetric: { 26 | observeSeconds: jest.fn(), 27 | }, 28 | }) 29 | let cfg: Awaited> 30 | 31 | beforeAll(async () => { 32 | cfg = await configFactory(envService, serviceName) 33 | }) 34 | 35 | describe(`method ${MoleculerService.prototype.onInit.name}`, () => { 36 | it('should successfully init moleculer and start service', async () => { 37 | const moleculerService = new MoleculerService( 38 | serviceName, 39 | actionExecutor, 40 | [appAction], 41 | cfg, 42 | asyncLocalStorage, 43 | logger, 44 | envService, 45 | metrics, 46 | {}, 47 | appApiService, 48 | ) 49 | 50 | jest.spyOn(moleculerService.serviceBroker, 'createService').mockReturnValue({}) 51 | jest.spyOn(moleculerService.serviceBroker, 'start').mockResolvedValue() 52 | 53 | await moleculerService.onInit() 54 | 55 | expect(moleculerService.serviceBroker.createService).toHaveBeenCalled() 56 | expect(moleculerService.serviceBroker.start).toHaveBeenCalled() 57 | }) 58 | 59 | it('should create service broker with default registry options', async () => { 60 | const { balancing, ...configWithoutBalancing } = await configFactory(envService, serviceName) 61 | const moleculerService = new MoleculerService( 62 | serviceName, 63 | actionExecutor, 64 | [appAction], 65 | configWithoutBalancing, 66 | asyncLocalStorage, 67 | logger, 68 | envService, 69 | metrics, 70 | {}, 71 | appApiService, 72 | ) 73 | 74 | expect(moleculerService.serviceBroker.options.registry).toEqual({ 75 | preferLocal: true, 76 | stopDelay: 100, 77 | strategy: 'RoundRobin', 78 | strategyOptions: {}, 79 | }) 80 | }) 81 | 82 | it('should create service broker with specified in env tracking options', () => { 83 | process.env.CONTEXT_TRACKING_ENABLED = 'false' 84 | process.env.CONTEXT_TRACKING_TIMEOUT = '54321' 85 | 86 | const moleculerService = new MoleculerService( 87 | serviceName, 88 | actionExecutor, 89 | [appAction], 90 | cfg, 91 | asyncLocalStorage, 92 | logger, 93 | envService, 94 | metrics, 95 | {}, 96 | appApiService, 97 | ) 98 | 99 | expect(moleculerService.serviceBroker.options.tracking).toEqual({ 100 | enabled: false, 101 | shutdownTimeout: 54321, 102 | }) 103 | }) 104 | }) 105 | 106 | describe(`method ${MoleculerService.prototype.onDestroy.name}`, () => { 107 | it('should successfully destroy moleculer and stop service', async () => { 108 | const moleculerService = new MoleculerService( 109 | serviceName, 110 | actionExecutor, 111 | [appAction], 112 | { ...cfg, cors: undefined }, 113 | asyncLocalStorage, 114 | logger, 115 | envService, 116 | metrics, 117 | {}, 118 | {}, 119 | ) 120 | 121 | jest.spyOn(moleculerService.serviceBroker, 'createService').mockReturnValue({}) 122 | jest.spyOn(moleculerService.serviceBroker, 'start').mockResolvedValue() 123 | jest.spyOn(moleculerService.serviceBroker, 'stop').mockResolvedValue() 124 | 125 | await moleculerService.onInit() 126 | await moleculerService.onDestroy() 127 | 128 | expect(moleculerService.serviceBroker.stop).toHaveBeenCalled() 129 | }) 130 | }) 131 | 132 | describe(`method ${MoleculerService.prototype.act.name}`, () => { 133 | it('should successfully call broker action', async () => { 134 | const expectedResult: string[] = [] 135 | const moleculerService = new MoleculerService( 136 | serviceName, 137 | actionExecutor, 138 | [appAction], 139 | cfg, 140 | asyncLocalStorage, 141 | logger, 142 | envService, 143 | metrics, 144 | {}, 145 | {}, 146 | ) 147 | 148 | jest.spyOn(moleculerService.serviceBroker, 'call').mockResolvedValue(expectedResult) 149 | 150 | expect(await moleculerService.act(serviceName, { name: 'auth', actionVersion: ActionVersion.V1 })).toEqual(expectedResult) 151 | expect(logger.info).toHaveBeenCalled() 152 | }) 153 | 154 | it('should fail to call broker action', async () => { 155 | const expectedError = new Error('Unable to execute action') 156 | const moleculerService = new MoleculerService( 157 | serviceName, 158 | actionExecutor, 159 | [appAction], 160 | cfg, 161 | asyncLocalStorage, 162 | logger, 163 | envService, 164 | metrics, 165 | ) 166 | 167 | jest.spyOn(moleculerService.serviceBroker, 'call').mockRejectedValue(expectedError) 168 | 169 | await expect(async () => { 170 | await moleculerService.act(serviceName, { name: 'auth', actionVersion: ActionVersion.V1 }) 171 | }).rejects.toEqual(expectedError) 172 | }) 173 | }) 174 | 175 | describe(`method ${MoleculerService.prototype.tryToAct.name}`, () => { 176 | it('should successfully call broker action', async () => { 177 | const expectedResult: string[] = [] 178 | const moleculerService = new MoleculerService( 179 | serviceName, 180 | actionExecutor, 181 | [appAction], 182 | cfg, 183 | asyncLocalStorage, 184 | logger, 185 | envService, 186 | metrics, 187 | {}, 188 | {}, 189 | ) 190 | 191 | jest.spyOn(moleculerService.serviceBroker, 'call').mockResolvedValue(expectedResult) 192 | 193 | expect(await moleculerService.tryToAct(serviceName, { name: 'auth', actionVersion: ActionVersion.V1 })).toEqual(expectedResult) 194 | expect(logger.info).toHaveBeenCalled() 195 | }) 196 | 197 | it('should not fail to call broker action', async () => { 198 | const expectedError = new Error('Unable to execute action') 199 | const moleculerService = new MoleculerService( 200 | serviceName, 201 | actionExecutor, 202 | [appAction], 203 | cfg, 204 | asyncLocalStorage, 205 | logger, 206 | envService, 207 | metrics, 208 | ) 209 | 210 | jest.spyOn(moleculerService.serviceBroker, 'call').mockRejectedValue(expectedError) 211 | 212 | expect(await moleculerService.tryToAct(serviceName, { name: 'auth', actionVersion: ActionVersion.V1 })).toBeUndefined() 213 | }) 214 | }) 215 | }) 216 | -------------------------------------------------------------------------------- /src/grpc/grpcClient.ts: -------------------------------------------------------------------------------- 1 | import { AsyncLocalStorage } from 'node:async_hooks' 2 | 3 | import { SpanKind, SpanStatusCode, context, propagation, trace } from '@opentelemetry/api' 4 | import { SEMATTRS_MESSAGING_DESTINATION, SEMATTRS_MESSAGING_SYSTEM, SEMATTRS_RPC_SYSTEM } from '@opentelemetry/semantic-conventions' 5 | import { 6 | ChannelCredentials, 7 | ChannelOptions, 8 | Client, 9 | ClientMiddlewareCall, 10 | Metadata, 11 | TsProtoServiceDefinition, 12 | createChannel, 13 | createClientFactory, 14 | } from 'nice-grpc' 15 | import { deadlineMiddleware } from 'nice-grpc-client-middleware-deadline' 16 | import protobuf from 'protobufjs' 17 | 18 | import { MetricsService, RequestMechanism, RequestStatus } from '@diia-inhouse/diia-metrics' 19 | import { QueueContext } from '@diia-inhouse/diia-queue' 20 | import { LogData, Logger } from '@diia-inhouse/types' 21 | import { utils } from '@diia-inhouse/utils' 22 | 23 | import { CallOptions } from '../interfaces/grpc' 24 | 25 | import wrappers from './wrappers' 26 | 27 | export class GrpcClientFactory { 28 | constructor( 29 | private readonly serviceName: string, 30 | private readonly logger: Logger, 31 | private readonly metrics: MetricsService, 32 | 33 | private readonly asyncLocalStorage?: AsyncLocalStorage, 34 | ) { 35 | Object.assign(protobuf.wrappers, wrappers) 36 | } 37 | 38 | createGrpcClient( 39 | definition: Service, 40 | serviceAddress: string, 41 | channelOptions: ChannelOptions = {}, 42 | ): Client { 43 | const channelImplementation = createChannel(serviceAddress, ChannelCredentials.createInsecure(), channelOptions) 44 | 45 | return createClientFactory() 46 | .use(this.loggingMiddleware.bind(this)) 47 | .use(this.metadataMiddleware.bind(this)(definition.name)) 48 | .use(this.errorHandlerMiddleware.bind(this)) 49 | .use(deadlineMiddleware) 50 | .create(definition, channelImplementation) 51 | } 52 | 53 | private metadataMiddleware( 54 | destinationServiceName: string, 55 | ): ( 56 | call: ClientMiddlewareCall, 57 | options: CallOptions, 58 | ) => AsyncGenerator, void | Awaited, undefined> { 59 | // eslint-disable-next-line @typescript-eslint/no-this-alias, unicorn/no-this-assignment 60 | const self = this 61 | 62 | return async function* ( 63 | call: ClientMiddlewareCall, 64 | options: CallOptions, 65 | ): AsyncGenerator, void | Awaited, undefined> { 66 | const startTime = process.hrtime.bigint() 67 | const { path } = call.method 68 | 69 | const defaultLabels = { 70 | mechanism: RequestMechanism.Grpc, 71 | source: self.serviceName, 72 | destination: destinationServiceName, 73 | route: path, 74 | } 75 | 76 | const logData = self.asyncLocalStorage?.getStore()?.logData ?? {} 77 | 78 | const meta = options.metadata || new Metadata() 79 | 80 | for (const key in logData) { 81 | const value = logData[key] 82 | if (key !== 'actionVersion' && value && !meta.has(key)) { 83 | meta.set(key, value) 84 | } 85 | } 86 | 87 | const tracer = trace.getTracer(self.serviceName) 88 | const span = tracer.startSpan( 89 | `send ${path}`, 90 | { 91 | kind: SpanKind.CLIENT, 92 | attributes: { 93 | [SEMATTRS_MESSAGING_SYSTEM]: RequestMechanism.Grpc, 94 | [SEMATTRS_MESSAGING_DESTINATION]: destinationServiceName, 95 | [SEMATTRS_RPC_SYSTEM]: RequestMechanism.Grpc, 96 | }, 97 | }, 98 | context.active(), 99 | ) 100 | const tracing = <{ traceparent?: string; tracestate?: string }>{} 101 | 102 | propagation.inject(trace.setSpan(context.active(), span), tracing) 103 | meta.set('tracing', JSON.stringify(tracing)) 104 | 105 | if (tracing.traceparent) { 106 | meta.set('traceparent', tracing.traceparent) 107 | } 108 | 109 | if (tracing.tracestate) { 110 | meta.set('tracestate', tracing.tracestate) 111 | } 112 | 113 | for (const kv of meta) { 114 | const [key, value] = kv 115 | 116 | if (ArrayBuffer.isView(value)) { 117 | continue 118 | } 119 | 120 | try { 121 | if ((value).length > 1) { 122 | span.setAttribute(`rpc.grpc.request.metadata.${key}`, value) 123 | } else if ((value).length === 1) { 124 | span.setAttribute(`rpc.grpc.request.metadata.${key}`, (value)[0]) 125 | } 126 | } catch { 127 | // ignore result 128 | } 129 | } 130 | 131 | options.metadata = meta 132 | 133 | try { 134 | const grpcResult: void | Awaited = yield* call.next(call.request, options) 135 | 136 | self.metrics.totalTimerMetric.observeSeconds( 137 | { ...defaultLabels, status: RequestStatus.Successful }, 138 | process.hrtime.bigint() - startTime, 139 | ) 140 | span.setStatus({ code: SpanStatusCode.OK }) 141 | 142 | return grpcResult 143 | } catch (err) { 144 | utils.handleError(err, (apiError) => { 145 | self.metrics.totalTimerMetric.observeSeconds( 146 | { 147 | ...defaultLabels, 148 | status: RequestStatus.Failed, 149 | errorType: apiError.getType(), 150 | statusCode: apiError.getCode(), 151 | }, 152 | process.hrtime.bigint() - startTime, 153 | ) 154 | 155 | span.recordException({ 156 | message: apiError.getMessage(), 157 | code: apiError.getCode(), 158 | name: apiError.getName(), 159 | }) 160 | span.setStatus({ code: SpanStatusCode.ERROR, message: apiError.getMessage() }) 161 | }) 162 | 163 | throw err 164 | } finally { 165 | span.end() 166 | } 167 | } 168 | } 169 | 170 | private async *loggingMiddleware( 171 | call: ClientMiddlewareCall, 172 | options: CallOptions, 173 | ): AsyncGenerator, void | Awaited, undefined> { 174 | const { 175 | request, 176 | method: { path }, 177 | } = call 178 | 179 | this.logger.info(`ACT OUT: ${path}`, { transport: 'grpc', params: request }) 180 | 181 | try { 182 | const grpcResult = yield* call.next(request, options) 183 | 184 | this.logger.info(`ACT OUT RESULT: ${path}`, grpcResult) 185 | 186 | return grpcResult 187 | } catch (err) { 188 | utils.handleError(err, (apiError) => { 189 | this.logger.error(`ACT OUT FAILED: ${path}`, { err: apiError }) 190 | }) 191 | 192 | throw err 193 | } 194 | } 195 | 196 | private async *errorHandlerMiddleware( 197 | call: ClientMiddlewareCall, 198 | options: CallOptions, 199 | ): AsyncGenerator, void | Awaited, undefined> { 200 | const { request } = call 201 | 202 | let trailer: Metadata | undefined 203 | try { 204 | const grpcResult = yield* call.next(request, { 205 | ...options, 206 | onTrailer(receivedTrailer) { 207 | trailer = receivedTrailer 208 | 209 | options.onTrailer?.(trailer) 210 | }, 211 | }) 212 | 213 | return grpcResult 214 | } catch (err) { 215 | utils.handleError(err, (apiError) => { 216 | const processCodeRaw = trailer?.get('processcode') 217 | const processCode = processCodeRaw ? Number.parseInt(processCodeRaw, 10) : undefined 218 | if (processCode) { 219 | apiError.setProcessCode(processCode) 220 | } 221 | 222 | throw apiError 223 | }) 224 | } 225 | } 226 | } 227 | -------------------------------------------------------------------------------- /src/actionExecutor.ts: -------------------------------------------------------------------------------- 1 | import { AsyncLocalStorage } from 'node:async_hooks' 2 | 3 | import { SpanStatusCode, context, propagation, trace } from '@opentelemetry/api' 4 | import { SEMATTRS_MESSAGE_ID, SEMATTRS_MESSAGE_TYPE, SEMATTRS_MESSAGING_SYSTEM } from '@opentelemetry/semantic-conventions' 5 | 6 | import { MetricsService, RequestStatus } from '@diia-inhouse/diia-metrics' 7 | import { RedlockService } from '@diia-inhouse/redis' 8 | import { 9 | AcquirerSession, 10 | ActionArguments, 11 | AlsData, 12 | LogData, 13 | Logger, 14 | PartnerSession, 15 | ServiceEntranceSession, 16 | ServiceUserSession, 17 | SessionType, 18 | TemporarySession, 19 | UserSession, 20 | } from '@diia-inhouse/types' 21 | import { utils } from '@diia-inhouse/utils' 22 | import { AppValidator } from '@diia-inhouse/validators' 23 | 24 | import { ExecuteActionParams } from './interfaces/actionExecutor' 25 | 26 | export class ActionExecutor { 27 | constructor( 28 | private readonly asyncLocalStorage: AsyncLocalStorage, 29 | private readonly logger: Logger, 30 | private readonly validator: AppValidator, 31 | private readonly serviceName: string, 32 | private readonly metrics: MetricsService, 33 | private readonly redlock: RedlockService | null = null, 34 | ) {} 35 | 36 | private readonly actionLockTtl = 30000 37 | 38 | async execute(params: ExecuteActionParams): Promise { 39 | const { action, transport, caller, tracingMetadata, spanKind, actionArguments, serviceName = this.serviceName } = params 40 | 41 | const serviceActionName = `${serviceName}.${action.name}` 42 | const telemetryActiveContext = propagation.extract(context.active(), tracingMetadata) 43 | const tracer = trace.getTracer(serviceName) 44 | const span = tracer.startSpan( 45 | `handle ${serviceActionName}`, 46 | { 47 | kind: spanKind, 48 | attributes: { 49 | [SEMATTRS_MESSAGING_SYSTEM]: transport, 50 | ...(caller && { 'messaging.caller': caller }), 51 | }, 52 | }, 53 | telemetryActiveContext, 54 | ) 55 | 56 | const startTime = process.hrtime.bigint() 57 | const defaultLabels = { 58 | mechanism: transport, 59 | ...(caller && { source: caller }), 60 | destination: serviceName, 61 | route: action.name, 62 | } 63 | 64 | span.addEvent('message', { [SEMATTRS_MESSAGE_ID]: 1, [SEMATTRS_MESSAGE_TYPE]: 'RECEIVED' }) 65 | 66 | return await context.with(trace.setSpan(telemetryActiveContext, span), async () => { 67 | const logData = this.buildLogData(actionArguments) 68 | 69 | const alsData: AlsData = { 70 | logData: this.logger.prepareContext(logData), 71 | session: actionArguments.session, 72 | headers: actionArguments.headers, 73 | } 74 | 75 | return await this.asyncLocalStorage?.run(alsData, async () => { 76 | this.logger.info(`ACT IN: ${serviceActionName}`, { 77 | version: action.actionVersion, 78 | params: actionArguments, 79 | headers: actionArguments.headers, 80 | transport, 81 | }) 82 | 83 | const actionLockResource = action.getLockResource?.(actionArguments) 84 | let lock 85 | 86 | if (actionLockResource && this.redlock) { 87 | const lockResource = `${action.name}.${actionLockResource}` 88 | 89 | lock = await this.redlock.lock(lockResource, this.actionLockTtl) 90 | } 91 | 92 | try { 93 | this.validator.validate(actionArguments, { params: { type: 'object', props: action.validationRules } }) 94 | 95 | if (actionArguments.headers) { 96 | actionArguments.headers.serviceCode = action.getServiceCode?.(actionArguments) 97 | } 98 | 99 | const res = await action.handler(actionArguments) 100 | 101 | this.logger.info(`ACT IN RESULT: ${serviceActionName}`, res) 102 | this.metrics.responseTotalTimerMetric.observeSeconds( 103 | { ...defaultLabels, status: RequestStatus.Successful }, 104 | process.hrtime.bigint() - startTime, 105 | ) 106 | span.setStatus({ code: SpanStatusCode.OK }) 107 | span.addEvent('message', { [SEMATTRS_MESSAGE_ID]: 2, [SEMATTRS_MESSAGE_TYPE]: 'SENT' }) 108 | span.end() 109 | 110 | return res 111 | } catch (err) { 112 | this.logger.error(`ACT IN FAILED: ${serviceActionName}`, { 113 | err, 114 | version: action.actionVersion, 115 | params: actionArguments, 116 | }) 117 | 118 | utils.handleError(err, (apiErr) => { 119 | this.metrics.responseTotalTimerMetric.observeSeconds( 120 | { 121 | ...defaultLabels, 122 | status: RequestStatus.Failed, 123 | errorType: apiErr.getType(), 124 | statusCode: apiErr.getCode(), 125 | }, 126 | process.hrtime.bigint() - startTime, 127 | ) 128 | 129 | span.recordException({ 130 | message: apiErr.getMessage(), 131 | code: apiErr.getCode(), 132 | name: apiErr.getName(), 133 | }) 134 | span.setStatus({ code: SpanStatusCode.ERROR, message: apiErr.getMessage() }) 135 | }) 136 | span.addEvent('message', { [SEMATTRS_MESSAGE_ID]: 2, [SEMATTRS_MESSAGE_TYPE]: 'SENT' }) 137 | span.end() 138 | throw err 139 | } finally { 140 | await lock?.release() 141 | } 142 | }) 143 | }) 144 | } 145 | 146 | private buildLogData(actionArguments: ActionArguments): LogData { 147 | const session = 'session' in actionArguments ? actionArguments.session : null 148 | const sessionType = session?.sessionType ?? SessionType.None 149 | const logData: LogData = { 150 | sessionType, 151 | ...actionArguments.headers, 152 | } 153 | 154 | switch (sessionType) { 155 | case SessionType.PortalUser: 156 | case SessionType.EResidentApplicant: 157 | case SessionType.EResident: 158 | case SessionType.User: { 159 | const { 160 | user: { identifier }, 161 | } = session 162 | 163 | logData.userIdentifier = identifier 164 | 165 | break 166 | } 167 | case SessionType.ServiceUser: { 168 | const { 169 | serviceUser: { login }, 170 | } = session 171 | 172 | logData.sessionOwnerId = login 173 | 174 | break 175 | } 176 | case SessionType.Partner: { 177 | const { 178 | partner: { _id: id }, 179 | } = session 180 | 181 | logData.sessionOwnerId = id.toString() 182 | 183 | break 184 | } 185 | case SessionType.Acquirer: { 186 | const { 187 | acquirer: { _id: id }, 188 | } = session 189 | 190 | logData.sessionOwnerId = id.toString() 191 | 192 | break 193 | } 194 | case SessionType.Temporary: { 195 | const { 196 | temporary: { mobileUid }, 197 | } = session 198 | 199 | logData.sessionOwnerId = mobileUid 200 | 201 | break 202 | } 203 | case SessionType.ServiceEntrance: { 204 | const { 205 | entrance: { acquirerId }, 206 | } = session 207 | 208 | logData.sessionOwnerId = acquirerId.toString() 209 | 210 | break 211 | } 212 | case SessionType.None: { 213 | break 214 | } 215 | default: { 216 | const unexpectedSessionType: never = sessionType 217 | 218 | this.logger.warn(`Unexpected session type for the logData: ${unexpectedSessionType}`) 219 | } 220 | } 221 | 222 | return logData 223 | } 224 | } 225 | -------------------------------------------------------------------------------- /tests/unit/grpc/grpcService.spec.ts: -------------------------------------------------------------------------------- 1 | import * as grpc from '@grpc/grpc-js' 2 | import { Metadata, Server, ServerCredentials, ServerUnaryCall, handleUnaryCall } from '@grpc/grpc-js' 3 | import { cloneDeep, set } from 'lodash' 4 | 5 | import Logger from '@diia-inhouse/diia-logger' 6 | import TestKit, { mockInstance } from '@diia-inhouse/test' 7 | import { ActionVersion, HttpStatusCode } from '@diia-inhouse/types' 8 | import { utils } from '@diia-inhouse/utils' 9 | 10 | import { ActionExecutor, GrpcServerConfig, GrpcService } from '../../../src' 11 | import { GrpcAction, grpcObjectWithAction, grpcObjectWithActionError } from '../../mocks' 12 | 13 | jest.mock('@grpc/proto-loader') 14 | jest.mock('@grpc/reflection') 15 | jest.mock('@grpc/grpc-js', () => { 16 | const mocked = >jest.createMockFromModule('@grpc/grpc-js') 17 | const { Metadata: actualMetadata } = jest.requireActual('@grpc/grpc-js') 18 | 19 | return { 20 | ...mocked, 21 | Metadata: actualMetadata, 22 | } 23 | }) 24 | 25 | describe(`${GrpcService.name}`, () => { 26 | const testKit = new TestKit() 27 | const actionExecutor = mockInstance(ActionExecutor) 28 | const logger = mockInstance(Logger) 29 | const config: GrpcServerConfig = { 30 | isEnabled: true, 31 | port: 5000, 32 | services: ['ua.gov.diia.publicservice.service-with-action'], 33 | isReflectionEnabled: true, 34 | maxReceiveMessageLength: 1024 * 1024 * 4, 35 | } 36 | 37 | describe(`method ${GrpcService.prototype.onInit.name}`, () => { 38 | it('should not start GRPC server', async () => { 39 | const loggerSpy = jest.spyOn(logger, 'info') 40 | 41 | const grpcService = new GrpcService({ grpcServer: { ...config, isEnabled: false } }, [], logger, actionExecutor) 42 | 43 | await grpcService.onInit() 44 | 45 | const [grpcServer] = (>Server).mock.instances 46 | 47 | expect(loggerSpy).toHaveBeenCalledWith('grpc server disabled') 48 | expect(grpcServer).toBeUndefined() 49 | }) 50 | 51 | it('should start GRPC server', async () => { 52 | const grpcService = new GrpcService({ grpcServer: { ...config, services: [] } }, [], logger, actionExecutor) 53 | 54 | const [grpcServer] = (>Server).mock.instances 55 | 56 | jest.spyOn(grpc, 'loadPackageDefinition').mockReturnValueOnce({ ...grpcObjectWithAction, ...grpcObjectWithActionError }) 57 | jest.spyOn(grpcServer, 'bindAsync').mockImplementationOnce((_port: string, _creds: ServerCredentials, cb) => { 58 | cb(null, 5000) 59 | }) 60 | 61 | expect(await grpcService.onInit()).toBeUndefined() 62 | }) 63 | 64 | it('should throw error if originalName was not provided for method', async () => { 65 | const grpcService = new GrpcService({ grpcServer: config }, [], logger, actionExecutor) 66 | 67 | const [grpcServer] = (>Server).mock.instances 68 | 69 | jest.spyOn(grpc, 'loadPackageDefinition').mockReturnValueOnce( 70 | set(cloneDeep(grpcObjectWithAction), 'service-with-action.service.action.originalName', ''), 71 | ) 72 | jest.spyOn(grpcServer, 'addService').mockReturnThis() 73 | jest.spyOn(grpcServer, 'bindAsync').mockImplementationOnce((_port: string, _creds: ServerCredentials, cb) => { 74 | cb(null, 5000) 75 | }) 76 | 77 | await expect(async () => await grpcService.onInit()).rejects.toThrow(new Error('Original name in method object is undefined')) 78 | }) 79 | 80 | it('should throw error if GRPC server was unable to start', async () => { 81 | const grpcService = new GrpcService({ grpcServer: { ...config, services: [] } }, [], logger, actionExecutor) 82 | 83 | const [grpcServer] = (>Server).mock.instances 84 | 85 | jest.spyOn(grpcServer, 'bindAsync').mockImplementationOnce((_port: string, _creds: ServerCredentials, cb) => { 86 | cb(new Error('Mocked error'), 5000) 87 | }) 88 | 89 | await expect(async () => await grpcService.onInit()).rejects.toThrow('Mocked error') 90 | }) 91 | 92 | it('should throw error if no action of specified version was found', async () => { 93 | const actionVersion = ActionVersion.V2 94 | const headers = testKit.session.getHeaders({ actionVersion }) 95 | const session = testKit.session.getUserSession() 96 | const sessionBase64 = utils.encodeObjectToBase64(session) 97 | const handlers: handleUnaryCall[] = [] 98 | const grpcService = new GrpcService({ grpcServer: config }, [new GrpcAction()], logger, actionExecutor) 99 | const [grpcServer] = (>Server).mock.instances 100 | 101 | jest.spyOn(grpc, 'loadPackageDefinition').mockReturnValueOnce(grpcObjectWithAction) 102 | jest.spyOn(grpcServer, 'addService').mockImplementation((_service, implementation) => { 103 | for (const key in implementation) { 104 | handlers.push(>implementation[key]) 105 | } 106 | }) 107 | jest.spyOn(grpcServer, 'bindAsync').mockImplementationOnce((_port: string, _creds: ServerCredentials, cb) => { 108 | cb(null, 5000) 109 | }) 110 | 111 | await grpcService.onInit() 112 | 113 | await handlers[0]( 114 | >({ 115 | metadata: Metadata.fromHttp2Headers({ ...headers, session: sessionBase64 }), 116 | request: { param: `${HttpStatusCode}` }, 117 | }), 118 | (err: unknown, resp) => { 119 | expect((<{ message: string; code: number }>err).message).toBe( 120 | `Configuration error: action not found for version ${actionVersion}`, 121 | ) 122 | expect((<{ message: string; code: number }>err).code).toBe(12) 123 | expect(resp).toBeNull() 124 | }, 125 | ) 126 | }) 127 | 128 | it('should throw error if no action file was found', async () => { 129 | const grpcService = new GrpcService({ grpcServer: config }, [], logger, actionExecutor) 130 | 131 | jest.spyOn(grpc, 'loadPackageDefinition').mockReturnValueOnce(grpcObjectWithAction) 132 | 133 | await expect(async () => await grpcService.onInit()).rejects.toThrow('Unable to find any action for action') 134 | }) 135 | }) 136 | 137 | describe(`method ${GrpcService.prototype.onDestroy.name}`, () => { 138 | it('should shutdown GRPC server', async () => { 139 | const grpcService = new GrpcService({ grpcServer: { ...config, services: [] } }, [], logger, actionExecutor) 140 | 141 | const [grpcServer] = (>Server).mock.instances 142 | 143 | jest.spyOn(grpcServer, 'bindAsync').mockImplementationOnce((_port: string, _creds: ServerCredentials, cb) => { 144 | cb(null, 5000) 145 | }) 146 | jest.spyOn(grpcServer, 'tryShutdown').mockImplementationOnce((cb) => { 147 | cb() 148 | }) 149 | 150 | await grpcService.onInit() 151 | 152 | const result = await grpcService.onDestroy() 153 | 154 | expect(result).toBeUndefined() 155 | expect(grpcServer.tryShutdown).toHaveBeenCalled() 156 | }) 157 | 158 | it('should reject with error', async () => { 159 | const grpcService = new GrpcService({ grpcServer: { ...config, services: [] } }, [], logger, actionExecutor) 160 | 161 | const [grpcServer] = (>Server).mock.instances 162 | 163 | jest.spyOn(grpcServer, 'bindAsync').mockImplementationOnce((_port: string, _creds: ServerCredentials, cb) => { 164 | cb(null, 5000) 165 | }) 166 | jest.spyOn(grpcServer, 'tryShutdown').mockImplementationOnce((cb) => { 167 | cb(new Error('Mocked error')) 168 | }) 169 | 170 | await grpcService.onInit() 171 | 172 | await expect(() => grpcService.onDestroy()).rejects.toThrow(new Error('Mocked error')) 173 | }) 174 | }) 175 | 176 | describe(`method ${GrpcService.prototype.onHealthCheck.name}`, () => { 177 | it('should have status UNKNOWN by default', async () => { 178 | const grpcService = new GrpcService({ grpcServer: { ...config, services: [] } }, [], logger, actionExecutor) 179 | 180 | await expect(grpcService.onHealthCheck()).resolves.toEqual({ 181 | status: HttpStatusCode.SERVICE_UNAVAILABLE, 182 | details: { grpcServer: 'UNKNOWN' }, 183 | }) 184 | }) 185 | }) 186 | }) 187 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### Node template 3 | # Logs 4 | logs 5 | *.log 6 | npm-debug.log* 7 | yarn-debug.log* 8 | yarn-error.log* 9 | 10 | # Runtime data 11 | pids 12 | *.pid 13 | *.seed 14 | *.pid.lock 15 | 16 | # Directory for instrumented libs generated by jscoverage/JSCover 17 | lib-cov 18 | 19 | # Coverage directory used by tools like istanbul 20 | coverage 21 | 22 | # nyc test coverage 23 | .nyc_output 24 | 25 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 26 | .grunt 27 | 28 | # Bower dependency directory (https://bower.io/) 29 | bower_components 30 | 31 | # node-waf configuration 32 | .lock-wscript 33 | 34 | # Compiled binary addons (https://nodejs.org/api/addons.html) 35 | build/Release 36 | 37 | # Dependency directories 38 | node_modules/ 39 | jspm_packages/ 40 | 41 | # Typescript v1 declaration files 42 | typings/ 43 | 44 | # Optional npm cache directory 45 | .npm 46 | 47 | # Optional eslint cache 48 | .eslintcache 49 | 50 | # Optional REPL history 51 | .node_repl_history 52 | 53 | # Output of 'npm pack' 54 | *.tgz 55 | 56 | # Yarn Integrity file 57 | .yarn-integrity 58 | 59 | # dotenv environment variables file 60 | .env 61 | .env.*.local 62 | 63 | # next.js build output 64 | .next 65 | ### macOS template 66 | # General 67 | .DS_Store 68 | .AppleDouble 69 | .LSOverride 70 | 71 | # Icon must end with two \r 72 | Icon 73 | 74 | # Thumbnails 75 | ._* 76 | 77 | # Files that might appear in the root of a volume 78 | .DocumentRevisions-V100 79 | .fseventsd 80 | .Spotlight-V100 81 | .TemporaryItems 82 | .Trashes 83 | .VolumeIcon.icns 84 | .com.apple.timemachine.donotpresent 85 | 86 | # Directories potentially created on remote AFP share 87 | .AppleDB 88 | .AppleDesktop 89 | Network Trash Folder 90 | Temporary Items 91 | .apdisk 92 | ### JetBrains template 93 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm 94 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 95 | 96 | # User-specific stuff: 97 | .idea/**/workspace.xml 98 | .idea/**/tasks.xml 99 | .idea/dictionaries 100 | 101 | # Sensitive or high-churn files: 102 | .idea/**/dataSources/ 103 | .idea/**/dataSources.ids 104 | .idea/**/dataSources.xml 105 | .idea/**/dataSources.local.xml 106 | .idea/**/sqlDataSources.xml 107 | .idea/**/dynamic.xml 108 | .idea/**/uiDesigner.xml 109 | 110 | # Gradle: 111 | .idea/**/gradle.xml 112 | .idea/**/libraries 113 | 114 | # CMake 115 | cmake-build-debug/ 116 | cmake-build-release/ 117 | 118 | # Mongo Explorer plugin: 119 | .idea/**/mongoSettings.xml 120 | 121 | ## File-based project format: 122 | *.iws 123 | 124 | ## Plugin-specific files: 125 | 126 | # IntelliJ 127 | out/ 128 | 129 | # mpeltonen/sbt-idea plugin 130 | .idea_modules/ 131 | 132 | # JIRA plugin 133 | atlassian-ide-plugin.xml 134 | 135 | # Cursive Clojure plugin 136 | .idea/replstate.xml 137 | 138 | # Crashlytics plugin (for Android Studio and IntelliJ) 139 | com_crashlytics_export_strings.xml 140 | crashlytics.properties 141 | crashlytics-build.properties 142 | fabric.properties 143 | ### Windows template 144 | # Windows thumbnail cache files 145 | Thumbs.db 146 | ehthumbs.db 147 | ehthumbs_vista.db 148 | 149 | # Dump file 150 | *.stackdump 151 | 152 | # Folder config file 153 | [Dd]esktop.ini 154 | 155 | # Recycle Bin used on file shares 156 | $RECYCLE.BIN/ 157 | 158 | # Windows Installer files 159 | *.cab 160 | *.msi 161 | *.msm 162 | *.msp 163 | 164 | # Windows shortcuts 165 | *.lnk 166 | ### VisualStudio template 167 | ## Ignore Visual Studio temporary files, build results, and 168 | ## files generated by popular Visual Studio add-ons. 169 | ## 170 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 171 | 172 | # User-specific files 173 | *.suo 174 | *.user 175 | *.userosscache 176 | *.sln.docstates 177 | 178 | # User-specific files (MonoDevelop/Xamarin Studio) 179 | *.userprefs 180 | 181 | # Build results 182 | [Dd]ebug/ 183 | [Dd]ebugPublic/ 184 | [Rr]elease/ 185 | [Rr]eleases/ 186 | x64/ 187 | x86/ 188 | bld/ 189 | [Bb]in/ 190 | [Oo]bj/ 191 | [Ll]og/ 192 | 193 | # Visual Studio 2015/2017 cache/options directory 194 | .vs/ 195 | # Uncomment if you have tasks that create the project's static files in wwwroot 196 | #wwwroot/ 197 | 198 | # Visual Studio 2017 auto generated files 199 | Generated\ Files/ 200 | 201 | # MSTest test Results 202 | [Tt]est[Rr]esult*/ 203 | [Bb]uild[Ll]og.* 204 | 205 | # NUNIT 206 | *.VisualState.xml 207 | TestResult.xml 208 | 209 | # Build Results of an ATL Project 210 | [Dd]ebugPS/ 211 | [Rr]eleasePS/ 212 | dlldata.c 213 | 214 | # Benchmark Results 215 | BenchmarkDotNet.Artifacts/ 216 | 217 | # .NET Core 218 | project.lock.json 219 | project.fragment.lock.json 220 | artifacts/ 221 | **/Properties/launchSettings.json 222 | 223 | # StyleCop 224 | StyleCopReport.xml 225 | 226 | # Files built by Visual Studio 227 | *_i.c 228 | *_p.c 229 | *_i.h 230 | *.ilk 231 | *.meta 232 | *.obj 233 | *.pch 234 | *.pdb 235 | *.pgc 236 | *.pgd 237 | *.rsp 238 | *.sbr 239 | *.tlb 240 | *.tli 241 | *.tlh 242 | *.tmp 243 | *.tmp_proj 244 | *.vspscc 245 | *.vssscc 246 | .builds 247 | *.pidb 248 | *.svclog 249 | *.scc 250 | 251 | # Chutzpah Test files 252 | _Chutzpah* 253 | 254 | # Visual C++ cache files 255 | ipch/ 256 | *.aps 257 | *.ncb 258 | *.opendb 259 | *.opensdf 260 | *.sdf 261 | *.cachefile 262 | *.VC.db 263 | *.VC.VC.opendb 264 | 265 | # Visual Studio profiler 266 | *.psess 267 | *.vsp 268 | *.vspx 269 | *.sap 270 | 271 | # Visual Studio Trace Files 272 | *.e2e 273 | 274 | # TFS 2012 Local Workspace 275 | $tf/ 276 | 277 | # Guidance Automation Toolkit 278 | *.gpState 279 | 280 | # ReSharper is a .NET coding add-in 281 | _ReSharper*/ 282 | *.[Rr]e[Ss]harper 283 | *.DotSettings.user 284 | 285 | # JustCode is a .NET coding add-in 286 | .JustCode 287 | 288 | # TeamCity is a build add-in 289 | _TeamCity* 290 | 291 | # DotCover is a Code Coverage Tool 292 | *.dotCover 293 | 294 | # AxoCover is a Code Coverage Tool 295 | .axoCover/* 296 | !.axoCover/settings.json 297 | 298 | # Visual Studio code coverage results 299 | *.coverage 300 | *.coveragexml 301 | 302 | # NCrunch 303 | _NCrunch_* 304 | .*crunch*.local.xml 305 | nCrunchTemp_* 306 | 307 | # MightyMoose 308 | *.mm.* 309 | AutoTest.Net/ 310 | 311 | # Web workbench (sass) 312 | .sass-cache/ 313 | 314 | # Installshield output folder 315 | [Ee]xpress/ 316 | 317 | # DocProject is a documentation generator add-in 318 | DocProject/buildhelp/ 319 | DocProject/Help/*.HxT 320 | DocProject/Help/*.HxC 321 | DocProject/Help/*.hhc 322 | DocProject/Help/*.hhk 323 | DocProject/Help/*.hhp 324 | DocProject/Help/Html2 325 | DocProject/Help/html 326 | 327 | # Click-Once directory 328 | publish/ 329 | 330 | # Publish Web Output 331 | *.[Pp]ublish.xml 332 | *.azurePubxml 333 | # Note: Comment the next line if you want to checkin your web deploy settings, 334 | # but database connection strings (with potential passwords) will be unencrypted 335 | *.pubxml 336 | *.publishproj 337 | 338 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 339 | # checkin your Azure Web App publish settings, but sensitive information contained 340 | # in these scripts will be unencrypted 341 | PublishScripts/ 342 | 343 | # NuGet Packages 344 | *.nupkg 345 | # The packages folder can be ignored because of Package Restore 346 | **/[Pp]ackages/* 347 | # except build/, which is used as an MSBuild target. 348 | !**/[Pp]ackages/build/ 349 | # Uncomment if necessary however generally it will be regenerated when needed 350 | #!**/[Pp]ackages/repositories.config 351 | # NuGet v3's project.json files produces more ignorable files 352 | *.nuget.props 353 | *.nuget.targets 354 | 355 | # Microsoft Azure Build Output 356 | csx/ 357 | *.build.csdef 358 | 359 | # Microsoft Azure Emulator 360 | ecf/ 361 | rcf/ 362 | 363 | # Windows Store app package directories and files 364 | AppPackages/ 365 | BundleArtifacts/ 366 | Package.StoreAssociation.xml 367 | _pkginfo.txt 368 | *.appx 369 | 370 | # Visual Studio cache files 371 | # files ending in .cache can be ignored 372 | *.[Cc]ache 373 | # but keep track of directories ending in .cache 374 | !*.[Cc]ache/ 375 | 376 | # Others 377 | ClientBin/ 378 | ~$* 379 | *~ 380 | *.dbmdl 381 | *.dbproj.schemaview 382 | *.jfm 383 | *.pfx 384 | *.publishsettings 385 | orleans.codegen.cs 386 | 387 | # Including strong name files can present a security risk 388 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 389 | #*.snk 390 | 391 | # Since there are multiple workflows, uncomment next line to ignore bower_components 392 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 393 | #bower_components/ 394 | 395 | # RIA/Silverlight projects 396 | Generated_Code/ 397 | 398 | # Backup & report files from converting an old project file 399 | # to a newer Visual Studio version. Backup files are not needed, 400 | # because we have git ;-) 401 | _UpgradeReport_Files/ 402 | Backup*/ 403 | UpgradeLog*.XML 404 | UpgradeLog*.htm 405 | 406 | # SQL Server files 407 | *.mdf 408 | *.ldf 409 | *.ndf 410 | 411 | # Business Intelligence projects 412 | *.rdl.data 413 | *.bim.layout 414 | *.bim_*.settings 415 | 416 | # Microsoft Fakes 417 | FakesAssemblies/ 418 | 419 | # GhostDoc plugin setting file 420 | *.GhostDoc.xml 421 | 422 | # Node.js Tools for Visual Studio 423 | .ntvs_analysis.dat 424 | 425 | # TypeScript v1 declaration files 426 | 427 | # Visual Studio 6 build log 428 | *.plg 429 | 430 | # Visual Studio 6 workspace options file 431 | *.opt 432 | 433 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 434 | *.vbw 435 | 436 | # Visual Studio LightSwitch build output 437 | **/*.HTMLClient/GeneratedArtifacts 438 | **/*.DesktopClient/GeneratedArtifacts 439 | **/*.DesktopClient/ModelManifest.xml 440 | **/*.Server/GeneratedArtifacts 441 | **/*.Server/ModelManifest.xml 442 | _Pvt_Extensions 443 | 444 | # Paket dependency manager 445 | .paket/paket.exe 446 | paket-files/ 447 | 448 | # FAKE - F# Make 449 | .fake/ 450 | 451 | # JetBrains Rider 452 | .idea/ 453 | *.sln.iml 454 | 455 | # CodeRush 456 | .cr/ 457 | 458 | # Python Tools for Visual Studio (PTVS) 459 | __pycache__/ 460 | *.pyc 461 | 462 | # Cake - Uncomment if you are using it 463 | # tools/** 464 | # !tools/packages.config 465 | 466 | # Tabs Studio 467 | *.tss 468 | 469 | # Telerik's JustMock configuration file 470 | *.jmconfig 471 | 472 | # BizTalk build output 473 | *.btp.cs 474 | *.btm.cs 475 | *.odx.cs 476 | *.xsd.cs 477 | 478 | # OpenCover UI analysis results 479 | OpenCover/ 480 | 481 | # Azure Stream Analytics local run output 482 | ASALocalRun/ 483 | 484 | # MSBuild Binary and Structured Log 485 | *.binlog 486 | 487 | .vscode 488 | 489 | .npmrc 490 | 491 | dist/ 492 | 493 | tests/integration/generated/ 494 | -------------------------------------------------------------------------------- /src/moleculer/moleculerWrapper.ts: -------------------------------------------------------------------------------- 1 | import { AsyncLocalStorage } from 'node:async_hooks' 2 | 3 | import { Span, SpanKind, SpanStatusCode, context, propagation, trace } from '@opentelemetry/api' 4 | import { SEMATTRS_MESSAGING_DESTINATION, SEMATTRS_MESSAGING_SYSTEM } from '@opentelemetry/semantic-conventions' 5 | import cookieParser from 'cookie-parser' 6 | import { extend } from 'lodash' 7 | import { 8 | ActionHandler, 9 | ActionSchema, 10 | BrokerOptions, 11 | CallingOptions, 12 | Context, 13 | Service, 14 | ServiceActionsSchema, 15 | ServiceBroker, 16 | ServiceEvents, 17 | ServiceSchema, 18 | } from 'moleculer' 19 | import ApiService from 'moleculer-web' 20 | 21 | import { MetricsService, RequestMechanism, RequestStatus } from '@diia-inhouse/diia-metrics' 22 | import { EnvService } from '@diia-inhouse/env' 23 | import { RedlockService } from '@diia-inhouse/redis' 24 | import { 25 | ActArguments, 26 | ActionArguments, 27 | ActionVersion, 28 | AlsData, 29 | CallActionNameVersion, 30 | Logger, 31 | OnDestroy, 32 | OnInit, 33 | } from '@diia-inhouse/types' 34 | import { utils } from '@diia-inhouse/utils' 35 | 36 | import { ActionExecutor } from '../actionExecutor' 37 | import { AppAction, AppApiService, BaseConfig } from '../interfaces' 38 | import { ContextMeta } from '../interfaces/moleculer' 39 | import { ACTION_PARAMS, ACTION_RESPONSE } from '../plugins/pluginConstants' 40 | 41 | import MoleculerLogger from './moleculerLogger' 42 | 43 | export default class MoleculerService implements OnInit, OnDestroy { 44 | serviceBroker: ServiceBroker 45 | 46 | service!: Service 47 | 48 | constructor( 49 | private readonly serviceName: string, 50 | private readonly actionExecutor: ActionExecutor, 51 | private readonly actionList: AppAction[], 52 | 53 | private readonly config: BaseConfig, 54 | private readonly asyncLocalStorage: AsyncLocalStorage, 55 | private readonly logger: Logger, 56 | private readonly envService: EnvService, 57 | 58 | private readonly metrics: MetricsService, 59 | 60 | private readonly moleculerEvents: ServiceEvents = {}, 61 | private readonly apiService: AppApiService | null = null, 62 | private readonly redlock: RedlockService | null = null, 63 | ) { 64 | const brokerOptions: BrokerOptions = { 65 | transporter: this.config.transporter, 66 | logger: new MoleculerLogger(this.logger), 67 | logLevel: 'warn', 68 | registry: { 69 | strategy: this.config.balancing?.strategy || 'RoundRobin', 70 | strategyOptions: this.config.balancing?.strategyOptions || {}, 71 | }, 72 | tracking: { 73 | enabled: process.env.CONTEXT_TRACKING_ENABLED ? process.env.CONTEXT_TRACKING_ENABLED === 'true' : true, 74 | shutdownTimeout: process.env.CONTEXT_TRACKING_TIMEOUT ? Number.parseInt(process.env.CONTEXT_TRACKING_TIMEOUT, 10) : 10000, 75 | }, 76 | metrics: { 77 | enabled: this.config.metrics?.moleculer?.prometheus?.isEnabled || false, 78 | reporter: [ 79 | { 80 | type: 'Prometheus', 81 | options: { 82 | port: this.config.metrics?.moleculer?.prometheus?.port ?? 3031, 83 | path: this.config.metrics?.moleculer?.prometheus?.path, 84 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 85 | defaultLabels: (registry: any): Record => ({ 86 | namespace: registry.broker.nasmespace, 87 | nodeID: registry.broker.nodeID, 88 | }), 89 | }, 90 | }, 91 | ], 92 | }, 93 | } 94 | 95 | if (this.config.tracing) { 96 | const { 97 | zipkin: { isEnabled, baseURL, sendIntervalSec }, 98 | } = this.config.tracing 99 | 100 | brokerOptions.tracing = { 101 | enabled: isEnabled, 102 | exporter: { 103 | type: 'Zipkin', 104 | options: { 105 | baseURL, 106 | interval: sendIntervalSec, 107 | payloadOptions: { 108 | debug: false, 109 | shared: false, 110 | }, 111 | defaultTags: null, 112 | }, 113 | }, 114 | } 115 | } 116 | 117 | this.serviceBroker = new ServiceBroker(brokerOptions) 118 | } 119 | 120 | async onInit(): Promise { 121 | const serviceActions = this.createActions(this.actionList) 122 | const serviceSchema: ServiceSchema = { name: this.serviceName, actions: serviceActions, events: this.moleculerEvents ?? {} } 123 | const options = this.addApiService(serviceSchema) 124 | 125 | this.service = this.serviceBroker.createService(options) 126 | 127 | await this.serviceBroker.start() 128 | } 129 | 130 | async onDestroy(): Promise { 131 | await this.serviceBroker.stop() 132 | } 133 | 134 | async act( 135 | serviceName: string, 136 | { name, actionVersion }: CallActionNameVersion, 137 | args?: ActArguments, 138 | opts?: CallingOptions, 139 | ): Promise { 140 | const actionName: string = utils.getActionNameWithVersion(name, actionVersion) 141 | const serviceActionName = `${serviceName}.${actionName}` 142 | let span: Span | undefined 143 | const startTime = process.hrtime.bigint() 144 | const defaultLabels = { 145 | mechanism: RequestMechanism.Moleculer, 146 | source: this.serviceName, 147 | destination: serviceName, 148 | route: serviceActionName, 149 | } 150 | 151 | try { 152 | const broker: ServiceBroker = this.serviceBroker 153 | 154 | const tracer = trace.getTracer(this.serviceName) 155 | 156 | span = tracer.startSpan( 157 | `send ${serviceActionName}`, 158 | { 159 | kind: SpanKind.PRODUCER, 160 | attributes: { 161 | [SEMATTRS_MESSAGING_SYSTEM]: RequestMechanism.Moleculer, 162 | [SEMATTRS_MESSAGING_DESTINATION]: serviceName, 163 | }, 164 | }, 165 | context.active(), 166 | ) 167 | const tracing = {} 168 | 169 | propagation.inject(trace.setSpan(context.active(), span), tracing) 170 | const { params = {}, session, headers: argsHeaders } = args || {} 171 | 172 | const headers = { ...this.asyncLocalStorage.getStore()?.logData, ...argsHeaders } 173 | const argsWithParams: Record = { params, session, headers } 174 | const callingOpts = { ...opts, meta: { tracing } } 175 | 176 | this.logger.info(`ACT OUT: ${serviceActionName}`, { 177 | params, 178 | session, 179 | argsHeaders, 180 | service: serviceName, 181 | action: actionName, 182 | callingOpts, 183 | }) 184 | const res = await broker.call(serviceActionName, argsWithParams, callingOpts) 185 | 186 | span.setStatus({ code: SpanStatusCode.OK }) 187 | span.end() 188 | this.logger.info(`ACT OUT RESULT: ${serviceActionName}`, res) 189 | 190 | this.metrics.totalTimerMetric.observeSeconds( 191 | { ...defaultLabels, status: RequestStatus.Successful }, 192 | process.hrtime.bigint() - startTime, 193 | ) 194 | 195 | return res 196 | } catch (err) { 197 | utils.handleError(err, (apiErr) => { 198 | span?.recordException({ 199 | message: apiErr.getMessage(), 200 | code: apiErr.getCode(), 201 | name: apiErr.getName(), 202 | }) 203 | span?.setStatus({ code: SpanStatusCode.ERROR, message: apiErr.getMessage() }) 204 | 205 | this.metrics.totalTimerMetric.observeSeconds( 206 | { 207 | ...defaultLabels, 208 | status: RequestStatus.Failed, 209 | errorType: apiErr.getType(), 210 | statusCode: apiErr.getCode(), 211 | }, 212 | process.hrtime.bigint() - startTime, 213 | ) 214 | }) 215 | 216 | span?.end() 217 | 218 | this.logger.error(`ACT OUT FAILED: ${serviceActionName}`, { err, service: serviceName, action: actionName, args }) 219 | throw err 220 | } 221 | } 222 | 223 | async tryToAct( 224 | serviceName: string, 225 | callActionNameVersion: CallActionNameVersion, 226 | args?: ActArguments, 227 | opts?: CallingOptions, 228 | ): Promise { 229 | try { 230 | return await this.act(serviceName, callActionNameVersion, args, opts) 231 | } catch { 232 | return 233 | } 234 | } 235 | 236 | private addApiService(serviceSchema: ServiceSchema): ServiceSchema { 237 | const { cors } = this.config 238 | if (!this.apiService || !cors) { 239 | return serviceSchema 240 | } 241 | 242 | const extendedOptions = extend(serviceSchema, { 243 | mixins: [ApiService], 244 | settings: { 245 | port: this.apiService.port, 246 | routes: this.apiService.routes, 247 | cors: { 248 | origin: cors.origins.join(', '), 249 | methods: cors.methods, 250 | allowedHeaders: cors.allowedHeaders, 251 | exposedHeaders: cors.exposedHeaders, 252 | credentials: cors.credentials, 253 | maxAge: cors.maxAge, 254 | }, 255 | mergeParams: true, 256 | use: [cookieParser()], 257 | qsOptions: { 258 | arrayLimit: 40, 259 | }, 260 | logRequest: 'debug', 261 | logResponse: 'debug', 262 | logRouteRegistration: 'debug', 263 | log4XXResponses: false, 264 | }, 265 | methods: this.apiService.methods, 266 | }) 267 | 268 | if (this.apiService.ip) { 269 | extendedOptions.settings.ip = this.apiService.ip 270 | } 271 | 272 | return extendedOptions 273 | } 274 | 275 | private createActions(actions: AppAction[]): ServiceActionsSchema { 276 | this.logger.info('Start actions initialization') 277 | 278 | try { 279 | const serviceActions: ServiceActionsSchema = {} 280 | 281 | for (const action of actions) { 282 | let actionVersion: ActionVersion | undefined 283 | if (action.actionVersion !== undefined) { 284 | actionVersion = action.actionVersion 285 | } 286 | 287 | const command = utils.getActionNameWithVersion(action.name, actionVersion) 288 | 289 | serviceActions[command] = this.addAction(action) 290 | 291 | this.logger.info(`Action [${command}] initialized successfully`) 292 | } 293 | 294 | return serviceActions 295 | } catch (err) { 296 | this.logger.error('Failed to init actions', { err }) 297 | throw err 298 | } 299 | } 300 | 301 | private addAction(action: AppAction): ActionSchema { 302 | if (action.getLockResource && !this.redlock) { 303 | throw new Error('Lock resource cannot be used without a redlock service') 304 | } 305 | 306 | const handler: ActionHandler = async (ctx: Context): Promise => { 307 | const { caller, meta, params } = ctx 308 | 309 | return await this.actionExecutor.execute({ 310 | action, 311 | caller: caller || undefined, 312 | tracingMetadata: meta?.tracing, 313 | actionArguments: params, 314 | transport: RequestMechanism.Moleculer, 315 | spanKind: SpanKind.CONSUMER, 316 | }) 317 | } 318 | 319 | if (this.envService.isProd()) { 320 | return { handler } 321 | } 322 | 323 | return { 324 | handler, 325 | [ACTION_PARAMS]: action.validationRules ? { params: { type: 'object', props: action.validationRules } } : {}, 326 | [ACTION_RESPONSE]: action[ACTION_RESPONSE], 327 | } 328 | } 329 | } 330 | -------------------------------------------------------------------------------- /LICENCE.md: -------------------------------------------------------------------------------- 1 | EUROPEAN UNION PUBLIC LICENCE v. 1.2 2 | EUPL © the European Union 2007, 2016 3 | 4 | This European Union Public Licence (the ‘EUPL’) applies to the Work (as defined 5 | below) which is provided under the terms of this Licence. Any use of the Work, 6 | other than as authorised under this Licence is prohibited (to the extent such 7 | use is covered by a right of the copyright holder of the Work). 8 | 9 | The Work is provided under the terms of this Licence when the Licensor (as 10 | defined below) has placed the following notice immediately following the 11 | copyright notice for the Work: 12 | 13 | Licensed under the EUPL 14 | 15 | or has expressed by any other means his willingness to license under the EUPL. 16 | 17 | 1. Definitions 18 | 19 | In this Licence, the following terms have the following meaning: 20 | 21 | - ‘The Licence’: this Licence. 22 | 23 | - ‘The Original Work’: the work or software distributed or communicated by the 24 | Licensor under this Licence, available as Source Code and also as Executable 25 | Code as the case may be. 26 | 27 | - ‘Derivative Works’: the works or software that could be created by the 28 | Licensee, based upon the Original Work or modifications thereof. This Licence 29 | does not define the extent of modification or dependence on the Original Work 30 | required in order to classify a work as a Derivative Work; this extent is 31 | determined by copyright law applicable in the country mentioned in Article 15. 32 | 33 | - ‘The Work’: the Original Work or its Derivative Works. 34 | 35 | - ‘The Source Code’: the human-readable form of the Work which is the most 36 | convenient for people to study and modify. 37 | 38 | - ‘The Executable Code’: any code which has generally been compiled and which is 39 | meant to be interpreted by a computer as a program. 40 | 41 | - ‘The Licensor’: the natural or legal person that distributes or communicates 42 | the Work under the Licence. 43 | 44 | - ‘Contributor(s)’: any natural or legal person who modifies the Work under the 45 | Licence, or otherwise contributes to the creation of a Derivative Work. 46 | 47 | - ‘The Licensee’ or ‘You’: any natural or legal person who makes any usage of 48 | the Work under the terms of the Licence. 49 | 50 | - ‘Distribution’ or ‘Communication’: any act of selling, giving, lending, 51 | renting, distributing, communicating, transmitting, or otherwise making 52 | available, online or offline, copies of the Work or providing access to its 53 | essential functionalities at the disposal of any other natural or legal 54 | person. 55 | 56 | 2. Scope of the rights granted by the Licence 57 | 58 | The Licensor hereby grants You a worldwide, royalty-free, non-exclusive, 59 | sublicensable licence to do the following, for the duration of copyright vested 60 | in the Original Work: 61 | 62 | - use the Work in any circumstance and for all usage, 63 | - reproduce the Work, 64 | - modify the Work, and make Derivative Works based upon the Work, 65 | - communicate to the public, including the right to make available or display 66 | the Work or copies thereof to the public and perform publicly, as the case may 67 | be, the Work, 68 | - distribute the Work or copies thereof, 69 | - lend and rent the Work or copies thereof, 70 | - sublicense rights in the Work or copies thereof. 71 | 72 | Those rights can be exercised on any media, supports and formats, whether now 73 | known or later invented, as far as the applicable law permits so. 74 | 75 | In the countries where moral rights apply, the Licensor waives his right to 76 | exercise his moral right to the extent allowed by law in order to make effective 77 | the licence of the economic rights here above listed. 78 | 79 | The Licensor grants to the Licensee royalty-free, non-exclusive usage rights to 80 | any patents held by the Licensor, to the extent necessary to make use of the 81 | rights granted on the Work under this Licence. 82 | 83 | 3. Communication of the Source Code 84 | 85 | The Licensor may provide the Work either in its Source Code form, or as 86 | Executable Code. If the Work is provided as Executable Code, the Licensor 87 | provides in addition a machine-readable copy of the Source Code of the Work 88 | along with each copy of the Work that the Licensor distributes or indicates, in 89 | a notice following the copyright notice attached to the Work, a repository where 90 | the Source Code is easily and freely accessible for as long as the Licensor 91 | continues to distribute or communicate the Work. 92 | 93 | 4. Limitations on copyright 94 | 95 | Nothing in this Licence is intended to deprive the Licensee of the benefits from 96 | any exception or limitation to the exclusive rights of the rights owners in the 97 | Work, of the exhaustion of those rights or of other applicable limitations 98 | thereto. 99 | 100 | 5. Obligations of the Licensee 101 | 102 | The grant of the rights mentioned above is subject to some restrictions and 103 | obligations imposed on the Licensee. Those obligations are the following: 104 | 105 | Attribution right: The Licensee shall keep intact all copyright, patent or 106 | trademarks notices and all notices that refer to the Licence and to the 107 | disclaimer of warranties. The Licensee must include a copy of such notices and a 108 | copy of the Licence with every copy of the Work he/she distributes or 109 | communicates. The Licensee must cause any Derivative Work to carry prominent 110 | notices stating that the Work has been modified and the date of modification. 111 | 112 | Copyleft clause: If the Licensee distributes or communicates copies of the 113 | Original Works or Derivative Works, this Distribution or Communication will be 114 | done under the terms of this Licence or of a later version of this Licence 115 | unless the Original Work is expressly distributed only under this version of the 116 | Licence — for example by communicating ‘EUPL v. 1.2 only’. The Licensee 117 | (becoming Licensor) cannot offer or impose any additional terms or conditions on 118 | the Work or Derivative Work that alter or restrict the terms of the Licence. 119 | 120 | Compatibility clause: If the Licensee Distributes or Communicates Derivative 121 | Works or copies thereof based upon both the Work and another work licensed under 122 | a Compatible Licence, this Distribution or Communication can be done under the 123 | terms of this Compatible Licence. For the sake of this clause, ‘Compatible 124 | Licence’ refers to the licences listed in the appendix attached to this Licence. 125 | Should the Licensee's obligations under the Compatible Licence conflict with 126 | his/her obligations under this Licence, the obligations of the Compatible 127 | Licence shall prevail. 128 | 129 | Provision of Source Code: When distributing or communicating copies of the Work, 130 | the Licensee will provide a machine-readable copy of the Source Code or indicate 131 | a repository where this Source will be easily and freely available for as long 132 | as the Licensee continues to distribute or communicate the Work. 133 | 134 | Legal Protection: This Licence does not grant permission to use the trade names, 135 | trademarks, service marks, or names of the Licensor, except as required for 136 | reasonable and customary use in describing the origin of the Work and 137 | reproducing the content of the copyright notice. 138 | 139 | 6. Chain of Authorship 140 | 141 | The original Licensor warrants that the copyright in the Original Work granted 142 | hereunder is owned by him/her or licensed to him/her and that he/she has the 143 | power and authority to grant the Licence. 144 | 145 | Each Contributor warrants that the copyright in the modifications he/she brings 146 | to the Work are owned by him/her or licensed to him/her and that he/she has the 147 | power and authority to grant the Licence. 148 | 149 | Each time You accept the Licence, the original Licensor and subsequent 150 | Contributors grant You a licence to their contributions to the Work, under the 151 | terms of this Licence. 152 | 153 | 7. Disclaimer of Warranty 154 | 155 | The Work is a work in progress, which is continuously improved by numerous 156 | Contributors. It is not a finished work and may therefore contain defects or 157 | ‘bugs’ inherent to this type of development. 158 | 159 | For the above reason, the Work is provided under the Licence on an ‘as is’ basis 160 | and without warranties of any kind concerning the Work, including without 161 | limitation merchantability, fitness for a particular purpose, absence of defects 162 | or errors, accuracy, non-infringement of intellectual property rights other than 163 | copyright as stated in Article 6 of this Licence. 164 | 165 | This disclaimer of warranty is an essential part of the Licence and a condition 166 | for the grant of any rights to the Work. 167 | 168 | 8. Disclaimer of Liability 169 | 170 | Except in the cases of wilful misconduct or damages directly caused to natural 171 | persons, the Licensor will in no event be liable for any direct or indirect, 172 | material or moral, damages of any kind, arising out of the Licence or of the use 173 | of the Work, including without limitation, damages for loss of goodwill, work 174 | stoppage, computer failure or malfunction, loss of data or any commercial 175 | damage, even if the Licensor has been advised of the possibility of such damage. 176 | However, the Licensor will be liable under statutory product liability laws as 177 | far such laws apply to the Work. 178 | 179 | 9. Additional agreements 180 | 181 | While distributing the Work, You may choose to conclude an additional agreement, 182 | defining obligations or services consistent with this Licence. However, if 183 | accepting obligations, You may act only on your own behalf and on your sole 184 | responsibility, not on behalf of the original Licensor or any other Contributor, 185 | and only if You agree to indemnify, defend, and hold each Contributor harmless 186 | for any liability incurred by, or claims asserted against such Contributor by 187 | the fact You have accepted any warranty or additional liability. 188 | 189 | 10. Acceptance of the Licence 190 | 191 | The provisions of this Licence can be accepted by clicking on an icon ‘I agree’ 192 | placed under the bottom of a window displaying the text of this Licence or by 193 | affirming consent in any other similar way, in accordance with the rules of 194 | applicable law. Clicking on that icon indicates your clear and irrevocable 195 | acceptance of this Licence and all of its terms and conditions. 196 | 197 | Similarly, you irrevocably accept this Licence and all of its terms and 198 | conditions by exercising any rights granted to You by Article 2 of this Licence, 199 | such as the use of the Work, the creation by You of a Derivative Work or the 200 | Distribution or Communication by You of the Work or copies thereof. 201 | 202 | 11. Information to the public 203 | 204 | In case of any Distribution or Communication of the Work by means of electronic 205 | communication by You (for example, by offering to download the Work from a 206 | remote location) the distribution channel or media (for example, a website) must 207 | at least provide to the public the information requested by the applicable law 208 | regarding the Licensor, the Licence and the way it may be accessible, concluded, 209 | stored and reproduced by the Licensee. 210 | 211 | 12. Termination of the Licence 212 | 213 | The Licence and the rights granted hereunder will terminate automatically upon 214 | any breach by the Licensee of the terms of the Licence. 215 | 216 | Such a termination will not terminate the licences of any person who has 217 | received the Work from the Licensee under the Licence, provided such persons 218 | remain in full compliance with the Licence. 219 | 220 | 13. Miscellaneous 221 | 222 | Without prejudice of Article 9 above, the Licence represents the complete 223 | agreement between the Parties as to the Work. 224 | 225 | If any provision of the Licence is invalid or unenforceable under applicable 226 | law, this will not affect the validity or enforceability of the Licence as a 227 | whole. Such provision will be construed or reformed so as necessary to make it 228 | valid and enforceable. 229 | 230 | The European Commission may publish other linguistic versions or new versions of 231 | this Licence or updated versions of the Appendix, so far this is required and 232 | reasonable, without reducing the scope of the rights granted by the Licence. New 233 | versions of the Licence will be published with a unique version number. 234 | 235 | All linguistic versions of this Licence, approved by the European Commission, 236 | have identical value. Parties can take advantage of the linguistic version of 237 | their choice. 238 | 239 | 14. Jurisdiction 240 | 241 | Without prejudice to specific agreement between parties, 242 | 243 | - any litigation resulting from the interpretation of this License, arising 244 | between the European Union institutions, bodies, offices or agencies, as a 245 | Licensor, and any Licensee, will be subject to the jurisdiction of the Court 246 | of Justice of the European Union, as laid down in article 272 of the Treaty on 247 | the Functioning of the European Union, 248 | 249 | - any litigation arising between other parties and resulting from the 250 | interpretation of this License, will be subject to the exclusive jurisdiction 251 | of the competent court where the Licensor resides or conducts its primary 252 | business. 253 | 254 | 15. Applicable Law 255 | 256 | Without prejudice to specific agreement between parties, 257 | 258 | - this Licence shall be governed by the law of the European Union Member State 259 | where the Licensor has his seat, resides or has his registered office, 260 | 261 | - this licence shall be governed by Belgian law if the Licensor has no seat, 262 | residence or registered office inside a European Union Member State. 263 | 264 | Appendix 265 | 266 | ‘Compatible Licences’ according to Article 5 EUPL are: 267 | 268 | - GNU General Public License (GPL) v. 2, v. 3 269 | - GNU Affero General Public License (AGPL) v. 3 270 | - Open Software License (OSL) v. 2.1, v. 3.0 271 | - Eclipse Public License (EPL) v. 1.0 272 | - CeCILL v. 2.0, v. 2.1 273 | - Mozilla Public Licence (MPL) v. 2 274 | - GNU Lesser General Public Licence (LGPL) v. 2.1, v. 3 275 | - Creative Commons Attribution-ShareAlike v. 3.0 Unported (CC BY-SA 3.0) for 276 | works other than software 277 | - European Union Public Licence (EUPL) v. 1.1, v. 1.2 278 | - Québec Free and Open-Source Licence — Reciprocity (LiLiQ-R) or Strong 279 | Reciprocity (LiLiQ-R+). 280 | 281 | The European Commission may update this Appendix to later versions of the above 282 | licences without producing a new version of the EUPL, as long as they provide 283 | the rights granted in Article 2 of this Licence and protect the covered Source 284 | Code from exclusive appropriation. 285 | 286 | All other changes or additions to this Appendix require the production of a new 287 | EUPL version. 288 | -------------------------------------------------------------------------------- /tests/unit/plugins/openapi/parser.spec.ts: -------------------------------------------------------------------------------- 1 | import { NodeFlags, Program, SyntaxKind, Type, TypeFlags, factory } from 'typescript' 2 | 3 | import typeParser from '../../../../src/plugins/openapi/parser' 4 | import { 5 | arrayMatcher, 6 | bufferMatcher, 7 | dateMatcher, 8 | emptyArrayMatcher, 9 | externalPackageTypeMatcher, 10 | indexedNumberMatcher, 11 | indexedStringMatcher, 12 | interfaceMatcher, 13 | literalMatcher, 14 | literalWithoutValueMatcher, 15 | objectIdMatcher, 16 | recordMatcher, 17 | stringMatcher, 18 | tupleMatcher, 19 | } from '../../../mocks' 20 | 21 | const typeChecker = { 22 | getIndexInfoOfType(): unknown { 23 | return 24 | }, 25 | getPropertiesOfType(): unknown { 26 | return 27 | }, 28 | getTypeOfSymbolAtLocation(_prop: unknown, { type }: { type: unknown }): unknown { 29 | return type 30 | }, 31 | isArrayType(): boolean { 32 | return false 33 | }, 34 | isTupleType(): boolean { 35 | return false 36 | }, 37 | typeToString(): string { 38 | return 'unknown' 39 | }, 40 | } 41 | 42 | const programMock = { 43 | getTypeChecker(): typeof typeChecker { 44 | return typeChecker 45 | }, 46 | isSourceFileFromExternalLibrary(): boolean { 47 | return false 48 | }, 49 | } 50 | 51 | const typePropMixin = { 52 | getDocumentationComment(): unknown[] { 53 | return [] 54 | }, 55 | getJsDocTags(): unknown[] { 56 | return [] 57 | }, 58 | getName(): string { 59 | return 'name' 60 | }, 61 | } 62 | 63 | describe(`OpenApi typeParser`, () => { 64 | describe(`method parseType`, () => { 65 | it('should return undefined if type has null flag', () => { 66 | const type = { 67 | flags: TypeFlags.Null, 68 | } 69 | 70 | const result = typeParser.parseType(type) 71 | 72 | expect(result).toBeUndefined() 73 | }) 74 | 75 | it('should return default object literal expression', () => { 76 | const type = ({ 77 | flags: 0, 78 | intrinsicName: 'intrinsicName', 79 | symbol: { name: 'symbolName' }, 80 | }) 81 | 82 | jest.spyOn(console, 'log').mockImplementation(() => {}) 83 | 84 | const expected = factory.createObjectLiteralExpression() 85 | 86 | const result = typeParser.parseType(type) 87 | 88 | expect(result).toEqual(expected) 89 | }) 90 | 91 | it('should parse string', () => { 92 | const type = ({ 93 | flags: TypeFlags.String, 94 | value: 'string1', 95 | symbol: { name: 'symbolName' }, 96 | intrinsicName: 'intrinsicName', 97 | }) 98 | 99 | const result = typeParser.parseType(type, (programMock)) 100 | 101 | expect(result).toMatchObject(stringMatcher) 102 | }) 103 | 104 | it('should parse literal', () => { 105 | const type = ({ 106 | flags: TypeFlags.NumberLike, 107 | value: 10, 108 | }) 109 | 110 | const result = typeParser.parseType(type) 111 | 112 | expect(result).toMatchObject(literalMatcher) 113 | }) 114 | 115 | it('should parse literal without value', () => { 116 | const type = ({ 117 | flags: TypeFlags.NumberLike, 118 | symbol: { name: 'symbolName' }, 119 | intrinsicName: 'true', 120 | }) 121 | 122 | const result = typeParser.parseType(type) 123 | 124 | expect(result).toMatchObject(literalWithoutValueMatcher) 125 | }) 126 | 127 | it('should parse array', () => { 128 | const type = ({ 129 | flags: TypeFlags.Object, 130 | typeArguments: [ 131 | { 132 | flags: TypeFlags.String, 133 | value: 'string1', 134 | symbol: { name: 'symbolName' }, 135 | intrinsicName: 'intrinsicName', 136 | }, 137 | ], 138 | }) 139 | 140 | jest.spyOn(typeChecker, 'isArrayType').mockReturnValueOnce(true) 141 | 142 | const result = typeParser.parseType(type, (programMock)) 143 | 144 | expect(result).toMatchObject(arrayMatcher) 145 | }) 146 | 147 | it('should parse empty array', () => { 148 | const type = ({ flags: TypeFlags.Object }) 149 | 150 | jest.spyOn(typeChecker, 'isArrayType').mockReturnValueOnce(true) 151 | 152 | const result = typeParser.parseType(type, (programMock)) 153 | 154 | expect(result).toMatchObject(emptyArrayMatcher) 155 | }) 156 | 157 | it('should parse empty tuple', () => { 158 | const type = ({ flags: TypeFlags.Object }) 159 | 160 | jest.spyOn(typeChecker, 'isTupleType').mockReturnValueOnce(true) 161 | 162 | const result = typeParser.parseType(type, (programMock)) 163 | 164 | expect(result).toMatchObject(emptyArrayMatcher) 165 | }) 166 | 167 | it('should parse tuple', () => { 168 | const type = ({ 169 | flags: TypeFlags.Object, 170 | typeArguments: [ 171 | { 172 | flags: TypeFlags.String, 173 | value: 'string1', 174 | intrinsicName: 'intrinsicName', 175 | }, 176 | { 177 | flags: TypeFlags.BooleanLike, 178 | value: false, 179 | }, 180 | ], 181 | }) 182 | 183 | jest.spyOn(typeChecker, 'isTupleType').mockReturnValueOnce(true) 184 | 185 | const result = typeParser.parseType(type, (programMock)) 186 | 187 | expect(result).toMatchObject(tupleMatcher) 188 | }) 189 | 190 | it('should parse ObjectId', () => { 191 | const type = ({ 192 | flags: TypeFlags.Object, 193 | symbol: { name: 'ObjectId' }, 194 | }) 195 | 196 | const result = typeParser.parseType(type, (programMock)) 197 | 198 | expect(result).toMatchObject(objectIdMatcher) 199 | }) 200 | 201 | it('should parse Date', () => { 202 | const type = ({ 203 | flags: TypeFlags.Object, 204 | symbol: { name: 'Date' }, 205 | }) 206 | 207 | const result = typeParser.parseType(type, (programMock)) 208 | 209 | expect(result).toMatchObject(dateMatcher) 210 | }) 211 | 212 | it('should parse Buffer', () => { 213 | const type = ({ 214 | flags: TypeFlags.Object, 215 | symbol: { name: 'Buffer' }, 216 | }) 217 | 218 | const result = typeParser.parseType(type, (programMock)) 219 | 220 | expect(result).toMatchObject(bufferMatcher) 221 | }) 222 | 223 | it('should parse indexed object of Number type', () => { 224 | const type = ({ flags: TypeFlags.Object }) 225 | 226 | jest.spyOn(typeChecker, 'getIndexInfoOfType').mockReturnValueOnce({ 227 | type: { 228 | flags: TypeFlags.Number, 229 | value: 13, 230 | }, 231 | }) 232 | 233 | const result = typeParser.parseType(type, (programMock)) 234 | 235 | expect(result).toMatchObject(indexedNumberMatcher) 236 | }) 237 | 238 | it('should parse indexed object of String type', () => { 239 | const type = ({ flags: TypeFlags.Object }) 240 | 241 | jest.spyOn(typeChecker, 'getIndexInfoOfType').mockReturnValueOnce({ 242 | type: { 243 | flags: TypeFlags.String, 244 | value: 'mocked string', 245 | }, 246 | }) 247 | 248 | const result = typeParser.parseType(type, (programMock)) 249 | 250 | expect(result).toMatchObject(indexedStringMatcher) 251 | }) 252 | 253 | it('should parse type from external package', () => { 254 | const type = ({ 255 | flags: TypeFlags.Object, 256 | symbol: { 257 | declarations: [ 258 | { 259 | getSourceFile(): { fileName: string } { 260 | return { fileName: 'node_modules/pkg-external/file.js' } 261 | }, 262 | }, 263 | ], 264 | getName(): string { 265 | return 'externalType' 266 | }, 267 | }, 268 | }) 269 | 270 | jest.spyOn(programMock, 'isSourceFileFromExternalLibrary').mockReturnValueOnce(true) 271 | 272 | const result = typeParser.parseType(type, (programMock)) 273 | 274 | expect(result).toMatchObject(externalPackageTypeMatcher) 275 | }) 276 | 277 | it('should parse record with many keys', () => { 278 | const type = ({ 279 | flags: TypeFlags.Object, 280 | aliasTypeArguments: [ 281 | { 282 | aliasSymbol: { 283 | escapedName: 'Record', 284 | }, 285 | }, 286 | ], 287 | symbol: { 288 | getName(): string { 289 | return '__type' 290 | }, 291 | }, 292 | }) 293 | 294 | jest.spyOn(typeChecker, 'getPropertiesOfType').mockReturnValue([ 295 | { 296 | type: { 297 | flags: TypeFlags.NumberLike, 298 | value: 17, 299 | }, 300 | escapedName: 'prop1', 301 | }, 302 | { escapedName: 'prop2' }, 303 | { escapedName: 'prop3' }, 304 | { escapedName: 'prop4' }, 305 | { escapedName: 'prop5' }, 306 | { escapedName: 'prop6' }, 307 | ]) 308 | 309 | const result = typeParser.parseType(type, (programMock)) 310 | 311 | expect(result).toMatchObject(recordMatcher) 312 | }) 313 | 314 | it('should parse interface', () => { 315 | const type = ({ 316 | flags: TypeFlags.Object, 317 | symbol: { 318 | getName(): string { 319 | return '__type' 320 | }, 321 | }, 322 | }) 323 | 324 | jest.spyOn(typeChecker, 'getPropertiesOfType').mockReturnValue([ 325 | { 326 | ...typePropMixin, 327 | type: { 328 | flags: TypeFlags.NumberLike, 329 | value: 19, 330 | }, 331 | escapedName: 'prop1', 332 | }, 333 | { 334 | ...typePropMixin, 335 | declarations: [ 336 | { 337 | type: { 338 | flags: TypeFlags.StringLike, 339 | value: 'string2', 340 | }, 341 | escapedName: 'prop2', 342 | }, 343 | ], 344 | }, 345 | ]) 346 | 347 | const result = typeParser.parseType(type, (programMock)) 348 | 349 | expect(result).toMatchObject(interfaceMatcher) 350 | }) 351 | 352 | it('should parse intersection', () => { 353 | const type = ({ 354 | flags: TypeFlags.Intersection, 355 | types: [ 356 | { 357 | flags: TypeFlags.String, 358 | value: 'string1', 359 | symbol: { name: 'symbolName' }, 360 | intrinsicName: 'intrinsicName', 361 | }, 362 | ], 363 | }) 364 | 365 | jest.spyOn(typeChecker, 'typeToString').mockReturnValue('unknown') 366 | 367 | const result = typeParser.parseType(type) 368 | 369 | expect(result).toMatchObject( 370 | expect.objectContaining({ 371 | flags: NodeFlags.Synthesized, 372 | kind: SyntaxKind.ObjectLiteralExpression, 373 | properties: expect.arrayContaining([ 374 | expect.objectContaining({ 375 | initializer: expect.objectContaining({ 376 | kind: SyntaxKind.StringLiteral, 377 | }), 378 | kind: SyntaxKind.PropertyAssignment, 379 | }), 380 | ]), 381 | }), 382 | ) 383 | }) 384 | 385 | it('should parse Enum', () => { 386 | const type = ({ 387 | flags: TypeFlags.EnumLike, 388 | types: [ 389 | { 390 | flags: TypeFlags.Enum, 391 | value: 'enum1', 392 | }, 393 | ], 394 | }) 395 | 396 | const result = typeParser.parseEnum(type) 397 | 398 | expect(result).toMatchObject( 399 | expect.objectContaining({ 400 | flags: NodeFlags.Synthesized, 401 | kind: SyntaxKind.ObjectLiteralExpression, 402 | properties: expect.arrayContaining([ 403 | expect.objectContaining({ 404 | initializer: expect.objectContaining({ 405 | kind: SyntaxKind.StringLiteral, 406 | }), 407 | kind: SyntaxKind.PropertyAssignment, 408 | }), 409 | ]), 410 | }), 411 | ) 412 | }) 413 | 414 | it('should parse union with couple types', () => { 415 | const type = ({ 416 | flags: TypeFlags.Union, 417 | types: [ 418 | { 419 | flags: TypeFlags.BooleanLiteral, 420 | value: false, 421 | }, 422 | { 423 | flags: TypeFlags.String, 424 | value: 'string', 425 | }, 426 | ], 427 | }) 428 | 429 | const result = typeParser.parseUnion(type) 430 | 431 | expect(result).toMatchObject( 432 | expect.objectContaining({ 433 | flags: NodeFlags.Synthesized, 434 | kind: SyntaxKind.ObjectLiteralExpression, 435 | }), 436 | ) 437 | }) 438 | 439 | it('should parse union with one types', () => { 440 | const type = ({ 441 | flags: TypeFlags.Union, 442 | types: [ 443 | { 444 | flags: TypeFlags.BooleanLiteral, 445 | value: false, 446 | }, 447 | ], 448 | }) 449 | 450 | const result = typeParser.parseUnion(type) 451 | 452 | expect(result).toMatchObject( 453 | expect.objectContaining({ 454 | flags: NodeFlags.Synthesized, 455 | kind: SyntaxKind.ObjectLiteralExpression, 456 | }), 457 | ) 458 | }) 459 | }) 460 | }) 461 | -------------------------------------------------------------------------------- /src/application.ts: -------------------------------------------------------------------------------- 1 | import { AsyncLocalStorage } from 'node:async_hooks' 2 | import path from 'node:path' 3 | 4 | import { AwilixContainer, InjectionMode, NameAndRegistrationPair, asClass, asValue, createContainer, listModules } from 'awilix' 5 | import { NameFormatter } from 'awilix/lib/load-modules' 6 | import { camelCase, upperFirst } from 'lodash' 7 | import { singular } from 'pluralize' 8 | import type { Class } from 'type-fest' 9 | 10 | import { Counter } from '@diia-inhouse/diia-metrics' 11 | import { EnvService } from '@diia-inhouse/env' 12 | import { 13 | AlsData, 14 | Logger, 15 | LoggerConstructor, 16 | OnBeforeApplicationShutdown, 17 | OnDestroy, 18 | OnInit, 19 | OnRegistrationsFinished, 20 | } from '@diia-inhouse/types' 21 | import { guards } from '@diia-inhouse/utils' 22 | 23 | import { getBaseDeps } from './baseDeps' 24 | import { GrpcService } from './grpc' 25 | import { 26 | AppConfigType, 27 | AppDepsType, 28 | ConfigFactoryFn, 29 | DepsFactoryFn, 30 | DepsType, 31 | LoadDepsFromFolderOptions, 32 | ServiceContext, 33 | ServiceOperator, 34 | } from './interfaces/application' 35 | import { BaseDeps } from './interfaces/deps' 36 | import MoleculerService from './moleculer/moleculerWrapper' 37 | 38 | export class Application { 39 | private config?: AppConfigType 40 | 41 | private container?: AwilixContainer> 42 | 43 | private baseContainer: AwilixContainer>> 44 | 45 | private groupedDepsNames: Record = {} 46 | 47 | private syncCommunicationClasses = [MoleculerService, GrpcService] 48 | 49 | private asyncCommunicationClasses: Class[] = [] 50 | 51 | constructor( 52 | private readonly serviceName: string, 53 | loggerPkg = '@diia-inhouse/diia-logger', 54 | ) { 55 | this.baseContainer = createContainer>>({ injectionMode: InjectionMode.CLASSIC }).register({ 56 | serviceName: asValue(this.serviceName), 57 | envService: asClass(EnvService).singleton(), 58 | // eslint-disable-next-line @typescript-eslint/no-var-requires 59 | logger: asClass(require(loggerPkg).default, { 60 | injector: () => ({ options: { logLevel: process.env.LOG_LEVEL } }), 61 | }).singleton(), 62 | asyncLocalStorage: asValue(new AsyncLocalStorage()), 63 | }) 64 | } 65 | 66 | async setConfig(factory: ConfigFactoryFn>): Promise { 67 | const envService = this.baseContainer.resolve('envService') 68 | 69 | await envService.init() 70 | this.config = await factory(envService, this.serviceName) 71 | 72 | return this 73 | } 74 | 75 | patchConfig(config: Partial>): void { 76 | if (!this.config) { 77 | throw new Error('Config should be set before patch') 78 | } 79 | 80 | Object.assign(this.config, config) 81 | } 82 | 83 | getConfig(): AppConfigType { 84 | if (!this.config) { 85 | throw new Error('Config should be set before getting') 86 | } 87 | 88 | return this.config 89 | } 90 | 91 | async setDeps(factory: DepsFactoryFn, AppDepsType>): Promise { 92 | if (!this.config) { 93 | throw new Error('Config should be set before deps') 94 | } 95 | 96 | const baseDeps = await this.getBaseDeps() 97 | 98 | this.baseContainer.register(baseDeps) 99 | 100 | const appDeps = await factory(this.config, this.baseContainer) 101 | 102 | this.container = this.baseContainer 103 | .createScope>() 104 | .register(>>appDeps) 105 | 106 | return this 107 | } 108 | 109 | async initialize(): Promise, DepsType>> { 110 | this.setOnShutDownHook() 111 | 112 | await this.loadDefaultDepsFolders() 113 | 114 | const ctx = this.createContext() 115 | 116 | return { 117 | ...ctx, 118 | start: this.start.bind(this), 119 | stop: this.stop.bind(this), 120 | } 121 | } 122 | 123 | defaultNameFormatter(folderName: string): NameFormatter { 124 | return (_, descriptor) => { 125 | const parsedPath = path.parse(descriptor.path) 126 | const fileName = parsedPath.name 127 | const dependencyPath = parsedPath.dir 128 | .split(`${this.getDepsDir()}${path.sep}${folderName}`)[1] 129 | .split(path.sep) 130 | .map((p) => upperFirst(p)) 131 | 132 | if (fileName !== 'index') { 133 | dependencyPath.push(upperFirst(fileName)) 134 | } 135 | 136 | const dependencyType = upperFirst(singular(folderName)) 137 | 138 | return camelCase(`${dependencyPath.join('')}${dependencyType}`) 139 | } 140 | } 141 | 142 | async loadDepsFromFolder(options: LoadDepsFromFolderOptions): Promise { 143 | const { folderName, fileMask = '**/*.js', nameFormatter = this.defaultNameFormatter(folderName), groupName } = options 144 | 145 | const directory = `${this.getDepsDir()}/${folderName}/${fileMask}` 146 | const modules = listModules(directory) 147 | 148 | if (groupName) { 149 | if (!Object.keys(this.baseContainer.registrations).includes(groupName)) { 150 | this.baseContainer.register(groupName, asValue([])) 151 | } 152 | 153 | this.groupedDepsNames[groupName] = this.groupedDepsNames[groupName] || [] 154 | } 155 | 156 | for (const module of modules) { 157 | const { name, path: modulePath } = module 158 | const registrationName = nameFormatter(name, { ...module, value: '' }) 159 | const dependency = await import(path.resolve(modulePath)) 160 | const defaultExport = dependency.default?.default ?? dependency.default 161 | 162 | if (typeof defaultExport === 'function') { 163 | this.baseContainer.register( 164 | registrationName, 165 | asClass(defaultExport, { 166 | injector: (c) => { 167 | const logger = c.resolve('logger') 168 | const childLogger = logger.child?.({ regName: registrationName }) 169 | 170 | return { logger: childLogger ?? logger } 171 | }, 172 | }).singleton(), 173 | ) 174 | 175 | if (groupName) { 176 | this.groupedDepsNames[groupName].push(registrationName) 177 | } 178 | } 179 | } 180 | 181 | return this 182 | } 183 | 184 | private getDepsDir(): string { 185 | return path.resolve(this.config?.depsDir ?? 'dist') 186 | } 187 | 188 | private async getBaseDeps(): Promise>>> { 189 | const { config, asyncCommunicationClasses } = this 190 | if (!config) { 191 | throw new Error('Config should be set before using [getBaseDeps]') 192 | } 193 | 194 | if (config.rabbit) { 195 | const { ExternalCommunicator, ExternalEventBus, ScheduledTask, EventBus, Task } = await import('@diia-inhouse/diia-queue') 196 | 197 | asyncCommunicationClasses.push(ExternalCommunicator, ExternalEventBus, ScheduledTask, EventBus, Task) 198 | } 199 | 200 | return await getBaseDeps(config) 201 | } 202 | 203 | private async start(): Promise { 204 | await this.resolveDeps() 205 | } 206 | 207 | private async stop(): Promise { 208 | if (!this.container) { 209 | throw new Error('Container should be initialized before stopping') 210 | } 211 | 212 | const registeredInstances = Object.keys(this.container.registrations) 213 | const logger = this.container.resolve('logger') 214 | const destroyOrder: OnDestroy[][] = [] 215 | const onBeforeAppShutdownInstances: OnBeforeApplicationShutdown[] = [] 216 | for (const name of registeredInstances) { 217 | const instance = this.container.resolve(name) 218 | if (guards.hasOnDestroyHook(instance)) { 219 | const order = this.getOnDestroyOrder(instance) 220 | 221 | destroyOrder[order] ??= [] 222 | destroyOrder[order].push(instance) 223 | } 224 | 225 | if (guards.hasOnBeforeApplicationShutdownHook(instance)) { 226 | onBeforeAppShutdownInstances.push(instance) 227 | } 228 | } 229 | 230 | const onDestroyErrors: Error[] = [] 231 | for (const [order, instancesWithOnDestroyHook = []] of destroyOrder.entries()) { 232 | const onDestroyTasks = instancesWithOnDestroyHook.map(async (instance) => { 233 | try { 234 | await instance.onDestroy() 235 | logger.info(`[onDestroy:${order}] Finished ${instance.constructor.name} destruction`) 236 | } catch (err) { 237 | logger.error(`[onDestroy:${order}] Failed ${instance.constructor.name} destruction`, { err }) 238 | throw err 239 | } 240 | }) 241 | const errors = await this.runHookTasks(onDestroyTasks) 242 | 243 | onDestroyErrors.push(...errors) 244 | } 245 | 246 | const onBeforeAppShutdownTasks = onBeforeAppShutdownInstances.map(async (instance) => { 247 | try { 248 | await instance.onBeforeApplicationShutdown() 249 | logger.info(`[onBeforeAppShutdown] Finished ${instance.constructor.name} destruction`) 250 | } catch (err) { 251 | logger.error(`[onBeforeAppShutdown] Failed ${instance.constructor.name} destruction`, { err }) 252 | throw err 253 | } 254 | }) 255 | 256 | const onBeforeAppShutdownErrors = await this.runHookTasks(onBeforeAppShutdownTasks) 257 | if (onDestroyErrors.length > 0 || onBeforeAppShutdownErrors.length > 0) { 258 | throw new AggregateError(onDestroyErrors.concat(onBeforeAppShutdownErrors), 'Failed to stop service') 259 | } 260 | } 261 | 262 | private async runHookTasks(tasks: Promise[]): Promise { 263 | const results = await Promise.allSettled(tasks) 264 | 265 | return results.filter(guards.isSettledError).map((err) => new Error(err.reason)) 266 | } 267 | 268 | private async loadDefaultDepsFolders(): Promise { 269 | await this.loadDepsFromFolder({ 270 | folderName: 'actions', 271 | groupName: 'actionList', 272 | }) 273 | 274 | await this.loadDepsFromFolder({ 275 | folderName: 'tasks', 276 | groupName: 'taskList', 277 | }) 278 | 279 | await this.loadDepsFromFolder({ 280 | folderName: 'scheduledTasks', 281 | groupName: 'scheduledTaskList', 282 | }) 283 | 284 | await this.loadDepsFromFolder({ 285 | folderName: 'eventListeners', 286 | groupName: 'eventListenerList', 287 | }) 288 | 289 | await this.loadDepsFromFolder({ 290 | folderName: 'externalEventListeners', 291 | groupName: 'externalEventListenerList', 292 | }) 293 | 294 | await this.loadDepsFromFolder({ 295 | folderName: 'services', 296 | }) 297 | 298 | await this.loadDepsFromFolder({ 299 | folderName: 'dataMappers', 300 | nameFormatter: (name): string => name, 301 | }) 302 | } 303 | 304 | private async resolveDeps(): Promise { 305 | if (!this.container) { 306 | throw new Error('Container should be initialized before deps resolving') 307 | } 308 | 309 | for (const [aggregateName, moduleNames] of Object.entries(this.groupedDepsNames)) { 310 | const group = this.container.resolve(aggregateName) 311 | 312 | for (const moduleName of moduleNames) { 313 | group.push(this.container.resolve(moduleName)) 314 | } 315 | } 316 | 317 | const registeredObjects = Object.keys(this.container.registrations) 318 | const logger = this.container.resolve('logger') 319 | const initOrder: [OnInit[], OnInit[], OnInit[], OnInit[]] = [[], [], [], []] 320 | const registrationsFinishedHooks: OnRegistrationsFinished[] = [] 321 | 322 | for (const name of registeredObjects) { 323 | const instance = this.container.resolve(name) 324 | if (guards.hasOnInitHook(instance)) { 325 | const order = this.getOnInitOrder(instance) 326 | 327 | initOrder[order].push(instance) 328 | } 329 | 330 | if (guards.hasOnRegistrationsFinishedHook(instance)) { 331 | registrationsFinishedHooks.push(instance) 332 | } 333 | } 334 | 335 | await Promise.all( 336 | registrationsFinishedHooks.map(async (instance) => { 337 | await instance.onRegistrationsFinished() 338 | logger.info(`[onRegistrationsFinished] Finished ${instance.constructor.name}`) 339 | }), 340 | ) 341 | 342 | for (const [order, instancesWithOnInitHook] of initOrder.entries()) { 343 | await Promise.all( 344 | instancesWithOnInitHook.map(async (instance) => { 345 | await instance.onInit() 346 | 347 | logger.info(`[onInit:${order}] Finished ${instance.constructor.name} initialization`) 348 | }), 349 | ) 350 | } 351 | } 352 | 353 | private getOnInitOrder(instance: object): 0 | 1 | 2 | 3 { 354 | if (instance.constructor.name === EnvService.name) { 355 | return 0 356 | } 357 | 358 | if (this.syncCommunicationClasses.some((item) => instance.constructor.name === item.name)) { 359 | return 2 360 | } 361 | 362 | if (this.asyncCommunicationClasses.some((item) => instance.constructor.name === item.name)) { 363 | return 3 364 | } 365 | 366 | return 1 367 | } 368 | 369 | private getOnDestroyOrder(instance: OnDestroy): number { 370 | const onInitOrder = this.getOnInitOrder(instance) 371 | 372 | return Math.abs(onInitOrder - 3) 373 | } 374 | 375 | private createContext(): ServiceContext, DepsType> { 376 | if (!this.container || !this.config) { 377 | throw new Error('Container and config should be initialized before creating context') 378 | } 379 | 380 | return { 381 | config: this.config, 382 | container: this.container, 383 | } 384 | } 385 | 386 | private setOnShutDownHook(): void { 387 | const listenTerminationSignals = this.config?.listenTerminationSignals ?? true 388 | if (listenTerminationSignals) { 389 | process.on('SIGINT', (err) => this.onShutDown('On SIGINT shutdown', err)) 390 | process.on('SIGQUIT', (err) => this.onShutDown('On SIGQUIT shutdown', err)) 391 | process.on('SIGTERM', (err) => this.onShutDown('On SIGTERM shutdown', err)) 392 | } 393 | 394 | const UncaughtExceptionMetric = new Counter('uncaught_exceptions_total') 395 | 396 | process.on('uncaughtException', async (err) => { 397 | UncaughtExceptionMetric.increment({}) 398 | await this.onShutDown('On uncaughtException shutdown', err) 399 | }) 400 | 401 | const UnhandledRejectionMetric = new Counter('unhandled_rejections_total') 402 | 403 | process.on('unhandledRejection', async (err: Error) => { 404 | UnhandledRejectionMetric.increment({}) 405 | await this.onShutDown('On unhandledRejection shutdown', err) 406 | }) 407 | } 408 | 409 | private async onShutDown(msg: string, error: unknown): Promise { 410 | if (error) { 411 | this.baseContainer?.resolve('logger').error(msg, { err: error }) 412 | } else { 413 | this.baseContainer?.resolve('logger').warn(msg) 414 | } 415 | 416 | try { 417 | await this.stop() 418 | } catch (err) { 419 | this.baseContainer?.resolve('logger').error('Failed to stop service. Shutdown completed', { err }) 420 | } 421 | 422 | // eslint-disable-next-line n/no-process-exit, unicorn/no-process-exit 423 | setImmediate(() => process.exit(error ? 1 : 0)) 424 | } 425 | } 426 | 427 | export default Application 428 | --------------------------------------------------------------------------------