Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions TASKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Marque as etapas conforme for concluindo. Use este arquivo como checklist.
- [x] Etapa 2: CORS e versionamento de rotas + testes
- [x] Etapa 3: Autenticacao JWT + testes
- [x] Etapa 4: Rate limiting + testes
- [ ] Etapa 5: Roteamento/proxy para microservicos + testes
- [x] Etapa 5: Roteamento/proxy para microservicos + testes
- [ ] Etapa 6: Padronizacao de respostas/erros + testes
- [ ] Etapa 7: OpenAPI centralizado/ajustes de docs + testes

Expand All @@ -21,8 +21,8 @@ Detalhamento (opcional):
- [x] Etapa 3.2: Testes de autenticacao
- [x] Etapa 4.1: Rate limiting
- [x] Etapa 4.2: Testes de rate limiting
- [ ] Etapa 5.1: Proxy/roteamento para servicos
- [ ] Etapa 5.2: Testes de roteamento
- [x] Etapa 5.1: Proxy/roteamento para servicos
- [x] Etapa 5.2: Testes de roteamento
- [ ] Etapa 6.1: Normalizacao de respostas/erros
- [ ] Etapa 6.2: Testes de erro padronizado
- [ ] Etapa 7.1: Ajustes de OpenAPI centralizado
Expand Down
4 changes: 3 additions & 1 deletion app/api/router.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from fastapi import APIRouter

from app.api.routes import health
from app.api.routes import gateway, health, proxy

api_router = APIRouter()
api_router.include_router(health.router, tags=["health"])
api_router.include_router(gateway.router, tags=["gateway"])
api_router.include_router(proxy.router, tags=["proxy"], include_in_schema=False)
47 changes: 47 additions & 0 deletions app/api/routes/gateway.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
from fastapi import APIRouter, Depends, status

from app.api.deps import require_authorization
from app.core.config import get_settings
from app.schemas.routes import RouteConfig, RouteCreate, RouteUpdate
from app.services.routes_store import RouteStore, get_route_store

settings = get_settings()

router = APIRouter(
dependencies=[Depends(require_authorization(settings.routes_admin_permission))]
)


@router.get("/routes", response_model=list[RouteConfig])
def list_routes(store: RouteStore = Depends(get_route_store)) -> list[RouteConfig]:
return store.list_routes()


@router.get("/routes/{route_id}", response_model=RouteConfig)
def get_route(
route_id: str, store: RouteStore = Depends(get_route_store)
) -> RouteConfig:
return store.get_route(route_id)


@router.post(
"/routes",
response_model=RouteConfig,
status_code=status.HTTP_201_CREATED,
)
def create_route(
payload: RouteCreate, store: RouteStore = Depends(get_route_store)
) -> RouteConfig:
return store.create_route(payload)


@router.put("/routes/{route_id}", response_model=RouteConfig)
def update_route(
route_id: str, payload: RouteUpdate, store: RouteStore = Depends(get_route_store)
) -> RouteConfig:
return store.update_route(route_id, payload)


@router.delete("/routes/{route_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_route(route_id: str, store: RouteStore = Depends(get_route_store)) -> None:
store.delete_route(route_id)
189 changes: 189 additions & 0 deletions app/api/routes/proxy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
from __future__ import annotations

from urllib.parse import urlsplit, urlunsplit

import httpx
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import Response

from app.schemas.routes import RouteAuth, RouteConfig
from app.services.auth_service import (
AuthServiceClient,
AuthServiceError,
get_auth_service_client,
)
from app.services.proxy_client import ProxyClient, get_proxy_client
from app.services.routes_store import RouteStore, get_route_store

router = APIRouter()

_HOP_BY_HOP_HEADERS = {
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailers",
"transfer-encoding",
"upgrade",
"host",
"content-length",
}


@router.api_route(
"/{path:path}", methods=["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD"]
)
async def proxy_request(
path: str,
request: Request,
store: RouteStore = Depends(get_route_store),
proxy_client: ProxyClient = Depends(get_proxy_client),
auth_client: AuthServiceClient = Depends(get_auth_service_client),
) -> Response:
raw_path = f"/{path}" if path else "/"
route = store.match_route(request.method, raw_path)
if not route:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="route_not_found"
)

_maybe_authorize(route, request, auth_client)

upstream_path = _normalize_path(route.rewrite_path(raw_path))
upstream_url = _build_upstream_url(route.upstream_base_url, upstream_path)
headers = _build_upstream_headers(request)
body = await request.body()

try:
upstream_response = await proxy_client.request(
request.method,
upstream_url,
headers=headers,
params=list(request.query_params.multi_items()),
content=body,
timeout=route.timeout_seconds,
)
except httpx.TimeoutException as exc:
raise HTTPException(
status_code=status.HTTP_504_GATEWAY_TIMEOUT, detail="upstream_timeout"
) from exc
except httpx.RequestError as exc:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY, detail="upstream_unavailable"
) from exc

response_headers = _filter_response_headers(upstream_response.headers)
return Response(
content=upstream_response.content,
status_code=upstream_response.status_code,
headers=response_headers,
)


def _maybe_authorize(
route: RouteConfig, request: Request, auth_client: AuthServiceClient
) -> dict:
auth_config = route.auth or RouteAuth()
needs_auth = auth_config.required or auth_config.permission or auth_config.roles
if not needs_auth:
return {}

token = _extract_bearer_token(request)
try:
claims = auth_client.validate_token(token)
if auth_config.permission:
auth_client.authorize(token, auth_config.permission)
if auth_config.roles:
_ensure_roles(claims, auth_config.roles)
except AuthServiceError as exc:
headers = (
{"WWW-Authenticate": "Bearer"}
if exc.status_code == status.HTTP_401_UNAUTHORIZED
else None
)
raise HTTPException(
status_code=exc.status_code,
detail=exc.detail,
headers=headers,
) from exc
return claims


def _extract_bearer_token(request: Request) -> str:
authorization = request.headers.get("Authorization")
if not authorization or not authorization.lower().startswith("bearer "):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="not_authenticated",
headers={"WWW-Authenticate": "Bearer"},
)
token = authorization.split(" ", 1)[1].strip()
if not token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="not_authenticated",
headers={"WWW-Authenticate": "Bearer"},
)
return token


def _ensure_roles(claims: dict, required_roles: list[str]) -> None:
claim_roles = claims.get("roles") if isinstance(claims, dict) else None
if claim_roles is None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="not_authorized"
)
if isinstance(claim_roles, str):
roles_set = {claim_roles}
else:
roles_set = {str(role) for role in claim_roles}
if not roles_set.intersection(required_roles):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="not_authorized"
)


def _normalize_path(path: str) -> str:
if not path:
return "/"
if not path.startswith("/"):
return f"/{path}"
return path


def _build_upstream_url(base_url: str, path: str) -> str:
parsed = urlsplit(base_url)
base_path = parsed.path.rstrip("/")
full_path = f"{base_path}{path}"
return urlunsplit((parsed.scheme, parsed.netloc, full_path, "", ""))


def _build_upstream_headers(request: Request) -> dict[str, str]:
headers = {}
for key, value in request.headers.items():
if key.lower() in _HOP_BY_HOP_HEADERS:
continue
headers[key] = value

client_host = request.client.host if request.client else None
existing_forwarded_for = request.headers.get("X-Forwarded-For")
if client_host:
if existing_forwarded_for:
headers["X-Forwarded-For"] = f"{existing_forwarded_for}, {client_host}"
else:
headers["X-Forwarded-For"] = client_host

headers.setdefault("X-Forwarded-Proto", request.url.scheme)
if "host" in request.headers:
headers.setdefault("X-Forwarded-Host", request.headers["host"])
return headers


def _filter_response_headers(headers: httpx.Headers) -> dict[str, str]:
filtered: dict[str, str] = {}
for key, value in headers.items():
if key.lower() in _HOP_BY_HOP_HEADERS:
continue
filtered[key] = value
return filtered
36 changes: 36 additions & 0 deletions app/cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
from __future__ import annotations

import os
import sys
from pathlib import Path

from dotenv import load_dotenv
from fastapi_cli.cli import main as fastapi_main

_ENV_PATH = Path(__file__).resolve().parents[2] / ".env"


def _load_env() -> None:
if _ENV_PATH.exists():
load_dotenv(_ENV_PATH, override=False)


def _inject_host_port(args: list[str]) -> list[str]:
if not args:
return args
if args[0] not in {"run", "dev"}:
return args

if "--host" not in args and os.getenv("APP_HOST"):
args = args + ["--host", os.environ["APP_HOST"]]
if "--port" not in args and os.getenv("APP_PORT"):
args = args + ["--port", os.environ["APP_PORT"]]
return args


def main() -> None:
_load_env()
if "PORT" not in os.environ and os.getenv("APP_PORT"):
os.environ["PORT"] = os.environ["APP_PORT"]
sys.argv = [sys.argv[0]] + _inject_host_port(sys.argv[1:])
fastapi_main()
68 changes: 46 additions & 22 deletions app/core/config.py
Original file line number Diff line number Diff line change
@@ -1,32 +1,56 @@
from functools import lru_cache
from pathlib import Path
from typing import Any

from pydantic import field_validator
from pydantic import field_validator, Field
from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
app_name: str = "api-gateway"
app_env: str = "local"
app_host: str = "0.0.0.0"
app_port: int = 8000
log_level: str = "INFO"
api_prefix: str = "/api/v1"
cors_allow_origins: list[str] = ["*"]
cors_allow_methods: list[str] = ["*"]
cors_allow_headers: list[str] = ["*"]
cors_allow_credentials: bool = False
auth_service_base_url: str = "http://auth-service:8000"
auth_service_validate_path: str = "/auth/validate"
auth_service_authorize_path: str = "/auth/authorize"
auth_service_timeout_seconds: float = 5.0
rate_limit_enabled: bool = True
rate_limit_requests: int = 100
rate_limit_window_seconds: int = 60
rate_limit_exempt_paths: list[str] = []
rate_limit_key_header: str = "X-Forwarded-For"

model_config = SettingsConfigDict(env_file=".env", case_sensitive=False)
app_name: str = Field(default="api-gateway", alias="APP_NAME")
app_env: str = Field(default="local", alias="APP_ENV")
app_host: str = Field(default="0.0.0.0", alias="APP_HOST")
app_port: int = Field(default=8000, alias="APP_PORT")
log_level: str = Field(default="INFO", alias="LOG_LEVEL")
api_prefix: str = Field(default="/api/v1", alias="API_PREFIX")
cors_allow_origins: list[str] = Field(default=["*"], alias="CORS_ALLOW_ORIGINS")
cors_allow_methods: list[str] = Field(default=["*"], alias="CORS_ALLOW_METHODS")
cors_allow_headers: list[str] = Field(default=["*"], alias="CORS_ALLOW_HEADERS")
cors_allow_credentials: bool = Field(default=False, alias="CORS_ALLOW_CREDENTIALS")
auth_service_base_url: str = Field(
default="http://auth-service:8000", alias="AUTH_SERVICE_BASE_URL"
)
auth_service_validate_path: str = Field(
default="/auth/validate", alias="AUTH_SERVICE_VALIDATE_PATH"
)
auth_service_authorize_path: str = Field(
default="/auth/authorize", alias="AUTH_SERVICE_AUTHORIZE_PATH"
)
auth_service_timeout_seconds: float = Field(
default=5.0, alias="AUTH_SERVICE_TIMEOUT_SECONDS"
)
rate_limit_enabled: bool = Field(default=True, alias="RATE_LIMIT_ENABLED")
rate_limit_requests: int = Field(default=100, alias="RATE_LIMIT_REQUESTS")
rate_limit_window_seconds: int = Field(
default=60, alias="RATE_LIMIT_WINDOW_SECONDS"
)
rate_limit_exempt_paths: list[str] = Field(
default=[], alias="RATE_LIMIT_EXEMPT_PATHS"
)
rate_limit_key_header: str = Field(
default="X-Forwarded-For", alias="RATE_LIMIT_KEY_HEADER"
)
routes_config_path: str = Field(default="routes.yaml", alias="ROUTES_CONFIG_PATH")
proxy_timeout_seconds: float = Field(default=10.0, alias="PROXY_TIMEOUT_SECONDS")
routes_admin_permission: str = Field(
default="gateway:routes:manage", alias="ROUTES_ADMIN_PERMISSION"
)

model_config = SettingsConfigDict(
env_file=Path(__file__).resolve().parents[2] / ".env",
case_sensitive=False,
populate_by_name=True,
)

@field_validator(
"cors_allow_origins",
Expand Down
Loading