diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index 8c000d5..0c7a76b 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -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 diff --git a/api/core/__init__.py b/api/core/__init__.py index 99bb04c..e269f89 100644 --- a/api/core/__init__.py +++ b/api/core/__init__.py @@ -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"] diff --git a/api/core/security.py b/api/core/security.py index 4a11bcc..3782c97 100644 --- a/api/core/security.py +++ b/api/core/security.py @@ -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( diff --git a/api/exceptions/__init__.py b/api/exceptions/__init__.py index e69de29..37cb103 100644 --- a/api/exceptions/__init__.py +++ b/api/exceptions/__init__.py @@ -0,0 +1,3 @@ +from . import custom_exceptions, handlers + +__all__ = ["custom_exceptions", "handlers"] diff --git a/api/exceptions/custom_exceptions.py b/api/exceptions/custom_exceptions.py new file mode 100644 index 0000000..b390a52 --- /dev/null +++ b/api/exceptions/custom_exceptions.py @@ -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 diff --git a/api/exceptions/handlers.py b/api/exceptions/handlers.py new file mode 100644 index 0000000..28fe72c --- /dev/null +++ b/api/exceptions/handlers.py @@ -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), + message=exc.detail, + ) + ) + + +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.", + ) + ) diff --git a/api/main.py b/api/main.py index 95bbcb8..42a8fb3 100644 --- a/api/main.py +++ b/api/main.py @@ -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", @@ -13,7 +26,7 @@ @asynccontextmanager -async def lifespan(app: FastAPI): +async def lifespan(_: FastAPI): await init_db() yield @@ -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) + app.include_router(quotes_router, prefix="/api") diff --git a/api/routers/quotes.py b/api/routers/quotes.py index 3ca5ec9..d19a8f5 100644 --- a/api/routers/quotes.py +++ b/api/routers/quotes.py @@ -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) @@ -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.", ), diff --git a/api/schemas/__init__.py b/api/schemas/__init__.py index 2a6bf7c..f9f5c6a 100644 --- a/api/schemas/__init__.py +++ b/api/schemas/__init__.py @@ -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"] diff --git a/api/schemas/error_schema.py b/api/schemas/error_schema.py new file mode 100644 index 0000000..25a3b38 --- /dev/null +++ b/api/schemas/error_schema.py @@ -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, + ) diff --git a/requirements.txt b/requirements.txt index 6973dd1..36a3032 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,83 +1,258 @@ +# This file was autogenerated by uv via the following command: +# uv export --format requirements-txt --no-hashes --output-file requirements.txt altair==6.0.0 + # via streamlit annotated-doc==0.0.4 + # via + # fastapi + # typer annotated-types==0.7.0 + # via pydantic anyio==4.13.0 + # via + # httpx + # starlette + # watchfiles attrs==26.1.0 + # via + # jsonschema + # referencing beanie==2.0.1 + # via finfrases blinker==1.9.0 + # via streamlit cachetools==7.0.5 + # via streamlit certifi==2026.2.25 + # via + # httpcore + # httpx + # requests + # sentry-sdk charset-normalizer==3.4.6 + # via requests click==8.3.1 + # via + # beanie + # rich-toolkit + # streamlit + # typer + # uvicorn +colorama==0.4.6 ; sys_platform == 'win32' + # via + # click + # loguru + # pytest + # uvicorn dnspython==2.8.0 + # via + # email-validator + # pymongo email-validator==2.3.0 + # via + # fastapi + # pydantic fastapi==0.135.2 + # via finfrases fastapi-cli==0.0.24 + # via fastapi fastapi-cloud-cli==0.15.0 + # via fastapi-cli fastar==0.9.0 + # via fastapi-cloud-cli gitdb==4.0.12 + # via gitpython gitpython==3.1.46 + # via streamlit h11==0.16.0 + # via + # httpcore + # uvicorn hiredis==3.3.1 + # via redis httpcore==1.0.9 + # via httpx httptools==0.7.1 + # via uvicorn httpx==0.28.1 + # via + # fastapi + # fastapi-cloud-cli idna==3.11 + # via + # anyio + # email-validator + # httpx + # requests iniconfig==2.3.0 + # via pytest jinja2==3.1.6 + # via + # altair + # fastapi + # pydeck jsonschema==4.26.0 + # via altair jsonschema-specifications==2025.9.1 + # via jsonschema lazy-model==0.4.0 + # via beanie loguru==0.7.3 + # via finfrases markdown-it-py==4.0.0 + # via rich markupsafe==3.0.3 + # via jinja2 mdurl==0.1.2 + # via markdown-it-py narwhals==2.18.1 + # via altair numpy==2.4.3 + # via + # pandas + # pydeck + # streamlit packaging==26.0 + # via + # altair + # pytest + # streamlit pandas==2.3.3 + # via streamlit pillow==12.1.1 + # via streamlit pluggy==1.6.0 + # via pytest protobuf==6.33.6 + # via streamlit pyarrow==23.0.1 + # via streamlit pydantic==2.12.5 + # via + # beanie + # fastapi + # fastapi-cloud-cli + # lazy-model + # pydantic-extra-types + # pydantic-settings pydantic-core==2.41.5 + # via pydantic pydantic-extra-types==2.11.1 + # via fastapi pydantic-settings==2.13.1 + # via fastapi pydeck==0.9.1 + # via streamlit pygments==2.19.2 + # via + # pytest + # rich pymongo==4.16.0 + # via + # beanie + # finfrases pytest==9.0.2 + # via + # finfrases + # pytest-asyncio pytest-asyncio==1.3.0 + # via finfrases python-dateutil==2.9.0.post0 + # via pandas python-dotenv==1.2.2 + # via + # finfrases + # pydantic-settings + # uvicorn python-multipart==0.0.22 + # via fastapi pytz==2026.1.post1 + # via pandas pyyaml==6.0.3 + # via uvicorn redis==7.4.0 + # via finfrases referencing==0.37.0 + # via + # jsonschema + # jsonschema-specifications requests==2.33.0 + # via + # finfrases + # streamlit rich==14.3.3 + # via + # rich-toolkit + # typer rich-toolkit==0.19.7 + # via + # fastapi-cli + # fastapi-cloud-cli rignore==0.7.6 + # via fastapi-cloud-cli rpds-py==0.30.0 + # via + # jsonschema + # referencing ruff==0.15.8 + # via finfrases sentry-sdk==2.56.0 + # via fastapi-cloud-cli shellingham==1.5.4 + # via typer six==1.17.0 + # via python-dateutil smmap==5.0.3 + # via gitdb starlette==1.0.0 + # via fastapi streamlit==1.55.0 + # via finfrases tenacity==9.1.4 + # via streamlit toml==0.10.2 + # via streamlit tornado==6.5.5 + # via streamlit typer==0.24.1 + # via + # fastapi-cli + # fastapi-cloud-cli typing-extensions==4.15.0 + # via + # altair + # beanie + # fastapi + # pydantic + # pydantic-core + # pydantic-extra-types + # rich-toolkit + # streamlit + # typing-inspection typing-inspection==0.4.2 + # via + # fastapi + # pydantic + # pydantic-settings tzdata==2025.3 + # via pandas urllib3==2.6.3 + # via + # requests + # sentry-sdk uvicorn==0.42.0 -uvloop==0.22.1 -watchdog==6.0.0 + # via + # fastapi + # fastapi-cli + # fastapi-cloud-cli + # finfrases +uvloop==0.22.1 ; platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32' + # via uvicorn +watchdog==6.0.0 ; sys_platform != 'darwin' + # via streamlit watchfiles==1.1.1 + # via uvicorn websockets==16.0 + # via uvicorn +win32-setctime==1.2.0 ; sys_platform == 'win32' + # via loguru