192 lines
5.7 KiB
TypeScript
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,
|
|
});
|
|
}
|
|
}
|