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

Detalhamento (opcional):
- [x] Etapa 1.1: Health check (/health)
Expand All @@ -25,5 +25,5 @@ Detalhamento (opcional):
- [x] Etapa 5.2: Testes de roteamento
- [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
- [x] Etapa 7.1: Ajustes de OpenAPI centralizado
- [x] Etapa 7.2: Testes/validacao de docs
37 changes: 33 additions & 4 deletions app/api/routes/gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,13 @@

from app.api.deps import require_authorization
from app.core.config import get_settings
from app.core.openapi import (
GATEWAY_CREATE_RESPONSES,
GATEWAY_DELETE_RESPONSES,
GATEWAY_DETAIL_RESPONSES,
GATEWAY_LIST_RESPONSES,
GATEWAY_UPDATE_RESPONSES,
)
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 @@ -14,15 +21,25 @@
)


@router.get("/routes", response_model=DataResponse[list[RouteConfig]])
@router.get(
"/routes",
response_model=DataResponse[list[RouteConfig]],
responses=GATEWAY_LIST_RESPONSES,
summary="List routes",
)
async def list_routes(
store: RouteStore = Depends(get_route_store),
) -> 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=DataResponse[RouteConfig])
@router.get(
"/routes/{route_id}",
response_model=DataResponse[RouteConfig],
responses=GATEWAY_DETAIL_RESPONSES,
summary="Get route",
)
async def get_route(
route_id: str, store: RouteStore = Depends(get_route_store)
) -> dict[str, RouteConfig]:
Expand All @@ -33,7 +50,9 @@ async def get_route(
@router.post(
"/routes",
response_model=DataResponse[RouteConfig],
responses=GATEWAY_CREATE_RESPONSES,
status_code=status.HTTP_201_CREATED,
summary="Create route",
)
async def create_route(
payload: RouteCreate, store: RouteStore = Depends(get_route_store)
Expand All @@ -42,15 +61,25 @@ async def create_route(
return build_data_payload(route)


@router.put("/routes/{route_id}", response_model=DataResponse[RouteConfig])
@router.put(
"/routes/{route_id}",
response_model=DataResponse[RouteConfig],
responses=GATEWAY_UPDATE_RESPONSES,
summary="Update route",
)
async def update_route(
route_id: str, payload: RouteUpdate, store: RouteStore = Depends(get_route_store)
) -> 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}", response_model=DataResponse[dict[str, bool]])
@router.delete(
"/routes/{route_id}",
response_model=DataResponse[dict[str, bool]],
responses=GATEWAY_DELETE_RESPONSES,
summary="Delete route",
)
async def delete_route(
route_id: str, store: RouteStore = Depends(get_route_store)
) -> dict[str, dict[str, bool]]:
Expand Down
8 changes: 7 additions & 1 deletion app/api/routes/health.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
from fastapi import APIRouter

from app.core.openapi import HEALTH_RESPONSES
from app.core.responses import DataResponse, build_data_payload

router = APIRouter()


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

from typing import Any

from fastapi import status

from app.core.responses import ErrorResponse, build_error_payload

OPENAPI_DESCRIPTION = "Centralized OpenAPI schema for the API gateway."

OPENAPI_TAGS = [
{"name": "health", "description": "Health check endpoints."},
{"name": "gateway", "description": "Gateway administration routes."},
{"name": "proxy", "description": "Proxy forwarding routes."},
]


ResponseDocs = dict[int | str, dict[str, Any]]


def _error_response(description: str, code: str) -> dict[str, Any]:
return {
"model": ErrorResponse,
"description": description,
"content": {"application/json": {"example": build_error_payload(code, code)}},
}


_ERROR_RESPONSES: ResponseDocs = {
status.HTTP_400_BAD_REQUEST: _error_response("Bad request", "bad_request"),
status.HTTP_401_UNAUTHORIZED: _error_response(
"Not authenticated", "not_authenticated"
),
status.HTTP_403_FORBIDDEN: _error_response("Not authorized", "not_authorized"),
status.HTTP_404_NOT_FOUND: _error_response("Not found", "not_found"),
status.HTTP_409_CONFLICT: _error_response("Conflict", "conflict"),
status.HTTP_422_UNPROCESSABLE_ENTITY: _error_response(
"Validation error", "validation_error"
),
status.HTTP_429_TOO_MANY_REQUESTS: _error_response("Rate limited", "rate_limited"),
status.HTTP_500_INTERNAL_SERVER_ERROR: _error_response(
"Internal server error", "internal_server_error"
),
status.HTTP_502_BAD_GATEWAY: _error_response("Bad gateway", "bad_gateway"),
status.HTTP_503_SERVICE_UNAVAILABLE: _error_response(
"Service unavailable", "service_unavailable"
),
status.HTTP_504_GATEWAY_TIMEOUT: _error_response(
"Gateway timeout", "gateway_timeout"
),
}


def responses_for(*status_codes: int) -> ResponseDocs:
return {code: _ERROR_RESPONSES[code] for code in status_codes}


def merge_responses(*responses: ResponseDocs) -> ResponseDocs:
merged: ResponseDocs = {}
for response in responses:
merged.update(response)
return merged


COMMON_RESPONSES = responses_for(
status.HTTP_429_TOO_MANY_REQUESTS,
status.HTTP_500_INTERNAL_SERVER_ERROR,
)
AUTH_RESPONSES = responses_for(
status.HTTP_401_UNAUTHORIZED,
status.HTTP_403_FORBIDDEN,
)
SERVICE_RESPONSES = responses_for(
status.HTTP_502_BAD_GATEWAY,
status.HTTP_503_SERVICE_UNAVAILABLE,
status.HTTP_504_GATEWAY_TIMEOUT,
)
VALIDATION_RESPONSES = responses_for(status.HTTP_422_UNPROCESSABLE_ENTITY)
CONFLICT_RESPONSES = responses_for(status.HTTP_409_CONFLICT)
NOT_FOUND_RESPONSES = responses_for(status.HTTP_404_NOT_FOUND)

HEALTH_RESPONSES = merge_responses(COMMON_RESPONSES)
GATEWAY_LIST_RESPONSES = merge_responses(
AUTH_RESPONSES, SERVICE_RESPONSES, COMMON_RESPONSES
)
GATEWAY_DETAIL_RESPONSES = merge_responses(GATEWAY_LIST_RESPONSES, NOT_FOUND_RESPONSES)
GATEWAY_CREATE_RESPONSES = merge_responses(
GATEWAY_LIST_RESPONSES, VALIDATION_RESPONSES, CONFLICT_RESPONSES
)
GATEWAY_UPDATE_RESPONSES = merge_responses(
GATEWAY_DETAIL_RESPONSES, VALIDATION_RESPONSES, CONFLICT_RESPONSES
)
GATEWAY_DELETE_RESPONSES = merge_responses(GATEWAY_DETAIL_RESPONSES)
3 changes: 3 additions & 0 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
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.openapi import OPENAPI_DESCRIPTION, OPENAPI_TAGS
from app.core.rate_limit import RateLimitMiddleware, RateLimiter


Expand All @@ -16,6 +17,8 @@ def create_app(settings: Settings | None = None) -> FastAPI:
app = FastAPI(
title=settings.app_name,
debug=settings.debug,
description=OPENAPI_DESCRIPTION,
openapi_tags=OPENAPI_TAGS,
default_response_class=ORJSONResponse,
)
register_exception_handlers(app)
Expand Down
39 changes: 39 additions & 0 deletions app/tests/test_openapi.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
from fastapi.testclient import TestClient

from app.core.config import Settings
from app.main import create_app


def _client() -> TestClient:
settings = Settings.model_validate({"rate_limit_enabled": False})
return TestClient(create_app(settings))


def test_openapi_includes_routes() -> None:
client = _client()

response = client.get("/openapi.json")

assert response.status_code == 200
payload = response.json()
paths = payload["paths"]
assert "/api/v1/health" in paths
assert "/api/v1/routes" in paths
assert "/api/v1/routes/{route_id}" in paths
assert "/api/v1/{path}" not in paths


def test_openapi_documents_error_responses() -> None:
client = _client()

response = client.get("/openapi.json")

assert response.status_code == 200
payload = response.json()
responses = payload["paths"]["/api/v1/routes"]["get"]["responses"]
assert "401" in responses
schema = responses["401"]["content"]["application/json"]["schema"]
assert schema["$ref"].endswith("/ErrorResponse")
tags = {tag["name"] for tag in payload.get("tags", [])}
assert "health" in tags
assert "gateway" in tags