diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml new file mode 100644 index 0000000..8c000d5 --- /dev/null +++ b/.github/workflows/python-tests.yml @@ -0,0 +1,34 @@ +name: Python Application Test + +on: + push: + branches: ["main"] + pull_request: + branches: ["main"] + +permissions: + contents: read + +jobs: + test-lint: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 + with: + python-version: "3.13" + + - name: Install the latest version of uv + uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + + - name: Install dependencies + run: uv sync --frozen + + - name: Lint + run: uv run ruff check . --output-format=github + + - name: Test with pytest + run: uv run pytest diff --git a/api/.env-example b/api/.env-example index bd48264..87402bd 100644 --- a/api/.env-example +++ b/api/.env-example @@ -1,3 +1,3 @@ MONGO_URI=mongodb://user:password123@localhost:27017/finfrases?authSource=admin -API_KEY=ampyLVT7rSby5SZtFUK7RLiwlsUt5igG3tB0cWhcIB8 \ No newline at end of file +API_KEY=CHANGE_ME \ No newline at end of file diff --git a/api/FinFrases/Quotes/Create Quote.yml b/api/FinFrases/Quotes/Create Quote.yml index 57639cf..1c927af 100644 --- a/api/FinFrases/Quotes/Create Quote.yml +++ b/api/FinFrases/Quotes/Create Quote.yml @@ -21,14 +21,14 @@ http: auth: type: apikey key: X-API-Key - value: ampyLVT7rSby5SZtFUK7RLiwlsUt5igG3tB0cWhcIB8 + value: "{{API_KEY}}" placement: header runtime: scripts: - type: before-request code: |- - let random = Math.floor(Math.random() * 100) + let random = Math.floor(Math.random() * 1000) let categories = [ "GERAL", "INVESTIMENTOS", "POUPANCA", "PSICOLOGIA", "DIVIDENDOS", "EDUCACAO", "EMPREENDEDORISMO", "ACAO", "FIIS" ] diff --git a/api/FinFrases/environments/local.yml b/api/FinFrases/environments/local.yml index be162e0..21995c0 100644 --- a/api/FinFrases/environments/local.yml +++ b/api/FinFrases/environments/local.yml @@ -4,3 +4,5 @@ variables: value: v1 - name: baseUrl value: http://localhost:8000/api + - secret: true + name: API_KEY diff --git a/api/core/__init__.py b/api/core/__init__.py index e69de29..99bb04c 100644 --- a/api/core/__init__.py +++ b/api/core/__init__.py @@ -0,0 +1,3 @@ +from . import cache, quote_service, quote_repository, security + +__all__ = ["cache", "quote_service", "quote_repository", "security"] diff --git a/api/core/cache.py b/api/core/cache.py index 58e69a2..9ba627d 100644 --- a/api/core/cache.py +++ b/api/core/cache.py @@ -53,7 +53,7 @@ def _normalize_cache_value(self, value): return str(value) - def cacheable(self, expire: Callable[[], int]): + def cacheable(self, expire: Callable[[], int] | int = 3600): def decorator(func): @wraps(func) async def wrapper(*args, **kwargs): @@ -82,7 +82,7 @@ async def wrapper(*args, **kwargs): await self.set( key=cache_key, value=dumps(serializable_result), - expire=expire(), + expire=expire() if callable(expire) else expire, ) return result diff --git a/api/core/security.py b/api/core/security.py index 4a82037..4a11bcc 100644 --- a/api/core/security.py +++ b/api/core/security.py @@ -6,14 +6,28 @@ from .config import settings from loguru import logger -api_key_header = APIKeyHeader(name="X-API-Key", auto_error=True) +api_key_header = APIKeyHeader( + name="X-API-Key", + scheme_name="API-Key", + description="Chave de API para autenticação.", + auto_error=False, +) -def verify_api_key(api_key: str = Security(api_key_header)): +def verify_api_key(api_key: str = Security(api_key_header)) -> str: + if not api_key: + logger.warning("Chave de API ausente.") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Credenciais ausentes.", + headers={"WWW-Authenticate": "API-Key"}, + ) + if not secrets.compare_digest(api_key, settings.API_KEY): - logger.warning(f"Chave de API inválida ou ausente. Chave recebida: {api_key}") + logger.warning("Chave de API inválida.") raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="Credenciais inválidas ou ausentes.", + detail="Credenciais inválidas.", + headers={"WWW-Authenticate": "API-Key"}, ) return api_key diff --git a/api/exceptions/__init__.py b/api/exceptions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/models/__init__.py b/api/models/__init__.py index e69de29..57230dd 100644 --- a/api/models/__init__.py +++ b/api/models/__init__.py @@ -0,0 +1,3 @@ +from . import enums, quote + +__all__ = ["enums", "quote"] diff --git a/api/repositories/__init__.py b/api/repositories/__init__.py new file mode 100644 index 0000000..2a8623f --- /dev/null +++ b/api/repositories/__init__.py @@ -0,0 +1,3 @@ +from . import quote_repository + +__all__ = ["quote_repository"] diff --git a/api/routers/__init__.py b/api/routers/__init__.py index e69de29..a8c86e1 100644 --- a/api/routers/__init__.py +++ b/api/routers/__init__.py @@ -0,0 +1,3 @@ +from . import quotes + +__all__ = ["quotes"] diff --git a/api/routers/quotes.py b/api/routers/quotes.py index 0621995..3ca5ec9 100644 --- a/api/routers/quotes.py +++ b/api/routers/quotes.py @@ -57,10 +57,15 @@ async def get_all_quotes( ), limit: int = Query( default=0, + ge=1, + le=100, description="Número máximo de citações a serem retornadas.", ), skip: int = Query( - default=0, description="Número de citações a serem ignoradas para paginação." + default=0, + ge=0, + le=1000, + description="Número de citações a serem ignoradas para paginação.", ), service: QuoteService = Depends(), repo: QuoteRepository = Depends(), @@ -89,7 +94,10 @@ async def get_all_quotes( ) async def get_random_quote( size: int = Query( - default=1, description="Número de citações aleatórias a serem retornadas." + default=1, + ge=1, + le=100, + description="Número de citações aleatórias a serem retornadas.", ), service: QuoteService = Depends(), repo: QuoteRepository = Depends(), diff --git a/api/schemas/__init__.py b/api/schemas/__init__.py index e69de29..2a6bf7c 100644 --- a/api/schemas/__init__.py +++ b/api/schemas/__init__.py @@ -0,0 +1,3 @@ +from . import pagination, quote_schema + +__all__ = ["pagination", "quote_schema"] diff --git a/api/services/__init__.py b/api/services/__init__.py index e69de29..a5c58a0 100644 --- a/api/services/__init__.py +++ b/api/services/__init__.py @@ -0,0 +1,3 @@ +from . import quote_service + +__all__ = ["quote_service"] diff --git a/api/tests/__init__.py b/api/tests/__init__.py new file mode 100644 index 0000000..dca74aa --- /dev/null +++ b/api/tests/__init__.py @@ -0,0 +1,3 @@ +from . import test_quote_service + +__all__ = ["test_quote_service"] diff --git a/api/tests/test_quote_service.py b/api/tests/test_quote_service.py new file mode 100644 index 0000000..51a86ad --- /dev/null +++ b/api/tests/test_quote_service.py @@ -0,0 +1,53 @@ +from unittest.mock import AsyncMock + +import pytest +from models.enums import CategoryQuote +from models.quote import Quote +from repositories.quote_repository import QuoteRepository +from schemas.quote_schema import CreateQuoteRequest +from services.quote_service import QuoteService + + +@pytest.fixture +def service(): + return QuoteService() + + +@pytest.fixture +def mock_repo(): + return AsyncMock(spec=QuoteRepository) + + +@pytest.fixture +def valid_request_data(): + return CreateQuoteRequest( + content="Investir é sobre ter paciência.", + author="Warren Buffett", + tags=[CategoryQuote.INVESTIMENTOS, CategoryQuote.EDUCACAO], + source="Book of Finances", + verified=True, + ) + + +class TestCreateQuote: + @pytest.mark.asyncio + async def test_create_quote_sucess(self, service, mock_repo, valid_request_data): + quote = Quote.model_construct( + content=valid_request_data.content, + author=valid_request_data.author, + tags=valid_request_data.tags, + source=valid_request_data.source, + verified=valid_request_data.verified, + ) + + mock_repo.create.return_value = quote + + result = await service.create_quote(quote=valid_request_data, repo=mock_repo) + + assert result.content == valid_request_data.content + assert result.author == valid_request_data.author + assert result.tags == valid_request_data.tags + assert result.source == valid_request_data.source + assert result.verified == valid_request_data.verified + + mock_repo.create.assert_called_once_with(valid_request_data) diff --git a/api/utils/__init__.py b/api/utils/__init__.py new file mode 100644 index 0000000..03235a6 --- /dev/null +++ b/api/utils/__init__.py @@ -0,0 +1,3 @@ +from . import utils + +__all__ = ["utils"] diff --git a/pyproject.toml b/pyproject.toml index 7b46975..8165c3b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,9 +10,11 @@ dependencies = [ "loguru>=0.7.3", "pymongo>=4.16.0", "pytest>=9.0.2", + "pytest-asyncio>=1.3.0", "python-dotenv>=1.2.2", "redis[hiredis]>=7.4.0", "requests>=2.33.0", + "ruff>=0.15.8", "streamlit>=1.55.0", "uvicorn>=0.42.0", ] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..6973dd1 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,83 @@ +altair==6.0.0 +annotated-doc==0.0.4 +annotated-types==0.7.0 +anyio==4.13.0 +attrs==26.1.0 +beanie==2.0.1 +blinker==1.9.0 +cachetools==7.0.5 +certifi==2026.2.25 +charset-normalizer==3.4.6 +click==8.3.1 +dnspython==2.8.0 +email-validator==2.3.0 +fastapi==0.135.2 +fastapi-cli==0.0.24 +fastapi-cloud-cli==0.15.0 +fastar==0.9.0 +gitdb==4.0.12 +gitpython==3.1.46 +h11==0.16.0 +hiredis==3.3.1 +httpcore==1.0.9 +httptools==0.7.1 +httpx==0.28.1 +idna==3.11 +iniconfig==2.3.0 +jinja2==3.1.6 +jsonschema==4.26.0 +jsonschema-specifications==2025.9.1 +lazy-model==0.4.0 +loguru==0.7.3 +markdown-it-py==4.0.0 +markupsafe==3.0.3 +mdurl==0.1.2 +narwhals==2.18.1 +numpy==2.4.3 +packaging==26.0 +pandas==2.3.3 +pillow==12.1.1 +pluggy==1.6.0 +protobuf==6.33.6 +pyarrow==23.0.1 +pydantic==2.12.5 +pydantic-core==2.41.5 +pydantic-extra-types==2.11.1 +pydantic-settings==2.13.1 +pydeck==0.9.1 +pygments==2.19.2 +pymongo==4.16.0 +pytest==9.0.2 +pytest-asyncio==1.3.0 +python-dateutil==2.9.0.post0 +python-dotenv==1.2.2 +python-multipart==0.0.22 +pytz==2026.1.post1 +pyyaml==6.0.3 +redis==7.4.0 +referencing==0.37.0 +requests==2.33.0 +rich==14.3.3 +rich-toolkit==0.19.7 +rignore==0.7.6 +rpds-py==0.30.0 +ruff==0.15.8 +sentry-sdk==2.56.0 +shellingham==1.5.4 +six==1.17.0 +smmap==5.0.3 +starlette==1.0.0 +streamlit==1.55.0 +tenacity==9.1.4 +toml==0.10.2 +tornado==6.5.5 +typer==0.24.1 +typing-extensions==4.15.0 +typing-inspection==0.4.2 +tzdata==2025.3 +urllib3==2.6.3 +uvicorn==0.42.0 +uvloop==0.22.1 +watchdog==6.0.0 +watchfiles==1.1.1 +websockets==16.0 diff --git a/uv.lock b/uv.lock index d8adcab..658c033 100644 --- a/uv.lock +++ b/uv.lock @@ -337,9 +337,11 @@ dependencies = [ { name = "loguru" }, { name = "pymongo" }, { name = "pytest" }, + { name = "pytest-asyncio" }, { name = "python-dotenv" }, { name = "redis", extra = ["hiredis"] }, { name = "requests" }, + { name = "ruff" }, { name = "streamlit" }, { name = "uvicorn" }, ] @@ -351,9 +353,11 @@ requires-dist = [ { name = "loguru", specifier = ">=0.7.3" }, { name = "pymongo", specifier = ">=4.16.0" }, { name = "pytest", specifier = ">=9.0.2" }, + { name = "pytest-asyncio", specifier = ">=1.3.0" }, { name = "python-dotenv", specifier = ">=1.2.2" }, { name = "redis", extras = ["hiredis"], specifier = ">=7.4.0" }, { name = "requests", specifier = ">=2.33.0" }, + { name = "ruff", specifier = ">=0.15.8" }, { name = "streamlit", specifier = ">=1.55.0" }, { name = "uvicorn", specifier = ">=0.42.0" }, ] @@ -1048,6 +1052,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, ] +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -1311,6 +1327,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" }, ] +[[package]] +name = "ruff" +version = "0.15.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/14/b0/73cf7550861e2b4824950b8b52eebdcc5adc792a00c514406556c5b80817/ruff-0.15.8.tar.gz", hash = "sha256:995f11f63597ee362130d1d5a327a87cb6f3f5eae3094c620bcc632329a4d26e", size = 4610921, upload-time = "2026-03-26T18:39:38.675Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/92/c445b0cd6da6e7ae51e954939cb69f97e008dbe750cfca89b8cedc081be7/ruff-0.15.8-py3-none-linux_armv6l.whl", hash = "sha256:cbe05adeba76d58162762d6b239c9056f1a15a55bd4b346cfd21e26cd6ad7bc7", size = 10527394, upload-time = "2026-03-26T18:39:41.566Z" }, + { url = "https://files.pythonhosted.org/packages/eb/92/f1c662784d149ad1414cae450b082cf736430c12ca78367f20f5ed569d65/ruff-0.15.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d3e3d0b6ba8dca1b7ef9ab80a28e840a20070c4b62e56d675c24f366ef330570", size = 10905693, upload-time = "2026-03-26T18:39:30.364Z" }, + { url = "https://files.pythonhosted.org/packages/ca/f2/7a631a8af6d88bcef997eb1bf87cc3da158294c57044aafd3e17030613de/ruff-0.15.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6ee3ae5c65a42f273f126686353f2e08ff29927b7b7e203b711514370d500de3", size = 10323044, upload-time = "2026-03-26T18:39:33.37Z" }, + { url = "https://files.pythonhosted.org/packages/67/18/1bf38e20914a05e72ef3b9569b1d5c70a7ef26cd188d69e9ca8ef588d5bf/ruff-0.15.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdce027ada77baa448077ccc6ebb2fa9c3c62fd110d8659d601cf2f475858d94", size = 10629135, upload-time = "2026-03-26T18:39:44.142Z" }, + { url = "https://files.pythonhosted.org/packages/d2/e9/138c150ff9af60556121623d41aba18b7b57d95ac032e177b6a53789d279/ruff-0.15.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:12e617fc01a95e5821648a6df341d80456bd627bfab8a829f7cfc26a14a4b4a3", size = 10348041, upload-time = "2026-03-26T18:39:52.178Z" }, + { url = "https://files.pythonhosted.org/packages/02/f1/5bfb9298d9c323f842c5ddeb85f1f10ef51516ac7a34ba446c9347d898df/ruff-0.15.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:432701303b26416d22ba696c39f2c6f12499b89093b61360abc34bcc9bf07762", size = 11121987, upload-time = "2026-03-26T18:39:55.195Z" }, + { url = "https://files.pythonhosted.org/packages/10/11/6da2e538704e753c04e8d86b1fc55712fdbdcc266af1a1ece7a51fff0d10/ruff-0.15.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d910ae974b7a06a33a057cb87d2a10792a3b2b3b35e33d2699fdf63ec8f6b17a", size = 11951057, upload-time = "2026-03-26T18:39:19.18Z" }, + { url = "https://files.pythonhosted.org/packages/83/f0/c9208c5fd5101bf87002fed774ff25a96eea313d305f1e5d5744698dc314/ruff-0.15.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2033f963c43949d51e6fdccd3946633c6b37c484f5f98c3035f49c27395a8ab8", size = 11464613, upload-time = "2026-03-26T18:40:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/f8/22/d7f2fabdba4fae9f3b570e5605d5eb4500dcb7b770d3217dca4428484b17/ruff-0.15.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f29b989a55572fb885b77464cf24af05500806ab4edf9a0fd8977f9759d85b1", size = 11257557, upload-time = "2026-03-26T18:39:57.972Z" }, + { url = "https://files.pythonhosted.org/packages/71/8c/382a9620038cf6906446b23ce8632ab8c0811b8f9d3e764f58bedd0c9a6f/ruff-0.15.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:ac51d486bf457cdc985a412fb1801b2dfd1bd8838372fc55de64b1510eff4bec", size = 11169440, upload-time = "2026-03-26T18:39:22.205Z" }, + { url = "https://files.pythonhosted.org/packages/4d/0d/0994c802a7eaaf99380085e4e40c845f8e32a562e20a38ec06174b52ef24/ruff-0.15.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c9861eb959edab053c10ad62c278835ee69ca527b6dcd72b47d5c1e5648964f6", size = 10605963, upload-time = "2026-03-26T18:39:46.682Z" }, + { url = "https://files.pythonhosted.org/packages/19/aa/d624b86f5b0aad7cef6bbf9cd47a6a02dfdc4f72c92a337d724e39c9d14b/ruff-0.15.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8d9a5b8ea13f26ae90838afc33f91b547e61b794865374f114f349e9036835fb", size = 10357484, upload-time = "2026-03-26T18:39:49.176Z" }, + { url = "https://files.pythonhosted.org/packages/35/c3/e0b7835d23001f7d999f3895c6b569927c4d39912286897f625736e1fd04/ruff-0.15.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c2a33a529fb3cbc23a7124b5c6ff121e4d6228029cba374777bd7649cc8598b8", size = 10830426, upload-time = "2026-03-26T18:40:03.702Z" }, + { url = "https://files.pythonhosted.org/packages/f0/51/ab20b322f637b369383adc341d761eaaa0f0203d6b9a7421cd6e783d81b9/ruff-0.15.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:75e5cd06b1cf3f47a3996cfc999226b19aa92e7cce682dcd62f80d7035f98f49", size = 11345125, upload-time = "2026-03-26T18:39:27.799Z" }, + { url = "https://files.pythonhosted.org/packages/37/e6/90b2b33419f59d0f2c4c8a48a4b74b460709a557e8e0064cf33ad894f983/ruff-0.15.8-py3-none-win32.whl", hash = "sha256:bc1f0a51254ba21767bfa9a8b5013ca8149dcf38092e6a9eb704d876de94dc34", size = 10571959, upload-time = "2026-03-26T18:39:36.117Z" }, + { url = "https://files.pythonhosted.org/packages/1f/a2/ef467cb77099062317154c63f234b8a7baf7cb690b99af760c5b68b9ee7f/ruff-0.15.8-py3-none-win_amd64.whl", hash = "sha256:04f79eff02a72db209d47d665ba7ebcad609d8918a134f86cb13dd132159fc89", size = 11743893, upload-time = "2026-03-26T18:39:25.01Z" }, + { url = "https://files.pythonhosted.org/packages/15/e2/77be4fff062fa78d9b2a4dea85d14785dac5f1d0c1fb58ed52331f0ebe28/ruff-0.15.8-py3-none-win_arm64.whl", hash = "sha256:cf891fa8e3bb430c0e7fac93851a5978fc99c8fa2c053b57b118972866f8e5f2", size = 11048175, upload-time = "2026-03-26T18:40:01.06Z" }, +] + [[package]] name = "sentry-sdk" version = "2.56.0"