Compare commits
2 Commits
rate-limit
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35314f4f8b | ||
|
|
49ef7997a2 |
7
.vscode/settings.json
vendored
Normal file
7
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"python.testing.pytestArgs": [
|
||||||
|
"tests"
|
||||||
|
],
|
||||||
|
"python.testing.unittestEnabled": false,
|
||||||
|
"python.testing.pytestEnabled": true
|
||||||
|
}
|
||||||
2
.vscode/tasks.json
vendored
2
.vscode/tasks.json
vendored
@ -6,7 +6,7 @@
|
|||||||
{
|
{
|
||||||
"label": "Run Mypy",
|
"label": "Run Mypy",
|
||||||
"type": "shell",
|
"type": "shell",
|
||||||
"command": "uv run mypy .",
|
"command": "uv run mypy . --no-incremental",
|
||||||
"problemMatcher": [
|
"problemMatcher": [
|
||||||
"$python"
|
"$python"
|
||||||
]
|
]
|
||||||
|
|||||||
@ -25,14 +25,10 @@ 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")
|
||||||
|
|
||||||
# Rate limiting settings
|
model_config = {
|
||||||
rate_limit_enabled: bool = Field(default=True, alias="RATE_LIMIT_ENABLED")
|
"env_file": ".env",
|
||||||
rate_limit_requests: int = Field(default=100, alias="RATE_LIMIT_REQUESTS")
|
"case_sensitive": False
|
||||||
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]
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
from typing import Callable
|
from typing import Callable, Any
|
||||||
|
|
||||||
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,13 +12,10 @@ from app.logging import get_logger
|
|||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
# Rate limiter
|
# Rate limiter
|
||||||
limiter = Limiter(
|
limiter = Limiter(key_func=get_remote_address)
|
||||||
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) -> Response:
|
async def logging_middleware(request: Request, call_next: Callable[[Request], Any]) -> Any:
|
||||||
"""Log all requests and responses."""
|
"""Log all requests and responses."""
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"Request received",
|
"Request received",
|
||||||
|
|||||||
@ -2,8 +2,10 @@
|
|||||||
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")
|
||||||
@ -11,7 +13,11 @@ async def health_check() -> dict[str, str]:
|
|||||||
"""Comprehensive health check endpoint."""
|
"""Comprehensive health check endpoint."""
|
||||||
|
|
||||||
# Check database connectivity
|
# Check database connectivity
|
||||||
db_healthy = DatabaseService.health_check()
|
try:
|
||||||
|
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"
|
||||||
|
|
||||||
|
|||||||
73
app/resources/payoff.py
Normal file
73
app/resources/payoff.py
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
"""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
17
main.py
@ -5,14 +5,11 @@ 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, limiter
|
from app.middleware import logging_middleware
|
||||||
from app.resources import health
|
from app.resources import health, payoff
|
||||||
|
|
||||||
|
|
||||||
def create_app() -> FastAPI:
|
def create_app() -> FastAPI:
|
||||||
@ -44,17 +41,9 @@ 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
|
||||||
|
|
||||||
|
|||||||
@ -37,6 +37,7 @@ 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
|
||||||
@ -48,15 +49,17 @@ multi_line_output = 3
|
|||||||
line_length = 88
|
line_length = 88
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
asyncio_mode = "auto"
|
pythonpath = ["."]
|
||||||
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",
|
||||||
]
|
]
|
||||||
|
|||||||
61
tests/test_health.py
Normal file
61
tests/test_health.py
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
"""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
4
uv.lock
generated
@ -715,10 +715,12 @@ 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]
|
||||||
@ -750,10 +752,12 @@ 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]]
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user