mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-23 08:46:43 +02:00
refactor(services): rename Go services, remove -go suffix
mana-search-go → mana-search mana-notify-go → mana-notify mana-crawler-go → mana-crawler mana-api-gateway-go → mana-api-gateway Legacy NestJS versions are deleted, suffix no longer needed. Updated all references in docker-compose, CLAUDE.md, package.json, Forgejo workflows, and service package.json files. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
79080d6654
commit
7e931b1c6d
90 changed files with 41 additions and 38 deletions
1
services/mana-api-gateway/.gitignore
vendored
Normal file
1
services/mana-api-gateway/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
dist/
|
||||
53
services/mana-api-gateway/CLAUDE.md
Normal file
53
services/mana-api-gateway/CLAUDE.md
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
# mana-api-gateway (Go)
|
||||
|
||||
Go replacement for the NestJS API Gateway. Handles API key management, rate limiting, credit billing, and service proxying.
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Language:** Go 1.25
|
||||
- **Database:** PostgreSQL (pgx v5)
|
||||
- **Cache/RateLimit:** Redis (sliding window)
|
||||
- **Port:** 3030
|
||||
|
||||
## Endpoints
|
||||
|
||||
### Public API (X-API-Key auth)
|
||||
- `POST /v1/search` — Web search (1 credit)
|
||||
- `POST /v1/extract` — Content extraction (1 credit)
|
||||
- `POST /v1/stt/transcribe` — Speech-to-text (10 credits/min)
|
||||
- `POST /v1/tts/synthesize` — Text-to-speech (1 credit/1000 chars)
|
||||
|
||||
### Management API (JWT auth)
|
||||
- `POST /api-keys` — Create API key
|
||||
- `GET /api-keys` — List user's keys
|
||||
- `DELETE /api-keys/{id}` — Delete key
|
||||
- `GET /api-keys/{id}/usage` — Daily usage stats
|
||||
|
||||
### System
|
||||
- `GET /health` — Health check (DB + Redis)
|
||||
- `GET /metrics` — Prometheus metrics
|
||||
|
||||
## Pricing Tiers
|
||||
|
||||
| Tier | Rate Limit | Monthly Credits | Price |
|
||||
|------|-----------|-----------------|-------|
|
||||
| Free | 10 req/min | 100 | €0 |
|
||||
| Pro | 100 req/min | 5,000 | €19/mo |
|
||||
| Enterprise | 1,000 req/min | 50,000 | €99/mo |
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
go run ./cmd/server # Dev
|
||||
go build ./cmd/server # Build
|
||||
go test ./... # Test
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
- `PORT` — Server port (3030)
|
||||
- `DATABASE_URL` — PostgreSQL connection
|
||||
- `REDIS_HOST`, `REDIS_PORT`, `REDIS_PASSWORD`
|
||||
- `SEARCH_SERVICE_URL`, `STT_SERVICE_URL`, `TTS_SERVICE_URL`
|
||||
- `MANA_CORE_AUTH_URL` — JWT validation
|
||||
- `ADMIN_USER_IDS` — Comma-separated admin user IDs
|
||||
23
services/mana-api-gateway/Dockerfile
Normal file
23
services/mana-api-gateway/Dockerfile
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
# Build stage
|
||||
FROM golang:1.25-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY services/mana-api-gateway-go/go.mod services/mana-api-gateway-go/go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY services/mana-api-gateway-go/ .
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /mana-api-gateway ./cmd/server
|
||||
|
||||
# Runtime stage
|
||||
FROM alpine:3.21
|
||||
|
||||
RUN apk --no-cache add ca-certificates tzdata
|
||||
|
||||
COPY --from=builder /mana-api-gateway /usr/local/bin/mana-api-gateway
|
||||
|
||||
EXPOSE 3030
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
||||
CMD wget -q --spider http://localhost:3030/health || exit 1
|
||||
|
||||
ENTRYPOINT ["mana-api-gateway"]
|
||||
138
services/mana-api-gateway/cmd/server/main.go
Normal file
138
services/mana-api-gateway/cmd/server/main.go
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/manacore/mana-api-gateway/internal/config"
|
||||
"github.com/manacore/mana-api-gateway/internal/db"
|
||||
"github.com/manacore/mana-api-gateway/internal/handler"
|
||||
"github.com/manacore/mana-api-gateway/internal/middleware"
|
||||
"github.com/manacore/mana-api-gateway/internal/proxy"
|
||||
"github.com/manacore/mana-api-gateway/internal/service"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/rs/cors"
|
||||
)
|
||||
|
||||
func main() {
|
||||
slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
|
||||
Level: slog.LevelInfo,
|
||||
})))
|
||||
|
||||
cfg := config.Load()
|
||||
ctx := context.Background()
|
||||
|
||||
// Database
|
||||
database, err := db.New(ctx, cfg.DatabaseURL)
|
||||
if err != nil {
|
||||
slog.Error("database connection failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer database.Close()
|
||||
|
||||
if err := database.Migrate(ctx); err != nil {
|
||||
slog.Error("migration failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Redis
|
||||
rdb := redis.NewClient(&redis.Options{
|
||||
Addr: fmt.Sprintf("%s:%d", cfg.RedisHost, cfg.RedisPort),
|
||||
Password: cfg.RedisPassword,
|
||||
DB: 0,
|
||||
})
|
||||
if err := rdb.Ping(ctx).Err(); err != nil {
|
||||
slog.Warn("redis unavailable, rate limiting disabled", "error", err)
|
||||
} else {
|
||||
slog.Info("redis connected")
|
||||
}
|
||||
defer rdb.Close()
|
||||
|
||||
// Services
|
||||
apiKeySvc := service.NewApiKeyService(database.Pool, cfg.KeyPrefixLive, cfg.KeyPrefixTest)
|
||||
usageSvc := service.NewUsageService(database.Pool)
|
||||
|
||||
// Handlers
|
||||
apiKeysHandler := handler.NewApiKeysHandler(apiKeySvc, usageSvc)
|
||||
healthHandler := handler.NewHealthHandler(database.Pool, rdb)
|
||||
|
||||
// Proxy
|
||||
serviceProxy := proxy.NewServiceProxy(cfg.SearchURL, cfg.STTURL, cfg.TTSURL, apiKeySvc, usageSvc)
|
||||
|
||||
// Middleware chains
|
||||
apiKeyAuth := middleware.ApiKeyMiddleware(apiKeySvc)
|
||||
rateLimit := middleware.RateLimitMiddleware(rdb, cfg.RedisPrefix)
|
||||
creditsCheck := middleware.CreditsMiddleware(apiKeySvc)
|
||||
jwtAuth := middleware.JWTMiddleware(cfg.AuthURL)
|
||||
|
||||
// Chain: API Key → Rate Limit → Credits → Handler
|
||||
publicChain := func(h http.Handler) http.Handler {
|
||||
return apiKeyAuth(rateLimit(creditsCheck(h)))
|
||||
}
|
||||
|
||||
// Routes
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// Health & Metrics (public, no auth)
|
||||
mux.HandleFunc("GET /health", healthHandler.Health)
|
||||
mux.HandleFunc("GET /metrics", healthHandler.Metrics)
|
||||
|
||||
// Public API (API Key auth + rate limit + credits)
|
||||
mux.Handle("POST /v1/search", publicChain(http.HandlerFunc(serviceProxy.HandleSearch)))
|
||||
mux.Handle("POST /v1/extract", publicChain(http.HandlerFunc(serviceProxy.HandleSearch)))
|
||||
mux.Handle("POST /v1/extract/bulk", publicChain(http.HandlerFunc(serviceProxy.HandleSearch)))
|
||||
mux.Handle("GET /v1/search/engines", publicChain(http.HandlerFunc(serviceProxy.HandleSearch)))
|
||||
mux.Handle("POST /v1/stt/transcribe", publicChain(http.HandlerFunc(serviceProxy.HandleSTT)))
|
||||
mux.Handle("GET /v1/stt/models", publicChain(http.HandlerFunc(serviceProxy.HandleSTT)))
|
||||
mux.Handle("GET /v1/stt/languages", publicChain(http.HandlerFunc(serviceProxy.HandleSTT)))
|
||||
mux.Handle("POST /v1/tts/synthesize", publicChain(http.HandlerFunc(serviceProxy.HandleTTS)))
|
||||
mux.Handle("GET /v1/tts/voices", publicChain(http.HandlerFunc(serviceProxy.HandleTTS)))
|
||||
mux.Handle("GET /v1/tts/languages", publicChain(http.HandlerFunc(serviceProxy.HandleTTS)))
|
||||
|
||||
// Management API (JWT auth)
|
||||
mux.Handle("POST /api-keys", jwtAuth(http.HandlerFunc(apiKeysHandler.CreateKey)))
|
||||
mux.Handle("GET /api-keys", jwtAuth(http.HandlerFunc(apiKeysHandler.ListKeys)))
|
||||
mux.Handle("DELETE /api-keys/{id}", jwtAuth(http.HandlerFunc(apiKeysHandler.DeleteKey)))
|
||||
mux.Handle("GET /api-keys/{id}/usage", jwtAuth(http.HandlerFunc(apiKeysHandler.GetUsage)))
|
||||
mux.Handle("GET /api-keys/{id}/usage/summary", jwtAuth(http.HandlerFunc(apiKeysHandler.GetUsageSummary)))
|
||||
|
||||
// CORS
|
||||
c := cors.New(cors.Options{
|
||||
AllowedOrigins: cfg.CORSOrigins,
|
||||
AllowedMethods: []string{"GET", "POST", "PATCH", "DELETE", "OPTIONS"},
|
||||
AllowedHeaders: []string{"Authorization", "Content-Type", "X-API-Key"},
|
||||
AllowCredentials: true,
|
||||
})
|
||||
|
||||
server := &http.Server{
|
||||
Addr: fmt.Sprintf(":%d", cfg.Port),
|
||||
Handler: c.Handler(mux),
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 120 * time.Second,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
}
|
||||
|
||||
// Graceful shutdown
|
||||
go func() {
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-sigCh
|
||||
|
||||
slog.Info("shutting down...")
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
server.Shutdown(ctx)
|
||||
}()
|
||||
|
||||
slog.Info("mana-api-gateway starting", "port", cfg.Port)
|
||||
if err := server.ListenAndServe(); err != http.ErrServerClosed {
|
||||
slog.Error("server error", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
21
services/mana-api-gateway/go.mod
Normal file
21
services/mana-api-gateway/go.mod
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
module github.com/manacore/mana-api-gateway
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/jackc/pgx/v5 v5.9.1
|
||||
github.com/redis/go-redis/v9 v9.18.0
|
||||
github.com/rs/cors v1.11.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
golang.org/x/text v0.29.0 // indirect
|
||||
)
|
||||
46
services/mana-api-gateway/go.sum
Normal file
46
services/mana-api-gateway/go.sum
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc=
|
||||
github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
|
||||
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
|
||||
github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA=
|
||||
github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
||||
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
93
services/mana-api-gateway/internal/config/config.go
Normal file
93
services/mana-api-gateway/internal/config/config.go
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Port int
|
||||
|
||||
DatabaseURL string
|
||||
|
||||
RedisHost string
|
||||
RedisPort int
|
||||
RedisPassword string
|
||||
RedisPrefix string
|
||||
|
||||
// Backend service URLs
|
||||
SearchURL string
|
||||
STTURL string
|
||||
TTSURL string
|
||||
|
||||
// Auth
|
||||
AuthURL string
|
||||
AdminUserIDs []string
|
||||
|
||||
// API Key settings
|
||||
KeyPrefixLive string
|
||||
KeyPrefixTest string
|
||||
|
||||
// Defaults
|
||||
DefaultRateLimit int
|
||||
DefaultMonthlyCredits int
|
||||
|
||||
CORSOrigins []string
|
||||
}
|
||||
|
||||
func Load() *Config {
|
||||
port, _ := strconv.Atoi(getEnv("PORT", "3030"))
|
||||
redisPort, _ := strconv.Atoi(getEnv("REDIS_PORT", "6379"))
|
||||
defaultRL, _ := strconv.Atoi(getEnv("DEFAULT_RATE_LIMIT", "10"))
|
||||
defaultCredits, _ := strconv.Atoi(getEnv("DEFAULT_MONTHLY_CREDITS", "100"))
|
||||
|
||||
var adminIDs []string
|
||||
if ids := os.Getenv("ADMIN_USER_IDS"); ids != "" {
|
||||
for _, id := range strings.Split(ids, ",") {
|
||||
id = strings.TrimSpace(id)
|
||||
if id != "" {
|
||||
adminIDs = append(adminIDs, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var origins []string
|
||||
if o := os.Getenv("CORS_ORIGINS"); o != "" {
|
||||
for _, origin := range strings.Split(o, ",") {
|
||||
origin = strings.TrimSpace(origin)
|
||||
if origin != "" {
|
||||
origins = append(origins, origin)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(origins) == 0 {
|
||||
origins = []string{"http://localhost:3000", "http://localhost:5173"}
|
||||
}
|
||||
|
||||
return &Config{
|
||||
Port: port,
|
||||
DatabaseURL: getEnv("DATABASE_URL", "postgresql://manacore:devpassword@localhost:5432/manacore"),
|
||||
RedisHost: getEnv("REDIS_HOST", "localhost"),
|
||||
RedisPort: redisPort,
|
||||
RedisPassword: getEnv("REDIS_PASSWORD", ""),
|
||||
RedisPrefix: getEnv("REDIS_PREFIX", "api-gateway:"),
|
||||
SearchURL: getEnv("SEARCH_SERVICE_URL", "http://localhost:3021"),
|
||||
STTURL: getEnv("STT_SERVICE_URL", "http://localhost:3020"),
|
||||
TTSURL: getEnv("TTS_SERVICE_URL", "http://localhost:3022"),
|
||||
AuthURL: getEnv("MANA_CORE_AUTH_URL", "http://localhost:3001"),
|
||||
AdminUserIDs: adminIDs,
|
||||
KeyPrefixLive: getEnv("API_KEY_PREFIX_LIVE", "sk_live_"),
|
||||
KeyPrefixTest: getEnv("API_KEY_PREFIX_TEST", "sk_test_"),
|
||||
DefaultRateLimit: defaultRL,
|
||||
DefaultMonthlyCredits: defaultCredits,
|
||||
CORSOrigins: origins,
|
||||
}
|
||||
}
|
||||
|
||||
func getEnv(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
119
services/mana-api-gateway/internal/db/db.go
Normal file
119
services/mana-api-gateway/internal/db/db.go
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// DB wraps a pgx connection pool.
|
||||
type DB struct {
|
||||
Pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// New creates a new database connection pool.
|
||||
func New(ctx context.Context, databaseURL string) (*DB, error) {
|
||||
config, err := pgxpool.ParseConfig(databaseURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse db config: %w", err)
|
||||
}
|
||||
|
||||
config.MaxConns = 20
|
||||
config.MinConns = 2
|
||||
config.MaxConnLifetime = 30 * time.Minute
|
||||
config.MaxConnIdleTime = 5 * time.Minute
|
||||
|
||||
pool, err := pgxpool.NewWithConfig(ctx, config)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create pool: %w", err)
|
||||
}
|
||||
|
||||
if err := pool.Ping(ctx); err != nil {
|
||||
return nil, fmt.Errorf("ping: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("database connected")
|
||||
return &DB{Pool: pool}, nil
|
||||
}
|
||||
|
||||
// Migrate creates the schema and tables.
|
||||
func (d *DB) Migrate(ctx context.Context) error {
|
||||
sql := `
|
||||
CREATE SCHEMA IF NOT EXISTS api_gateway;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS api_gateway.api_keys (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
key TEXT NOT NULL UNIQUE,
|
||||
key_hash TEXT NOT NULL,
|
||||
key_prefix TEXT NOT NULL,
|
||||
user_id TEXT,
|
||||
organization_id TEXT,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT DEFAULT '',
|
||||
tier TEXT NOT NULL DEFAULT 'free',
|
||||
rate_limit INT NOT NULL DEFAULT 10,
|
||||
monthly_credits INT NOT NULL DEFAULT 100,
|
||||
credits_used INT NOT NULL DEFAULT 0,
|
||||
credits_reset_at TIMESTAMPTZ,
|
||||
allowed_endpoints JSONB DEFAULT '["search"]',
|
||||
allowed_ips JSONB,
|
||||
active BOOLEAN NOT NULL DEFAULT true,
|
||||
expires_at TIMESTAMPTZ,
|
||||
last_used_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_api_keys_key_hash ON api_gateway.api_keys(key_hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_api_keys_user_id ON api_gateway.api_keys(user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS api_gateway.api_usage (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
api_key_id UUID NOT NULL REFERENCES api_gateway.api_keys(id) ON DELETE CASCADE,
|
||||
endpoint TEXT NOT NULL,
|
||||
method TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
request_size INT,
|
||||
response_size INT,
|
||||
latency_ms INT,
|
||||
status_code INT,
|
||||
credits_used INT NOT NULL DEFAULT 0,
|
||||
credit_reason TEXT,
|
||||
metadata JSONB,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_api_usage_key_id ON api_gateway.api_usage(api_key_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_api_usage_created ON api_gateway.api_usage(created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS api_gateway.api_usage_daily (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
api_key_id UUID NOT NULL REFERENCES api_gateway.api_keys(id) ON DELETE CASCADE,
|
||||
date DATE NOT NULL,
|
||||
endpoint TEXT NOT NULL,
|
||||
request_count INT NOT NULL DEFAULT 0,
|
||||
credits_used INT NOT NULL DEFAULT 0,
|
||||
total_latency_ms INT NOT NULL DEFAULT 0,
|
||||
error_count INT NOT NULL DEFAULT 0,
|
||||
UNIQUE(api_key_id, date, endpoint)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_api_usage_daily_date ON api_gateway.api_usage_daily(date);
|
||||
`
|
||||
|
||||
_, err := d.Pool.Exec(ctx, sql)
|
||||
if err != nil {
|
||||
return fmt.Errorf("migrate: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("database migrated")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close closes the connection pool.
|
||||
func (d *DB) Close() {
|
||||
d.Pool.Close()
|
||||
}
|
||||
132
services/mana-api-gateway/internal/handler/apikeys.go
Normal file
132
services/mana-api-gateway/internal/handler/apikeys.go
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/manacore/mana-api-gateway/internal/middleware"
|
||||
"github.com/manacore/mana-api-gateway/internal/service"
|
||||
)
|
||||
|
||||
// ApiKeysHandler handles API key management endpoints.
|
||||
type ApiKeysHandler struct {
|
||||
apiKeyService *service.ApiKeyService
|
||||
usageService *service.UsageService
|
||||
}
|
||||
|
||||
// NewApiKeysHandler creates a new handler.
|
||||
func NewApiKeysHandler(apiKeySvc *service.ApiKeyService, usageSvc *service.UsageService) *ApiKeysHandler {
|
||||
return &ApiKeysHandler{apiKeyService: apiKeySvc, usageService: usageSvc}
|
||||
}
|
||||
|
||||
// CreateKey handles POST /api-keys
|
||||
func (h *ApiKeysHandler) CreateKey(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r)
|
||||
if userID == "" {
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Tier string `json:"tier"`
|
||||
IsTest bool `json:"isTest"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
if body.Name == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name is required"})
|
||||
return
|
||||
}
|
||||
|
||||
if body.Tier == "" {
|
||||
body.Tier = "free"
|
||||
}
|
||||
|
||||
rawKey, apiKey, err := h.apiKeyService.Create(r.Context(), userID, body.Name, body.Description, body.Tier, body.IsTest)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to create key"})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusCreated, map[string]any{
|
||||
"key": rawKey,
|
||||
"apiKey": apiKey,
|
||||
})
|
||||
}
|
||||
|
||||
// ListKeys handles GET /api-keys
|
||||
func (h *ApiKeysHandler) ListKeys(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r)
|
||||
if userID == "" {
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
keys, err := h.apiKeyService.ListByUser(r.Context(), userID)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to list keys"})
|
||||
return
|
||||
}
|
||||
|
||||
if keys == nil {
|
||||
keys = []service.ApiKey{}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, keys)
|
||||
}
|
||||
|
||||
// DeleteKey handles DELETE /api-keys/{id}
|
||||
func (h *ApiKeysHandler) DeleteKey(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r)
|
||||
keyID := r.PathValue("id")
|
||||
|
||||
if err := h.apiKeyService.Delete(r.Context(), keyID, userID); err != nil {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "key not found"})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{"message": "key deleted"})
|
||||
}
|
||||
|
||||
// GetUsage handles GET /api-keys/{id}/usage
|
||||
func (h *ApiKeysHandler) GetUsage(w http.ResponseWriter, r *http.Request) {
|
||||
keyID := r.PathValue("id")
|
||||
|
||||
usage, err := h.usageService.GetDailyUsage(r.Context(), keyID, 30)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to get usage"})
|
||||
return
|
||||
}
|
||||
|
||||
if usage == nil {
|
||||
usage = []service.DailyUsage{}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, usage)
|
||||
}
|
||||
|
||||
// GetUsageSummary handles GET /api-keys/{id}/usage/summary
|
||||
func (h *ApiKeysHandler) GetUsageSummary(w http.ResponseWriter, r *http.Request) {
|
||||
keyID := r.PathValue("id")
|
||||
since := time.Now().AddDate(0, -1, 0) // last 30 days
|
||||
|
||||
summary, err := h.usageService.GetSummary(r.Context(), keyID, since)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to get summary"})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, summary)
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, data any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(data)
|
||||
}
|
||||
69
services/mana-api-gateway/internal/handler/health.go
Normal file
69
services/mana-api-gateway/internal/handler/health.go
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// HealthHandler handles health and metrics endpoints.
|
||||
type HealthHandler struct {
|
||||
pool *pgxpool.Pool
|
||||
redis *redis.Client
|
||||
startTime time.Time
|
||||
}
|
||||
|
||||
// NewHealthHandler creates a new health handler.
|
||||
func NewHealthHandler(pool *pgxpool.Pool, rdb *redis.Client) *HealthHandler {
|
||||
return &HealthHandler{pool: pool, redis: rdb, startTime: time.Now()}
|
||||
}
|
||||
|
||||
// Health handles GET /health
|
||||
func (h *HealthHandler) Health(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
dbOK := "ok"
|
||||
if err := h.pool.Ping(ctx); err != nil {
|
||||
dbOK = "error"
|
||||
}
|
||||
|
||||
redisOK := "ok"
|
||||
if err := h.redis.Ping(ctx).Err(); err != nil {
|
||||
redisOK = "error"
|
||||
}
|
||||
|
||||
status := "ok"
|
||||
if dbOK != "ok" || redisOK != "ok" {
|
||||
status = "degraded"
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"status": status,
|
||||
"service": "mana-api-gateway",
|
||||
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
||||
"uptime": time.Since(h.startTime).Seconds(),
|
||||
"database": dbOK,
|
||||
"redis": redisOK,
|
||||
})
|
||||
}
|
||||
|
||||
// Metrics handles GET /metrics (Prometheus format)
|
||||
func (h *HealthHandler) Metrics(w http.ResponseWriter, r *http.Request) {
|
||||
uptime := time.Since(h.startTime).Seconds()
|
||||
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
fmt.Fprintf(w, "# HELP mana_api_gateway_uptime_seconds Gateway uptime\n")
|
||||
fmt.Fprintf(w, "# TYPE mana_api_gateway_uptime_seconds gauge\n")
|
||||
fmt.Fprintf(w, "mana_api_gateway_uptime_seconds %.0f\n", uptime)
|
||||
|
||||
// DB pool stats
|
||||
stats := h.pool.Stat()
|
||||
fmt.Fprintf(w, "# HELP mana_api_gateway_db_connections Database connection pool\n")
|
||||
fmt.Fprintf(w, "# TYPE mana_api_gateway_db_connections gauge\n")
|
||||
fmt.Fprintf(w, "mana_api_gateway_db_connections{state=\"total\"} %d\n", stats.TotalConns())
|
||||
fmt.Fprintf(w, "mana_api_gateway_db_connections{state=\"idle\"} %d\n", stats.IdleConns())
|
||||
fmt.Fprintf(w, "mana_api_gateway_db_connections{state=\"acquired\"} %d\n", stats.AcquiredConns())
|
||||
}
|
||||
106
services/mana-api-gateway/internal/middleware/apikey.go
Normal file
106
services/mana-api-gateway/internal/middleware/apikey.go
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/manacore/mana-api-gateway/internal/service"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
|
||||
const ApiKeyContextKey contextKey = "apiKey"
|
||||
|
||||
var endpointRegex = regexp.MustCompile(`/v1/(\w+)`)
|
||||
|
||||
// ApiKeyMiddleware validates X-API-Key header and attaches key data to context.
|
||||
func ApiKeyMiddleware(apiKeyService *service.ApiKeyService) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
rawKey := r.Header.Get("X-API-Key")
|
||||
if rawKey == "" {
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]string{
|
||||
"error": "API key required. Use X-API-Key header.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
keyData, err := apiKeyService.ValidateKey(r.Context(), rawKey)
|
||||
if err != nil {
|
||||
slog.Debug("invalid api key", "error", err)
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]string{
|
||||
"error": "Invalid API key",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if !keyData.Active {
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]string{
|
||||
"error": "API key is disabled",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if keyData.ExpiresAt != nil && time.Now().After(*keyData.ExpiresAt) {
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]string{
|
||||
"error": "API key has expired",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Check endpoint permission
|
||||
endpoint := extractEndpoint(r.URL.Path)
|
||||
if !hasEndpointPermission(keyData, endpoint) {
|
||||
writeJSON(w, http.StatusForbidden, map[string]string{
|
||||
"error": "Endpoint '" + endpoint + "' not allowed for this API key. Upgrade your plan.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Attach key data to context
|
||||
ctx := context.WithValue(r.Context(), ApiKeyContextKey, keyData)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// GetApiKey retrieves the API key data from the request context.
|
||||
func GetApiKey(r *http.Request) *service.ApiKeyData {
|
||||
data, _ := r.Context().Value(ApiKeyContextKey).(*service.ApiKeyData)
|
||||
return data
|
||||
}
|
||||
|
||||
func extractEndpoint(path string) string {
|
||||
m := endpointRegex.FindStringSubmatch(path)
|
||||
if len(m) >= 2 {
|
||||
return m[1]
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
func hasEndpointPermission(keyData *service.ApiKeyData, endpoint string) bool {
|
||||
if keyData.AllowedEndpoints == "" {
|
||||
return true
|
||||
}
|
||||
var allowed []string
|
||||
if err := json.Unmarshal([]byte(keyData.AllowedEndpoints), &allowed); err != nil {
|
||||
return true
|
||||
}
|
||||
for _, e := range allowed {
|
||||
if e == endpoint || strings.HasPrefix(endpoint, e) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, data any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(data)
|
||||
}
|
||||
101
services/mana-api-gateway/internal/middleware/jwt.go
Normal file
101
services/mana-api-gateway/internal/middleware/jwt.go
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
type jwtContextKey string
|
||||
|
||||
const UserIDContextKey jwtContextKey = "userID"
|
||||
const UserRoleContextKey jwtContextKey = "userRole"
|
||||
|
||||
// JWTClaims holds the JWT token claims.
|
||||
type JWTClaims struct {
|
||||
Sub string `json:"sub"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// JWTMiddleware validates Bearer JWT tokens for management endpoints.
|
||||
// Uses JWKS from mana-core-auth (simplified: accepts any valid JWT structure for now).
|
||||
func JWTMiddleware(authURL string) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]string{
|
||||
"error": "Authorization header required. Use Bearer <JWT>.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
|
||||
// Validate JWT via auth service
|
||||
userID, role, err := validateJWT(r.Context(), authURL, tokenStr)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]string{
|
||||
"error": "Invalid or expired token",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.WithValue(r.Context(), UserIDContextKey, userID)
|
||||
ctx = context.WithValue(ctx, UserRoleContextKey, role)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// GetUserID returns the authenticated user ID from context.
|
||||
func GetUserID(r *http.Request) string {
|
||||
id, _ := r.Context().Value(UserIDContextKey).(string)
|
||||
return id
|
||||
}
|
||||
|
||||
// GetUserRole returns the user role from context.
|
||||
func GetUserRole(r *http.Request) string {
|
||||
role, _ := r.Context().Value(UserRoleContextKey).(string)
|
||||
return role
|
||||
}
|
||||
|
||||
// validateJWT calls mana-core-auth /api/v1/auth/validate to verify the token.
|
||||
func validateJWT(ctx context.Context, authURL, token string) (userID, role string, err error) {
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", authURL+"/api/v1/auth/validate", strings.NewReader(`{"token":"`+token+`"}`))
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("auth service: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", "", fmt.Errorf("auth validation failed: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Valid bool `json:"valid"`
|
||||
UserID string `json:"userId"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
if !result.Valid {
|
||||
return "", "", fmt.Errorf("token not valid")
|
||||
}
|
||||
|
||||
return result.UserID, result.Role, nil
|
||||
}
|
||||
99
services/mana-api-gateway/internal/middleware/ratelimit.go
Normal file
99
services/mana-api-gateway/internal/middleware/ratelimit.go
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
package middleware
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/manacore/mana-api-gateway/internal/service"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// RateLimitMiddleware enforces per-key sliding window rate limits using Redis.
|
||||
func RateLimitMiddleware(rdb *redis.Client, prefix string) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
keyData := GetApiKey(r)
|
||||
if keyData == nil {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
key := prefix + "ratelimit:" + keyData.ID
|
||||
limit := keyData.RateLimit
|
||||
window := int64(60) // 60 seconds
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
windowStart := now - window*1000
|
||||
|
||||
pipe := rdb.Pipeline()
|
||||
pipe.ZRemRangeByScore(ctx, key, "0", strconv.FormatInt(windowStart, 10))
|
||||
countCmd := pipe.ZCard(ctx, key)
|
||||
pipe.Exec(ctx)
|
||||
|
||||
count := countCmd.Val()
|
||||
|
||||
if count >= int64(limit) {
|
||||
w.Header().Set("X-RateLimit-Limit", strconv.Itoa(limit))
|
||||
w.Header().Set("X-RateLimit-Remaining", "0")
|
||||
w.Header().Set("Retry-After", "60")
|
||||
writeJSON(w, http.StatusTooManyRequests, map[string]any{
|
||||
"error": "Rate limit exceeded",
|
||||
"limit": limit,
|
||||
"remaining": 0,
|
||||
"retryAfter": 60,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Add current request
|
||||
rdb.ZAdd(ctx, key, redis.Z{Score: float64(now), Member: fmt.Sprintf("%d", now)})
|
||||
rdb.Expire(ctx, key, time.Duration(window)*time.Second)
|
||||
|
||||
remaining := int64(limit) - count - 1
|
||||
if remaining < 0 {
|
||||
remaining = 0
|
||||
}
|
||||
|
||||
w.Header().Set("X-RateLimit-Limit", strconv.Itoa(limit))
|
||||
w.Header().Set("X-RateLimit-Remaining", strconv.FormatInt(remaining, 10))
|
||||
w.Header().Set("X-RateLimit-Reset", strconv.FormatInt(now/1000+window, 10))
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// CreditsMiddleware checks if the API key has enough credits.
|
||||
func CreditsMiddleware(apiKeyService *service.ApiKeyService) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
keyData := GetApiKey(r)
|
||||
if keyData == nil {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
endpoint := extractEndpoint(r.URL.Path)
|
||||
estimatedCredits := service.CreditCosts[endpoint]
|
||||
if estimatedCredits == 0 {
|
||||
estimatedCredits = 1
|
||||
}
|
||||
|
||||
ok, err := apiKeyService.HasEnoughCredits(r.Context(), keyData.ID, estimatedCredits)
|
||||
if err != nil || !ok {
|
||||
writeJSON(w, http.StatusPaymentRequired, map[string]any{
|
||||
"error": "Insufficient credits",
|
||||
"creditsRequired": estimatedCredits,
|
||||
"creditsUsed": keyData.CreditsUsed,
|
||||
"monthlyCredits": keyData.MonthlyCredits,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
157
services/mana-api-gateway/internal/proxy/proxy.go
Normal file
157
services/mana-api-gateway/internal/proxy/proxy.go
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
package proxy
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/manacore/mana-api-gateway/internal/middleware"
|
||||
"github.com/manacore/mana-api-gateway/internal/service"
|
||||
)
|
||||
|
||||
// ServiceProxy proxies requests to backend services and tracks usage.
|
||||
type ServiceProxy struct {
|
||||
searchProxy *httputil.ReverseProxy
|
||||
sttProxy *httputil.ReverseProxy
|
||||
ttsProxy *httputil.ReverseProxy
|
||||
|
||||
apiKeyService *service.ApiKeyService
|
||||
usageService *service.UsageService
|
||||
}
|
||||
|
||||
// NewServiceProxy creates a new service proxy.
|
||||
func NewServiceProxy(searchURL, sttURL, ttsURL string, apiKeySvc *service.ApiKeyService, usageSvc *service.UsageService) *ServiceProxy {
|
||||
return &ServiceProxy{
|
||||
searchProxy: createProxy(searchURL),
|
||||
sttProxy: createProxy(sttURL),
|
||||
ttsProxy: createProxy(ttsURL),
|
||||
apiKeyService: apiKeySvc,
|
||||
usageService: usageSvc,
|
||||
}
|
||||
}
|
||||
|
||||
func createProxy(targetURL string) *httputil.ReverseProxy {
|
||||
target, err := url.Parse(targetURL)
|
||||
if err != nil {
|
||||
slog.Error("invalid proxy target", "url", targetURL, "error", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
proxy := httputil.NewSingleHostReverseProxy(target)
|
||||
proxy.Transport = &http.Transport{
|
||||
MaxIdleConns: 100,
|
||||
MaxIdleConnsPerHost: 20,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
}
|
||||
|
||||
return proxy
|
||||
}
|
||||
|
||||
// HandleSearch proxies to the search service.
|
||||
func (p *ServiceProxy) HandleSearch(w http.ResponseWriter, r *http.Request) {
|
||||
p.proxyRequest(w, r, p.searchProxy, "search", "/api/v1")
|
||||
}
|
||||
|
||||
// HandleSTT proxies to the STT service.
|
||||
func (p *ServiceProxy) HandleSTT(w http.ResponseWriter, r *http.Request) {
|
||||
p.proxyRequest(w, r, p.sttProxy, "stt", "")
|
||||
}
|
||||
|
||||
// HandleTTS proxies to the TTS service.
|
||||
func (p *ServiceProxy) HandleTTS(w http.ResponseWriter, r *http.Request) {
|
||||
p.proxyRequest(w, r, p.ttsProxy, "tts", "")
|
||||
}
|
||||
|
||||
func (p *ServiceProxy) proxyRequest(w http.ResponseWriter, r *http.Request, proxy *httputil.ReverseProxy, endpoint, pathPrefix string) {
|
||||
if proxy == nil {
|
||||
http.Error(w, `{"error":"service unavailable"}`, http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
keyData := middleware.GetApiKey(r)
|
||||
start := time.Now()
|
||||
|
||||
// Rewrite path: /v1/search -> /api/v1/search (or whatever the backend expects)
|
||||
originalPath := r.URL.Path
|
||||
if pathPrefix != "" {
|
||||
r.URL.Path = strings.Replace(r.URL.Path, "/v1/"+endpoint, pathPrefix+"/"+endpoint, 1)
|
||||
}
|
||||
|
||||
// Use a response recorder to capture status code
|
||||
rec := &responseRecorder{ResponseWriter: w, statusCode: 200}
|
||||
|
||||
proxy.ServeHTTP(rec, r)
|
||||
|
||||
// Log usage
|
||||
latency := time.Since(start).Milliseconds()
|
||||
credits := service.CreditCosts[endpoint]
|
||||
if credits == 0 {
|
||||
credits = 1
|
||||
}
|
||||
|
||||
// Deduct credits
|
||||
if keyData != nil {
|
||||
p.apiKeyService.IncrementCredits(r.Context(), keyData.ID, credits)
|
||||
|
||||
// Log usage asynchronously
|
||||
go func() {
|
||||
p.usageService.LogUsage(r.Context(), service.UsageEntry{
|
||||
ApiKeyID: keyData.ID,
|
||||
Endpoint: endpoint,
|
||||
Method: r.Method,
|
||||
Path: originalPath,
|
||||
RequestSize: int(r.ContentLength),
|
||||
ResponseSize: rec.size,
|
||||
LatencyMs: int(latency),
|
||||
StatusCode: rec.statusCode,
|
||||
CreditsUsed: credits,
|
||||
CreditReason: endpoint,
|
||||
})
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
// responseRecorder captures the status code and response size.
|
||||
type responseRecorder struct {
|
||||
http.ResponseWriter
|
||||
statusCode int
|
||||
size int
|
||||
}
|
||||
|
||||
func (r *responseRecorder) WriteHeader(code int) {
|
||||
r.statusCode = code
|
||||
r.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func (r *responseRecorder) Write(b []byte) (int, error) {
|
||||
n, err := r.ResponseWriter.Write(b)
|
||||
r.size += n
|
||||
return n, err
|
||||
}
|
||||
|
||||
// Flush implements http.Flusher for streaming responses.
|
||||
func (r *responseRecorder) Flush() {
|
||||
if f, ok := r.ResponseWriter.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
// Unwrap returns the underlying ResponseWriter (for http.ResponseController).
|
||||
func (r *responseRecorder) Unwrap() http.ResponseWriter {
|
||||
return r.ResponseWriter
|
||||
}
|
||||
|
||||
// ReadFrom implements io.ReaderFrom for efficient copying.
|
||||
func (r *responseRecorder) ReadFrom(src io.Reader) (int64, error) {
|
||||
if rf, ok := r.ResponseWriter.(io.ReaderFrom); ok {
|
||||
n, err := rf.ReadFrom(src)
|
||||
r.size += int(n)
|
||||
return n, err
|
||||
}
|
||||
// Fallback: use io.Copy which will call Write
|
||||
return io.Copy(r.ResponseWriter, src)
|
||||
}
|
||||
256
services/mana-api-gateway/internal/service/apikeys.go
Normal file
256
services/mana-api-gateway/internal/service/apikeys.go
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// ApiKey represents a stored API key.
|
||||
type ApiKey struct {
|
||||
ID string `json:"id"`
|
||||
Key string `json:"key"` // masked in responses
|
||||
KeyHash string `json:"-"`
|
||||
KeyPrefix string `json:"keyPrefix"`
|
||||
UserID *string `json:"userId"`
|
||||
OrganizationID *string `json:"organizationId"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Tier string `json:"tier"`
|
||||
RateLimit int `json:"rateLimit"`
|
||||
MonthlyCredits int `json:"monthlyCredits"`
|
||||
CreditsUsed int `json:"creditsUsed"`
|
||||
CreditsResetAt *time.Time `json:"creditsResetAt"`
|
||||
AllowedEndpoints string `json:"allowedEndpoints"` // JSON array
|
||||
AllowedIPs *string `json:"allowedIps"`
|
||||
Active bool `json:"active"`
|
||||
ExpiresAt *time.Time `json:"expiresAt"`
|
||||
LastUsedAt *time.Time `json:"lastUsedAt"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// ApiKeyData is the validated key data attached to requests.
|
||||
type ApiKeyData struct {
|
||||
ID string
|
||||
UserID *string
|
||||
OrganizationID *string
|
||||
Name string
|
||||
Tier string
|
||||
RateLimit int
|
||||
MonthlyCredits int
|
||||
CreditsUsed int
|
||||
AllowedEndpoints string
|
||||
AllowedIPs *string
|
||||
Active bool
|
||||
ExpiresAt *time.Time
|
||||
}
|
||||
|
||||
// PricingTier defines limits for a tier.
|
||||
type PricingTier struct {
|
||||
Name string
|
||||
RateLimit int
|
||||
MonthlyCredits int
|
||||
Endpoints []string
|
||||
Price int // cents
|
||||
}
|
||||
|
||||
var Tiers = map[string]PricingTier{
|
||||
"free": {Name: "Free", RateLimit: 10, MonthlyCredits: 100, Endpoints: []string{"search"}, Price: 0},
|
||||
"pro": {Name: "Pro", RateLimit: 100, MonthlyCredits: 5000, Endpoints: []string{"search", "stt", "tts"}, Price: 1900},
|
||||
"enterprise": {Name: "Enterprise", RateLimit: 1000, MonthlyCredits: 50000, Endpoints: []string{"search", "stt", "tts"}, Price: 9900},
|
||||
}
|
||||
|
||||
// CreditCosts per endpoint.
|
||||
var CreditCosts = map[string]int{
|
||||
"search": 1,
|
||||
"extract": 1,
|
||||
"stt": 10, // per minute
|
||||
"tts": 1, // per 1000 chars
|
||||
}
|
||||
|
||||
// ApiKeyService manages API keys in PostgreSQL.
|
||||
type ApiKeyService struct {
|
||||
pool *pgxpool.Pool
|
||||
prefixLive string
|
||||
prefixTest string
|
||||
}
|
||||
|
||||
// NewApiKeyService creates a new service.
|
||||
func NewApiKeyService(pool *pgxpool.Pool, prefixLive, prefixTest string) *ApiKeyService {
|
||||
return &ApiKeyService{pool: pool, prefixLive: prefixLive, prefixTest: prefixTest}
|
||||
}
|
||||
|
||||
// GenerateKey creates a new API key string.
|
||||
func (s *ApiKeyService) GenerateKey(isTest bool) (key, hash, prefix string) {
|
||||
pfx := s.prefixLive
|
||||
if isTest {
|
||||
pfx = s.prefixTest
|
||||
}
|
||||
|
||||
b := make([]byte, 24)
|
||||
rand.Read(b)
|
||||
randomPart := base64.RawURLEncoding.EncodeToString(b)
|
||||
key = pfx + randomPart
|
||||
|
||||
h := sha256.Sum256([]byte(key))
|
||||
hash = fmt.Sprintf("%x", h)
|
||||
prefix = pfx
|
||||
return
|
||||
}
|
||||
|
||||
// MaskKey hides most of the key for display.
|
||||
func (s *ApiKeyService) MaskKey(key string) string {
|
||||
if len(key) <= 12 {
|
||||
return key
|
||||
}
|
||||
pfx := s.prefixLive
|
||||
if len(key) > len(s.prefixTest) && key[:len(s.prefixTest)] == s.prefixTest {
|
||||
pfx = s.prefixTest
|
||||
}
|
||||
return pfx + "..." + key[len(key)-4:]
|
||||
}
|
||||
|
||||
// Create creates a new API key.
|
||||
func (s *ApiKeyService) Create(ctx context.Context, userID, name, description, tier string, isTest bool) (string, *ApiKey, error) {
|
||||
key, hash, prefix := s.GenerateKey(isTest)
|
||||
|
||||
t, ok := Tiers[tier]
|
||||
if !ok {
|
||||
t = Tiers["free"]
|
||||
tier = "free"
|
||||
}
|
||||
|
||||
endpoints, _ := json.Marshal(t.Endpoints)
|
||||
resetAt := nextMonthReset()
|
||||
|
||||
row := s.pool.QueryRow(ctx, `
|
||||
INSERT INTO api_gateway.api_keys (key, key_hash, key_prefix, user_id, name, description, tier, rate_limit, monthly_credits, credits_used, credits_reset_at, allowed_endpoints, active)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 0, $10, $11, true)
|
||||
RETURNING id, created_at, updated_at
|
||||
`, key, hash, prefix, userID, name, description, tier, t.RateLimit, t.MonthlyCredits, resetAt, string(endpoints))
|
||||
|
||||
var apiKey ApiKey
|
||||
var id string
|
||||
var createdAt, updatedAt time.Time
|
||||
if err := row.Scan(&id, &createdAt, &updatedAt); err != nil {
|
||||
return "", nil, fmt.Errorf("create key: %w", err)
|
||||
}
|
||||
|
||||
apiKey = ApiKey{
|
||||
ID: id, Key: s.MaskKey(key), KeyPrefix: prefix,
|
||||
UserID: &userID, Name: name, Description: description,
|
||||
Tier: tier, RateLimit: t.RateLimit, MonthlyCredits: t.MonthlyCredits,
|
||||
CreditsUsed: 0, CreditsResetAt: &resetAt, AllowedEndpoints: string(endpoints),
|
||||
Active: true, CreatedAt: createdAt, UpdatedAt: updatedAt,
|
||||
}
|
||||
|
||||
return key, &apiKey, nil
|
||||
}
|
||||
|
||||
// ValidateKey looks up a key by its hash.
|
||||
func (s *ApiKeyService) ValidateKey(ctx context.Context, rawKey string) (*ApiKeyData, error) {
|
||||
h := sha256.Sum256([]byte(rawKey))
|
||||
hash := fmt.Sprintf("%x", h)
|
||||
|
||||
var data ApiKeyData
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT id, user_id, organization_id, name, tier, rate_limit, monthly_credits, credits_used, allowed_endpoints, allowed_ips, active, expires_at
|
||||
FROM api_gateway.api_keys WHERE key_hash = $1
|
||||
`, hash).Scan(&data.ID, &data.UserID, &data.OrganizationID, &data.Name, &data.Tier,
|
||||
&data.RateLimit, &data.MonthlyCredits, &data.CreditsUsed, &data.AllowedEndpoints,
|
||||
&data.AllowedIPs, &data.Active, &data.ExpiresAt)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Update last_used_at
|
||||
s.pool.Exec(ctx, `UPDATE api_gateway.api_keys SET last_used_at = NOW() WHERE id = $1`, data.ID)
|
||||
|
||||
return &data, nil
|
||||
}
|
||||
|
||||
// ListByUser returns all keys for a user (masked).
|
||||
func (s *ApiKeyService) ListByUser(ctx context.Context, userID string) ([]ApiKey, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, key, key_prefix, user_id, name, description, tier, rate_limit, monthly_credits, credits_used, credits_reset_at, allowed_endpoints, active, expires_at, last_used_at, created_at, updated_at
|
||||
FROM api_gateway.api_keys WHERE user_id = $1 ORDER BY created_at DESC
|
||||
`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var keys []ApiKey
|
||||
for rows.Next() {
|
||||
var k ApiKey
|
||||
if err := rows.Scan(&k.ID, &k.Key, &k.KeyPrefix, &k.UserID, &k.Name, &k.Description,
|
||||
&k.Tier, &k.RateLimit, &k.MonthlyCredits, &k.CreditsUsed, &k.CreditsResetAt,
|
||||
&k.AllowedEndpoints, &k.Active, &k.ExpiresAt, &k.LastUsedAt, &k.CreatedAt, &k.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
k.Key = s.MaskKey(k.Key)
|
||||
keys = append(keys, k)
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
// Delete removes an API key.
|
||||
func (s *ApiKeyService) Delete(ctx context.Context, id, userID string) error {
|
||||
tag, err := s.pool.Exec(ctx, `DELETE FROM api_gateway.api_keys WHERE id = $1 AND user_id = $2`, id, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return fmt.Errorf("key not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IncrementCredits adds used credits to a key.
|
||||
func (s *ApiKeyService) IncrementCredits(ctx context.Context, keyID string, amount int) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
UPDATE api_gateway.api_keys
|
||||
SET credits_used = CASE
|
||||
WHEN credits_reset_at < NOW() THEN $2
|
||||
ELSE credits_used + $2
|
||||
END,
|
||||
credits_reset_at = CASE
|
||||
WHEN credits_reset_at < NOW() THEN $3
|
||||
ELSE credits_reset_at
|
||||
END
|
||||
WHERE id = $1
|
||||
`, keyID, amount, nextMonthReset())
|
||||
return err
|
||||
}
|
||||
|
||||
// HasEnoughCredits checks if a key has sufficient credits.
|
||||
func (s *ApiKeyService) HasEnoughCredits(ctx context.Context, keyID string, required int) (bool, error) {
|
||||
var used, total int
|
||||
var resetAt *time.Time
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT credits_used, monthly_credits, credits_reset_at FROM api_gateway.api_keys WHERE id = $1
|
||||
`, keyID).Scan(&used, &total, &resetAt)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
// If past reset date, credits are effectively 0
|
||||
if resetAt != nil && time.Now().After(*resetAt) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
return used+required <= total, nil
|
||||
}
|
||||
|
||||
func nextMonthReset() time.Time {
|
||||
now := time.Now()
|
||||
return time.Date(now.Year(), now.Month()+1, 1, 0, 0, 0, 0, time.UTC)
|
||||
}
|
||||
82
services/mana-api-gateway/internal/service/apikeys_test.go
Normal file
82
services/mana-api-gateway/internal/service/apikeys_test.go
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestGenerateKey(t *testing.T) {
|
||||
svc := &ApiKeyService{prefixLive: "sk_live_", prefixTest: "sk_test_"}
|
||||
|
||||
key, hash, prefix := svc.GenerateKey(false)
|
||||
if prefix != "sk_live_" {
|
||||
t.Errorf("expected sk_live_ prefix, got %s", prefix)
|
||||
}
|
||||
if len(key) < 20 {
|
||||
t.Errorf("key too short: %s", key)
|
||||
}
|
||||
if len(hash) != 64 { // SHA256 hex
|
||||
t.Errorf("hash wrong length: %d", len(hash))
|
||||
}
|
||||
|
||||
// Test key
|
||||
key2, _, prefix2 := svc.GenerateKey(true)
|
||||
if prefix2 != "sk_test_" {
|
||||
t.Errorf("expected sk_test_ prefix, got %s", prefix2)
|
||||
}
|
||||
if key == key2 {
|
||||
t.Error("keys should be unique")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaskKey(t *testing.T) {
|
||||
svc := &ApiKeyService{prefixLive: "sk_live_", prefixTest: "sk_test_"}
|
||||
|
||||
tests := []struct {
|
||||
key string
|
||||
want string
|
||||
}{
|
||||
{"sk_live_abcdefghijklmnop1234", "sk_live_...1234"},
|
||||
{"sk_test_xyz9876543210abcdef", "sk_test_...cdef"},
|
||||
{"short", "short"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got := svc.MaskKey(tt.key)
|
||||
if got != tt.want {
|
||||
t.Errorf("MaskKey(%q) = %q, want %q", tt.key, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextMonthReset(t *testing.T) {
|
||||
reset := nextMonthReset()
|
||||
now := time.Now()
|
||||
|
||||
if reset.Before(now) {
|
||||
t.Error("reset should be in the future")
|
||||
}
|
||||
if reset.Day() != 1 {
|
||||
t.Errorf("reset should be first of month, got day %d", reset.Day())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPricingTiers(t *testing.T) {
|
||||
free := Tiers["free"]
|
||||
if free.RateLimit != 10 {
|
||||
t.Errorf("free rate limit = %d, want 10", free.RateLimit)
|
||||
}
|
||||
if free.MonthlyCredits != 100 {
|
||||
t.Errorf("free monthly credits = %d, want 100", free.MonthlyCredits)
|
||||
}
|
||||
|
||||
pro := Tiers["pro"]
|
||||
if pro.RateLimit != 100 {
|
||||
t.Errorf("pro rate limit = %d, want 100", pro.RateLimit)
|
||||
}
|
||||
|
||||
enterprise := Tiers["enterprise"]
|
||||
if enterprise.RateLimit != 1000 {
|
||||
t.Errorf("enterprise rate limit = %d, want 1000", enterprise.RateLimit)
|
||||
}
|
||||
}
|
||||
114
services/mana-api-gateway/internal/service/usage.go
Normal file
114
services/mana-api-gateway/internal/service/usage.go
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// UsageEntry is a single API usage log entry.
|
||||
type UsageEntry struct {
|
||||
ApiKeyID string `json:"apiKeyId"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
Method string `json:"method"`
|
||||
Path string `json:"path"`
|
||||
RequestSize int `json:"requestSize"`
|
||||
ResponseSize int `json:"responseSize"`
|
||||
LatencyMs int `json:"latencyMs"`
|
||||
StatusCode int `json:"statusCode"`
|
||||
CreditsUsed int `json:"creditsUsed"`
|
||||
CreditReason string `json:"creditReason"`
|
||||
}
|
||||
|
||||
// DailyUsage is an aggregated daily usage entry.
|
||||
type DailyUsage struct {
|
||||
Date string `json:"date"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
RequestCount int `json:"requestCount"`
|
||||
CreditsUsed int `json:"creditsUsed"`
|
||||
ErrorCount int `json:"errorCount"`
|
||||
}
|
||||
|
||||
// UsageSummary is an overview of usage.
|
||||
type UsageSummary struct {
|
||||
TotalRequests int `json:"totalRequests"`
|
||||
TotalCredits int `json:"totalCredits"`
|
||||
TotalErrors int `json:"totalErrors"`
|
||||
}
|
||||
|
||||
// UsageService logs and queries API usage.
|
||||
type UsageService struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// NewUsageService creates a new usage service.
|
||||
func NewUsageService(pool *pgxpool.Pool) *UsageService {
|
||||
return &UsageService{pool: pool}
|
||||
}
|
||||
|
||||
// LogUsage records a single API request.
|
||||
func (s *UsageService) LogUsage(ctx context.Context, entry UsageEntry) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO api_gateway.api_usage (api_key_id, endpoint, method, path, request_size, response_size, latency_ms, status_code, credits_used, credit_reason)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
`, entry.ApiKeyID, entry.Endpoint, entry.Method, entry.Path,
|
||||
entry.RequestSize, entry.ResponseSize, entry.LatencyMs, entry.StatusCode,
|
||||
entry.CreditsUsed, entry.CreditReason)
|
||||
|
||||
// Also upsert daily aggregation
|
||||
isError := 0
|
||||
if entry.StatusCode >= 400 {
|
||||
isError = 1
|
||||
}
|
||||
s.pool.Exec(ctx, `
|
||||
INSERT INTO api_gateway.api_usage_daily (api_key_id, date, endpoint, request_count, credits_used, total_latency_ms, error_count)
|
||||
VALUES ($1, CURRENT_DATE, $2, 1, $3, $4, $5)
|
||||
ON CONFLICT (api_key_id, date, endpoint)
|
||||
DO UPDATE SET
|
||||
request_count = api_gateway.api_usage_daily.request_count + 1,
|
||||
credits_used = api_gateway.api_usage_daily.credits_used + $3,
|
||||
total_latency_ms = api_gateway.api_usage_daily.total_latency_ms + $4,
|
||||
error_count = api_gateway.api_usage_daily.error_count + $5
|
||||
`, entry.ApiKeyID, entry.Endpoint, entry.CreditsUsed, entry.LatencyMs, isError)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// GetDailyUsage returns daily aggregated usage for a key.
|
||||
func (s *UsageService) GetDailyUsage(ctx context.Context, keyID string, days int) ([]DailyUsage, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT date::text, endpoint, request_count, credits_used, error_count
|
||||
FROM api_gateway.api_usage_daily
|
||||
WHERE api_key_id = $1 AND date >= CURRENT_DATE - $2::int
|
||||
ORDER BY date DESC
|
||||
`, keyID, days)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var usage []DailyUsage
|
||||
for rows.Next() {
|
||||
var u DailyUsage
|
||||
if err := rows.Scan(&u.Date, &u.Endpoint, &u.RequestCount, &u.CreditsUsed, &u.ErrorCount); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
usage = append(usage, u)
|
||||
}
|
||||
return usage, nil
|
||||
}
|
||||
|
||||
// GetSummary returns a usage summary for a key over a period.
|
||||
func (s *UsageService) GetSummary(ctx context.Context, keyID string, since time.Time) (*UsageSummary, error) {
|
||||
var summary UsageSummary
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT COALESCE(SUM(request_count), 0), COALESCE(SUM(credits_used), 0), COALESCE(SUM(error_count), 0)
|
||||
FROM api_gateway.api_usage_daily
|
||||
WHERE api_key_id = $1 AND date >= $2
|
||||
`, keyID, since).Scan(&summary.TotalRequests, &summary.TotalCredits, &summary.TotalErrors)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &summary, nil
|
||||
}
|
||||
11
services/mana-api-gateway/package.json
Normal file
11
services/mana-api-gateway/package.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"name": "mana-api-gateway",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "Go API Gateway replacing NestJS mana-api-gateway",
|
||||
"scripts": {
|
||||
"build": "go build -ldflags=\"-s -w\" -o dist/mana-api-gateway ./cmd/server",
|
||||
"dev": "go run ./cmd/server",
|
||||
"test": "go test ./..."
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue