mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-23 01:46: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
103
services/mana-api-gateway/src/proxy/proxy.controller.ts
Normal file
103
services/mana-api-gateway/src/proxy/proxy.controller.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Get,
|
||||
Body,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
UploadedFile,
|
||||
Res,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { Response } from 'express';
|
||||
import { ApiKeyGuard } from '../guards/api-key.guard';
|
||||
import { RateLimitGuard } from '../guards/rate-limit.guard';
|
||||
import { CreditsGuard } from '../guards/credits.guard';
|
||||
import { UsageTrackingInterceptor } from '../common/interceptors/usage-tracking.interceptor';
|
||||
import { ApiKeyParam } from '../common/decorators/api-key.decorator';
|
||||
import { ApiKeyData } from '../api-keys/api-keys.service';
|
||||
import {
|
||||
SearchProxyService,
|
||||
SearchRequestDto,
|
||||
ExtractRequestDto,
|
||||
BulkExtractRequestDto,
|
||||
} from './services/search-proxy.service';
|
||||
import { SttProxyService, TranscribeRequestDto } from './services/stt-proxy.service';
|
||||
import { TtsProxyService, SynthesizeRequestDto } from './services/tts-proxy.service';
|
||||
|
||||
@Controller('v1')
|
||||
@UseGuards(ApiKeyGuard, RateLimitGuard, CreditsGuard)
|
||||
@UseInterceptors(UsageTrackingInterceptor)
|
||||
export class ProxyController {
|
||||
constructor(
|
||||
private readonly searchProxy: SearchProxyService,
|
||||
private readonly sttProxy: SttProxyService,
|
||||
private readonly ttsProxy: TtsProxyService
|
||||
) {}
|
||||
|
||||
// === SEARCH ===
|
||||
|
||||
@Post('search')
|
||||
async search(@Body() body: SearchRequestDto, @ApiKeyParam() apiKey: ApiKeyData) {
|
||||
return this.searchProxy.search(body);
|
||||
}
|
||||
|
||||
@Get('search/engines')
|
||||
async getEngines() {
|
||||
return this.searchProxy.getEngines();
|
||||
}
|
||||
|
||||
@Post('extract')
|
||||
async extract(@Body() body: ExtractRequestDto) {
|
||||
return this.searchProxy.extract(body);
|
||||
}
|
||||
|
||||
@Post('extract/bulk')
|
||||
async bulkExtract(@Body() body: BulkExtractRequestDto) {
|
||||
return this.searchProxy.bulkExtract(body);
|
||||
}
|
||||
|
||||
// === STT ===
|
||||
|
||||
@Post('stt/transcribe')
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
async transcribe(@UploadedFile() file: Express.Multer.File, @Body() body: TranscribeRequestDto) {
|
||||
return this.sttProxy.transcribe(file, body);
|
||||
}
|
||||
|
||||
@Get('stt/models')
|
||||
async getSttModels() {
|
||||
return this.sttProxy.getModels();
|
||||
}
|
||||
|
||||
@Get('stt/languages')
|
||||
async getSttLanguages() {
|
||||
return this.sttProxy.getLanguages();
|
||||
}
|
||||
|
||||
// === TTS ===
|
||||
|
||||
@Post('tts/synthesize')
|
||||
async synthesize(@Body() body: SynthesizeRequestDto, @Res() res: Response) {
|
||||
const audio = await this.ttsProxy.synthesize(body);
|
||||
|
||||
const format = body.format || 'mp3';
|
||||
const contentType =
|
||||
format === 'wav' ? 'audio/wav' : format === 'ogg' ? 'audio/ogg' : 'audio/mpeg';
|
||||
|
||||
res.setHeader('Content-Type', contentType);
|
||||
res.setHeader('Content-Length', audio.length);
|
||||
res.send(audio);
|
||||
}
|
||||
|
||||
@Get('tts/voices')
|
||||
async getTtsVoices() {
|
||||
return this.ttsProxy.getVoices();
|
||||
}
|
||||
|
||||
@Get('tts/languages')
|
||||
async getTtsLanguages() {
|
||||
return this.ttsProxy.getLanguages();
|
||||
}
|
||||
}
|
||||
56
services/mana-api-gateway/src/proxy/proxy.module.ts
Normal file
56
services/mana-api-gateway/src/proxy/proxy.module.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { MulterModule } from '@nestjs/platform-express';
|
||||
import { memoryStorage } from 'multer';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import Redis from 'ioredis';
|
||||
import { ProxyController } from './proxy.controller';
|
||||
import { SearchProxyService, SttProxyService, TtsProxyService } from './services';
|
||||
import { ApiKeysModule } from '../api-keys/api-keys.module';
|
||||
import { UsageModule } from '../usage/usage.module';
|
||||
import { CreditsModule } from '../credits/credits.module';
|
||||
import { ApiKeyGuard } from '../guards/api-key.guard';
|
||||
import { RateLimitGuard, REDIS_CLIENT } from '../guards/rate-limit.guard';
|
||||
import { CreditsGuard } from '../guards/credits.guard';
|
||||
import { UsageTrackingInterceptor } from '../common/interceptors/usage-tracking.interceptor';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
MulterModule.register({
|
||||
storage: memoryStorage(),
|
||||
limits: {
|
||||
fileSize: 100 * 1024 * 1024, // 100MB max file size
|
||||
},
|
||||
}),
|
||||
ApiKeysModule,
|
||||
UsageModule,
|
||||
CreditsModule,
|
||||
],
|
||||
controllers: [ProxyController],
|
||||
providers: [
|
||||
SearchProxyService,
|
||||
SttProxyService,
|
||||
TtsProxyService,
|
||||
ApiKeyGuard,
|
||||
RateLimitGuard,
|
||||
CreditsGuard,
|
||||
UsageTrackingInterceptor,
|
||||
{
|
||||
provide: REDIS_CLIENT,
|
||||
useFactory: (configService: ConfigService) => {
|
||||
const host = configService.get<string>('redis.host') || 'localhost';
|
||||
const port = configService.get<number>('redis.port') || 6379;
|
||||
const password = configService.get<string>('redis.password');
|
||||
|
||||
return new Redis({
|
||||
host,
|
||||
port,
|
||||
password: password || undefined,
|
||||
keyPrefix: configService.get<string>('redis.keyPrefix') || 'api-gateway:',
|
||||
});
|
||||
},
|
||||
inject: [ConfigService],
|
||||
},
|
||||
],
|
||||
exports: [REDIS_CLIENT],
|
||||
})
|
||||
export class ProxyModule {}
|
||||
3
services/mana-api-gateway/src/proxy/services/index.ts
Normal file
3
services/mana-api-gateway/src/proxy/services/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export * from './search-proxy.service';
|
||||
export * from './stt-proxy.service';
|
||||
export * from './tts-proxy.service';
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
import { Injectable, HttpException, HttpStatus } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
export interface SearchRequestDto {
|
||||
query: string;
|
||||
options?: {
|
||||
categories?: string[];
|
||||
engines?: string[];
|
||||
language?: string;
|
||||
limit?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ExtractRequestDto {
|
||||
url: string;
|
||||
options?: {
|
||||
includeMarkdown?: boolean;
|
||||
maxLength?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface BulkExtractRequestDto {
|
||||
urls: string[];
|
||||
options?: {
|
||||
includeMarkdown?: boolean;
|
||||
maxLength?: number;
|
||||
};
|
||||
concurrency?: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SearchProxyService {
|
||||
private readonly searchUrl: string;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
this.searchUrl = this.configService.get('services.search') || 'http://localhost:3021';
|
||||
}
|
||||
|
||||
async search(body: SearchRequestDto): Promise<any> {
|
||||
const response = await fetch(`${this.searchUrl}/api/v1/search`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new HttpException(
|
||||
`Search service error: ${error}`,
|
||||
response.status || HttpStatus.BAD_GATEWAY
|
||||
);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async extract(body: ExtractRequestDto): Promise<any> {
|
||||
const response = await fetch(`${this.searchUrl}/api/v1/extract`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new HttpException(
|
||||
`Extract service error: ${error}`,
|
||||
response.status || HttpStatus.BAD_GATEWAY
|
||||
);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async bulkExtract(body: BulkExtractRequestDto): Promise<any> {
|
||||
const response = await fetch(`${this.searchUrl}/api/v1/extract/bulk`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new HttpException(
|
||||
`Bulk extract service error: ${error}`,
|
||||
response.status || HttpStatus.BAD_GATEWAY
|
||||
);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async getEngines(): Promise<any> {
|
||||
const response = await fetch(`${this.searchUrl}/api/v1/search/engines`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new HttpException('Failed to get search engines', HttpStatus.BAD_GATEWAY);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
import { Injectable, HttpException, HttpStatus } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
export interface TranscribeRequestDto {
|
||||
language?: string;
|
||||
model?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SttProxyService {
|
||||
private readonly sttUrl: string;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
this.sttUrl = this.configService.get('services.stt') || 'http://localhost:3020';
|
||||
}
|
||||
|
||||
async transcribe(file: Express.Multer.File, options: TranscribeRequestDto): Promise<any> {
|
||||
const formData = new FormData();
|
||||
const uint8Array = new Uint8Array(file.buffer);
|
||||
formData.append('file', new Blob([uint8Array], { type: file.mimetype }), file.originalname);
|
||||
|
||||
if (options.language) {
|
||||
formData.append('language', options.language);
|
||||
}
|
||||
if (options.model) {
|
||||
formData.append('model', options.model);
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.sttUrl}/api/v1/transcribe`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new HttpException(
|
||||
`STT service error: ${error}`,
|
||||
response.status || HttpStatus.BAD_GATEWAY
|
||||
);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async getModels(): Promise<any> {
|
||||
const response = await fetch(`${this.sttUrl}/api/v1/models`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new HttpException('Failed to get STT models', HttpStatus.BAD_GATEWAY);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async getLanguages(): Promise<any> {
|
||||
const response = await fetch(`${this.sttUrl}/api/v1/languages`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new HttpException('Failed to get STT languages', HttpStatus.BAD_GATEWAY);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
import { Injectable, HttpException, HttpStatus } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
export interface SynthesizeRequestDto {
|
||||
text: string;
|
||||
voice?: string;
|
||||
language?: string;
|
||||
speed?: number;
|
||||
format?: 'mp3' | 'wav' | 'ogg';
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TtsProxyService {
|
||||
private readonly ttsUrl: string;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
this.ttsUrl = this.configService.get('services.tts') || 'http://localhost:3022';
|
||||
}
|
||||
|
||||
async synthesize(body: SynthesizeRequestDto): Promise<Buffer> {
|
||||
const response = await fetch(`${this.ttsUrl}/api/v1/synthesize`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new HttpException(
|
||||
`TTS service error: ${error}`,
|
||||
response.status || HttpStatus.BAD_GATEWAY
|
||||
);
|
||||
}
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
return Buffer.from(arrayBuffer);
|
||||
}
|
||||
|
||||
async getVoices(): Promise<any> {
|
||||
const response = await fetch(`${this.ttsUrl}/api/v1/voices`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new HttpException('Failed to get TTS voices', HttpStatus.BAD_GATEWAY);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async getLanguages(): Promise<any> {
|
||||
const response = await fetch(`${this.ttsUrl}/api/v1/languages`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new HttpException('Failed to get TTS languages', HttpStatus.BAD_GATEWAY);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue