mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-28 20:37:42 +02:00
✨ feat: add mana-api-gateway for monetizing core services
Implement custom NestJS API Gateway for mana-search, mana-stt, and mana-tts: - API Key management with CRUD operations and key regeneration - Redis-based sliding window rate limiting - Credit-based billing with tier support (free, pro, enterprise) - Usage tracking with daily aggregates - Proxy services to backend microservices - Prometheus metrics endpoint - JWT auth for management API, API key auth for public API Database schema uses separate `api_gateway` schema in shared manacore DB.
This commit is contained in:
parent
fbd315eac0
commit
6f1b2654f1
48 changed files with 2507 additions and 0 deletions
|
|
@ -0,0 +1,22 @@
|
|||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
import { ApiKeyData } from '../../api-keys/api-keys.service';
|
||||
|
||||
/**
|
||||
* Parameter decorator to extract the validated API key data from the request.
|
||||
* Must be used with ApiKeyGuard.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* @Post('search')
|
||||
* @UseGuards(ApiKeyGuard)
|
||||
* search(@ApiKeyData() apiKey: ApiKeyData) {
|
||||
* return { keyId: apiKey.id };
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export const ApiKeyParam = createParamDecorator(
|
||||
(data: unknown, ctx: ExecutionContext): ApiKeyData => {
|
||||
const request = ctx.switchToHttp().getRequest();
|
||||
return request.apiKey;
|
||||
}
|
||||
);
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus } from '@nestjs/common';
|
||||
import { Response } from 'express';
|
||||
|
||||
@Catch()
|
||||
export class HttpExceptionFilter implements ExceptionFilter {
|
||||
catch(exception: unknown, host: ArgumentsHost) {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
|
||||
let status = HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
let message = 'Internal server error';
|
||||
let details: any = undefined;
|
||||
|
||||
if (exception instanceof HttpException) {
|
||||
status = exception.getStatus();
|
||||
const exceptionResponse = exception.getResponse();
|
||||
|
||||
if (typeof exceptionResponse === 'string') {
|
||||
message = exceptionResponse;
|
||||
} else if (typeof exceptionResponse === 'object') {
|
||||
const resp = exceptionResponse as any;
|
||||
message = resp.message || message;
|
||||
details = resp;
|
||||
}
|
||||
} else if (exception instanceof Error) {
|
||||
message = exception.message;
|
||||
console.error('Unhandled exception:', exception);
|
||||
}
|
||||
|
||||
response.status(status).json({
|
||||
statusCode: status,
|
||||
message,
|
||||
timestamp: new Date().toISOString(),
|
||||
...(details && status !== HttpStatus.INTERNAL_SERVER_ERROR && { details }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
|
||||
import { Observable } from 'rxjs';
|
||||
import { tap, catchError } from 'rxjs/operators';
|
||||
import { ApiKeyData } from '../../api-keys/api-keys.service';
|
||||
import { ApiKeysService } from '../../api-keys/api-keys.service';
|
||||
import { UsageService } from '../../usage/usage.service';
|
||||
import { CreditsService } from '../../credits/credits.service';
|
||||
import { CREDIT_COSTS } from '../../config/pricing';
|
||||
|
||||
@Injectable()
|
||||
export class UsageTrackingInterceptor implements NestInterceptor {
|
||||
constructor(
|
||||
private readonly usageService: UsageService,
|
||||
private readonly creditsService: CreditsService,
|
||||
private readonly apiKeysService: ApiKeysService
|
||||
) {}
|
||||
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const response = context.switchToHttp().getResponse();
|
||||
const apiKey = request.apiKey as ApiKeyData;
|
||||
const startTime = Date.now();
|
||||
|
||||
if (!apiKey) {
|
||||
return next.handle();
|
||||
}
|
||||
|
||||
return next.handle().pipe(
|
||||
tap(async (responseBody) => {
|
||||
const latencyMs = Date.now() - startTime;
|
||||
const endpoint = this.extractEndpoint(request.path);
|
||||
|
||||
// Calculate credits
|
||||
const creditsUsed = this.calculateCredits(endpoint, request, responseBody);
|
||||
|
||||
// Track usage
|
||||
await this.usageService.track({
|
||||
apiKeyId: apiKey.id,
|
||||
endpoint,
|
||||
method: request.method,
|
||||
path: request.path,
|
||||
latencyMs,
|
||||
statusCode: response.statusCode || 200,
|
||||
creditsUsed,
|
||||
metadata: {
|
||||
userAgent: request.headers['user-agent'],
|
||||
},
|
||||
});
|
||||
|
||||
// Increment credits used on the API key
|
||||
if (creditsUsed > 0) {
|
||||
await this.apiKeysService.incrementCreditsUsed(apiKey.id, creditsUsed);
|
||||
}
|
||||
|
||||
// Deduct credits from user account if applicable
|
||||
if (apiKey.userId && creditsUsed > 0) {
|
||||
try {
|
||||
await this.creditsService.deduct(apiKey.userId, creditsUsed, {
|
||||
appId: 'api-gateway',
|
||||
description: `API: ${endpoint}`,
|
||||
apiKeyId: apiKey.id,
|
||||
});
|
||||
} catch (error) {
|
||||
// Log but don't fail the request
|
||||
console.error('Failed to deduct credits from user account:', error);
|
||||
}
|
||||
}
|
||||
}),
|
||||
catchError(async (error) => {
|
||||
const latencyMs = Date.now() - startTime;
|
||||
const endpoint = this.extractEndpoint(request.path);
|
||||
|
||||
// Track failed requests (no credits deducted)
|
||||
await this.usageService.track({
|
||||
apiKeyId: apiKey.id,
|
||||
endpoint,
|
||||
method: request.method,
|
||||
path: request.path,
|
||||
latencyMs,
|
||||
statusCode: error.status || 500,
|
||||
creditsUsed: 0,
|
||||
metadata: {
|
||||
userAgent: request.headers['user-agent'],
|
||||
error: error.message,
|
||||
},
|
||||
});
|
||||
|
||||
throw error;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private extractEndpoint(path: string): string {
|
||||
const match = path.match(/\/v1\/(\w+)/);
|
||||
return match ? match[1] : 'unknown';
|
||||
}
|
||||
|
||||
private calculateCredits(endpoint: string, request: any, response: any): number {
|
||||
switch (endpoint) {
|
||||
case 'search':
|
||||
return CREDIT_COSTS.search;
|
||||
case 'tts':
|
||||
const text = request.body?.text || '';
|
||||
return Math.max(1, Math.ceil(text.length / 1000) * CREDIT_COSTS.tts.per1000Chars);
|
||||
case 'stt':
|
||||
// Calculate from actual audio duration if available in response
|
||||
const minutes = response?.duration ? response.duration / 60 : 1;
|
||||
return Math.max(1, Math.ceil(minutes) * CREDIT_COSTS.stt.perMinute);
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue