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: 4 additions & 2 deletions .github/workflows/python-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@ jobs:
run: uv sync --frozen

- name: Lint
run: uv run ruff check . --output-format=github
run: |
uv run ruff check . --output-format=github

- name: Test with pytest
run: uv run pytest
run: |
uv run pytest
4 changes: 2 additions & 2 deletions api/core/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
from . import cache, quote_service, quote_repository, security
from . import cache, config, database, security

__all__ = ["cache", "quote_service", "quote_repository", "security"]
__all__ = ["cache", "config", "database", "security"]
2 changes: 1 addition & 1 deletion api/core/security.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
)


def verify_api_key(api_key: str = Security(api_key_header)) -> str:
def verify_api_key(api_key: str | None = Security(api_key_header)) -> str:
if not api_key:
logger.warning("Chave de API ausente.")
raise HTTPException(
Expand Down
3 changes: 3 additions & 0 deletions api/exceptions/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from . import custom_exceptions, handlers

__all__ = ["custom_exceptions", "handlers"]
8 changes: 8 additions & 0 deletions api/exceptions/custom_exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
class ResourceNotFoundException(Exception):
def __init__(self, message: str) -> None:
self.message = message


class DomainValidationException(Exception):
def __init__(self, message: str) -> None:
self.message = message
67 changes: 67 additions & 0 deletions api/exceptions/handlers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
from http import HTTPStatus

from fastapi import Request
from fastapi.exceptions import RequestValidationError, HTTPException
from fastapi.responses import JSONResponse
from loguru import logger
from schemas.error_schema import ErrorResponse

from .custom_exceptions import DomainValidationException, ResourceNotFoundException


def _json_error_response(error: ErrorResponse) -> JSONResponse:
return JSONResponse(
status_code=error.status_code, content=error.model_dump(exclude_none=True)
)


async def resource_not_found_handler(
_: Request, exc: ResourceNotFoundException
) -> JSONResponse:
return _json_error_response(
ErrorResponse.from_http_status(
status_code=HTTPStatus.NOT_FOUND, message=exc.message
)
)


async def domain_validation_handler(
_: Request, exc: DomainValidationException
) -> JSONResponse:
return _json_error_response(
ErrorResponse.from_http_status(
status_code=HTTPStatus.BAD_REQUEST, message=exc.message
)
)


async def request_validation_handler(
_: Request, exc: RequestValidationError
) -> JSONResponse:
return _json_error_response(
ErrorResponse.from_http_status(
status_code=HTTPStatus.UNPROCESSABLE_ENTITY,
message="Erro de validação nos dados enviados",
details=exc.errors(),
)
)


async def http_handler(_: Request, exc: HTTPException) -> JSONResponse:
return _json_error_response(
ErrorResponse.from_http_status(
HTTPStatus(exc.status_code),
Comment on lines +51 to +53
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HTTPStatus(exc.status_code) will raise ValueError for non-standard/custom HTTP status codes (FastAPI allows any int). If that happens, the handler itself will error and you’ll lose the intended response formatting. Consider falling back to HTTPStatus.INTERNAL_SERVER_ERROR or using the raw exc.status_code int when it’s not a valid HTTPStatus member.

Suggested change
return _json_error_response(
ErrorResponse.from_http_status(
HTTPStatus(exc.status_code),
status = (
HTTPStatus(exc.status_code)
if exc.status_code in HTTPStatus._value2member_map_
else HTTPStatus.INTERNAL_SERVER_ERROR
)
return _json_error_response(
ErrorResponse.from_http_status(
status,

Copilot uses AI. Check for mistakes.
message=exc.detail,
Comment on lines +51 to +54
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

http_handler passes exc.detail into ErrorResponse.message, but HTTPException.detail can be non-string (e.g., dict/list). That will raise a Pydantic validation error inside the exception handler and can prevent the standardized error response from being returned. Consider ensuring message is always a string (e.g., str(exc.detail) when not a string) and optionally put the original detail payload into details.

Suggested change
return _json_error_response(
ErrorResponse.from_http_status(
HTTPStatus(exc.status_code),
message=exc.detail,
detail = exc.detail
message = detail if isinstance(detail, str) else str(detail)
details = None if isinstance(detail, str) else detail
return _json_error_response(
ErrorResponse.from_http_status(
HTTPStatus(exc.status_code),
message=message,
details=details,

Copilot uses AI. Check for mistakes.
)
)


async def global_exception_handler(request: Request, exc: Exception) -> JSONResponse:
logger.error(f"Erro inesperado em {request.url}: {repr(exc)}")

return _json_error_response(
ErrorResponse.from_http_status(
HTTPStatus.INTERNAL_SERVER_ERROR,
message="Ocorreu um erro interno no servidor. Por favor, tente novamente mais tarde.",
)
)
21 changes: 20 additions & 1 deletion api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,21 @@

from core.database import init_db
from fastapi import FastAPI
from fastapi.exceptions import RequestValidationError, HTTPException
from routers.quotes import api_router as quotes_router

from exceptions.custom_exceptions import (
DomainValidationException,
ResourceNotFoundException,
)
from exceptions.handlers import (
http_handler,
domain_validation_handler,
resource_not_found_handler,
request_validation_handler,
global_exception_handler,
)

tags_metadata = [
{
"name": "Frases",
Expand All @@ -13,7 +26,7 @@


@asynccontextmanager
async def lifespan(app: FastAPI):
async def lifespan(_: FastAPI):
await init_db()
yield

Expand Down Expand Up @@ -47,4 +60,10 @@ async def lifespan(app: FastAPI):
lifespan=lifespan,
)

app.add_exception_handler(HTTPException, http_handler) # type: ignore
app.add_exception_handler(DomainValidationException, domain_validation_handler) # type: ignore
app.add_exception_handler(ResourceNotFoundException, resource_not_found_handler) # type: ignore
app.add_exception_handler(RequestValidationError, request_validation_handler) # type: ignore
app.add_exception_handler(Exception, global_exception_handler)
Comment on lines +63 to +67
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Global exception handler registration introduces an API-level contract (standardized error shape), but there are no automated tests asserting the JSON schema/status codes for common cases (HTTPException, validation errors, uncaught exceptions). Adding a small FastAPI TestClient suite around these handlers would prevent regressions and ensure clients can rely on the new error response format.

Copilot uses AI. Check for mistakes.

app.include_router(quotes_router, prefix="/api")
4 changes: 2 additions & 2 deletions api/routers/quotes.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ async def post_quote(
new_quote: CreateQuoteRequest,
service: QuoteService = Depends(),
repo: QuoteRepository = Depends(),
_: str = Depends(verify_api_key),
_: str | None = Depends(verify_api_key),
) -> Quote:
return await service.create_quote(quote=new_quote, repo=repo)

Expand Down Expand Up @@ -57,7 +57,7 @@ async def get_all_quotes(
),
limit: int = Query(
default=0,
ge=1,
ge=0,
le=100,
description="Número máximo de citações a serem retornadas.",
),
Expand Down
4 changes: 2 additions & 2 deletions api/schemas/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
from . import pagination, quote_schema
from . import error_schema, pagination, quote_schema

__all__ = ["pagination", "quote_schema"]
__all__ = ["error_schema", "pagination", "quote_schema"]
25 changes: 25 additions & 0 deletions api/schemas/error_schema.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
from pydantic import BaseModel
from http import HTTPStatus
from typing import Any, Sequence


class ErrorResponse(BaseModel):
status_code: HTTPStatus
error: str
message: str
details: Sequence[Any] | dict[str, Any] | list[Any] | None = None

@classmethod
def from_http_status(
cls,
status_code: HTTPStatus,
message: str,
details: Sequence[Any] | dict[str, Any] | list[Any] | None = None,
error: str | None = None,
) -> "ErrorResponse":
return ErrorResponse(
status_code=status_code,
error=error or status_code.phrase,
message=message,
details=details,
)
Comment on lines +12 to +25
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

from_http_status is a @classmethod but it constructs ErrorResponse(...) directly instead of using cls(...). Using cls avoids hard-coding the concrete type and makes the method safe to reuse if this model is subclassed or customized later.

Copilot uses AI. Check for mistakes.
Loading