Compare commits
10 Commits
a5f96d0710
...
35314f4f8b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35314f4f8b | ||
|
|
49ef7997a2 | ||
|
|
6c07d47b1b | ||
|
|
ef769e40b6 | ||
|
|
b1087405d1 | ||
|
|
d6870f814e | ||
|
|
fc3f311e56 | ||
|
|
46be7cabb8 | ||
|
|
cd2f13caf2 | ||
|
|
5fc035b5df |
9
.gitignore
vendored
9
.gitignore
vendored
@ -1 +1,10 @@
|
||||
.aider*
|
||||
|
||||
.venv
|
||||
|
||||
__pycache__
|
||||
*.pyc
|
||||
|
||||
.mypy_cache
|
||||
|
||||
.env
|
||||
|
||||
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
|
||||
}
|
||||
55
.vscode/tasks.json
vendored
Normal file
55
.vscode/tasks.json
vendored
Normal file
@ -0,0 +1,55 @@
|
||||
{
|
||||
// See https://go.microsoft.com/fwlink/?LinkId=733558
|
||||
// for the documentation about the tasks.json format
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "Run Mypy",
|
||||
"type": "shell",
|
||||
"command": "uv run mypy . --no-incremental",
|
||||
"problemMatcher": [
|
||||
"$python"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Isort Check",
|
||||
"type": "shell",
|
||||
"command": "uv run isort . --check --diff",
|
||||
"problemMatcher": [
|
||||
"$python"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Isort Fix",
|
||||
"type": "shell",
|
||||
"command": "uv run isort .",
|
||||
"problemMatcher": [
|
||||
"$python"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "AutoPEP8 Check",
|
||||
"type": "shell",
|
||||
"command": "uv run autopep8 app/**/*.py --diff",
|
||||
"problemMatcher": [
|
||||
"$python"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "AutoPEP8 Fix",
|
||||
"type": "shell",
|
||||
"command": "uv run autopep8 app/**/*.py --in-place",
|
||||
"problemMatcher": [
|
||||
"$python"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Autoflake Fix",
|
||||
"type": "shell",
|
||||
"command": "uv run autoflake -i app/**/*.py",
|
||||
"problemMatcher": [
|
||||
"$python"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
26
README.md
26
README.md
@ -0,0 +1,26 @@
|
||||
# Loan Operations API (loapi)
|
||||
|
||||
A FastAPI-based loan operations management system.
|
||||
|
||||
## Features
|
||||
|
||||
- RESTful API for loan operations
|
||||
- Automatic API documentation
|
||||
- Request/response validation
|
||||
- Middleware for logging and error handling
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
uv sync
|
||||
|
||||
# Run the development server
|
||||
uv run python main.py
|
||||
```
|
||||
|
||||
## API Documentation
|
||||
|
||||
Once running, visit:
|
||||
- Swagger UI: http://localhost:8000/docs
|
||||
- ReDoc: http://localhost:8000/redoc
|
||||
0
app/__init__.py
Normal file
0
app/__init__.py
Normal file
34
app/config.py
Normal file
34
app/config.py
Normal file
@ -0,0 +1,34 @@
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
env: str = Field(default='DEV', alias="ENV")
|
||||
|
||||
# Server settings
|
||||
host: str = Field(default="0.0.0.0", alias="HOST")
|
||||
port: int = Field(default=8000, alias="PORT")
|
||||
debug: bool = Field(default=False, alias="DEBUG")
|
||||
log_level: str = Field(default="INFO", alias="LOG_LEVEL")
|
||||
|
||||
# Database settings
|
||||
oracle_user: str = Field(alias="ORACLE_USER")
|
||||
oracle_password: str = Field(alias="ORACLE_PASSWORD")
|
||||
oracle_dsn: str = Field(alias="ORACLE_DSN")
|
||||
|
||||
# Sentry settings
|
||||
sentry_dsn: str | None = Field(default=None, alias="SENTRY_DSN")
|
||||
|
||||
# Datadog settings
|
||||
dd_service: str = Field(default="loapi", alias="DD_SERVICE")
|
||||
dd_env: str = Field(default="development", alias="DD_ENV")
|
||||
dd_version: str = Field(default="1.0.0", alias="DD_VERSION")
|
||||
|
||||
model_config = {
|
||||
"env_file": ".env",
|
||||
"case_sensitive": False
|
||||
}
|
||||
|
||||
|
||||
settings = Settings() # type:ignore[call-arg]
|
||||
50
app/database.py
Normal file
50
app/database.py
Normal file
@ -0,0 +1,50 @@
|
||||
"""Database configuration and connection management."""
|
||||
from typing import Generator
|
||||
|
||||
from sqlalchemy import MetaData, create_engine
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""Base class for SQLAlchemy models."""
|
||||
metadata = MetaData()
|
||||
|
||||
|
||||
# Database URL construction
|
||||
DATABASE_URL = f"oracle+oracledb://{settings.oracle_user}:{settings.oracle_password}@{settings.oracle_dsn}"
|
||||
|
||||
# Synchronous engine for Oracle
|
||||
engine = create_engine(
|
||||
DATABASE_URL,
|
||||
echo=settings.debug,
|
||||
pool_pre_ping=True,
|
||||
pool_recycle=3600,
|
||||
)
|
||||
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
|
||||
def get_db() -> Generator[Session, None]:
|
||||
"""Dependency to get database session."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
class DatabaseService:
|
||||
"""Database service for health checks and utilities."""
|
||||
|
||||
@staticmethod
|
||||
def health_check() -> bool:
|
||||
"""Check database connectivity."""
|
||||
try:
|
||||
with SessionLocal() as db:
|
||||
db.execute("SELECT 1 FROM DUAL") # type:ignore[call-overload]
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
42
app/logging.py
Normal file
42
app/logging.py
Normal file
@ -0,0 +1,42 @@
|
||||
"""Logging configuration with structured logging."""
|
||||
import logging
|
||||
import sys
|
||||
|
||||
import structlog
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
def configure_logging() -> None:
|
||||
"""Configure structured logging."""
|
||||
|
||||
structlog.configure(
|
||||
processors=[
|
||||
structlog.stdlib.filter_by_level,
|
||||
structlog.stdlib.add_logger_name,
|
||||
structlog.stdlib.add_log_level,
|
||||
structlog.stdlib.PositionalArgumentsFormatter(),
|
||||
structlog.processors.TimeStamper(fmt="iso"),
|
||||
structlog.processors.StackInfoRenderer(),
|
||||
structlog.processors.format_exc_info,
|
||||
structlog.processors.UnicodeDecoder(),
|
||||
structlog.processors.JSONRenderer(
|
||||
) if not settings.debug else structlog.dev.ConsoleRenderer(),
|
||||
],
|
||||
context_class=dict,
|
||||
logger_factory=structlog.stdlib.LoggerFactory(),
|
||||
wrapper_class=structlog.stdlib.BoundLogger,
|
||||
cache_logger_on_first_use=True,
|
||||
)
|
||||
|
||||
# Configure standard logging
|
||||
logging.basicConfig(
|
||||
format="%(message)s",
|
||||
stream=sys.stdout,
|
||||
level=getattr(logging, settings.log_level.upper()),
|
||||
)
|
||||
|
||||
|
||||
def get_logger(name: str) -> structlog.BoundLogger:
|
||||
"""Get a configured logger instance."""
|
||||
return structlog.get_logger(name) # type:ignore[no-any-return]
|
||||
36
app/middleware.py
Normal file
36
app/middleware.py
Normal file
@ -0,0 +1,36 @@
|
||||
from typing import Callable, Any
|
||||
|
||||
from fastapi import Request, Response
|
||||
from slowapi import Limiter, _rate_limit_exceeded_handler
|
||||
from slowapi.errors import RateLimitExceeded
|
||||
from slowapi.middleware import SlowAPIMiddleware
|
||||
from slowapi.util import get_remote_address
|
||||
|
||||
from app.config import settings
|
||||
from app.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Rate limiter
|
||||
limiter = Limiter(key_func=get_remote_address)
|
||||
|
||||
|
||||
async def logging_middleware(request: Request, call_next: Callable[[Request], Any]) -> Any:
|
||||
"""Log all requests and responses."""
|
||||
logger.debug(
|
||||
"Request received",
|
||||
method=request.method,
|
||||
url=str(request.url),
|
||||
client_ip=get_remote_address(request)
|
||||
)
|
||||
|
||||
response = await call_next(request)
|
||||
|
||||
logger.debug(
|
||||
"Response sent",
|
||||
status_code=response.status_code,
|
||||
method=request.method,
|
||||
url=str(request.url)
|
||||
)
|
||||
|
||||
return response
|
||||
0
app/resources/__init__.py
Normal file
0
app/resources/__init__.py
Normal file
30
app/resources/health.py
Normal file
30
app/resources/health.py
Normal file
@ -0,0 +1,30 @@
|
||||
"""Health check endpoints."""
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.database import DatabaseService
|
||||
from app.logging import get_logger
|
||||
|
||||
router = APIRouter()
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
@router.get("/health-check")
|
||||
async def health_check() -> dict[str, str]:
|
||||
"""Comprehensive health check endpoint."""
|
||||
|
||||
# Check database connectivity
|
||||
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"
|
||||
|
||||
result = {
|
||||
"status": status,
|
||||
"service": "loapi",
|
||||
"database": "connected" if db_healthy else "disconnected"
|
||||
}
|
||||
|
||||
return result
|
||||
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
|
||||
16
example.env
Normal file
16
example.env
Normal file
@ -0,0 +1,16 @@
|
||||
# Server settings
|
||||
HOST=0.0.0.0
|
||||
PORT=8000
|
||||
DEBUG=true
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
# Database settings
|
||||
ORACLE_USER=your_oracle_user
|
||||
ORACLE_PASSWORD=your_oracle_password
|
||||
ORACLE_DSN=your_oracle_dsn
|
||||
|
||||
# Monitoring
|
||||
SENTRY_DSN=
|
||||
DD_SERVICE=
|
||||
DD_ENV=
|
||||
DD_VERSION=
|
||||
65
main.py
65
main.py
@ -1,6 +1,67 @@
|
||||
def main():
|
||||
print("Hello from loapi!")
|
||||
"""
|
||||
Main entry point for the Loan Operations API.
|
||||
"""
|
||||
import sentry_sdk
|
||||
from ddtrace import patch_all
|
||||
from fastapi import APIRouter, FastAPI
|
||||
from sentry_sdk.integrations.fastapi import FastApiIntegration
|
||||
|
||||
from app.config import settings
|
||||
from app.logging import configure_logging, get_logger
|
||||
from app.middleware import logging_middleware
|
||||
from app.resources import health, payoff
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
"""Create and configure the FastAPI application."""
|
||||
|
||||
# Configure monitoring
|
||||
if settings.sentry_dsn:
|
||||
sentry_sdk.init(
|
||||
dsn=settings.sentry_dsn,
|
||||
integrations=[FastApiIntegration()],
|
||||
traces_sample_rate=1.0,
|
||||
environment=settings.dd_env,
|
||||
)
|
||||
|
||||
if settings.dd_service:
|
||||
# Configure Datadog tracing
|
||||
patch_all()
|
||||
|
||||
# Configure logging
|
||||
configure_logging()
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Create FastAPI app
|
||||
app = FastAPI(
|
||||
title="Loan Operations API",
|
||||
description="SBA Loan Operations API",
|
||||
version="1.0.0",
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
)
|
||||
|
||||
# Include all endpoint routers
|
||||
app.include_router(health.router, tags=["health"])
|
||||
app.include_router(payoff.router, tags=["payoff"])
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Run the application."""
|
||||
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run(
|
||||
app,
|
||||
host=settings.host,
|
||||
port=settings.port,
|
||||
log_level=settings.log_level.lower(),
|
||||
reload=settings.debug,
|
||||
)
|
||||
|
||||
app = create_app()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
@ -3,5 +3,63 @@ name = "loapi"
|
||||
version = "1.0.0"
|
||||
description = "Loan Operations API"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = []
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"fastapi>=0.104.0",
|
||||
"uvicorn[standard]>=0.24.0",
|
||||
"sqlalchemy>=2.0.0",
|
||||
"oracledb>=1.4.0",
|
||||
"pydantic>=2.5.0",
|
||||
"pydantic-settings>=2.1.0",
|
||||
"slowapi>=0.1.9",
|
||||
"sentry-sdk[fastapi]>=1.38.0",
|
||||
"ddtrace>=2.5.0",
|
||||
"structlog>=23.2.0",
|
||||
"alembic>=1.13.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=7.4.0",
|
||||
"pytest-asyncio>=0.21.0",
|
||||
"pytest-cov>=4.1.0",
|
||||
"mypy>=1.7.0",
|
||||
"pylint>=3.0.0",
|
||||
"autopep8>=2.0.0",
|
||||
"isort>=5.12.0",
|
||||
"types-requests>=2.31.0",
|
||||
"httpx>=0.25.0",
|
||||
]
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.14"
|
||||
strict = true
|
||||
warn_return_any = true
|
||||
warn_unused_configs = true
|
||||
disallow_untyped_defs = true
|
||||
exclude = ["tests"]
|
||||
|
||||
[tool.pylint.messages_control]
|
||||
max-line-length = 88
|
||||
disable = ["C0114", "C0116"]
|
||||
|
||||
[tool.isort]
|
||||
profile = "black"
|
||||
multi_line_output = 3
|
||||
line_length = 88
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
pythonpath = ["."]
|
||||
testpaths = ["tests"]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"autoflake>=2.3.1",
|
||||
"autopep8>=2.3.2",
|
||||
"httpx>=0.28.1",
|
||||
"isort>=7.0.0",
|
||||
"mypy>=1.19.1",
|
||||
"pylint-pydantic>=0.4.1",
|
||||
"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()
|
||||
Loading…
x
Reference in New Issue
Block a user