feat: add tbank grpc client service

This commit is contained in:
Sergey Krylov 2026-06-16 22:23:33 +03:00
parent 825095106c
commit 4ef2f8d05e
3 changed files with 245 additions and 0 deletions

View File

@ -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<string, unknown> = {
'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: '<redacted>',
'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'),
}),
});
});
});

View File

@ -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<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 = 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<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'),
ChannelCredentials.createSsl(),
);
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 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,
});
}
}

View File

@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { TBankClientService } from './services/tbank-client.service';
@Module({
providers: [TBankClientService],
exports: [TBankClientService],
})
export class TBankModule {}