moex-vibe/apps/backend/src/modules/tbank/services/tbank-client.service.ts
Sergey Krylov 8c2a6c9e3c
All checks were successful
CI / lint (pull_request) Successful in 2m11s
CI / test (pull_request) Successful in 1m58s
CI / build (pull_request) Successful in 2m3s
CI / lint (push) Successful in 1m57s
CI / test (push) Successful in 2m4s
CI / build (push) Successful in 2m8s
fix: trust tbank grpc root certificate
2026-06-17 07:39:37 +03:00

192 lines
5.7 KiB
TypeScript

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 { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { TBANK_PROTO_FILES, TBANK_PROTO_PACKAGE } from '../tbank.config';
type GrpcUnary<TRequest, TResponse> = (
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<typeof loadPackageDefinition>;
private readonly clientCache = new Map<string, Client>();
constructor(private readonly configService: ConfigService) {
this.requestTimeoutMs = this.configService.get<number>('app.tbank.requestTimeoutMs', 10000);
this.queue = new PQueue({
interval: 1000,
intervalCap: this.configService.get<number>('app.tbank.rateLimitPerSecond', 5),
});
const protoRoot = this.resolveProtoRoot();
const definition = loadSync(
Object.values(TBANK_PROTO_FILES).map((fileName) => join(protoRoot, fileName)),
{
includeDirs: [protoRoot],
keepCase: false,
longs: String,
enums: String,
defaults: true,
oneofs: true,
},
);
this.packageDefinition = loadPackageDefinition(definition);
}
private resolveProtoRoot(): string {
const distProtoRoot = join(__dirname, '..', 'proto', 'contracts');
if (existsSync(distProtoRoot)) return distProtoRoot;
return join(process.cwd(), 'src', 'modules', 'tbank', 'proto', 'contracts');
}
createMetadata(): Metadata {
const token = this.configService.get<string>('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<string>('app.tbank.appName', '');
if (appName) metadata.set('x-app-name', appName);
return metadata;
}
redactMetadata(metadata: Metadata): Record<string, string> {
const result: Record<string, string> = {};
for (const key of Object.keys(metadata.getMap())) {
if (key.toLowerCase() === 'authorization') {
result.Authorization = '<redacted>';
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<string>('app.tbank.baseUrl', 'invest-public-api.tbank.ru:443'),
this.createChannelCredentials(),
);
this.clientCache.set(serviceName, client);
return client;
}
async callUnary<TRequest, TResponse>(
label: string,
method: GrpcUnary<TRequest, TResponse>,
request: TRequest,
): Promise<TResponse> {
const metadata = this.createMetadata();
const deadline = new Date(Date.now() + this.requestTimeoutMs);
return this.queue.add(
() =>
new Promise<TResponse>((resolve, reject) => {
method(request, metadata, { deadline }, (error, response) => {
if (error) {
reject(this.mapGrpcError(label, error));
return;
}
resolve(response as TResponse);
});
}),
) as Promise<TResponse>;
}
private resolveProtoNamespace(): Record<string, unknown> {
return TBANK_PROTO_PACKAGE.split('.').reduce<Record<string, unknown>>(
(current, part) => {
return current[part] as Record<string, unknown>;
},
this.packageDefinition as Record<string, unknown>,
);
}
private createChannelCredentials(): ChannelCredentials {
const caCertPath = this.configService.get<string>('app.tbank.caCertPath', '');
if (!caCertPath) return ChannelCredentials.createSsl();
try {
return ChannelCredentials.createSsl(readFileSync(caCertPath));
} catch (error) {
this.logger.error(
`Failed to read T-Bank CA certificate from ${caCertPath}: ${
error instanceof Error ? error.message : String(error)
}`,
);
throw new ServiceUnavailableException('T-Bank CA certificate is not readable');
}
}
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,
});
}
}