From 4ef2f8d05e711d6cdef46af56a0f053c02265121 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Tue, 16 Jun 2026 22:23:33 +0300 Subject: [PATCH] feat: add tbank grpc client service --- .../services/tbank-client.service.spec.ts | 73 ++++++++ .../tbank/services/tbank-client.service.ts | 164 ++++++++++++++++++ .../backend/src/modules/tbank/tbank.module.ts | 8 + 3 files changed, 245 insertions(+) create mode 100644 apps/backend/src/modules/tbank/services/tbank-client.service.spec.ts create mode 100644 apps/backend/src/modules/tbank/services/tbank-client.service.ts create mode 100644 apps/backend/src/modules/tbank/tbank.module.ts diff --git a/apps/backend/src/modules/tbank/services/tbank-client.service.spec.ts b/apps/backend/src/modules/tbank/services/tbank-client.service.spec.ts new file mode 100644 index 0000000..83b19f3 --- /dev/null +++ b/apps/backend/src/modules/tbank/services/tbank-client.service.spec.ts @@ -0,0 +1,73 @@ +import { ServiceUnavailableException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Metadata, status } from '@grpc/grpc-js'; +import { TBankClientService } from './tbank-client.service'; + +describe('TBankClientService', () => { + const config = { + get: vi.fn((key: string, fallback?: unknown) => { + const values: Record = { + 'app.tbank.token': 'token-1', + 'app.tbank.appName': 'ksv741.moex-vibe', + 'app.tbank.rateLimitPerSecond': 5, + 'app.tbank.requestTimeoutMs': 10000, + }; + + return values[key] ?? fallback; + }), + } as unknown as ConfigService; + + it('builds redacted authorization metadata', () => { + const service = new TBankClientService(config); + const metadata = service.createMetadata(); + + expect(metadata.get('Authorization')).toEqual(['Bearer token-1']); + expect(metadata.get('x-app-name')).toEqual(['ksv741.moex-vibe']); + expect(service.redactMetadata(metadata)).toEqual({ + Authorization: '', + 'x-app-name': 'ksv741.moex-vibe', + }); + }); + + it('throws integration unavailable when token is missing', async () => { + const missingConfig = { + get: vi.fn((key: string, fallback?: unknown) => + key === 'app.tbank.token' ? '' : (fallback as unknown), + ), + } as unknown as ConfigService; + const service = new TBankClientService(missingConfig); + + await expect( + service.callUnary( + 'UsersService/GetAccounts', + (_request, _metadata, _options, callback) => { + callback(null, {}); + }, + {}, + ), + ).rejects.toThrow(ServiceUnavailableException); + }); + + it('wraps grpc errors with status code and tracking id', async () => { + const service = new TBankClientService(config); + const error = Object.assign(new Error('Too many requests'), { + code: status.RESOURCE_EXHAUSTED, + metadata: new Metadata(), + }); + error.metadata.set('x-tracking-id', 'tracking-1'); + + await expect( + service.callUnary( + 'OperationsService/GetPortfolio', + (_request, _metadata, _options, callback) => { + callback(error, null); + }, + {}, + ), + ).rejects.toMatchObject({ + response: expect.objectContaining({ + message: expect.stringContaining('T-Bank upstream error'), + }), + }); + }); +}); diff --git a/apps/backend/src/modules/tbank/services/tbank-client.service.ts b/apps/backend/src/modules/tbank/services/tbank-client.service.ts new file mode 100644 index 0000000..ab2b4cb --- /dev/null +++ b/apps/backend/src/modules/tbank/services/tbank-client.service.ts @@ -0,0 +1,164 @@ +import { + BadGatewayException, + Injectable, + Logger, + ServiceUnavailableException, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { + CallOptions, + ChannelCredentials, + Client, + ClientUnaryCall, + loadPackageDefinition, + Metadata, + ServiceError, + status, +} from '@grpc/grpc-js'; +import { loadSync } from '@grpc/proto-loader'; +import PQueue from 'p-queue'; +import { join } from 'node:path'; +import { TBANK_PROTO_FILES, TBANK_PROTO_PACKAGE } from '../tbank.config'; + +type GrpcUnary = ( + request: TRequest, + metadata: Metadata, + options: CallOptions, + callback: (error: ServiceError | null, response: TResponse | null) => void, +) => ClientUnaryCall; + +type GrpcServiceConstructor = new (address: string, credentials: ChannelCredentials) => Client; + +@Injectable() +export class TBankClientService { + private readonly logger = new Logger(TBankClientService.name); + private readonly queue: PQueue; + private readonly requestTimeoutMs: number; + private readonly packageDefinition: ReturnType; + private readonly clientCache = new Map(); + + constructor(private readonly configService: ConfigService) { + this.requestTimeoutMs = this.configService.get('app.tbank.requestTimeoutMs', 10000); + this.queue = new PQueue({ + interval: 1000, + intervalCap: this.configService.get('app.tbank.rateLimitPerSecond', 5), + }); + + const protoRoot = join(__dirname, '..', 'proto', 'contracts'); + const definition = loadSync(Object.values(TBANK_PROTO_FILES), { + includeDirs: [protoRoot], + keepCase: false, + longs: String, + enums: String, + defaults: true, + oneofs: true, + }); + + this.packageDefinition = loadPackageDefinition(definition); + } + + createMetadata(): Metadata { + const token = this.configService.get('app.tbank.token', ''); + if (!token) { + throw new ServiceUnavailableException('T-Bank integration is not configured'); + } + + const metadata = new Metadata(); + metadata.set('Authorization', `Bearer ${token}`); + + const appName = this.configService.get('app.tbank.appName', ''); + if (appName) metadata.set('x-app-name', appName); + + return metadata; + } + + redactMetadata(metadata: Metadata): Record { + const result: Record = {}; + + for (const key of Object.keys(metadata.getMap())) { + if (key.toLowerCase() === 'authorization') { + result.Authorization = ''; + continue; + } + + result[key] = String(metadata.get(key)[0]); + } + + return result; + } + + getServiceClient( + serviceName: 'UsersService' | 'OperationsService' | 'InstrumentsService', + ): Client { + const cached = this.clientCache.get(serviceName); + if (cached) return cached; + + const namespace = this.resolveProtoNamespace(); + const ServiceCtor = namespace[serviceName] as GrpcServiceConstructor; + const client = new ServiceCtor( + this.configService.get('app.tbank.baseUrl', 'invest-public-api.tbank.ru:443'), + ChannelCredentials.createSsl(), + ); + + this.clientCache.set(serviceName, client); + + return client; + } + + async callUnary( + label: string, + method: GrpcUnary, + request: TRequest, + ): Promise { + const metadata = this.createMetadata(); + const deadline = new Date(Date.now() + this.requestTimeoutMs); + + return this.queue.add( + () => + new Promise((resolve, reject) => { + method(request, metadata, { deadline }, (error, response) => { + if (error) { + reject(this.mapGrpcError(label, error)); + return; + } + + resolve(response as TResponse); + }); + }), + ) as Promise; + } + + private resolveProtoNamespace(): Record { + return TBANK_PROTO_PACKAGE.split('.').reduce>( + (current, part) => { + return current[part] as Record; + }, + this.packageDefinition as Record, + ); + } + + private mapGrpcError(label: string, error: ServiceError): Error { + const trackingId = error.metadata?.get('x-tracking-id')?.[0]; + const retryAfter = error.metadata?.get('x-ratelimit-reset')?.[0]; + const publicMessage = + error.code === status.RESOURCE_EXHAUSTED + ? 'T-Bank upstream error: rate limit exceeded' + : `T-Bank upstream error while calling ${label}`; + + this.logger.warn( + JSON.stringify({ + label, + code: error.code, + trackingId, + retryAfter, + message: error.message, + }), + ); + + return new BadGatewayException({ + message: publicMessage, + trackingId: trackingId ? String(trackingId) : null, + retryAfter: retryAfter ? String(retryAfter) : null, + }); + } +} diff --git a/apps/backend/src/modules/tbank/tbank.module.ts b/apps/backend/src/modules/tbank/tbank.module.ts new file mode 100644 index 0000000..d43b382 --- /dev/null +++ b/apps/backend/src/modules/tbank/tbank.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { TBankClientService } from './services/tbank-client.service'; + +@Module({ + providers: [TBankClientService], + exports: [TBankClientService], +}) +export class TBankModule {}