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:
Till JS 2026-03-28 10:18:40 +01:00
parent 79080d6654
commit 7e931b1c6d
90 changed files with 41 additions and 38 deletions

1
services/mana-api-gateway/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
dist/

View 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

View 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"]

View 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)
}
}

View 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
)

View 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=

View 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
}

View 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()
}

View 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)
}

View 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())
}

View 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)
}

View 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
}

View 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)
})
}
}

View 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)
}

View 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)
}

View 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)
}
}

View 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
}

View 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 ./..."
}
}