Compare commits

..

1 Commits

Author SHA1 Message Date
Timothy Farrell
0d3558da31 Add rate-limiting but it doesn't work. 2025-12-28 19:01:46 -06:00
10 changed files with 31 additions and 167 deletions

View File

@ -1,7 +0,0 @@
{
"python.testing.pytestArgs": [
"tests"
],
"python.testing.unittestEnabled": false,
"python.testing.pytestEnabled": true
}

2
.vscode/tasks.json vendored
View File

@ -6,7 +6,7 @@
{ {
"label": "Run Mypy", "label": "Run Mypy",
"type": "shell", "type": "shell",
"command": "uv run mypy . --no-incremental", "command": "uv run mypy .",
"problemMatcher": [ "problemMatcher": [
"$python" "$python"
] ]

View File

@ -25,10 +25,14 @@ class Settings(BaseSettings):
dd_env: str = Field(default="development", alias="DD_ENV") dd_env: str = Field(default="development", alias="DD_ENV")
dd_version: str = Field(default="1.0.0", alias="DD_VERSION") dd_version: str = Field(default="1.0.0", alias="DD_VERSION")
model_config = { # Rate limiting settings
"env_file": ".env", rate_limit_enabled: bool = Field(default=True, alias="RATE_LIMIT_ENABLED")
"case_sensitive": False rate_limit_requests: int = Field(default=100, alias="RATE_LIMIT_REQUESTS")
} rate_limit_period: str = Field(default="1/minute", alias="RATE_LIMIT_PERIOD")
class Config:
env_file = ".env"
case_sensitive = False
settings = Settings() # type:ignore[call-arg] settings = Settings() # type:ignore[call-arg]

View File

@ -1,4 +1,4 @@
from typing import Callable, Any from typing import Callable
from fastapi import Request, Response from fastapi import Request, Response
from slowapi import Limiter, _rate_limit_exceeded_handler from slowapi import Limiter, _rate_limit_exceeded_handler
@ -12,10 +12,13 @@ from app.logging import get_logger
logger = get_logger(__name__) logger = get_logger(__name__)
# Rate limiter # Rate limiter
limiter = Limiter(key_func=get_remote_address) limiter = Limiter(
key_func=get_remote_address,
default_limits=[f"{settings.rate_limit_requests}/{settings.rate_limit_period}"] if settings.rate_limit_enabled else []
)
async def logging_middleware(request: Request, call_next: Callable[[Request], Any]) -> Any: async def logging_middleware(request: Request, call_next: Callable) -> Response:
"""Log all requests and responses.""" """Log all requests and responses."""
logger.debug( logger.debug(
"Request received", "Request received",

View File

@ -2,10 +2,8 @@
from fastapi import APIRouter from fastapi import APIRouter
from app.database import DatabaseService from app.database import DatabaseService
from app.logging import get_logger
router = APIRouter() router = APIRouter()
logger = get_logger(__name__)
@router.get("/health-check") @router.get("/health-check")
@ -13,11 +11,7 @@ async def health_check() -> dict[str, str]:
"""Comprehensive health check endpoint.""" """Comprehensive health check endpoint."""
# Check database connectivity # Check database connectivity
try:
db_healthy = DatabaseService.health_check() db_healthy = DatabaseService.health_check()
except Exception as e:
logger.error("Database health check failed", exc_info=e)
db_healthy = False
status = "healthy" if db_healthy else "unhealthy" status = "healthy" if db_healthy else "unhealthy"

View File

@ -1,73 +0,0 @@
"""Pay-off endpoints."""
from datetime import date
from decimal import Decimal
from enum import Enum
from typing import Optional
from fastapi import APIRouter
from pydantic import BaseModel, Field, validator
from app.logging import get_logger
router = APIRouter()
logger = get_logger(__name__)
class PayoffStatus(str, Enum):
"""Enum for payoff status values."""
PROCESSED = "processed"
PENDING = "pending"
FAILED = "failed"
CANCELLED = "cancelled"
class PayoffRequest(BaseModel):
"""Request model for loan payoff."""
loan_id: str = Field(..., description="Loan identifier", min_length=1, max_length=50)
payoff_amount: Decimal = Field(..., description="Payoff amount", gt=0, decimal_places=2)
payoff_date: date = Field(..., description="Payoff date")
borrower_name: Optional[str] = Field(None, description="Borrower name", max_length=100)
payment_method: str = Field(..., description="Payment method", regex="^(check|wire|ach|cash)$")
notes: Optional[str] = Field(None, description="Additional notes", max_length=500)
@validator('payoff_date')
def validate_payoff_date(cls, v):
"""Validate payoff date is not in the future."""
from datetime import date
if v > date.today():
raise ValueError('Payoff date cannot be in the future')
return v
class PayoffResponse(BaseModel):
"""Response model for loan payoff."""
status: PayoffStatus
loan_id: str
payoff_amount: Decimal
payoff_date: date
transaction_id: str
@router.post("/payoff", response_model=PayoffResponse)
async def create_payoff(payoff_request: PayoffRequest) -> PayoffResponse:
"""Create a loan payoff record."""
logger.info(f"Processing payoff for loan {payoff_request.loan_id}")
# TODO: Add database logic to process payoff
# For now, return a mock response
import uuid
transaction_id = str(uuid.uuid4())
response = PayoffResponse(
status=PayoffStatus.PROCESSED,
loan_id=payoff_request.loan_id,
payoff_amount=payoff_request.payoff_amount,
payoff_date=payoff_request.payoff_date,
transaction_id=transaction_id
)
logger.info(f"Payoff processed successfully for loan {payoff_request.loan_id}, transaction {transaction_id}")
return response

17
main.py
View File

@ -5,11 +5,14 @@ import sentry_sdk
from ddtrace import patch_all from ddtrace import patch_all
from fastapi import APIRouter, FastAPI from fastapi import APIRouter, FastAPI
from sentry_sdk.integrations.fastapi import FastApiIntegration from sentry_sdk.integrations.fastapi import FastApiIntegration
from slowapi import _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from slowapi.middleware import SlowAPIMiddleware
from app.config import settings from app.config import settings
from app.logging import configure_logging, get_logger from app.logging import configure_logging, get_logger
from app.middleware import logging_middleware from app.middleware import logging_middleware, limiter
from app.resources import health, payoff from app.resources import health
def create_app() -> FastAPI: def create_app() -> FastAPI:
@ -41,9 +44,17 @@ def create_app() -> FastAPI:
redoc_url="/redoc", redoc_url="/redoc",
) )
# Add rate limiting middleware if enabled
if settings.rate_limit_enabled:
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
app.add_middleware(SlowAPIMiddleware)
# Add logging middleware
app.middleware("http")(logging_middleware)
# Include all endpoint routers # Include all endpoint routers
app.include_router(health.router, tags=["health"]) app.include_router(health.router, tags=["health"])
app.include_router(payoff.router, tags=["payoff"])
return app return app

View File

@ -37,7 +37,6 @@ strict = true
warn_return_any = true warn_return_any = true
warn_unused_configs = true warn_unused_configs = true
disallow_untyped_defs = true disallow_untyped_defs = true
exclude = ["tests"]
[tool.pylint.messages_control] [tool.pylint.messages_control]
max-line-length = 88 max-line-length = 88
@ -49,17 +48,15 @@ multi_line_output = 3
line_length = 88 line_length = 88
[tool.pytest.ini_options] [tool.pytest.ini_options]
pythonpath = ["."] asyncio_mode = "auto"
testpaths = ["tests"] testpaths = ["tests"]
[dependency-groups] [dependency-groups]
dev = [ dev = [
"autoflake>=2.3.1", "autoflake>=2.3.1",
"autopep8>=2.3.2", "autopep8>=2.3.2",
"httpx>=0.28.1",
"isort>=7.0.0", "isort>=7.0.0",
"mypy>=1.19.1", "mypy>=1.19.1",
"pylint-pydantic>=0.4.1", "pylint-pydantic>=0.4.1",
"pylint[pydantic]>=4.0.4", "pylint[pydantic]>=4.0.4",
"pytest[coverage]>=9.0.2",
] ]

View File

@ -1,61 +0,0 @@
"""Tests for health check endpoints."""
import pytest
from fastapi.testclient import TestClient
from unittest.mock import patch
from main import create_app
class TestHealthCheck:
"""Test cases for health check endpoint."""
@pytest.fixture
def client(self) -> TestClient:
"""Create test client."""
app = create_app()
return TestClient(app)
@patch('app.database.DatabaseService.health_check')
def test_health_check_database_connected(self, mock_db_health, client) -> None:
"""Test health check when database is connected."""
# Mock database as healthy
mock_db_health.return_value = True
response = client.get("/health-check")
assert response.status_code == 200
data = response.json()
assert data["status"] == "healthy"
assert data["service"] == "loapi"
assert data["database"] == "connected"
mock_db_health.assert_called_once()
@patch('app.database.DatabaseService.health_check')
def test_health_check_database_disconnected(self, mock_db_health, client):
"""Test health check when database is disconnected."""
# Mock database as unhealthy
mock_db_health.return_value = False
response = client.get("/health-check")
assert response.status_code == 200
data = response.json()
assert data["status"] == "unhealthy"
assert data["service"] == "loapi"
assert data["database"] == "disconnected"
mock_db_health.assert_called_once()
@patch('app.database.DatabaseService.health_check')
def test_health_check_database_exception(self, mock_db_health, client):
"""Test health check when database check raises exception."""
# Mock database health check to raise exception
mock_db_health.side_effect = Exception("Database connection failed")
response = client.get("/health-check")
assert response.status_code == 200
data = response.json()
assert data["status"] == "unhealthy"
assert data["service"] == "loapi"
assert data["database"] == "disconnected"
mock_db_health.assert_called_once()

4
uv.lock generated
View File

@ -715,12 +715,10 @@ dev = [
dev = [ dev = [
{ name = "autoflake" }, { name = "autoflake" },
{ name = "autopep8" }, { name = "autopep8" },
{ name = "httpx" },
{ name = "isort" }, { name = "isort" },
{ name = "mypy" }, { name = "mypy" },
{ name = "pylint" }, { name = "pylint" },
{ name = "pylint-pydantic" }, { name = "pylint-pydantic" },
{ name = "pytest" },
] ]
[package.metadata] [package.metadata]
@ -752,12 +750,10 @@ provides-extras = ["dev"]
dev = [ dev = [
{ name = "autoflake", specifier = ">=2.3.1" }, { name = "autoflake", specifier = ">=2.3.1" },
{ name = "autopep8", specifier = ">=2.3.2" }, { name = "autopep8", specifier = ">=2.3.2" },
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "isort", specifier = ">=7.0.0" }, { name = "isort", specifier = ">=7.0.0" },
{ name = "mypy", specifier = ">=1.19.1" }, { name = "mypy", specifier = ">=1.19.1" },
{ name = "pylint", extras = ["pydantic"], specifier = ">=4.0.4" }, { name = "pylint", extras = ["pydantic"], specifier = ">=4.0.4" },
{ name = "pylint-pydantic", specifier = ">=0.4.1" }, { name = "pylint-pydantic", specifier = ">=0.4.1" },
{ name = "pytest", extras = ["coverage"], specifier = ">=9.0.2" },
] ]
[[package]] [[package]]