feat: add tbank grpc client service
This commit is contained in:
parent
825095106c
commit
4ef2f8d05e
@ -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'),
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
164
apps/backend/src/modules/tbank/services/tbank-client.service.ts
Normal file
164
apps/backend/src/modules/tbank/services/tbank-client.service.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
8
apps/backend/src/modules/tbank/tbank.module.ts
Normal file
8
apps/backend/src/modules/tbank/tbank.module.ts
Normal 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 {}
|
||||
Loading…
x
Reference in New Issue
Block a user