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