mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:21:09 +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
34
services/mana-api-gateway/.env.example
Normal file
34
services/mana-api-gateway/.env.example
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
# Server
|
||||||
|
PORT=3030
|
||||||
|
NODE_ENV=development
|
||||||
|
|
||||||
|
# Database (same as mana-core-auth)
|
||||||
|
DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/manacore
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
REDIS_HOST=localhost
|
||||||
|
REDIS_PORT=6379
|
||||||
|
REDIS_PASSWORD=
|
||||||
|
|
||||||
|
# Backend Services
|
||||||
|
SEARCH_SERVICE_URL=http://localhost:3021
|
||||||
|
STT_SERVICE_URL=http://localhost:3020
|
||||||
|
TTS_SERVICE_URL=http://localhost:3022
|
||||||
|
|
||||||
|
# Auth Service (for JWT validation & credits)
|
||||||
|
MANA_CORE_AUTH_URL=http://localhost:3001
|
||||||
|
|
||||||
|
# API Key Generation
|
||||||
|
API_KEY_PREFIX_LIVE=sk_live_
|
||||||
|
API_KEY_PREFIX_TEST=sk_test_
|
||||||
|
|
||||||
|
# Rate Limiting Defaults
|
||||||
|
DEFAULT_RATE_LIMIT=10
|
||||||
|
DEFAULT_MONTHLY_CREDITS=100
|
||||||
|
|
||||||
|
# CORS
|
||||||
|
CORS_ORIGINS=http://localhost:3000,http://localhost:5173
|
||||||
|
|
||||||
|
# Development Auth Bypass
|
||||||
|
DEV_BYPASS_AUTH=true
|
||||||
|
DEV_USER_ID=00000000-0000-0000-0000-000000000000
|
||||||
31
services/mana-api-gateway/.gitignore
vendored
Normal file
31
services/mana-api-gateway/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# Environment files
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
# Test coverage
|
||||||
|
coverage/
|
||||||
|
|
||||||
|
# Drizzle
|
||||||
|
drizzle/meta/
|
||||||
274
services/mana-api-gateway/CLAUDE.md
Normal file
274
services/mana-api-gateway/CLAUDE.md
Normal file
|
|
@ -0,0 +1,274 @@
|
||||||
|
# Mana API Gateway
|
||||||
|
|
||||||
|
Custom NestJS API Gateway for monetizing ManaCore services (mana-search, mana-stt, mana-tts).
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
- **Port**: 3030
|
||||||
|
- **Technology**: NestJS 10 + Drizzle ORM + Redis
|
||||||
|
- **Purpose**: API Key Management, Usage Tracking, Rate Limiting, Credit-based Billing
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────┐
|
||||||
|
│ API Gateway │
|
||||||
|
│ (Port 3030) │
|
||||||
|
│ │
|
||||||
|
Clients ───────────>│ • API Key Validation │
|
||||||
|
(X-API-Key Header) │ • Rate Limiting │
|
||||||
|
│ • Usage Tracking │
|
||||||
|
│ • Credit Deduction │
|
||||||
|
└───────────┬─────────────┘
|
||||||
|
│
|
||||||
|
┌─────────────────────┼─────────────────────┐
|
||||||
|
│ │ │
|
||||||
|
▼ ▼ ▼
|
||||||
|
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
|
||||||
|
│ mana-search │ │ mana-stt │ │ mana-tts │
|
||||||
|
│ (Port 3021) │ │ (Port 3020) │ │ (Port 3022) │
|
||||||
|
└───────────────┘ └───────────────┘ └───────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dependencies
|
||||||
|
pnpm install
|
||||||
|
|
||||||
|
# Push database schema
|
||||||
|
pnpm db:push
|
||||||
|
|
||||||
|
# Start in development mode
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Production
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build
|
||||||
|
pnpm build
|
||||||
|
|
||||||
|
# Start
|
||||||
|
pnpm start
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Public API (with API Key)
|
||||||
|
|
||||||
|
| Method | Endpoint | Description | Credits |
|
||||||
|
|--------|----------|-------------|---------|
|
||||||
|
| POST | `/v1/search` | Web search | 1 |
|
||||||
|
| POST | `/v1/extract` | Content extraction | 1 |
|
||||||
|
| POST | `/v1/extract/bulk` | Bulk extraction | 1 per URL |
|
||||||
|
| GET | `/v1/search/engines` | Available search engines | 0 |
|
||||||
|
| POST | `/v1/stt/transcribe` | Audio → Text | 10/min |
|
||||||
|
| GET | `/v1/stt/models` | Available STT models | 0 |
|
||||||
|
| GET | `/v1/stt/languages` | Supported languages | 0 |
|
||||||
|
| POST | `/v1/tts/synthesize` | Text → Audio | 1/1000 chars |
|
||||||
|
| GET | `/v1/tts/voices` | Available voices | 0 |
|
||||||
|
| GET | `/v1/tts/languages` | Supported languages | 0 |
|
||||||
|
|
||||||
|
### Management API (with JWT)
|
||||||
|
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| POST | `/api-keys` | Create new API key |
|
||||||
|
| GET | `/api-keys` | List all API keys |
|
||||||
|
| GET | `/api-keys/:id` | Get API key details |
|
||||||
|
| PATCH | `/api-keys/:id` | Update API key |
|
||||||
|
| DELETE | `/api-keys/:id` | Delete API key |
|
||||||
|
| POST | `/api-keys/:id/regenerate` | Regenerate API key |
|
||||||
|
| GET | `/api-keys/:id/usage` | Get usage statistics |
|
||||||
|
| GET | `/api-keys/:id/usage/summary` | Get usage summary |
|
||||||
|
|
||||||
|
### System
|
||||||
|
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| GET | `/health` | Health check |
|
||||||
|
| GET | `/metrics` | Prometheus metrics |
|
||||||
|
|
||||||
|
## Pricing Tiers
|
||||||
|
|
||||||
|
| Tier | Rate Limit | Monthly Credits | Endpoints | Price |
|
||||||
|
|------|------------|-----------------|-----------|-------|
|
||||||
|
| Free | 10 req/min | 100 | Search only | Free |
|
||||||
|
| Pro | 100 req/min | 5,000 | All | €19/month |
|
||||||
|
| Enterprise | 1,000 req/min | 50,000 | All | €99/month |
|
||||||
|
|
||||||
|
## Credit Costs
|
||||||
|
|
||||||
|
| Operation | Cost |
|
||||||
|
|-----------|------|
|
||||||
|
| Search | 1 credit |
|
||||||
|
| Extract | 1 credit |
|
||||||
|
| STT | 10 credits/minute |
|
||||||
|
| TTS | 1 credit/1000 chars |
|
||||||
|
|
||||||
|
## Usage Examples
|
||||||
|
|
||||||
|
### Create an API Key
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# First, get a JWT token from mana-core-auth
|
||||||
|
TOKEN=$(curl -s -X POST http://localhost:3001/api/v1/auth/login \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"email": "user@example.com", "password": "password"}' | jq -r '.accessToken')
|
||||||
|
|
||||||
|
# Create an API key
|
||||||
|
curl -X POST http://localhost:3030/api-keys \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"name": "My API Key", "tier": "free"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Use the API
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Search
|
||||||
|
curl -X POST http://localhost:3030/v1/search \
|
||||||
|
-H "X-API-Key: sk_live_xxx" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"query": "quantum computing"}'
|
||||||
|
|
||||||
|
# Extract content
|
||||||
|
curl -X POST http://localhost:3030/v1/extract \
|
||||||
|
-H "X-API-Key: sk_live_xxx" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"url": "https://example.com/article"}'
|
||||||
|
|
||||||
|
# Text-to-Speech
|
||||||
|
curl -X POST http://localhost:3030/v1/tts/synthesize \
|
||||||
|
-H "X-API-Key: sk_live_xxx" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"text": "Hello, world!", "voice": "en-US-1"}' \
|
||||||
|
--output audio.mp3
|
||||||
|
|
||||||
|
# Speech-to-Text
|
||||||
|
curl -X POST http://localhost:3030/v1/stt/transcribe \
|
||||||
|
-H "X-API-Key: sk_live_xxx" \
|
||||||
|
-F "file=@audio.wav"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Check Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:3030/api-keys/{id}/usage \
|
||||||
|
-H "Authorization: Bearer $TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `PORT` | 3030 | API port |
|
||||||
|
| `DATABASE_URL` | - | PostgreSQL connection URL |
|
||||||
|
| `REDIS_HOST` | localhost | Redis host |
|
||||||
|
| `REDIS_PORT` | 6379 | Redis port |
|
||||||
|
| `SEARCH_SERVICE_URL` | http://localhost:3021 | mana-search URL |
|
||||||
|
| `STT_SERVICE_URL` | http://localhost:3020 | mana-stt URL |
|
||||||
|
| `TTS_SERVICE_URL` | http://localhost:3022 | mana-tts URL |
|
||||||
|
| `MANA_CORE_AUTH_URL` | http://localhost:3001 | Auth service URL |
|
||||||
|
|
||||||
|
## Development Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dependencies
|
||||||
|
pnpm install
|
||||||
|
|
||||||
|
# Start development server
|
||||||
|
pnpm dev
|
||||||
|
|
||||||
|
# Build for production
|
||||||
|
pnpm build
|
||||||
|
|
||||||
|
# Start production server
|
||||||
|
pnpm start
|
||||||
|
|
||||||
|
# Type checking
|
||||||
|
pnpm type-check
|
||||||
|
|
||||||
|
# Linting
|
||||||
|
pnpm lint
|
||||||
|
|
||||||
|
# Database commands
|
||||||
|
pnpm db:push # Push schema to database
|
||||||
|
pnpm db:generate # Generate migrations
|
||||||
|
pnpm db:migrate # Run migrations
|
||||||
|
pnpm db:studio # Open Drizzle Studio
|
||||||
|
```
|
||||||
|
|
||||||
|
## Database Schema
|
||||||
|
|
||||||
|
The gateway uses its own schema (`api_gateway`) in the shared ManaCore database:
|
||||||
|
|
||||||
|
- `api_gateway.api_keys` - API key storage and configuration
|
||||||
|
- `api_gateway.api_usage` - Detailed usage logs
|
||||||
|
- `api_gateway.api_usage_daily` - Aggregated daily usage for billing
|
||||||
|
|
||||||
|
## Rate Limiting
|
||||||
|
|
||||||
|
Rate limiting uses Redis with a sliding window algorithm:
|
||||||
|
- Each API key has a configurable rate limit (requests per minute)
|
||||||
|
- Rate limit headers are included in responses:
|
||||||
|
- `X-RateLimit-Limit` - Maximum requests per minute
|
||||||
|
- `X-RateLimit-Remaining` - Remaining requests
|
||||||
|
- `X-RateLimit-Reset` - Unix timestamp when limit resets
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
Two types of authentication:
|
||||||
|
1. **X-API-Key header** - For public API endpoints (`/v1/*`)
|
||||||
|
2. **Bearer JWT token** - For management endpoints (`/api-keys/*`)
|
||||||
|
|
||||||
|
The JWT token is validated against mana-core-auth service.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
services/mana-api-gateway/
|
||||||
|
├── src/
|
||||||
|
│ ├── main.ts # Application entry point
|
||||||
|
│ ├── app.module.ts # Root module
|
||||||
|
│ ├── config/
|
||||||
|
│ │ ├── configuration.ts # App configuration
|
||||||
|
│ │ └── pricing.ts # Pricing tiers and credit costs
|
||||||
|
│ ├── db/
|
||||||
|
│ │ ├── schema/ # Drizzle schemas
|
||||||
|
│ │ ├── database.module.ts # Database provider
|
||||||
|
│ │ ├── connection.ts # DB connection
|
||||||
|
│ │ └── migrate.ts # Migration script
|
||||||
|
│ ├── api-keys/ # API key management
|
||||||
|
│ ├── usage/ # Usage tracking
|
||||||
|
│ ├── proxy/ # Proxy services to backends
|
||||||
|
│ ├── guards/ # Auth, rate limit, credits guards
|
||||||
|
│ ├── common/ # Decorators, filters, interceptors
|
||||||
|
│ ├── credits/ # Credits service (mana-core-auth client)
|
||||||
|
│ ├── metrics/ # Prometheus metrics
|
||||||
|
│ └── health/ # Health check endpoint
|
||||||
|
├── drizzle.config.ts # Drizzle Kit configuration
|
||||||
|
├── package.json
|
||||||
|
├── tsconfig.json
|
||||||
|
└── Dockerfile
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### API Key not working
|
||||||
|
|
||||||
|
1. Check the key is valid: `curl -H "X-API-Key: $KEY" http://localhost:3030/health`
|
||||||
|
2. Check the key is active in the database
|
||||||
|
3. Check the key hasn't expired
|
||||||
|
4. Check the endpoint is allowed for the key's tier
|
||||||
|
|
||||||
|
### Rate limit exceeded
|
||||||
|
|
||||||
|
Wait for the `X-RateLimit-Reset` timestamp, or upgrade to a higher tier.
|
||||||
|
|
||||||
|
### Credits exhausted
|
||||||
|
|
||||||
|
Check usage with the management API, or wait for monthly reset.
|
||||||
32
services/mana-api-gateway/Dockerfile
Normal file
32
services/mana-api-gateway/Dockerfile
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
FROM node:20-alpine AS base
|
||||||
|
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
FROM base AS deps
|
||||||
|
COPY package.json pnpm-lock.yaml* ./
|
||||||
|
RUN pnpm install --frozen-lockfile --prod=false
|
||||||
|
|
||||||
|
# Build the application
|
||||||
|
FROM base AS builder
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
RUN pnpm build
|
||||||
|
|
||||||
|
# Production image
|
||||||
|
FROM base AS runner
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
# Create non-root user
|
||||||
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
|
RUN adduser --system --uid 1001 nestjs
|
||||||
|
USER nestjs
|
||||||
|
|
||||||
|
# Copy built application
|
||||||
|
COPY --from=builder --chown=nestjs:nodejs /app/dist ./dist
|
||||||
|
COPY --from=builder --chown=nestjs:nodejs /app/node_modules ./node_modules
|
||||||
|
COPY --from=builder --chown=nestjs:nodejs /app/package.json ./
|
||||||
|
|
||||||
|
EXPOSE 3030
|
||||||
|
|
||||||
|
CMD ["node", "dist/main.js"]
|
||||||
6
services/mana-api-gateway/drizzle.config.ts
Normal file
6
services/mana-api-gateway/drizzle.config.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { createDrizzleConfig } from '@manacore/shared-drizzle-config';
|
||||||
|
|
||||||
|
export default createDrizzleConfig({
|
||||||
|
dbName: 'manacore',
|
||||||
|
schemaFilter: ['api_gateway'],
|
||||||
|
});
|
||||||
8
services/mana-api-gateway/nest-cli.json
Normal file
8
services/mana-api-gateway/nest-cli.json
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/nest-cli",
|
||||||
|
"collection": "@nestjs/schematics",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"compilerOptions": {
|
||||||
|
"deleteOutDir": true
|
||||||
|
}
|
||||||
|
}
|
||||||
59
services/mana-api-gateway/package.json
Normal file
59
services/mana-api-gateway/package.json
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
{
|
||||||
|
"name": "@manacore/api-gateway",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "ManaCore API Gateway for monetizing core services",
|
||||||
|
"private": true,
|
||||||
|
"license": "UNLICENSED",
|
||||||
|
"scripts": {
|
||||||
|
"build": "nest build",
|
||||||
|
"dev": "nest start --watch",
|
||||||
|
"start": "node dist/main",
|
||||||
|
"start:dev": "nest start --watch",
|
||||||
|
"start:debug": "nest start --debug --watch",
|
||||||
|
"start:prod": "node dist/main",
|
||||||
|
"lint": "eslint \"{src,test}/**/*.ts\" --fix",
|
||||||
|
"type-check": "tsc --noEmit",
|
||||||
|
"test": "jest",
|
||||||
|
"test:watch": "jest --watch",
|
||||||
|
"test:cov": "jest --coverage",
|
||||||
|
"db:push": "drizzle-kit push",
|
||||||
|
"db:generate": "drizzle-kit generate",
|
||||||
|
"db:migrate": "tsx src/db/migrate.ts",
|
||||||
|
"db:studio": "drizzle-kit studio"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@manacore/shared-nestjs-auth": "workspace:*",
|
||||||
|
"@nestjs/common": "^10.4.17",
|
||||||
|
"@nestjs/config": "^3.3.0",
|
||||||
|
"@nestjs/core": "^10.4.17",
|
||||||
|
"@nestjs/platform-express": "^10.4.17",
|
||||||
|
"class-transformer": "^0.5.1",
|
||||||
|
"class-validator": "^0.14.1",
|
||||||
|
"drizzle-orm": "^0.38.4",
|
||||||
|
"ioredis": "^5.4.2",
|
||||||
|
"multer": "^1.4.5-lts.1",
|
||||||
|
"postgres": "^3.4.5",
|
||||||
|
"prom-client": "^15.1.3",
|
||||||
|
"reflect-metadata": "^0.2.2",
|
||||||
|
"rxjs": "^7.8.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@manacore/shared-drizzle-config": "workspace:*",
|
||||||
|
"@nestjs/cli": "^10.4.9",
|
||||||
|
"@nestjs/schematics": "^10.2.3",
|
||||||
|
"@nestjs/testing": "^10.4.17",
|
||||||
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/multer": "^1.4.12",
|
||||||
|
"@types/node": "^22.10.7",
|
||||||
|
"drizzle-kit": "^0.30.4",
|
||||||
|
"jest": "^29.7.0",
|
||||||
|
"ts-jest": "^29.2.5",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"tsx": "^4.19.2",
|
||||||
|
"typescript": "^5.7.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0",
|
||||||
|
"pnpm": ">=9.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,97 @@
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Patch,
|
||||||
|
Delete,
|
||||||
|
Body,
|
||||||
|
Param,
|
||||||
|
Query,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
|
||||||
|
import { ApiKeysService } from './api-keys.service';
|
||||||
|
import { CreateApiKeyDto, UpdateApiKeyDto } from './dto';
|
||||||
|
import { UsageService } from '../usage/usage.service';
|
||||||
|
|
||||||
|
@Controller('api-keys')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
export class ApiKeysController {
|
||||||
|
constructor(
|
||||||
|
private readonly apiKeyService: ApiKeysService,
|
||||||
|
private readonly usageService: UsageService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
async create(@CurrentUser() user: CurrentUserData, @Body() dto: CreateApiKeyDto) {
|
||||||
|
const result = await this.apiKeyService.create(user.userId, dto);
|
||||||
|
return {
|
||||||
|
message: 'API key created successfully. Save your key - it will not be shown again.',
|
||||||
|
key: result.key,
|
||||||
|
apiKey: result.apiKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
async list(@CurrentUser() user: CurrentUserData) {
|
||||||
|
const keys = await this.apiKeyService.listByUser(user.userId);
|
||||||
|
return { apiKeys: keys };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
async get(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
|
||||||
|
const key = await this.apiKeyService.getByIdAndUser(id, user.userId);
|
||||||
|
return { apiKey: key };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
async update(
|
||||||
|
@CurrentUser() user: CurrentUserData,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: UpdateApiKeyDto
|
||||||
|
) {
|
||||||
|
const key = await this.apiKeyService.update(id, user.userId, dto);
|
||||||
|
return { apiKey: key };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
async delete(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
|
||||||
|
await this.apiKeyService.delete(id, user.userId);
|
||||||
|
return { message: 'API key deleted successfully' };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/regenerate')
|
||||||
|
async regenerate(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
|
||||||
|
const result = await this.apiKeyService.regenerate(id, user.userId);
|
||||||
|
return {
|
||||||
|
message: 'API key regenerated successfully. Save your new key - it will not be shown again.',
|
||||||
|
key: result.key,
|
||||||
|
apiKey: result.apiKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/usage')
|
||||||
|
async getUsage(
|
||||||
|
@CurrentUser() user: CurrentUserData,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Query('days') days?: string
|
||||||
|
) {
|
||||||
|
// Verify ownership
|
||||||
|
await this.apiKeyService.getByIdAndUser(id, user.userId);
|
||||||
|
|
||||||
|
const daysNum = parseInt(days || '30', 10);
|
||||||
|
const usage = await this.usageService.getDailyUsage(id, daysNum);
|
||||||
|
|
||||||
|
return { usage };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/usage/summary')
|
||||||
|
async getUsageSummary(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
|
||||||
|
// Verify ownership
|
||||||
|
await this.apiKeyService.getByIdAndUser(id, user.userId);
|
||||||
|
|
||||||
|
const summary = await this.usageService.getUsageSummary(id);
|
||||||
|
|
||||||
|
return { summary };
|
||||||
|
}
|
||||||
|
}
|
||||||
12
services/mana-api-gateway/src/api-keys/api-keys.module.ts
Normal file
12
services/mana-api-gateway/src/api-keys/api-keys.module.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { Module, forwardRef } from '@nestjs/common';
|
||||||
|
import { ApiKeysController } from './api-keys.controller';
|
||||||
|
import { ApiKeysService } from './api-keys.service';
|
||||||
|
import { UsageModule } from '../usage/usage.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [forwardRef(() => UsageModule)],
|
||||||
|
controllers: [ApiKeysController],
|
||||||
|
providers: [ApiKeysService],
|
||||||
|
exports: [ApiKeysService],
|
||||||
|
})
|
||||||
|
export class ApiKeysModule {}
|
||||||
277
services/mana-api-gateway/src/api-keys/api-keys.service.ts
Normal file
277
services/mana-api-gateway/src/api-keys/api-keys.service.ts
Normal file
|
|
@ -0,0 +1,277 @@
|
||||||
|
import { Injectable, Inject, NotFoundException, ForbiddenException } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { eq, and } from 'drizzle-orm';
|
||||||
|
import * as crypto from 'crypto';
|
||||||
|
import { DATABASE_CONNECTION } from '../db/database.module';
|
||||||
|
import { apiKeys, ApiKey, NewApiKey } from '../db/schema';
|
||||||
|
import { CreateApiKeyDto, UpdateApiKeyDto } from './dto';
|
||||||
|
import { PRICING_TIERS, PricingTier } from '../config/pricing';
|
||||||
|
|
||||||
|
export interface ApiKeyData {
|
||||||
|
id: string;
|
||||||
|
userId: string | null;
|
||||||
|
organizationId: string | null;
|
||||||
|
name: string;
|
||||||
|
tier: string;
|
||||||
|
rateLimit: number;
|
||||||
|
monthlyCredits: number;
|
||||||
|
creditsUsed: number;
|
||||||
|
allowedEndpoints: string | null;
|
||||||
|
allowedIps: string | null;
|
||||||
|
active: boolean;
|
||||||
|
expiresAt: Date | null;
|
||||||
|
lastUsedAt: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ApiKeysService {
|
||||||
|
private readonly keyPrefixLive: string;
|
||||||
|
private readonly keyPrefixTest: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Inject(DATABASE_CONNECTION)
|
||||||
|
private readonly db: ReturnType<typeof import('../db/connection').getDb>,
|
||||||
|
private readonly configService: ConfigService
|
||||||
|
) {
|
||||||
|
this.keyPrefixLive = this.configService.get('apiKey.prefixLive') || 'sk_live_';
|
||||||
|
this.keyPrefixTest = this.configService.get('apiKey.prefixTest') || 'sk_test_';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a new API key
|
||||||
|
*/
|
||||||
|
private generateKey(isTest: boolean = false): { key: string; hash: string; prefix: string } {
|
||||||
|
const prefix = isTest ? this.keyPrefixTest : this.keyPrefixLive;
|
||||||
|
const randomPart = crypto.randomBytes(24).toString('base64url');
|
||||||
|
const key = `${prefix}${randomPart}`;
|
||||||
|
const hash = crypto.createHash('sha256').update(key).digest('hex');
|
||||||
|
return { key, hash, prefix };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new API key for a user
|
||||||
|
*/
|
||||||
|
async create(userId: string, dto: CreateApiKeyDto): Promise<{ key: string; apiKey: ApiKey }> {
|
||||||
|
const { key, hash, prefix } = this.generateKey(dto.isTest);
|
||||||
|
const tier = (dto.tier || 'free') as PricingTier;
|
||||||
|
const tierConfig = PRICING_TIERS[tier];
|
||||||
|
|
||||||
|
const newKey: NewApiKey = {
|
||||||
|
key: key,
|
||||||
|
keyHash: hash,
|
||||||
|
keyPrefix: prefix,
|
||||||
|
userId,
|
||||||
|
name: dto.name,
|
||||||
|
description: dto.description,
|
||||||
|
tier,
|
||||||
|
rateLimit: tierConfig.rateLimit,
|
||||||
|
monthlyCredits: tierConfig.monthlyCredits,
|
||||||
|
creditsUsed: 0,
|
||||||
|
creditsResetAt: this.getNextMonthReset(),
|
||||||
|
allowedEndpoints: dto.allowedEndpoints
|
||||||
|
? JSON.stringify(dto.allowedEndpoints)
|
||||||
|
: JSON.stringify(tierConfig.endpoints),
|
||||||
|
allowedIps: dto.allowedIps ? JSON.stringify(dto.allowedIps) : null,
|
||||||
|
active: true,
|
||||||
|
expiresAt: dto.expiresAt ? new Date(dto.expiresAt) : null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const [created] = await this.db.insert(apiKeys).values(newKey).returning();
|
||||||
|
|
||||||
|
// Return the full key only on creation (it's not stored)
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
apiKey: { ...created, key: this.maskKey(key) },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all API keys for a user (keys are masked)
|
||||||
|
*/
|
||||||
|
async listByUser(userId: string): Promise<ApiKey[]> {
|
||||||
|
const keys = await this.db.select().from(apiKeys).where(eq(apiKeys.userId, userId));
|
||||||
|
|
||||||
|
return keys.map((k) => ({
|
||||||
|
...k,
|
||||||
|
key: this.maskKey(k.key),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single API key by ID (verified for user ownership)
|
||||||
|
*/
|
||||||
|
async getByIdAndUser(id: string, userId: string): Promise<ApiKey> {
|
||||||
|
const [key] = await this.db
|
||||||
|
.select()
|
||||||
|
.from(apiKeys)
|
||||||
|
.where(and(eq(apiKeys.id, id), eq(apiKeys.userId, userId)));
|
||||||
|
|
||||||
|
if (!key) {
|
||||||
|
throw new NotFoundException('API key not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...key, key: this.maskKey(key.key) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate an API key and return its data
|
||||||
|
*/
|
||||||
|
async validateKey(rawKey: string): Promise<ApiKeyData | null> {
|
||||||
|
const hash = crypto.createHash('sha256').update(rawKey).digest('hex');
|
||||||
|
|
||||||
|
const [key] = await this.db.select().from(apiKeys).where(eq(apiKeys.keyHash, hash));
|
||||||
|
|
||||||
|
if (!key) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update last used timestamp
|
||||||
|
await this.db.update(apiKeys).set({ lastUsedAt: new Date() }).where(eq(apiKeys.id, key.id));
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: key.id,
|
||||||
|
userId: key.userId,
|
||||||
|
organizationId: key.organizationId,
|
||||||
|
name: key.name,
|
||||||
|
tier: key.tier,
|
||||||
|
rateLimit: key.rateLimit,
|
||||||
|
monthlyCredits: key.monthlyCredits,
|
||||||
|
creditsUsed: key.creditsUsed,
|
||||||
|
allowedEndpoints: key.allowedEndpoints,
|
||||||
|
allowedIps: key.allowedIps,
|
||||||
|
active: key.active,
|
||||||
|
expiresAt: key.expiresAt,
|
||||||
|
lastUsedAt: key.lastUsedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an API key
|
||||||
|
*/
|
||||||
|
async update(id: string, userId: string, dto: UpdateApiKeyDto): Promise<ApiKey> {
|
||||||
|
// Verify ownership
|
||||||
|
await this.getByIdAndUser(id, userId);
|
||||||
|
|
||||||
|
const updates: Partial<NewApiKey> = {
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (dto.name !== undefined) updates.name = dto.name;
|
||||||
|
if (dto.description !== undefined) updates.description = dto.description;
|
||||||
|
if (dto.allowedEndpoints !== undefined) {
|
||||||
|
updates.allowedEndpoints = JSON.stringify(dto.allowedEndpoints);
|
||||||
|
}
|
||||||
|
if (dto.allowedIps !== undefined) {
|
||||||
|
updates.allowedIps = JSON.stringify(dto.allowedIps);
|
||||||
|
}
|
||||||
|
if (dto.active !== undefined) updates.active = dto.active;
|
||||||
|
if (dto.expiresAt !== undefined) {
|
||||||
|
updates.expiresAt = dto.expiresAt ? new Date(dto.expiresAt) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [updated] = await this.db
|
||||||
|
.update(apiKeys)
|
||||||
|
.set(updates)
|
||||||
|
.where(eq(apiKeys.id, id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return { ...updated, key: this.maskKey(updated.key) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete an API key
|
||||||
|
*/
|
||||||
|
async delete(id: string, userId: string): Promise<void> {
|
||||||
|
// Verify ownership
|
||||||
|
await this.getByIdAndUser(id, userId);
|
||||||
|
|
||||||
|
await this.db.delete(apiKeys).where(eq(apiKeys.id, id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regenerate an API key
|
||||||
|
*/
|
||||||
|
async regenerate(id: string, userId: string): Promise<{ key: string; apiKey: ApiKey }> {
|
||||||
|
// Verify ownership
|
||||||
|
const existing = await this.getByIdAndUser(id, userId);
|
||||||
|
const isTest = existing.keyPrefix === this.keyPrefixTest;
|
||||||
|
|
||||||
|
const { key, hash, prefix } = this.generateKey(isTest);
|
||||||
|
|
||||||
|
const [updated] = await this.db
|
||||||
|
.update(apiKeys)
|
||||||
|
.set({
|
||||||
|
key,
|
||||||
|
keyHash: hash,
|
||||||
|
keyPrefix: prefix,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(apiKeys.id, id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
apiKey: { ...updated, key: this.maskKey(key) },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Increment credits used for an API key
|
||||||
|
*/
|
||||||
|
async incrementCreditsUsed(id: string, amount: number): Promise<void> {
|
||||||
|
const [key] = await this.db.select().from(apiKeys).where(eq(apiKeys.id, id));
|
||||||
|
|
||||||
|
if (key) {
|
||||||
|
// Check if we need to reset credits
|
||||||
|
if (key.creditsResetAt && new Date() > key.creditsResetAt) {
|
||||||
|
await this.db
|
||||||
|
.update(apiKeys)
|
||||||
|
.set({
|
||||||
|
creditsUsed: amount,
|
||||||
|
creditsResetAt: this.getNextMonthReset(),
|
||||||
|
})
|
||||||
|
.where(eq(apiKeys.id, id));
|
||||||
|
} else {
|
||||||
|
await this.db
|
||||||
|
.update(apiKeys)
|
||||||
|
.set({
|
||||||
|
creditsUsed: key.creditsUsed + amount,
|
||||||
|
})
|
||||||
|
.where(eq(apiKeys.id, id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if API key has enough credits
|
||||||
|
*/
|
||||||
|
async hasEnoughCredits(id: string, requiredCredits: number): Promise<boolean> {
|
||||||
|
const [key] = await this.db.select().from(apiKeys).where(eq(apiKeys.id, id));
|
||||||
|
|
||||||
|
if (!key) return false;
|
||||||
|
|
||||||
|
// Check if we need to reset credits
|
||||||
|
if (key.creditsResetAt && new Date() > key.creditsResetAt) {
|
||||||
|
return true; // Credits will be reset
|
||||||
|
}
|
||||||
|
|
||||||
|
return key.creditsUsed + requiredCredits <= key.monthlyCredits;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mask an API key for display (show only prefix and last 4 chars)
|
||||||
|
*/
|
||||||
|
private maskKey(key: string): string {
|
||||||
|
if (key.length <= 12) return key;
|
||||||
|
const prefix = key.startsWith(this.keyPrefixTest) ? this.keyPrefixTest : this.keyPrefixLive;
|
||||||
|
return `${prefix}...${key.slice(-4)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the next month reset date
|
||||||
|
*/
|
||||||
|
private getNextMonthReset(): Date {
|
||||||
|
const now = new Date();
|
||||||
|
return new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { IsString, IsOptional, IsEnum, IsArray, IsDateString } from 'class-validator';
|
||||||
|
import { PricingTier } from '../../config/pricing';
|
||||||
|
|
||||||
|
export class CreateApiKeyDto {
|
||||||
|
@IsString()
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(['free', 'pro', 'enterprise'])
|
||||||
|
tier?: PricingTier;
|
||||||
|
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
@IsOptional()
|
||||||
|
allowedEndpoints?: string[];
|
||||||
|
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
@IsOptional()
|
||||||
|
allowedIps?: string[];
|
||||||
|
|
||||||
|
@IsDateString()
|
||||||
|
@IsOptional()
|
||||||
|
expiresAt?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
isTest?: boolean;
|
||||||
|
}
|
||||||
2
services/mana-api-gateway/src/api-keys/dto/index.ts
Normal file
2
services/mana-api-gateway/src/api-keys/dto/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export * from './create-api-key.dto';
|
||||||
|
export * from './update-api-key.dto';
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { IsString, IsOptional, IsBoolean, IsArray, IsDateString } from 'class-validator';
|
||||||
|
|
||||||
|
export class UpdateApiKeyDto {
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
@IsOptional()
|
||||||
|
allowedEndpoints?: string[];
|
||||||
|
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
@IsOptional()
|
||||||
|
allowedIps?: string[];
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
@IsOptional()
|
||||||
|
active?: boolean;
|
||||||
|
|
||||||
|
@IsDateString()
|
||||||
|
@IsOptional()
|
||||||
|
expiresAt?: string;
|
||||||
|
}
|
||||||
27
services/mana-api-gateway/src/app.module.ts
Normal file
27
services/mana-api-gateway/src/app.module.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ConfigModule } from '@nestjs/config';
|
||||||
|
import configuration from './config/configuration';
|
||||||
|
import { DatabaseModule } from './db/database.module';
|
||||||
|
import { HealthModule } from './health/health.module';
|
||||||
|
import { ApiKeysModule } from './api-keys/api-keys.module';
|
||||||
|
import { UsageModule } from './usage/usage.module';
|
||||||
|
import { ProxyModule } from './proxy/proxy.module';
|
||||||
|
import { CreditsModule } from './credits/credits.module';
|
||||||
|
import { MetricsModule } from './metrics/metrics.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
ConfigModule.forRoot({
|
||||||
|
isGlobal: true,
|
||||||
|
load: [configuration],
|
||||||
|
}),
|
||||||
|
DatabaseModule,
|
||||||
|
HealthModule,
|
||||||
|
ApiKeysModule,
|
||||||
|
UsageModule,
|
||||||
|
ProxyModule,
|
||||||
|
CreditsModule,
|
||||||
|
MetricsModule,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class AppModule {}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
42
services/mana-api-gateway/src/config/configuration.ts
Normal file
42
services/mana-api-gateway/src/config/configuration.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
export default () => ({
|
||||||
|
port: parseInt(process.env.PORT || '3030', 10),
|
||||||
|
nodeEnv: process.env.NODE_ENV || 'development',
|
||||||
|
|
||||||
|
database: {
|
||||||
|
url: process.env.DATABASE_URL || 'postgresql://manacore:devpassword@localhost:5432/manacore',
|
||||||
|
},
|
||||||
|
|
||||||
|
cors: {
|
||||||
|
origins: process.env.CORS_ORIGINS?.split(',') || [
|
||||||
|
'http://localhost:3000',
|
||||||
|
'http://localhost:5173',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
redis: {
|
||||||
|
host: process.env.REDIS_HOST || 'localhost',
|
||||||
|
port: parseInt(process.env.REDIS_PORT || '6379', 10),
|
||||||
|
password: process.env.REDIS_PASSWORD,
|
||||||
|
keyPrefix: 'api-gateway:',
|
||||||
|
},
|
||||||
|
|
||||||
|
services: {
|
||||||
|
search: process.env.SEARCH_SERVICE_URL || 'http://localhost:3021',
|
||||||
|
stt: process.env.STT_SERVICE_URL || 'http://localhost:3020',
|
||||||
|
tts: process.env.TTS_SERVICE_URL || 'http://localhost:3022',
|
||||||
|
},
|
||||||
|
|
||||||
|
auth: {
|
||||||
|
url: process.env.MANA_CORE_AUTH_URL || 'http://localhost:3001',
|
||||||
|
},
|
||||||
|
|
||||||
|
apiKey: {
|
||||||
|
prefixLive: process.env.API_KEY_PREFIX_LIVE || 'sk_live_',
|
||||||
|
prefixTest: process.env.API_KEY_PREFIX_TEST || 'sk_test_',
|
||||||
|
},
|
||||||
|
|
||||||
|
defaults: {
|
||||||
|
rateLimit: parseInt(process.env.DEFAULT_RATE_LIMIT || '10', 10),
|
||||||
|
monthlyCredits: parseInt(process.env.DEFAULT_MONTHLY_CREDITS || '100', 10),
|
||||||
|
},
|
||||||
|
});
|
||||||
41
services/mana-api-gateway/src/config/pricing.ts
Normal file
41
services/mana-api-gateway/src/config/pricing.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
export const PRICING_TIERS = {
|
||||||
|
free: {
|
||||||
|
name: 'Free',
|
||||||
|
rateLimit: 10, // 10 requests/minute
|
||||||
|
monthlyCredits: 100,
|
||||||
|
endpoints: ['search'] as const, // Only search
|
||||||
|
features: [] as const,
|
||||||
|
price: 0,
|
||||||
|
},
|
||||||
|
pro: {
|
||||||
|
name: 'Pro',
|
||||||
|
rateLimit: 100, // 100 requests/minute
|
||||||
|
monthlyCredits: 5000,
|
||||||
|
endpoints: ['search', 'stt', 'tts'] as const,
|
||||||
|
features: ['priority_support'] as const,
|
||||||
|
price: 1900, // 19 EUR in cents
|
||||||
|
},
|
||||||
|
enterprise: {
|
||||||
|
name: 'Enterprise',
|
||||||
|
rateLimit: 1000, // 1000 requests/minute
|
||||||
|
monthlyCredits: 50000,
|
||||||
|
endpoints: ['search', 'stt', 'tts'] as const,
|
||||||
|
features: ['priority_support', 'sla', 'dedicated_support'] as const,
|
||||||
|
price: 9900, // 99 EUR in cents
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type PricingTier = keyof typeof PRICING_TIERS;
|
||||||
|
|
||||||
|
// Credit costs per operation
|
||||||
|
export const CREDIT_COSTS = {
|
||||||
|
search: 1, // 1 credit per search
|
||||||
|
stt: {
|
||||||
|
perMinute: 10, // 10 credits per minute of audio
|
||||||
|
},
|
||||||
|
tts: {
|
||||||
|
per1000Chars: 1, // 1 credit per 1000 characters
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type Endpoint = 'search' | 'stt' | 'tts';
|
||||||
8
services/mana-api-gateway/src/credits/credits.module.ts
Normal file
8
services/mana-api-gateway/src/credits/credits.module.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { CreditsService } from './credits.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [CreditsService],
|
||||||
|
exports: [CreditsService],
|
||||||
|
})
|
||||||
|
export class CreditsModule {}
|
||||||
92
services/mana-api-gateway/src/credits/credits.service.ts
Normal file
92
services/mana-api-gateway/src/credits/credits.service.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
import { Injectable, HttpException, HttpStatus } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
|
||||||
|
export interface DeductCreditsOptions {
|
||||||
|
appId: string;
|
||||||
|
description: string;
|
||||||
|
apiKeyId?: string;
|
||||||
|
metadata?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreditBalance {
|
||||||
|
balance: number;
|
||||||
|
freeCreditsRemaining: number;
|
||||||
|
dailyFreeCredits: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CreditsService {
|
||||||
|
private readonly authUrl: string;
|
||||||
|
|
||||||
|
constructor(private readonly configService: ConfigService) {
|
||||||
|
this.authUrl = this.configService.get('auth.url') || 'http://localhost:3001';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deduct credits from a user's account
|
||||||
|
*/
|
||||||
|
async deduct(userId: string, amount: number, options: DeductCreditsOptions): Promise<void> {
|
||||||
|
const response = await fetch(`${this.authUrl}/api/v1/credits/consume`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
userId,
|
||||||
|
amount,
|
||||||
|
appId: options.appId,
|
||||||
|
description: options.description,
|
||||||
|
metadata: {
|
||||||
|
...options.metadata,
|
||||||
|
apiKeyId: options.apiKeyId,
|
||||||
|
source: 'api-gateway',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.text();
|
||||||
|
console.error(`Failed to deduct credits for user ${userId}:`, error);
|
||||||
|
// Don't throw - credit deduction failure shouldn't fail the request
|
||||||
|
// The API key credits are already tracked
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a user's credit balance
|
||||||
|
*/
|
||||||
|
async getBalance(userId: string): Promise<CreditBalance | null> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${this.authUrl}/api/v1/credits/balance/${userId}`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to get credit balance for user ${userId}:`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add credits to a user's account (for testing/admin)
|
||||||
|
*/
|
||||||
|
async addCredits(userId: string, amount: number, reason: string): Promise<void> {
|
||||||
|
const response = await fetch(`${this.authUrl}/api/v1/credits/add`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
userId,
|
||||||
|
amount,
|
||||||
|
appId: 'api-gateway',
|
||||||
|
description: reason,
|
||||||
|
type: 'bonus',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.text();
|
||||||
|
throw new HttpException(`Failed to add credits: ${error}`, HttpStatus.BAD_GATEWAY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
33
services/mana-api-gateway/src/db/connection.ts
Normal file
33
services/mana-api-gateway/src/db/connection.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||||
|
import postgres from 'postgres';
|
||||||
|
import * as schema from './schema';
|
||||||
|
|
||||||
|
let connection: ReturnType<typeof postgres> | null = null;
|
||||||
|
let db: ReturnType<typeof drizzle> | null = null;
|
||||||
|
|
||||||
|
export function getConnection(databaseUrl: string) {
|
||||||
|
if (!connection) {
|
||||||
|
connection = postgres(databaseUrl, {
|
||||||
|
max: 10,
|
||||||
|
idle_timeout: 20,
|
||||||
|
connect_timeout: 10,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return connection;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDb(databaseUrl: string) {
|
||||||
|
if (!db) {
|
||||||
|
const conn = getConnection(databaseUrl);
|
||||||
|
db = drizzle(conn, { schema });
|
||||||
|
}
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function closeConnection() {
|
||||||
|
if (connection) {
|
||||||
|
await connection.end();
|
||||||
|
connection = null;
|
||||||
|
db = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
24
services/mana-api-gateway/src/db/database.module.ts
Normal file
24
services/mana-api-gateway/src/db/database.module.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { getDb } from './connection';
|
||||||
|
|
||||||
|
export const DATABASE_CONNECTION = 'DATABASE_CONNECTION';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: DATABASE_CONNECTION,
|
||||||
|
useFactory: (configService: ConfigService) => {
|
||||||
|
const databaseUrl = configService.get<string>('database.url');
|
||||||
|
if (!databaseUrl) {
|
||||||
|
throw new Error('DATABASE_URL is not configured');
|
||||||
|
}
|
||||||
|
return getDb(databaseUrl);
|
||||||
|
},
|
||||||
|
inject: [ConfigService],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
exports: [DATABASE_CONNECTION],
|
||||||
|
})
|
||||||
|
export class DatabaseModule {}
|
||||||
25
services/mana-api-gateway/src/db/migrate.ts
Normal file
25
services/mana-api-gateway/src/db/migrate.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import 'dotenv/config';
|
||||||
|
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||||
|
import { migrate } from 'drizzle-orm/postgres-js/migrator';
|
||||||
|
import postgres from 'postgres';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const databaseUrl =
|
||||||
|
process.env.DATABASE_URL || 'postgresql://manacore:devpassword@localhost:5432/manacore';
|
||||||
|
|
||||||
|
console.log('Connecting to database...');
|
||||||
|
const connection = postgres(databaseUrl, { max: 1 });
|
||||||
|
const db = drizzle(connection);
|
||||||
|
|
||||||
|
console.log('Running migrations...');
|
||||||
|
await migrate(db, { migrationsFolder: './src/db/migrations' });
|
||||||
|
|
||||||
|
console.log('Migrations complete!');
|
||||||
|
await connection.end();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error('Migration failed:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
51
services/mana-api-gateway/src/db/schema/api-keys.schema.ts
Normal file
51
services/mana-api-gateway/src/db/schema/api-keys.schema.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
import { pgSchema, uuid, text, integer, boolean, timestamp, index } from 'drizzle-orm/pg-core';
|
||||||
|
|
||||||
|
export const apiGatewaySchema = pgSchema('api_gateway');
|
||||||
|
|
||||||
|
export const apiKeys = apiGatewaySchema.table(
|
||||||
|
'api_keys',
|
||||||
|
{
|
||||||
|
id: uuid('id').defaultRandom().primaryKey(),
|
||||||
|
|
||||||
|
// Key identifiers
|
||||||
|
key: text('key').notNull().unique(), // sk_live_xxx or sk_test_xxx
|
||||||
|
keyHash: text('key_hash').notNull(), // SHA256 hash for lookup
|
||||||
|
keyPrefix: text('key_prefix').notNull(), // sk_live_ or sk_test_
|
||||||
|
|
||||||
|
// Owner (can be user or organization)
|
||||||
|
userId: text('user_id'), // B2C owner
|
||||||
|
organizationId: text('organization_id'), // B2B owner
|
||||||
|
|
||||||
|
// Metadata
|
||||||
|
name: text('name').notNull(), // "Production API Key"
|
||||||
|
description: text('description'),
|
||||||
|
|
||||||
|
// Tier & Limits
|
||||||
|
tier: text('tier').notNull().default('free'), // free, pro, enterprise
|
||||||
|
rateLimit: integer('rate_limit').notNull().default(10), // requests/minute
|
||||||
|
monthlyCredits: integer('monthly_credits').notNull().default(100),
|
||||||
|
creditsUsed: integer('credits_used').notNull().default(0),
|
||||||
|
creditsResetAt: timestamp('credits_reset_at', { withTimezone: true }),
|
||||||
|
|
||||||
|
// Permissions
|
||||||
|
allowedEndpoints: text('allowed_endpoints'), // JSON array: ["search", "tts"]
|
||||||
|
allowedIps: text('allowed_ips'), // JSON array or null for any
|
||||||
|
|
||||||
|
// Status
|
||||||
|
active: boolean('active').notNull().default(true),
|
||||||
|
expiresAt: timestamp('expires_at', { withTimezone: true }),
|
||||||
|
lastUsedAt: timestamp('last_used_at', { withTimezone: true }),
|
||||||
|
|
||||||
|
// Timestamps
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
keyHashIdx: index('api_keys_key_hash_idx').on(table.keyHash),
|
||||||
|
userIdIdx: index('api_keys_user_id_idx').on(table.userId),
|
||||||
|
organizationIdIdx: index('api_keys_organization_id_idx').on(table.organizationId),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
export type ApiKey = typeof apiKeys.$inferSelect;
|
||||||
|
export type NewApiKey = typeof apiKeys.$inferInsert;
|
||||||
2
services/mana-api-gateway/src/db/schema/index.ts
Normal file
2
services/mana-api-gateway/src/db/schema/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export * from './api-keys.schema';
|
||||||
|
export * from './usage.schema';
|
||||||
70
services/mana-api-gateway/src/db/schema/usage.schema.ts
Normal file
70
services/mana-api-gateway/src/db/schema/usage.schema.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
import { uuid, text, integer, timestamp, jsonb, index, unique, date } from 'drizzle-orm/pg-core';
|
||||||
|
import { apiGatewaySchema, apiKeys } from './api-keys.schema';
|
||||||
|
|
||||||
|
// Detailed usage log
|
||||||
|
export const apiUsage = apiGatewaySchema.table(
|
||||||
|
'api_usage',
|
||||||
|
{
|
||||||
|
id: uuid('id').defaultRandom().primaryKey(),
|
||||||
|
|
||||||
|
// Key reference
|
||||||
|
apiKeyId: uuid('api_key_id')
|
||||||
|
.references(() => apiKeys.id, { onDelete: 'cascade' })
|
||||||
|
.notNull(),
|
||||||
|
|
||||||
|
// Request details
|
||||||
|
endpoint: text('endpoint').notNull(), // search, stt, tts
|
||||||
|
method: text('method').notNull(), // POST, GET
|
||||||
|
path: text('path').notNull(), // /v1/search
|
||||||
|
|
||||||
|
// Metrics
|
||||||
|
requestSize: integer('request_size'), // bytes
|
||||||
|
responseSize: integer('response_size'), // bytes
|
||||||
|
latencyMs: integer('latency_ms'), // milliseconds
|
||||||
|
statusCode: integer('status_code'), // 200, 400, 500...
|
||||||
|
|
||||||
|
// Credit calculation
|
||||||
|
creditsUsed: integer('credits_used').notNull().default(0),
|
||||||
|
creditReason: text('credit_reason'), // "1000 characters TTS"
|
||||||
|
|
||||||
|
// Metadata
|
||||||
|
metadata: jsonb('metadata'), // additional context (user agent, etc.)
|
||||||
|
|
||||||
|
// Timestamp
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
apiKeyIdIdx: index('api_usage_api_key_id_idx').on(table.apiKeyId),
|
||||||
|
createdAtIdx: index('api_usage_created_at_idx').on(table.createdAt),
|
||||||
|
endpointIdx: index('api_usage_endpoint_idx').on(table.endpoint),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Aggregated daily usage (for dashboard/billing)
|
||||||
|
export const apiUsageDaily = apiGatewaySchema.table(
|
||||||
|
'api_usage_daily',
|
||||||
|
{
|
||||||
|
id: uuid('id').defaultRandom().primaryKey(),
|
||||||
|
apiKeyId: uuid('api_key_id')
|
||||||
|
.references(() => apiKeys.id, { onDelete: 'cascade' })
|
||||||
|
.notNull(),
|
||||||
|
date: date('date').notNull(),
|
||||||
|
endpoint: text('endpoint').notNull(),
|
||||||
|
|
||||||
|
// Aggregates
|
||||||
|
requestCount: integer('request_count').notNull().default(0),
|
||||||
|
creditsUsed: integer('credits_used').notNull().default(0),
|
||||||
|
totalLatencyMs: integer('total_latency_ms').notNull().default(0),
|
||||||
|
errorCount: integer('error_count').notNull().default(0),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
// Unique constraint for upsert
|
||||||
|
uniqueDaily: unique('api_usage_daily_unique').on(table.apiKeyId, table.date, table.endpoint),
|
||||||
|
dateIdx: index('api_usage_daily_date_idx').on(table.date),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
export type ApiUsage = typeof apiUsage.$inferSelect;
|
||||||
|
export type NewApiUsage = typeof apiUsage.$inferInsert;
|
||||||
|
export type ApiUsageDaily = typeof apiUsageDaily.$inferSelect;
|
||||||
|
export type NewApiUsageDaily = typeof apiUsageDaily.$inferInsert;
|
||||||
91
services/mana-api-gateway/src/guards/api-key.guard.ts
Normal file
91
services/mana-api-gateway/src/guards/api-key.guard.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
CanActivate,
|
||||||
|
ExecutionContext,
|
||||||
|
UnauthorizedException,
|
||||||
|
ForbiddenException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ApiKeysService, ApiKeyData } from '../api-keys/api-keys.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ApiKeyGuard implements CanActivate {
|
||||||
|
constructor(private readonly apiKeyService: ApiKeysService) {}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
const request = context.switchToHttp().getRequest();
|
||||||
|
const apiKey = this.extractApiKey(request);
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
throw new UnauthorizedException('API key required. Use X-API-Key header.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate key
|
||||||
|
const keyData = await this.apiKeyService.validateKey(apiKey);
|
||||||
|
|
||||||
|
if (!keyData) {
|
||||||
|
throw new UnauthorizedException('Invalid API key');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!keyData.active) {
|
||||||
|
throw new UnauthorizedException('API key is disabled');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keyData.expiresAt && new Date(keyData.expiresAt) < new Date()) {
|
||||||
|
throw new UnauthorizedException('API key has expired');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check endpoint permission
|
||||||
|
const endpoint = this.extractEndpoint(request.path);
|
||||||
|
if (!this.hasEndpointPermission(keyData, endpoint)) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
`Endpoint '${endpoint}' not allowed for this API key. Upgrade your plan to access this endpoint.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check IP restriction
|
||||||
|
if (!this.hasIpPermission(keyData, request)) {
|
||||||
|
throw new ForbiddenException('Request from this IP address is not allowed');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach key data to request for later use
|
||||||
|
request.apiKey = keyData;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractApiKey(request: any): string | undefined {
|
||||||
|
return request.headers['x-api-key'];
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractEndpoint(path: string): string {
|
||||||
|
// /v1/search -> search, /v1/stt/transcribe -> stt
|
||||||
|
const match = path.match(/\/v1\/(\w+)/);
|
||||||
|
return match ? match[1] : 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
private hasEndpointPermission(keyData: ApiKeyData, endpoint: string): boolean {
|
||||||
|
if (!keyData.allowedEndpoints) return true; // No restrictions
|
||||||
|
try {
|
||||||
|
const allowed = JSON.parse(keyData.allowedEndpoints) as string[];
|
||||||
|
return allowed.includes(endpoint);
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private hasIpPermission(keyData: ApiKeyData, request: any): boolean {
|
||||||
|
if (!keyData.allowedIps) return true; // No restrictions
|
||||||
|
|
||||||
|
try {
|
||||||
|
const allowedIps = JSON.parse(keyData.allowedIps) as string[];
|
||||||
|
const clientIp =
|
||||||
|
request.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
|
||||||
|
request.connection?.remoteAddress ||
|
||||||
|
request.ip;
|
||||||
|
|
||||||
|
return allowedIps.includes(clientIp);
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
63
services/mana-api-gateway/src/guards/credits.guard.ts
Normal file
63
services/mana-api-gateway/src/guards/credits.guard.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
CanActivate,
|
||||||
|
ExecutionContext,
|
||||||
|
HttpException,
|
||||||
|
HttpStatus,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ApiKeysService, ApiKeyData } from '../api-keys/api-keys.service';
|
||||||
|
import { CREDIT_COSTS } from '../config/pricing';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CreditsGuard implements CanActivate {
|
||||||
|
constructor(private readonly apiKeyService: ApiKeysService) {}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
const request = context.switchToHttp().getRequest();
|
||||||
|
const apiKey = request.apiKey as ApiKeyData;
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
return true; // Let ApiKeyGuard handle missing key
|
||||||
|
}
|
||||||
|
|
||||||
|
const endpoint = this.extractEndpoint(request.path);
|
||||||
|
const estimatedCredits = this.estimateCredits(endpoint, request);
|
||||||
|
|
||||||
|
const hasCredits = await this.apiKeyService.hasEnoughCredits(apiKey.id, estimatedCredits);
|
||||||
|
|
||||||
|
if (!hasCredits) {
|
||||||
|
throw new HttpException(
|
||||||
|
{
|
||||||
|
statusCode: HttpStatus.PAYMENT_REQUIRED,
|
||||||
|
message: 'Insufficient credits. Please upgrade your plan or wait for monthly reset.',
|
||||||
|
creditsRequired: estimatedCredits,
|
||||||
|
creditsUsed: apiKey.creditsUsed,
|
||||||
|
monthlyLimit: apiKey.monthlyCredits,
|
||||||
|
},
|
||||||
|
HttpStatus.PAYMENT_REQUIRED
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractEndpoint(path: string): string {
|
||||||
|
const match = path.match(/\/v1\/(\w+)/);
|
||||||
|
return match ? match[1] : 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
private estimateCredits(endpoint: string, request: 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':
|
||||||
|
// Estimate based on file size or default to 1 minute
|
||||||
|
return CREDIT_COSTS.stt.perMinute;
|
||||||
|
default:
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
3
services/mana-api-gateway/src/guards/index.ts
Normal file
3
services/mana-api-gateway/src/guards/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
export * from './api-key.guard';
|
||||||
|
export * from './rate-limit.guard';
|
||||||
|
export * from './credits.guard';
|
||||||
70
services/mana-api-gateway/src/guards/rate-limit.guard.ts
Normal file
70
services/mana-api-gateway/src/guards/rate-limit.guard.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
CanActivate,
|
||||||
|
ExecutionContext,
|
||||||
|
HttpException,
|
||||||
|
HttpStatus,
|
||||||
|
Inject,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import Redis from 'ioredis';
|
||||||
|
import { ApiKeyData } from '../api-keys/api-keys.service';
|
||||||
|
|
||||||
|
export const REDIS_CLIENT = 'REDIS_CLIENT';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RateLimitGuard implements CanActivate {
|
||||||
|
constructor(@Inject(REDIS_CLIENT) private readonly redis: Redis) {}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
const request = context.switchToHttp().getRequest();
|
||||||
|
const apiKey = request.apiKey as ApiKeyData;
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
return true; // Let ApiKeyGuard handle missing key
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = `ratelimit:${apiKey.id}`;
|
||||||
|
const limit = apiKey.rateLimit;
|
||||||
|
const window = 60; // 60 seconds
|
||||||
|
|
||||||
|
// Sliding window rate limiting using sorted set
|
||||||
|
const now = Date.now();
|
||||||
|
const windowStart = now - window * 1000;
|
||||||
|
|
||||||
|
// Remove old entries
|
||||||
|
await this.redis.zremrangebyscore(key, 0, windowStart);
|
||||||
|
|
||||||
|
// Count current requests
|
||||||
|
const count = await this.redis.zcard(key);
|
||||||
|
|
||||||
|
if (count >= limit) {
|
||||||
|
// Get the oldest entry to calculate retry-after
|
||||||
|
const oldestEntries = await this.redis.zrange(key, 0, 0, 'WITHSCORES');
|
||||||
|
const oldestTimestamp = oldestEntries.length > 1 ? parseInt(oldestEntries[1], 10) : now;
|
||||||
|
const retryAfter = Math.ceil((oldestTimestamp + window * 1000 - now) / 1000);
|
||||||
|
|
||||||
|
throw new HttpException(
|
||||||
|
{
|
||||||
|
statusCode: HttpStatus.TOO_MANY_REQUESTS,
|
||||||
|
message: 'Rate limit exceeded',
|
||||||
|
retryAfter,
|
||||||
|
limit,
|
||||||
|
remaining: 0,
|
||||||
|
},
|
||||||
|
HttpStatus.TOO_MANY_REQUESTS
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add current request
|
||||||
|
await this.redis.zadd(key, now, `${now}`);
|
||||||
|
await this.redis.expire(key, window);
|
||||||
|
|
||||||
|
// Add rate limit headers to response
|
||||||
|
const response = context.switchToHttp().getResponse();
|
||||||
|
response.setHeader('X-RateLimit-Limit', limit);
|
||||||
|
response.setHeader('X-RateLimit-Remaining', Math.max(0, limit - count - 1));
|
||||||
|
response.setHeader('X-RateLimit-Reset', Math.ceil(now / 1000) + window);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
13
services/mana-api-gateway/src/health/health.controller.ts
Normal file
13
services/mana-api-gateway/src/health/health.controller.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { Controller, Get } from '@nestjs/common';
|
||||||
|
|
||||||
|
@Controller('health')
|
||||||
|
export class HealthController {
|
||||||
|
@Get()
|
||||||
|
check() {
|
||||||
|
return {
|
||||||
|
status: 'ok',
|
||||||
|
service: 'api-gateway',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
7
services/mana-api-gateway/src/health/health.module.ts
Normal file
7
services/mana-api-gateway/src/health/health.module.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { HealthController } from './health.controller';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [HealthController],
|
||||||
|
})
|
||||||
|
export class HealthModule {}
|
||||||
36
services/mana-api-gateway/src/main.ts
Normal file
36
services/mana-api-gateway/src/main.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { AppModule } from './app.module';
|
||||||
|
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
const app = await NestFactory.create(AppModule);
|
||||||
|
|
||||||
|
const configService = app.get(ConfigService);
|
||||||
|
const port = configService.get<number>('port') || 3030;
|
||||||
|
const corsOrigins = configService.get<string[]>('cors.origins') || [];
|
||||||
|
|
||||||
|
// Enable CORS
|
||||||
|
app.enableCors({
|
||||||
|
origin: corsOrigins,
|
||||||
|
credentials: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Global validation pipe
|
||||||
|
app.useGlobalPipes(
|
||||||
|
new ValidationPipe({
|
||||||
|
whitelist: true,
|
||||||
|
transform: true,
|
||||||
|
forbidNonWhitelisted: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Global exception filter
|
||||||
|
app.useGlobalFilters(new HttpExceptionFilter());
|
||||||
|
|
||||||
|
await app.listen(port);
|
||||||
|
console.log(`API Gateway running on port ${port}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
bootstrap();
|
||||||
15
services/mana-api-gateway/src/metrics/metrics.controller.ts
Normal file
15
services/mana-api-gateway/src/metrics/metrics.controller.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { Controller, Get, Res } from '@nestjs/common';
|
||||||
|
import { Response } from 'express';
|
||||||
|
import { MetricsService } from './metrics.service';
|
||||||
|
|
||||||
|
@Controller('metrics')
|
||||||
|
export class MetricsController {
|
||||||
|
constructor(private readonly metricsService: MetricsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
async getMetrics(@Res() res: Response) {
|
||||||
|
const metrics = await this.metricsService.getMetrics();
|
||||||
|
res.setHeader('Content-Type', this.metricsService.getContentType());
|
||||||
|
res.send(metrics);
|
||||||
|
}
|
||||||
|
}
|
||||||
11
services/mana-api-gateway/src/metrics/metrics.module.ts
Normal file
11
services/mana-api-gateway/src/metrics/metrics.module.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { MetricsController } from './metrics.controller';
|
||||||
|
import { MetricsService } from './metrics.service';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
controllers: [MetricsController],
|
||||||
|
providers: [MetricsService],
|
||||||
|
exports: [MetricsService],
|
||||||
|
})
|
||||||
|
export class MetricsModule {}
|
||||||
82
services/mana-api-gateway/src/metrics/metrics.service.ts
Normal file
82
services/mana-api-gateway/src/metrics/metrics.service.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
import { Injectable, OnModuleInit } from '@nestjs/common';
|
||||||
|
import * as client from 'prom-client';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MetricsService implements OnModuleInit {
|
||||||
|
private readonly register: client.Registry;
|
||||||
|
|
||||||
|
// Counters
|
||||||
|
public readonly requestsTotal: client.Counter<string>;
|
||||||
|
public readonly creditsUsedTotal: client.Counter<string>;
|
||||||
|
public readonly errorsTotal: client.Counter<string>;
|
||||||
|
|
||||||
|
// Histograms
|
||||||
|
public readonly requestDuration: client.Histogram<string>;
|
||||||
|
|
||||||
|
// Gauges
|
||||||
|
public readonly activeApiKeys: client.Gauge<string>;
|
||||||
|
public readonly rateLimitExceeded: client.Counter<string>;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.register = new client.Registry();
|
||||||
|
|
||||||
|
// Add default metrics
|
||||||
|
client.collectDefaultMetrics({ register: this.register });
|
||||||
|
|
||||||
|
// Custom metrics
|
||||||
|
this.requestsTotal = new client.Counter({
|
||||||
|
name: 'api_gateway_requests_total',
|
||||||
|
help: 'Total number of API requests',
|
||||||
|
labelNames: ['endpoint', 'method', 'status', 'tier'],
|
||||||
|
registers: [this.register],
|
||||||
|
});
|
||||||
|
|
||||||
|
this.creditsUsedTotal = new client.Counter({
|
||||||
|
name: 'api_gateway_credits_used_total',
|
||||||
|
help: 'Total credits consumed',
|
||||||
|
labelNames: ['endpoint', 'tier'],
|
||||||
|
registers: [this.register],
|
||||||
|
});
|
||||||
|
|
||||||
|
this.errorsTotal = new client.Counter({
|
||||||
|
name: 'api_gateway_errors_total',
|
||||||
|
help: 'Total number of errors',
|
||||||
|
labelNames: ['endpoint', 'error_type'],
|
||||||
|
registers: [this.register],
|
||||||
|
});
|
||||||
|
|
||||||
|
this.requestDuration = new client.Histogram({
|
||||||
|
name: 'api_gateway_request_duration_seconds',
|
||||||
|
help: 'Request duration in seconds',
|
||||||
|
labelNames: ['endpoint', 'method'],
|
||||||
|
buckets: [0.01, 0.05, 0.1, 0.5, 1, 2, 5, 10],
|
||||||
|
registers: [this.register],
|
||||||
|
});
|
||||||
|
|
||||||
|
this.activeApiKeys = new client.Gauge({
|
||||||
|
name: 'api_gateway_active_api_keys',
|
||||||
|
help: 'Number of active API keys',
|
||||||
|
labelNames: ['tier'],
|
||||||
|
registers: [this.register],
|
||||||
|
});
|
||||||
|
|
||||||
|
this.rateLimitExceeded = new client.Counter({
|
||||||
|
name: 'api_gateway_rate_limit_exceeded_total',
|
||||||
|
help: 'Total number of rate limit exceeded events',
|
||||||
|
labelNames: ['tier'],
|
||||||
|
registers: [this.register],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onModuleInit() {
|
||||||
|
// Initial setup if needed
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMetrics(): Promise<string> {
|
||||||
|
return this.register.metrics();
|
||||||
|
}
|
||||||
|
|
||||||
|
getContentType(): string {
|
||||||
|
return this.register.contentType;
|
||||||
|
}
|
||||||
|
}
|
||||||
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
23
services/mana-api-gateway/src/usage/dto/usage-query.dto.ts
Normal file
23
services/mana-api-gateway/src/usage/dto/usage-query.dto.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { IsOptional, IsString, IsDateString, IsInt, Min, Max } from 'class-validator';
|
||||||
|
import { Transform } from 'class-transformer';
|
||||||
|
|
||||||
|
export class UsageQueryDto {
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => parseInt(value, 10))
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(365)
|
||||||
|
days?: number = 30;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
startDate?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
endDate?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
endpoint?: string;
|
||||||
|
}
|
||||||
10
services/mana-api-gateway/src/usage/usage.module.ts
Normal file
10
services/mana-api-gateway/src/usage/usage.module.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { Module, forwardRef } from '@nestjs/common';
|
||||||
|
import { UsageService } from './usage.service';
|
||||||
|
import { ApiKeysModule } from '../api-keys/api-keys.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [forwardRef(() => ApiKeysModule)],
|
||||||
|
providers: [UsageService],
|
||||||
|
exports: [UsageService],
|
||||||
|
})
|
||||||
|
export class UsageModule {}
|
||||||
187
services/mana-api-gateway/src/usage/usage.service.ts
Normal file
187
services/mana-api-gateway/src/usage/usage.service.ts
Normal file
|
|
@ -0,0 +1,187 @@
|
||||||
|
import { Injectable, Inject } from '@nestjs/common';
|
||||||
|
import { eq, sql, gte, and, desc } from 'drizzle-orm';
|
||||||
|
import { DATABASE_CONNECTION } from '../db/database.module';
|
||||||
|
import { apiUsage, apiUsageDaily, NewApiUsage } from '../db/schema';
|
||||||
|
|
||||||
|
export interface TrackUsageParams {
|
||||||
|
apiKeyId: string;
|
||||||
|
endpoint: string;
|
||||||
|
method: string;
|
||||||
|
path: string;
|
||||||
|
latencyMs: number;
|
||||||
|
statusCode: number;
|
||||||
|
creditsUsed: number;
|
||||||
|
requestSize?: number;
|
||||||
|
responseSize?: number;
|
||||||
|
creditReason?: string;
|
||||||
|
metadata?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsageSummary {
|
||||||
|
totalRequests: number;
|
||||||
|
totalCreditsUsed: number;
|
||||||
|
avgLatencyMs: number;
|
||||||
|
errorCount: number;
|
||||||
|
byEndpoint: Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
requests: number;
|
||||||
|
credits: number;
|
||||||
|
avgLatencyMs: number;
|
||||||
|
errors: number;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class UsageService {
|
||||||
|
constructor(
|
||||||
|
@Inject(DATABASE_CONNECTION)
|
||||||
|
private readonly db: ReturnType<typeof import('../db/connection').getDb>
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track a single API usage event
|
||||||
|
*/
|
||||||
|
async track(params: TrackUsageParams): Promise<void> {
|
||||||
|
const usage: NewApiUsage = {
|
||||||
|
apiKeyId: params.apiKeyId,
|
||||||
|
endpoint: params.endpoint,
|
||||||
|
method: params.method,
|
||||||
|
path: params.path,
|
||||||
|
latencyMs: params.latencyMs,
|
||||||
|
statusCode: params.statusCode,
|
||||||
|
creditsUsed: params.creditsUsed,
|
||||||
|
requestSize: params.requestSize,
|
||||||
|
responseSize: params.responseSize,
|
||||||
|
creditReason: params.creditReason,
|
||||||
|
metadata: params.metadata,
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.db.insert(apiUsage).values(usage);
|
||||||
|
|
||||||
|
// Also update daily aggregates
|
||||||
|
await this.updateDailyAggregate(params);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update daily usage aggregate
|
||||||
|
*/
|
||||||
|
private async updateDailyAggregate(params: TrackUsageParams): Promise<void> {
|
||||||
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
const isError = params.statusCode >= 400;
|
||||||
|
|
||||||
|
// Upsert daily aggregate
|
||||||
|
await this.db
|
||||||
|
.insert(apiUsageDaily)
|
||||||
|
.values({
|
||||||
|
apiKeyId: params.apiKeyId,
|
||||||
|
date: today,
|
||||||
|
endpoint: params.endpoint,
|
||||||
|
requestCount: 1,
|
||||||
|
creditsUsed: params.creditsUsed,
|
||||||
|
totalLatencyMs: params.latencyMs,
|
||||||
|
errorCount: isError ? 1 : 0,
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: [apiUsageDaily.apiKeyId, apiUsageDaily.date, apiUsageDaily.endpoint],
|
||||||
|
set: {
|
||||||
|
requestCount: sql`${apiUsageDaily.requestCount} + 1`,
|
||||||
|
creditsUsed: sql`${apiUsageDaily.creditsUsed} + ${params.creditsUsed}`,
|
||||||
|
totalLatencyMs: sql`${apiUsageDaily.totalLatencyMs} + ${params.latencyMs}`,
|
||||||
|
errorCount: isError ? sql`${apiUsageDaily.errorCount} + 1` : apiUsageDaily.errorCount,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get daily usage for an API key
|
||||||
|
*/
|
||||||
|
async getDailyUsage(apiKeyId: string, days: number = 30) {
|
||||||
|
const startDate = new Date();
|
||||||
|
startDate.setDate(startDate.getDate() - days);
|
||||||
|
|
||||||
|
const usage = await this.db
|
||||||
|
.select()
|
||||||
|
.from(apiUsageDaily)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(apiUsageDaily.apiKeyId, apiKeyId),
|
||||||
|
gte(apiUsageDaily.date, startDate.toISOString().split('T')[0])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(desc(apiUsageDaily.date));
|
||||||
|
|
||||||
|
return usage;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get usage summary for an API key
|
||||||
|
*/
|
||||||
|
async getUsageSummary(apiKeyId: string, days: number = 30): Promise<UsageSummary> {
|
||||||
|
const startDate = new Date();
|
||||||
|
startDate.setDate(startDate.getDate() - days);
|
||||||
|
|
||||||
|
const dailyUsage = await this.getDailyUsage(apiKeyId, days);
|
||||||
|
|
||||||
|
const summary: UsageSummary = {
|
||||||
|
totalRequests: 0,
|
||||||
|
totalCreditsUsed: 0,
|
||||||
|
avgLatencyMs: 0,
|
||||||
|
errorCount: 0,
|
||||||
|
byEndpoint: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
let totalLatency = 0;
|
||||||
|
|
||||||
|
for (const day of dailyUsage) {
|
||||||
|
summary.totalRequests += day.requestCount;
|
||||||
|
summary.totalCreditsUsed += day.creditsUsed;
|
||||||
|
totalLatency += day.totalLatencyMs;
|
||||||
|
summary.errorCount += day.errorCount;
|
||||||
|
|
||||||
|
if (!summary.byEndpoint[day.endpoint]) {
|
||||||
|
summary.byEndpoint[day.endpoint] = {
|
||||||
|
requests: 0,
|
||||||
|
credits: 0,
|
||||||
|
avgLatencyMs: 0,
|
||||||
|
errors: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ep = summary.byEndpoint[day.endpoint];
|
||||||
|
ep.requests += day.requestCount;
|
||||||
|
ep.credits += day.creditsUsed;
|
||||||
|
ep.avgLatencyMs += day.totalLatencyMs;
|
||||||
|
ep.errors += day.errorCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (summary.totalRequests > 0) {
|
||||||
|
summary.avgLatencyMs = Math.round(totalLatency / summary.totalRequests);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate average latency per endpoint
|
||||||
|
for (const endpoint of Object.keys(summary.byEndpoint)) {
|
||||||
|
const ep = summary.byEndpoint[endpoint];
|
||||||
|
if (ep.requests > 0) {
|
||||||
|
ep.avgLatencyMs = Math.round(ep.avgLatencyMs / ep.requests);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get recent usage logs for an API key
|
||||||
|
*/
|
||||||
|
async getRecentLogs(apiKeyId: string, limit: number = 100) {
|
||||||
|
const logs = await this.db
|
||||||
|
.select()
|
||||||
|
.from(apiUsage)
|
||||||
|
.where(eq(apiUsage.apiKeyId, apiKeyId))
|
||||||
|
.orderBy(desc(apiUsage.createdAt))
|
||||||
|
.limit(limit);
|
||||||
|
|
||||||
|
return logs;
|
||||||
|
}
|
||||||
|
}
|
||||||
4
services/mana-api-gateway/tsconfig.build.json
Normal file
4
services/mana-api-gateway/tsconfig.build.json
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"exclude": ["node_modules", "dist", "test", "**/*spec.ts"]
|
||||||
|
}
|
||||||
25
services/mana-api-gateway/tsconfig.json
Normal file
25
services/mana-api-gateway/tsconfig.json
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "commonjs",
|
||||||
|
"declaration": true,
|
||||||
|
"removeComments": true,
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"target": "ES2022",
|
||||||
|
"sourceMap": true,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"baseUrl": "./",
|
||||||
|
"incremental": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strictNullChecks": true,
|
||||||
|
"noImplicitAny": true,
|
||||||
|
"strictBindCallApply": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"resolveJsonModule": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue