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
2 changes: 1 addition & 1 deletion .github/workflows/python-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ jobs:
enable-cache: true

- name: Install dependencies
run: uv sync --frozen
run: uv sync --frozen --extra dev

- name: Lint
run: |
Expand Down
Empty file added api/__init__.py
Empty file.
9 changes: 7 additions & 2 deletions api/core/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,17 @@ def _normalize_cache_value(self, value):

return str(value)

def _is_cacheable(self, value):
return isinstance(value, (str, int, float, bool, type(None), list, dict))

def cacheable(self, expire: Callable[[], int] | int = 3600):
def decorator(func):
@wraps(func)
async def wrapper(*args, **kwargs):
clean_kwargs = {
k: self._normalize_cache_value(v) for k, v in kwargs.items()
k: self._normalize_cache_value(v)
for k, v in kwargs.items()
if self._is_cacheable(v)
}
Comment thread
WillianSilva51 marked this conversation as resolved.

cache_key = f"{func.__name__}:{dumps(clean_kwargs, sort_keys=True)}"
Expand All @@ -72,7 +77,7 @@ async def wrapper(*args, **kwargs):

result = await func(*args, **kwargs)

if result:
if result is not None:
serializable_result = [
model.model_dump(mode="json") for model in result
]
Expand Down
2 changes: 1 addition & 1 deletion api/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ class Settings(BaseSettings):
API_KEY: str

model_config = SettingsConfigDict(
env_file=".env", env_file_encoding="utf-8", extra="ignore"
env_file="api/.env", env_file_encoding="utf-8", extra="ignore"
)


Expand Down
4 changes: 2 additions & 2 deletions api/core/database.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
from beanie import init_beanie
from models.quote import Quote
from api.models.quote import Quote
from pymongo import AsyncMongoClient

from core.config import settings
from api.core.config import settings

client: AsyncMongoClient | None = None

Expand Down
3 changes: 3 additions & 0 deletions api/core/exceptions/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from . import custom_exceptions

__all__ = ["custom_exceptions"]
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
class ResourceNotFoundException(Exception):
def __init__(self, message: str) -> None:
super().__init__(message)
self.message = message


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

__all__ = ["exception_handlers"]
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@
from fastapi.exceptions import RequestValidationError, HTTPException
from fastapi.responses import JSONResponse
from loguru import logger
from schemas.error_schema import ErrorResponse
from api.schemas.error_schema import ErrorResponse

from .custom_exceptions import DomainValidationException, ResourceNotFoundException
from api.core.exceptions.custom_exceptions import (
DomainValidationException,
ResourceNotFoundException,
)


def _json_error_response(error: ErrorResponse) -> JSONResponse:
Expand Down Expand Up @@ -57,7 +60,7 @@ async def http_handler(_: Request, exc: HTTPException) -> JSONResponse:


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

return _json_error_response(
ErrorResponse.from_http_status(
Expand Down
3 changes: 0 additions & 3 deletions api/exceptions/__init__.py

This file was deleted.

17 changes: 13 additions & 4 deletions api/main.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
from contextlib import asynccontextmanager

from core.database import init_db
from api.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 fastapi.middleware.cors import CORSMiddleware
from api.routers.quotes import api_router as quotes_router

from exceptions.custom_exceptions import (
from api.core.exceptions.custom_exceptions import (
DomainValidationException,
ResourceNotFoundException,
)
from exceptions.handlers import (
from api.core.handlers.exception_handlers import (
http_handler,
domain_validation_handler,
resource_not_found_handler,
Expand Down Expand Up @@ -60,6 +61,14 @@ async def lifespan(_: FastAPI):
lifespan=lifespan,
)

app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
Comment thread
WillianSilva51 marked this conversation as resolved.
allow_methods=["*"],
allow_headers=["*"],
)

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
Expand Down
11 changes: 8 additions & 3 deletions api/repositories/quote_repository.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
from models.quote import Quote
from schemas.quote_schema import CreateQuoteRequest
from api.models.quote import Quote
from api.schemas.quote_schema import CreateQuoteRequest


class QuoteRepository:
async def create(self, quote_data: CreateQuoteRequest) -> Quote:
new_quote = Quote(**quote_data.model_dump())
new_quote = Quote.model_validate(quote_data.model_dump())

return await new_quote.insert()

Expand All @@ -25,3 +25,8 @@ async def get_random_quote(self, size: int) -> list[Quote]:
result = await Quote.aggregate(agregation).to_list()

return [Quote.model_validate(doc) for doc in result]

async def get_quote_by_content_and_author(
self, content: str, author: str
) -> Quote | None:
return await Quote.find_one({"content": content, "author": author})
20 changes: 10 additions & 10 deletions api/routers/quotes.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
from http import HTTPStatus

from core.cache import RedisCache
from core.security import verify_api_key
from api.core.cache import RedisCache
from api.core.security import verify_api_key
from fastapi import APIRouter, Depends, Query
from models.enums import CategoryQuote
from models.quote import Quote
from repositories.quote_repository import QuoteRepository
from schemas.pagination import PaginatedResponse
from schemas.quote_schema import CreateQuoteRequest, QuoteResponse
from services.quote_service import QuoteService
from utils.utils import expiration_midnight
from api.models.enums import CategoryQuote
from api.models.quote import Quote
from api.repositories.quote_repository import QuoteRepository
from api.schemas.pagination import PaginatedResponse
from api.schemas.quote_schema import CreateQuoteRequest, QuoteResponse
from api.services.quote_service import QuoteService
from api.utils.utils import expiration_midnight

api_router = APIRouter(prefix="/v1/quotes", tags=["frases"])
cache = RedisCache()
Expand All @@ -28,7 +28,7 @@ async def post_quote(
new_quote: CreateQuoteRequest,
service: QuoteService = Depends(),
repo: QuoteRepository = Depends(),
_: str | None = Depends(verify_api_key),
_: str = Depends(verify_api_key),
) -> Quote:
return await service.create_quote(quote=new_quote, repo=repo)

Expand Down
2 changes: 1 addition & 1 deletion api/schemas/quote_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from typing import Annotated

from beanie import PydanticObjectId
from models.enums import CategoryQuote
from api.models.enums import CategoryQuote
from pydantic import BaseModel, Field, ConfigDict


Expand Down
19 changes: 14 additions & 5 deletions api/services/quote_service.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
from models.enums import CategoryQuote
from models.quote import Quote
from repositories.quote_repository import QuoteRepository
from schemas.quote_schema import (
from api.core.exceptions.custom_exceptions import DomainValidationException
from api.models.enums import CategoryQuote
from api.models.quote import Quote
from api.repositories.quote_repository import QuoteRepository
from api.schemas.quote_schema import (
CreateQuoteRequest,
)
from loguru import logger
Expand All @@ -13,6 +14,14 @@ async def create_quote(
) -> Quote:
logger.info(f"Criando nova citação: {quote}")

if (
await repo.get_quote_by_content_and_author(quote.content, quote.author)
is not None
):
raise DomainValidationException(
f"A frase '{quote.content}' do autor '{quote.author}' já existe"
)

new_quote = await repo.create(quote)

return new_quote
Expand All @@ -35,7 +44,7 @@ async def get_all(
if source:
filters["source"] = source
if tags:
filters = {"$in": tags}
filters["tags"] = {"$in": tags}

logger.info(
f"Obtendo citações com filtros: {filters}, limit: {limit}, skip: {skip}"
Expand Down
4 changes: 2 additions & 2 deletions api/tests/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
from . import test_quote_service
from . import conftest, test_quote_service

__all__ = ["test_quote_service"]
__all__ = ["conftest", "test_quote_service"]
10 changes: 10 additions & 0 deletions api/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import os
import pytest

os.environ["MONGO_URI"] = "mongodb://localhost:27017/testdb"
os.environ["API_KEY"] = "fake_test_key"


@pytest.fixture(autouse=True, scope="session")
def setup_test_env():
pass
75 changes: 58 additions & 17 deletions api/tests/test_quote_service.py
Original file line number Diff line number Diff line change
@@ -1,25 +1,28 @@
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
from api.models.enums import CategoryQuote
from api.models.quote import Quote
from api.repositories.quote_repository import QuoteRepository
from api.schemas.quote_schema import CreateQuoteRequest
from api.services.quote_service import QuoteService
from api.core.exceptions.custom_exceptions import (
DomainValidationException,
)


@pytest.fixture
def service():
def service() -> QuoteService:
return QuoteService()


@pytest.fixture
def mock_repo():
def mock_repo() -> AsyncMock:
return AsyncMock(spec=QuoteRepository)


@pytest.fixture
def valid_request_data():
def valid_request_data() -> CreateQuoteRequest:
return CreateQuoteRequest(
content="Investir é sobre ter paciência.",
author="Warren Buffett",
Expand All @@ -29,17 +32,24 @@ def valid_request_data():
)


@pytest.fixture
def quote(valid_request_data):
return 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,
)


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,
)
async def test_create_quote_success(
self, service, mock_repo, valid_request_data, quote
):

mock_repo.get_quote_by_content_and_author.return_value = None
mock_repo.create.return_value = quote

result = await service.create_quote(quote=valid_request_data, repo=mock_repo)
Expand All @@ -50,4 +60,35 @@ async def test_create_quote_sucess(self, service, mock_repo, valid_request_data)
assert result.source == valid_request_data.source
assert result.verified == valid_request_data.verified

mock_repo.create.assert_called_once_with(valid_request_data)
mock_repo.get_quote_by_content_and_author.assert_awaited_once_with(
valid_request_data.content, valid_request_data.author
)
mock_repo.create.assert_awaited_once_with(valid_request_data)

@pytest.mark.asyncio
async def test_create_quote_failure(self, service, mock_repo, valid_request_data):
expected_message = f"A frase '{valid_request_data.content}' do autor '{valid_request_data.author}' já existe"

mock_repo.get_quote_by_content_and_author.return_value = 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,
)

with pytest.raises(DomainValidationException) as exc_info:
await service.create_quote(quote=valid_request_data, repo=mock_repo)

assert str(exc_info.value) == expected_message

mock_repo.get_quote_by_content_and_author.assert_awaited_once_with(
valid_request_data.content, valid_request_data.author
)
mock_repo.create.assert_not_awaited()


class TestGetAllQuotes:
@pytest.mark.asyncio
async def test_get_all_success(self, service, mock_repo):
pass
Comment thread
WillianSilva51 marked this conversation as resolved.
9 changes: 6 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,15 @@ dependencies = [
"fastapi[standard]>=0.135.2",
"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",
]
[project.optional-dependencies]
dev = [
"pytest>=9.0.2",
"pytest-asyncio>=1.3.0",
"ruff>=0.15.8",
]
Loading