API Gateway -- единая точка входа для всех клиентских запросов к микросервисам. Отвечает за маршрутизацию, аутентификацию, rate limiting, логирование и трансформацию запросов/ответов.
<?php
declare(strict_types=1);
interface Middleware
{
public function handle(Request $request, callable $next): Response;
}
final class MiddlewarePipeline
{
/** @var Middleware[] */
private array $middlewares = [];
public function pipe(Middleware $middleware): self
{
$this->middlewares[] = $middleware;
return $this;
}
public function process(Request $request): Response
{
$pipeline = array_reduce(
array_reverse($this->middlewares),
fn (callable $next, Middleware $middleware) => fn (Request $req) => $middleware->handle($req, $next),
fn (Request $req) => new Response(404, 'Not Found'),
);
return $pipeline($request);
}
}
// Gateway setup
final class ApiGateway
{
private MiddlewarePipeline $pipeline;
public function __construct(
private readonly RouteRegistry $routes,
private readonly ServiceRegistry $services,
) {
$this->pipeline = new MiddlewarePipeline();
// Order matters: executed top to bottom
$this->pipeline
->pipe(new RequestIdMiddleware())
->pipe(new CorsMiddleware())
->pipe(new RequestLoggingMiddleware())
->pipe(new AuthenticationMiddleware())
->pipe(new RateLimitMiddleware())
->pipe(new RequestValidationMiddleware())
->pipe(new CircuitBreakerMiddleware())
->pipe(new ProxyMiddleware($this->routes, $this->services));
}
public function handle(Request $request): Response
{
return $this->pipeline->process($request);
}
}
package gateway
import "net/http"
// Middleware is a function that wraps an HTTP handler.
type Middleware func(http.Handler) http.Handler
// Pipeline chains multiple middlewares into a single handler.
type Pipeline struct {
middlewares []Middleware
}
func NewPipeline() *Pipeline {
return &Pipeline{}
}
func (p *Pipeline) Use(mw Middleware) *Pipeline {
p.middlewares = append(p.middlewares, mw)
return p
}
// Build wraps the final handler with all middlewares (first added = outermost).
func (p *Pipeline) Build(final http.Handler) http.Handler {
h := final
for i := len(p.middlewares) - 1; i >= 0; i-- {
h = p.middlewares[i](h)
}
return h
}
// ApiGateway is the main entry point for all client requests.
type ApiGateway struct {
handler http.Handler
}
func NewApiGateway(routes *RouteRegistry, services *ServiceRegistry) *ApiGateway {
proxy := NewProxyHandler(routes, services)
pipeline := NewPipeline()
pipeline.
Use(RequestIDMiddleware).
Use(CORSMiddleware).
Use(RequestLoggingMiddleware).
Use(AuthenticationMW).
Use(RateLimitMW).
Use(CircuitBreakerMW)
return &ApiGateway{handler: pipeline.Build(proxy)}
}
func (g *ApiGateway) ServeHTTP(w http.ResponseWriter, r *http.Request) {
g.handler.ServeHTTP(w, r)
}
namespace Gateway;
// Middleware wraps a RequestDelegate -- the ASP.NET Core pipeline shape.
public delegate RequestDelegate Middleware(RequestDelegate next);
// Pipeline chains multiple middlewares into a single delegate.
public sealed class Pipeline
{
private readonly List<Middleware> _middlewares = [];
public Pipeline Use(Middleware middleware)
{
_middlewares.Add(middleware);
return this;
}
// Build wraps the final delegate with all middlewares (first added = outermost).
public RequestDelegate Build(RequestDelegate final)
{
var handler = final;
for (var i = _middlewares.Count - 1; i >= 0; i--)
{
handler = _middlewares[i](handler);
}
return handler;
}
}
// ApiGateway is the main entry point for all client requests.
public sealed class ApiGateway
{
private readonly RequestDelegate _handler;
public ApiGateway(
RouteRegistry routes,
ServiceRegistry services,
IHttpClientFactory clients,
IJwtValidator jwtValidator,
IApiKeyRepository apiKeys,
CircuitBreaker breaker)
{
var proxy = new ProxyHandler(routes, services, clients);
// Each middleware is a class exposing InvokeAsync(HttpContext); the lambda
// adapts its constructor to the Middleware delegate above.
// Order matters: executed top to bottom
var pipeline = new Pipeline()
.Use(next => new RequestIdMiddleware(next).InvokeAsync)
.Use(next => new CorsMiddleware(next).InvokeAsync)
.Use(next => new RequestLoggingMiddleware(next).InvokeAsync)
.Use(next => new AuthenticationMiddleware(next, jwtValidator, apiKeys).InvokeAsync)
.Use(next => new RateLimitMiddleware(next).InvokeAsync)
.Use(next => new CircuitBreakerMiddleware(next, breaker).InvokeAsync);
_handler = pipeline.Build(proxy.InvokeAsync);
}
public Task InvokeAsync(HttpContext context) => _handler(context);
}
from collections.abc import Awaitable, Callable
from starlette.requests import Request
from starlette.responses import Response
# Handler takes a request and returns a response.
Handler = Callable[[Request], Awaitable[Response]]
# Middleware wraps a handler into another handler.
Middleware = Callable[[Handler], Handler]
class Pipeline:
"""Chains multiple middlewares into a single handler."""
def __init__(self) -> None:
self._middlewares: list[Middleware] = []
def use(self, middleware: Middleware) -> "Pipeline":
self._middlewares.append(middleware)
return self
def build(self, final: Handler) -> Handler:
# First added = outermost, so wrap in reverse order.
handler = final
for middleware in reversed(self._middlewares):
handler = middleware(handler)
return handler
class ApiGateway:
"""Main entry point for all client requests."""
def __init__(self, routes: RouteRegistry, services: ServiceRegistry) -> None:
proxy = ProxyHandler(routes, services, httpx.AsyncClient())
# Order matters: executed top to bottom
pipeline = (
Pipeline()
.use(request_id_middleware)
.use(cors_middleware)
.use(request_logging_middleware)
.use(authentication_middleware)
.use(rate_limit_middleware)
.use(circuit_breaker_middleware)
)
self._handler = pipeline.build(proxy.handle)
async def __call__(self, request: Request) -> Response:
return await self._handler(request)
### 3.2 Аутентификация (JWT)
<?php
declare(strict_types=1);
final class AuthenticationMiddleware implements Middleware
{
private const PUBLIC_PATHS = [
'/api/v1/auth/login',
'/api/v1/auth/register',
'/api/v1/health',
];
public function __construct(
private readonly JwtValidator $jwtValidator,
private readonly ApiKeyRepository $apiKeyRepo,
) {}
public function handle(Request $request, callable $next): Response
{
// Skip auth for public endpoints
if ($this->isPublicPath($request->getPath())) {
return $next($request);
}
// Try JWT first
$authHeader = $request->getHeader('Authorization');
if ($authHeader !== null && str_starts_with($authHeader, 'Bearer ')) {
$token = substr($authHeader, 7);
return $this->authenticateJwt($token, $request, $next);
}
// Try API Key
$apiKey = $request->getHeader('X-API-Key');
if ($apiKey !== null) {
return $this->authenticateApiKey($apiKey, $request, $next);
}
return new Response(401, json_encode([
'error' => 'Authentication required',
'code' => 'AUTH_REQUIRED',
]));
}
private function authenticateJwt(string $token, Request $request, callable $next): Response
{
try {
$claims = $this->jwtValidator->validate($token);
// Inject user context into request
$request = $request->withAttribute('user_id', $claims['sub']);
$request = $request->withAttribute('roles', $claims['roles'] ?? []);
return $next($request);
} catch (TokenExpiredException) {
return new Response(401, json_encode([
'error' => 'Token expired',
'code' => 'TOKEN_EXPIRED',
]));
} catch (InvalidTokenException) {
return new Response(401, json_encode([
'error' => 'Invalid token',
'code' => 'INVALID_TOKEN',
]));
}
}
private function authenticateApiKey(string $apiKey, Request $request, callable $next): Response
{
$keyData = $this->apiKeyRepo->findByKey($apiKey);
if ($keyData === null || !$keyData->isActive) {
return new Response(401, json_encode([
'error' => 'Invalid API key',
'code' => 'INVALID_API_KEY',
]));
}
$request = $request->withAttribute('api_key_id', $keyData->id);
$request = $request->withAttribute('permissions', $keyData->permissions);
return $next($request);
}
private function isPublicPath(string $path): bool
{
foreach (self::PUBLIC_PATHS as $publicPath) {
if (str_starts_with($path, $publicPath)) {
return true;
}
}
return false;
}
}
package gateway
import (
"context"
"encoding/json"
"net/http"
"strings"
)
type contextKey string
const (
ctxUserID contextKey = "user_id"
ctxRoles contextKey = "roles"
ctxAPIKeyID contextKey = "api_key_id"
ctxPermissions contextKey = "permissions"
)
var publicPaths = []string{
"/api/v1/auth/login",
"/api/v1/auth/register",
"/api/v1/health",
}
// AuthenticationMW is a middleware that validates JWT or API key.
func AuthenticationMW(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Skip auth for public endpoints
for _, p := range publicPaths {
if strings.HasPrefix(r.URL.Path, p) {
next.ServeHTTP(w, r)
return
}
}
// Try JWT
if auth := r.Header.Get("Authorization"); strings.HasPrefix(auth, "Bearer ") {
token := auth[7:]
claims, err := validateJWT(token)
if err != nil {
writeJSON(w, http.StatusUnauthorized, map[string]string{
"error": "Invalid or expired token",
"code": "INVALID_TOKEN",
})
return
}
ctx := context.WithValue(r.Context(), ctxUserID, claims.Subject)
ctx = context.WithValue(ctx, ctxRoles, claims.Roles)
next.ServeHTTP(w, r.WithContext(ctx))
return
}
// Try API Key
if apiKey := r.Header.Get("X-API-Key"); apiKey != "" {
keyData, err := findAPIKey(apiKey)
if err != nil || !keyData.IsActive {
writeJSON(w, http.StatusUnauthorized, map[string]string{
"error": "Invalid API key",
"code": "INVALID_API_KEY",
})
return
}
ctx := context.WithValue(r.Context(), ctxAPIKeyID, keyData.ID)
ctx = context.WithValue(ctx, ctxPermissions, keyData.Permissions)
next.ServeHTTP(w, r.WithContext(ctx))
return
}
writeJSON(w, http.StatusUnauthorized, map[string]string{
"error": "Authentication required",
"code": "AUTH_REQUIRED",
})
})
}
func writeJSON(w http.ResponseWriter, status int, data any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(data)
}
namespace Gateway;
// AuthenticationMiddleware validates JWT or API key.
public sealed class AuthenticationMiddleware(
RequestDelegate next,
IJwtValidator jwtValidator,
IApiKeyRepository apiKeys)
{
private static readonly string[] PublicPaths =
[
"/api/v1/auth/login",
"/api/v1/auth/register",
"/api/v1/health",
];
public async Task InvokeAsync(HttpContext context)
{
var path = context.Request.Path.Value ?? string.Empty;
// Skip auth for public endpoints
if (PublicPaths.Any(p => path.StartsWith(p, StringComparison.Ordinal)))
{
await next(context);
return;
}
// Try JWT first
var authHeader = context.Request.Headers.Authorization.ToString();
if (authHeader.StartsWith("Bearer ", StringComparison.Ordinal))
{
await AuthenticateJwtAsync(context, authHeader[7..]);
return;
}
// Try API Key
if (context.Request.Headers.TryGetValue("X-API-Key", out var apiKey))
{
await AuthenticateApiKeyAsync(context, apiKey.ToString());
return;
}
await WriteErrorAsync(context, 401, "Authentication required", "AUTH_REQUIRED");
}
private async Task AuthenticateJwtAsync(HttpContext context, string token)
{
try
{
var claims = await jwtValidator.ValidateAsync(token);
// Inject user context into the request (HttpContext.Items is per-request state)
context.Items["user_id"] = claims.Subject;
context.Items["roles"] = claims.Roles;
}
catch (TokenExpiredException)
{
await WriteErrorAsync(context, 401, "Token expired", "TOKEN_EXPIRED");
return;
}
catch (InvalidTokenException)
{
await WriteErrorAsync(context, 401, "Invalid token", "INVALID_TOKEN");
return;
}
await next(context);
}
private async Task AuthenticateApiKeyAsync(HttpContext context, string apiKey)
{
var keyData = await apiKeys.FindByKeyAsync(apiKey);
if (keyData is not { IsActive: true })
{
await WriteErrorAsync(context, 401, "Invalid API key", "INVALID_API_KEY");
return;
}
context.Items["api_key_id"] = keyData.Id;
context.Items["permissions"] = keyData.Permissions;
await next(context);
}
private static Task WriteErrorAsync(HttpContext context, int status, string error, string code)
{
context.Response.StatusCode = status;
return context.Response.WriteAsJsonAsync(new { error, code });
}
}
from dataclasses import dataclass
from starlette.requests import Request
from starlette.responses import JSONResponse, Response
# str.startswith accepts a tuple, so a single call covers every public prefix.
PUBLIC_PATHS = (
"/api/v1/auth/login",
"/api/v1/auth/register",
"/api/v1/health",
)
@dataclass(slots=True)
class AuthenticationMiddleware:
"""Validates JWT or API key before passing the request downstream."""
jwt_validator: JwtValidator
api_keys: ApiKeyRepository
def __call__(self, next_handler: Handler) -> Handler:
async def handle(request: Request) -> Response:
# Skip auth for public endpoints
if request.url.path.startswith(PUBLIC_PATHS):
return await next_handler(request)
# Try JWT first
auth_header = request.headers.get("authorization", "")
if auth_header.startswith("Bearer "):
return await self._authenticate_jwt(auth_header[7:], request, next_handler)
# Try API Key
api_key = request.headers.get("x-api-key")
if api_key:
return await self._authenticate_api_key(api_key, request, next_handler)
return _auth_error(401, "Authentication required", "AUTH_REQUIRED")
return handle
async def _authenticate_jwt(
self, token: str, request: Request, next_handler: Handler
) -> Response:
try:
claims = await self.jwt_validator.validate(token)
except TokenExpiredError:
return _auth_error(401, "Token expired", "TOKEN_EXPIRED")
except InvalidTokenError:
return _auth_error(401, "Invalid token", "INVALID_TOKEN")
# Inject user context into the request scope
request.scope["user_id"] = claims.subject
request.scope["roles"] = claims.roles
return await next_handler(request)
async def _authenticate_api_key(
self, api_key: str, request: Request, next_handler: Handler
) -> Response:
key_data = await self.api_keys.find_by_key(api_key)
if key_data is None or not key_data.is_active:
return _auth_error(401, "Invalid API key", "INVALID_API_KEY")
request.scope["api_key_id"] = key_data.id
request.scope["permissions"] = key_data.permissions
return await next_handler(request)
def _auth_error(status: int, error: str, code: str) -> JSONResponse:
return JSONResponse({"error": error, "code": code}, status_code=status)
### 3.3 Маршрутизация и Proxy
<?php
declare(strict_types=1);
final class RouteRegistry
{
/** @var array<string, RouteConfig> */
private array $routes = [];
public function register(string $pattern, RouteConfig $config): void
{
$this->routes[$pattern] = $config;
}
public function match(string $method, string $path): ?RouteMatch
{
foreach ($this->routes as $pattern => $config) {
if ($this->matchesPattern($method, $path, $pattern, $config)) {
$params = $this->extractParams($path, $pattern);
return new RouteMatch($config, $params);
}
}
return null;
}
private function matchesPattern(
string $method,
string $path,
string $pattern,
RouteConfig $config,
): bool {
if (!in_array($method, $config->methods, true)) {
return false;
}
$regex = preg_replace('/\{(\w+)\}/', '(?P<$1>[^/]+)', $pattern);
return (bool) preg_match("#^{$regex}$#", $path);
}
private function extractParams(string $path, string $pattern): array
{
$regex = preg_replace('/\{(\w+)\}/', '(?P<$1>[^/]+)', $pattern);
preg_match("#^{$regex}$#", $path, $matches);
return array_filter($matches, 'is_string', ARRAY_FILTER_USE_KEY);
}
}
final readonly class RouteConfig
{
public function __construct(
public string $serviceName,
public string $targetPath,
public array $methods = ['GET', 'POST', 'PUT', 'DELETE'],
public bool $authRequired = true,
public ?string $rateLimit = null,
public int $timeoutMs = 5000,
) {}
}
final class ProxyMiddleware implements Middleware
{
public function __construct(
private readonly RouteRegistry $routes,
private readonly ServiceRegistry $services,
private readonly HttpClient $httpClient,
) {}
public function handle(Request $request, callable $next): Response
{
$match = $this->routes->match($request->getMethod(), $request->getPath());
if ($match === null) {
return new Response(404, json_encode([
'error' => 'Route not found',
]));
}
$service = $this->services->getHealthy($match->config->serviceName);
if ($service === null) {
return new Response(503, json_encode([
'error' => 'Service unavailable',
]));
}
// Build target URL
$targetUrl = $service->baseUrl . $match->config->targetPath;
foreach ($match->params as $key => $value) {
$targetUrl = str_replace("{{$key}}", $value, $targetUrl);
}
// Forward request
try {
return $this->httpClient->request(
method: $request->getMethod(),
url: $targetUrl,
headers: $this->forwardHeaders($request),
body: $request->getBody(),
timeoutMs: $match->config->timeoutMs,
);
} catch (TimeoutException) {
return new Response(504, json_encode([
'error' => 'Gateway timeout',
]));
}
}
private function forwardHeaders(Request $request): array
{
$headers = $request->getHeaders();
// Add gateway context
$headers['X-Request-Id'] = $request->getAttribute('request_id');
$headers['X-User-Id'] = $request->getAttribute('user_id', '');
$headers['X-Forwarded-For'] = $request->getClientIp();
$headers['X-Gateway-Time'] = (string) microtime(true);
// Remove hop-by-hop headers
unset($headers['Connection'], $headers['Keep-Alive']);
return $headers;
}
}
package gateway
import (
"encoding/json"
"io"
"net/http"
"regexp"
"strings"
"time"
)
// RouteConfig defines the routing configuration for a path pattern.
type RouteConfig struct {
ServiceName string
TargetPath string
Methods []string
AuthRequired bool
TimeoutMs int
}
// RouteMatch holds a matched route and extracted path parameters.
type RouteMatch struct {
Config RouteConfig
Params map[string]string
}
// RouteRegistry stores and matches route patterns to service configs.
type RouteRegistry struct {
routes map[string]RouteConfig
}
func NewRouteRegistry() *RouteRegistry {
return &RouteRegistry{routes: make(map[string]RouteConfig)}
}
func (r *RouteRegistry) Register(pattern string, config RouteConfig) {
r.routes[pattern] = config
}
func (r *RouteRegistry) Match(method, path string) *RouteMatch {
for pattern, config := range r.routes {
if !containsMethod(config.Methods, method) {
continue
}
re := regexp.MustCompile("^" + regexp.MustCompile(`\{(\w+)\}`).ReplaceAllString(pattern, `(?P<$1>[^/]+)`) + "$")
matches := re.FindStringSubmatch(path)
if matches == nil {
continue
}
params := make(map[string]string)
for i, name := range re.SubexpNames() {
if i > 0 && name != "" {
params[name] = matches[i]
}
}
return &RouteMatch{Config: config, Params: params}
}
return nil
}
func containsMethod(methods []string, method string) bool {
for _, m := range methods {
if m == method {
return true
}
}
return false
}
// NewProxyHandler returns an http.Handler that proxies requests to backend services.
func NewProxyHandler(routes *RouteRegistry, services *ServiceRegistry) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
match := routes.Match(r.Method, r.URL.Path)
if match == nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "Route not found"})
return
}
svc := services.GetHealthy(match.Config.ServiceName)
if svc == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "Service unavailable"})
return
}
targetURL := svc.BaseURL + match.Config.TargetPath
for k, v := range match.Params {
targetURL = strings.ReplaceAll(targetURL, "{"+k+"}", v)
}
timeout := time.Duration(match.Config.TimeoutMs) * time.Millisecond
client := &http.Client{Timeout: timeout}
proxyReq, _ := http.NewRequestWithContext(r.Context(), r.Method, targetURL, r.Body)
for key, vals := range r.Header {
for _, val := range vals {
proxyReq.Header.Add(key, val)
}
}
proxyReq.Header.Set("X-Forwarded-For", r.RemoteAddr)
resp, err := client.Do(proxyReq)
if err != nil {
writeJSON(w, http.StatusGatewayTimeout, map[string]string{"error": "Gateway timeout"})
return
}
defer resp.Body.Close()
for key, vals := range resp.Header {
for _, val := range vals {
w.Header().Add(key, val)
}
}
w.WriteHeader(resp.StatusCode)
io.Copy(w, resp.Body)
})
}
namespace Gateway;
using System.Text.RegularExpressions;
// RouteConfig defines the routing configuration for a path pattern.
public sealed record RouteConfig(
string ServiceName,
string TargetPath,
string[] Methods,
bool AuthRequired = true,
string? RateLimit = null,
int TimeoutMs = 5000);
// RouteMatch holds a matched route and extracted path parameters.
public sealed record RouteMatch(RouteConfig Config, IReadOnlyDictionary<string, string> Params);
// RouteRegistry stores and matches route patterns to service configs.
public sealed class RouteRegistry
{
private static readonly Regex Placeholder = new(@"\{(\w+)\}", RegexOptions.Compiled);
private readonly Dictionary<string, (RouteConfig Config, Regex Pattern)> _routes = [];
public void Register(string pattern, RouteConfig config)
{
// Compile the pattern once at registration instead of on every request
var regex = new Regex(
"^" + Placeholder.Replace(pattern, "(?<$1>[^/]+)") + "$",
RegexOptions.Compiled);
_routes[pattern] = (config, regex);
}
public RouteMatch? Match(string method, string path)
{
foreach (var (config, pattern) in _routes.Values)
{
if (!config.Methods.Contains(method))
{
continue;
}
var match = pattern.Match(path);
if (!match.Success)
{
continue;
}
var parameters = pattern.GetGroupNames()
.Where(name => !int.TryParse(name, out _))
.ToDictionary(name => name, name => match.Groups[name].Value);
return new RouteMatch(config, parameters);
}
return null;
}
}
// ProxyHandler forwards matched requests to the backend service.
public sealed class ProxyHandler(
RouteRegistry routes,
ServiceRegistry services,
IHttpClientFactory clients)
{
private static readonly string[] HopByHopHeaders = ["Connection", "Keep-Alive", "Host"];
public async Task InvokeAsync(HttpContext context)
{
var match = routes.Match(context.Request.Method, context.Request.Path);
if (match is null)
{
await WriteErrorAsync(context, 404, "Route not found");
return;
}
var service = services.GetHealthy(match.Config.ServiceName);
if (service is null)
{
await WriteErrorAsync(context, 503, "Service unavailable");
return;
}
// Build target URL
var targetUrl = service.BaseUrl + match.Config.TargetPath;
foreach (var (key, value) in match.Params)
{
targetUrl = targetUrl.Replace("{" + key + "}", value);
}
// Forward request
var proxyRequest = new HttpRequestMessage(new HttpMethod(context.Request.Method), targetUrl)
{
Content = new StreamContent(context.Request.Body),
};
ForwardHeaders(context, proxyRequest);
using var timeout = new CancellationTokenSource(match.Config.TimeoutMs);
try
{
using var response = await clients.CreateClient("gateway")
.SendAsync(proxyRequest, HttpCompletionOption.ResponseHeadersRead, timeout.Token);
context.Response.StatusCode = (int)response.StatusCode;
foreach (var (name, values) in response.Headers)
{
context.Response.Headers[name] = values.ToArray();
}
await response.Content.CopyToAsync(context.Response.Body);
}
catch (OperationCanceledException)
{
await WriteErrorAsync(context, 504, "Gateway timeout");
}
}
private static void ForwardHeaders(HttpContext context, HttpRequestMessage request)
{
foreach (var (name, values) in context.Request.Headers)
{
// Remove hop-by-hop headers
if (HopByHopHeaders.Contains(name, StringComparer.OrdinalIgnoreCase))
{
continue;
}
request.Headers.TryAddWithoutValidation(name, values.ToArray());
}
// Add gateway context
request.Headers.TryAddWithoutValidation("X-Request-Id", context.Items["request_id"]?.ToString());
request.Headers.TryAddWithoutValidation("X-User-Id", context.Items["user_id"]?.ToString() ?? string.Empty);
request.Headers.TryAddWithoutValidation("X-Forwarded-For", context.Connection.RemoteIpAddress?.ToString());
request.Headers.TryAddWithoutValidation("X-Gateway-Time", DateTimeOffset.UtcNow.ToString("O"));
}
private static Task WriteErrorAsync(HttpContext context, int status, string error)
{
context.Response.StatusCode = status;
return context.Response.WriteAsJsonAsync(new { error });
}
}
import re
from dataclasses import dataclass
from datetime import UTC, datetime
import httpx
from starlette.requests import Request
from starlette.responses import JSONResponse, Response
PLACEHOLDER_RE = re.compile(r"\{(\w+)\}")
HOP_BY_HOP = frozenset({"connection", "keep-alive", "host"})
@dataclass(frozen=True, slots=True)
class RouteConfig:
"""Routing configuration for a path pattern."""
service_name: str
target_path: str
methods: tuple[str, ...] = ("GET", "POST", "PUT", "DELETE")
auth_required: bool = True
rate_limit: str | None = None
timeout_ms: int = 5000
@dataclass(frozen=True, slots=True)
class RouteMatch:
"""A matched route together with extracted path parameters."""
config: RouteConfig
params: dict[str, str]
class RouteRegistry:
"""Stores and matches route patterns to service configs."""
def __init__(self) -> None:
self._routes: dict[str, tuple[RouteConfig, re.Pattern[str]]] = {}
def register(self, pattern: str, config: RouteConfig) -> None:
# Compile the pattern once at registration instead of on every request
regex = re.compile("^" + PLACEHOLDER_RE.sub(r"(?P<\1>[^/]+)", pattern) + "$")
self._routes[pattern] = (config, regex)
def match(self, method: str, path: str) -> RouteMatch | None:
for config, regex in self._routes.values():
if method not in config.methods:
continue
matched = regex.match(path)
if matched is None:
continue
return RouteMatch(config, matched.groupdict())
return None
@dataclass(slots=True)
class ProxyHandler:
"""Forwards matched requests to the backend service."""
routes: RouteRegistry
services: ServiceRegistry
client: httpx.AsyncClient
async def handle(self, request: Request) -> Response:
match = self.routes.match(request.method, request.url.path)
if match is None:
return JSONResponse({"error": "Route not found"}, status_code=404)
service = await self.services.get_healthy(match.config.service_name)
if service is None:
return JSONResponse({"error": "Service unavailable"}, status_code=503)
# Build target URL
target_url = service.base_url + match.config.target_path
for key, value in match.params.items():
target_url = target_url.replace("{" + key + "}", value)
# Forward request
try:
upstream = await self.client.request(
request.method,
target_url,
headers=self._forward_headers(request),
content=await request.body(),
timeout=match.config.timeout_ms / 1000,
)
except httpx.TimeoutException:
return JSONResponse({"error": "Gateway timeout"}, status_code=504)
return Response(
content=upstream.content,
status_code=upstream.status_code,
headers=dict(upstream.headers),
)
@staticmethod
def _forward_headers(request: Request) -> dict[str, str]:
# Remove hop-by-hop headers (header names are already lowercased by Starlette)
headers = {k: v for k, v in request.headers.items() if k not in HOP_BY_HOP}
# Add gateway context
headers["x-request-id"] = str(request.scope.get("request_id", ""))
headers["x-user-id"] = str(request.scope.get("user_id", ""))
headers["x-forwarded-for"] = request.client.host if request.client else ""
headers["x-gateway-time"] = datetime.now(UTC).isoformat()
return headers