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 @@ -7,7 +7,7 @@ Marque as etapas conforme for concluindo. Use este arquivo como checklist.
- [x] Etapa 3: Autenticacao JWT + testes
- [x] Etapa 4: Rate limiting + testes
- [x] Etapa 5: Roteamento/proxy para microservicos + testes
- [ ] Etapa 6: Padronizacao de respostas/erros + testes
- [x] Etapa 6: Padronizacao de respostas/erros + testes
- [ ] Etapa 7: OpenAPI centralizado/ajustes de docs + testes

Detalhamento (opcional):
Expand All @@ -23,7 +23,7 @@ Detalhamento (opcional):
- [x] Etapa 4.2: Testes de rate limiting
- [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
- [x] Etapa 6.1: Normalizacao de respostas/erros
- [x] Etapa 6.2: Testes de erro padronizado
- [ ] Etapa 7.1: Ajustes de OpenAPI centralizado
- [ ] Etapa 7.2: Testes/validacao de docs
34 changes: 20 additions & 14 deletions app/api/routes/gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

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

Expand All @@ -13,40 +14,45 @@
)


@router.get("/routes", response_model=list[RouteConfig])
@router.get("/routes", response_model=DataResponse[list[RouteConfig]])
async def list_routes(
store: RouteStore = Depends(get_route_store),
) -> list[RouteConfig]:
return await to_thread.run_sync(store.list_routes)
) -> dict[str, list[RouteConfig]]:
routes = await to_thread.run_sync(store.list_routes)
return build_data_payload(routes)


@router.get("/routes/{route_id}", response_model=RouteConfig)
@router.get("/routes/{route_id}", response_model=DataResponse[RouteConfig])
async def get_route(
route_id: str, store: RouteStore = Depends(get_route_store)
) -> RouteConfig:
return await to_thread.run_sync(store.get_route, route_id)
) -> dict[str, RouteConfig]:
route = await to_thread.run_sync(store.get_route, route_id)
return build_data_payload(route)


@router.post(
"/routes",
response_model=RouteConfig,
response_model=DataResponse[RouteConfig],
status_code=status.HTTP_201_CREATED,
)
async def create_route(
payload: RouteCreate, store: RouteStore = Depends(get_route_store)
) -> RouteConfig:
return await to_thread.run_sync(store.create_route, payload)
) -> dict[str, RouteConfig]:
route = await to_thread.run_sync(store.create_route, payload)
return build_data_payload(route)


@router.put("/routes/{route_id}", response_model=RouteConfig)
@router.put("/routes/{route_id}", response_model=DataResponse[RouteConfig])
async def update_route(
route_id: str, payload: RouteUpdate, store: RouteStore = Depends(get_route_store)
) -> RouteConfig:
return await to_thread.run_sync(store.update_route, route_id, payload)
) -> dict[str, RouteConfig]:
route = await to_thread.run_sync(store.update_route, route_id, payload)
return build_data_payload(route)


@router.delete("/routes/{route_id}", status_code=status.HTTP_204_NO_CONTENT)
@router.delete("/routes/{route_id}", response_model=DataResponse[dict[str, bool]])
async def delete_route(
route_id: str, store: RouteStore = Depends(get_route_store)
) -> None:
) -> dict[str, dict[str, bool]]:
await to_thread.run_sync(store.delete_route, route_id)
return build_data_payload({"deleted": True})
8 changes: 5 additions & 3 deletions app/api/routes/health.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
from fastapi import APIRouter

from app.core.responses import DataResponse, build_data_payload

router = APIRouter()


@router.get("/health")
async def health_check() -> dict:
return {"status": "ok"}
@router.get("/health", response_model=DataResponse[dict[str, str]])
async def health_check() -> dict[str, dict[str, str]]:
return build_data_payload({"status": "ok"})
126 changes: 126 additions & 0 deletions app/core/exception_handlers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
from __future__ import annotations

import logging
import re
from collections.abc import Awaitable, Callable
from typing import Any, cast

from fastapi import FastAPI, Request, status
from fastapi.exceptions import RequestValidationError
from fastapi.responses import ORJSONResponse
from starlette.exceptions import HTTPException as StarletteHTTPException
from starlette.responses import Response

from app.core.responses import build_error_payload

logger = logging.getLogger(__name__)

_CODE_NORMALIZE = re.compile(r"[^a-z0-9]+")

_DEFAULT_CODES = {
status.HTTP_400_BAD_REQUEST: "bad_request",
status.HTTP_401_UNAUTHORIZED: "not_authenticated",
status.HTTP_403_FORBIDDEN: "not_authorized",
status.HTTP_404_NOT_FOUND: "not_found",
status.HTTP_405_METHOD_NOT_ALLOWED: "method_not_allowed",
status.HTTP_409_CONFLICT: "conflict",
status.HTTP_422_UNPROCESSABLE_ENTITY: "validation_error",
status.HTTP_429_TOO_MANY_REQUESTS: "rate_limited",
status.HTTP_500_INTERNAL_SERVER_ERROR: "internal_server_error",
status.HTTP_502_BAD_GATEWAY: "bad_gateway",
status.HTTP_503_SERVICE_UNAVAILABLE: "service_unavailable",
status.HTTP_504_GATEWAY_TIMEOUT: "gateway_timeout",
}


ExceptionHandler = Callable[[Request, Exception], Response | Awaitable[Response]]


def register_exception_handlers(app: FastAPI) -> None:
app.add_exception_handler(
StarletteHTTPException,
cast(ExceptionHandler, http_exception_handler),
)
app.add_exception_handler(
RequestValidationError,
cast(ExceptionHandler, validation_exception_handler),
)
app.add_exception_handler(
Exception,
cast(ExceptionHandler, unhandled_exception_handler),
)


def _normalize_code(value: str) -> str:
cleaned = _CODE_NORMALIZE.sub("_", value.strip().lower()).strip("_")
return cleaned or "error"


def _default_code(status_code: int) -> str:
return _DEFAULT_CODES.get(status_code, "http_error")


def _extract_error(detail: Any, *, default_code: str) -> tuple[str, str, Any | None]:
if isinstance(detail, str):
return _normalize_code(detail), detail, None

if isinstance(detail, dict):
if "code" in detail:
raw_code = str(detail["code"])
message = str(detail.get("message", raw_code))
details = detail.get("details")
extra = {
key: value
for key, value in detail.items()
if key not in {"code", "message", "details"}
}
if extra:
details = details or extra
return _normalize_code(raw_code), message, details

if "detail" in detail and isinstance(detail["detail"], str):
raw = detail["detail"]
extras = {key: value for key, value in detail.items() if key != "detail"}
return _normalize_code(raw), raw, extras or None

details = detail if detail not in (None, "") else None
return default_code, default_code, details


async def http_exception_handler(
request: Request, exc: StarletteHTTPException
) -> ORJSONResponse:
code, message, details = _extract_error(
exc.detail, default_code=_default_code(exc.status_code)
)
return ORJSONResponse(
status_code=exc.status_code,
content=build_error_payload(code, message, details),
headers=exc.headers,
)


async def validation_exception_handler(
request: Request, exc: RequestValidationError
) -> ORJSONResponse:
return ORJSONResponse(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
content=build_error_payload(
code="validation_error",
message="validation_error",
details=exc.errors(),
),
)


async def unhandled_exception_handler(
request: Request, exc: Exception
) -> ORJSONResponse:
logger.exception("Unhandled error", exc_info=exc)
return ORJSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content=build_error_payload(
code="internal_server_error",
message="internal_server_error",
),
)
8 changes: 5 additions & 3 deletions app/core/rate_limit.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@
from typing import Callable, Iterable

from fastapi import Request, status
from fastapi.responses import JSONResponse, Response
from fastapi.responses import ORJSONResponse, Response
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint

from app.core.responses import build_error_payload


@dataclass
class RateLimitState:
Expand Down Expand Up @@ -91,9 +93,9 @@ async def dispatch(
result = self._limiter.hit(key)

if not result.allowed:
return JSONResponse(
return ORJSONResponse(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
content={"detail": "rate_limited"},
content=build_error_payload("rate_limited", "rate_limited"),
headers=_rate_limit_headers(result, blocked=True),
)

Expand Down
34 changes: 34 additions & 0 deletions app/core/responses.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
from __future__ import annotations

from typing import Any, Generic, TypeVar

from pydantic import BaseModel

T = TypeVar("T")


class DataResponse(BaseModel, Generic[T]):
data: T


class ErrorDetail(BaseModel):
code: str
message: str
details: Any | None = None


class ErrorResponse(BaseModel):
error: ErrorDetail


def build_data_payload(data: Any) -> dict[str, Any]:
return {"data": data}


def build_error_payload(
code: str, message: str | None = None, details: Any | None = None
) -> dict[str, Any]:
payload = {"error": {"code": code, "message": message or code}}
if details is not None:
payload["error"]["details"] = details
return payload
4 changes: 4 additions & 0 deletions app/main.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import ORJSONResponse

from app.api.router import api_router
from app.core.config import Settings, get_settings
from app.core.exception_handlers import register_exception_handlers
from app.core.logging import configure_logging
from app.core.rate_limit import RateLimitMiddleware, RateLimiter

Expand All @@ -14,7 +16,9 @@ def create_app(settings: Settings | None = None) -> FastAPI:
app = FastAPI(
title=settings.app_name,
debug=settings.debug,
default_response_class=ORJSONResponse,
)
register_exception_handlers(app)

app.add_middleware(
CORSMiddleware,
Expand Down
19 changes: 15 additions & 4 deletions app/tests/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,12 @@

import httpx
from fastapi import Depends, FastAPI
from fastapi.responses import ORJSONResponse
from fastapi.testclient import TestClient

from app.api.deps import require_authentication, require_authorization
from app.core.exception_handlers import register_exception_handlers
from app.core.responses import build_data_payload
from app.services.auth_service import (
AuthContext,
AuthServiceClient,
Expand All @@ -14,20 +17,21 @@


def _build_app(client: AuthServiceClient) -> FastAPI:
app = FastAPI()
app = FastAPI(default_response_class=ORJSONResponse)
register_exception_handlers(app)
app.dependency_overrides[get_auth_service_client] = lambda: client

@app.get("/protected")
async def protected(
context: AuthContext = Depends(require_authentication),
) -> dict[str, Any]:
return {"subject": context.claims.get("sub")}
return build_data_payload({"subject": context.claims.get("sub")})

@app.get("/admin")
async def admin(
context: AuthContext = Depends(require_authorization("admin:read")),
) -> dict[str, Any]:
return {"ok": True}
return build_data_payload({"ok": True})

return app

Expand Down Expand Up @@ -82,6 +86,9 @@ def test_auth_missing_token_returns_401() -> None:

assert response.status_code == 401
assert response.headers["www-authenticate"] == "Bearer"
assert response.json() == {
"error": {"code": "not_authenticated", "message": "not_authenticated"}
}


def test_auth_invalid_token_returns_401() -> None:
Expand All @@ -91,6 +98,7 @@ def test_auth_invalid_token_returns_401() -> None:
response = client.get("/protected", headers={"Authorization": "Bearer bad-token"})

assert response.status_code == 401
assert response.json()["error"]["code"] == "invalid_token"


def test_auth_valid_token_allows_request() -> None:
Expand All @@ -100,7 +108,7 @@ def test_auth_valid_token_allows_request() -> None:
response = client.get("/protected", headers={"Authorization": "Bearer valid-token"})

assert response.status_code == 200
assert response.json() == {"subject": "user-123"}
assert response.json() == {"data": {"subject": "user-123"}}


def test_authorization_forbidden_returns_403() -> None:
Expand All @@ -110,6 +118,7 @@ def test_authorization_forbidden_returns_403() -> None:
response = client.get("/admin", headers={"Authorization": "Bearer limited-token"})

assert response.status_code == 403
assert response.json()["error"]["code"] == "not_authorized"


def test_authorization_allows_admin() -> None:
Expand All @@ -119,6 +128,7 @@ def test_authorization_allows_admin() -> None:
response = client.get("/admin", headers={"Authorization": "Bearer admin-token"})

assert response.status_code == 200
assert response.json() == {"data": {"ok": True}}


def test_auth_service_unavailable_returns_503() -> None:
Expand All @@ -131,3 +141,4 @@ async def handler(request: httpx.Request) -> httpx.Response:
response = client.get("/protected", headers={"Authorization": "Bearer valid-token"})

assert response.status_code == 503
assert response.json()["error"]["code"] == "auth_service_unavailable"
Loading