From 3824ec8094efbc5ecc657851625c65dea037c19a Mon Sep 17 00:00:00 2001 From: Dannon Baker Date: Mon, 29 Sep 2025 19:59:49 -0400 Subject: [PATCH 01/10] feat: add backend infrastructure foundation Creates the base infrastructure layer for BRC Analytics backend: - FastAPI application with health and cache endpoints - Redis caching service with TTL management - Docker Compose setup (backend + redis + nginx) - nginx reverse proxy configuration - uv for dependency management - Ruff formatting for Python code - E2E health check tests This branch serves as the foundation for feature branches to build upon. --- .gitignore | 5 + backend/.dockerignore | 35 +++ backend/.env.example | 14 ++ backend/Dockerfile | 46 ++++ backend/README.md | 99 ++++++++ backend/app/__init__.py | 1 + backend/app/api/__init__.py | 1 + backend/app/api/v1/__init__.py | 1 + backend/app/api/v1/cache.py | 31 +++ backend/app/api/v1/health.py | 16 ++ backend/app/core/__init__.py | 1 + backend/app/core/cache.py | 118 ++++++++++ backend/app/core/config.py | 34 +++ backend/app/core/dependencies.py | 16 ++ backend/app/main.py | 31 +++ backend/app/services/__init__.py | 1 + backend/pyproject.toml | 32 +++ backend/ruff.toml | 12 + backend/uv.lock | 372 +++++++++++++++++++++++++++++++ docker-compose.yml | 58 +++++ nginx.conf | 66 ++++++ playwright.config.ts | 39 ++++ pyproject.toml | 1 + scripts/format-python.sh | 4 +- tests/e2e/03-api-health.spec.ts | 45 ++++ 25 files changed, 1077 insertions(+), 2 deletions(-) create mode 100644 backend/.dockerignore create mode 100644 backend/.env.example create mode 100644 backend/Dockerfile create mode 100644 backend/README.md create mode 100644 backend/app/__init__.py create mode 100644 backend/app/api/__init__.py create mode 100644 backend/app/api/v1/__init__.py create mode 100644 backend/app/api/v1/cache.py create mode 100644 backend/app/api/v1/health.py create mode 100644 backend/app/core/__init__.py create mode 100644 backend/app/core/cache.py create mode 100644 backend/app/core/config.py create mode 100644 backend/app/core/dependencies.py create mode 100644 backend/app/main.py create mode 100644 backend/app/services/__init__.py create mode 100644 backend/pyproject.toml create mode 100644 backend/ruff.toml create mode 100644 backend/uv.lock create mode 100644 docker-compose.yml create mode 100644 nginx.conf create mode 100644 playwright.config.ts create mode 100644 tests/e2e/03-api-health.spec.ts diff --git a/.gitignore b/.gitignore index fa70ebdb..1c26dcfc 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ npm-debug.log* /.env*.local /.env.development /.env.production +backend/.env # typescript *.tsbuildinfo @@ -32,6 +33,10 @@ npm-debug.log* # Build Dir /out +# Playwright test artifacts +/test-results +/tests/screenshots + # python venv __pycache__ diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 00000000..34842948 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,35 @@ +# Python +__pycache__ +*.pyc +*.pyo +*.pyd +.Python +.pytest_cache +.mypy_cache + +# Virtual environments +.env +.venv +env/ +venv/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Git +.git +.gitignore + +# Documentation +*.md +docs/ + +# Tests +tests/ \ No newline at end of file diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 00000000..f786a758 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,14 @@ +# Redis Configuration +REDIS_URL=redis://redis:6379/0 + +# Database Configuration (for future use) +DATABASE_URL=postgresql://user:pass@localhost/dbname + +# Application Configuration +CORS_ORIGINS=http://localhost:3000,http://localhost +LOG_LEVEL=INFO +ENVIRONMENT=development + +# Rate Limiting +RATE_LIMIT_REQUESTS=100 +RATE_LIMIT_WINDOW=60 \ No newline at end of file diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 00000000..def55cdc --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,46 @@ +# Multi-stage build for uv-based Python application +FROM python:3.12-slim as builder + +# Install uv +COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv + +WORKDIR /app + +# Copy dependency files +COPY pyproject.toml uv.lock ./ + +# Install dependencies into .venv +RUN uv sync --frozen --no-install-project + +# Production stage +FROM python:3.12-slim as runtime + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# Copy uv for runtime +COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv + +# Create non-root user +RUN useradd --create-home --shell /bin/bash app + +WORKDIR /app + +# Copy virtual environment from builder +COPY --from=builder /app/.venv /app/.venv + +# Add venv to PATH +ENV PATH="/app/.venv/bin:$PATH" + +# Copy application code +COPY . . + +# Change ownership +RUN chown -R app:app /app +USER app + +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 00000000..6f5a8d44 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,99 @@ +# BRC Analytics Backend + +FastAPI backend infrastructure for BRC Analytics. + +## Features + +- FastAPI REST API +- Redis caching with TTL support +- Health check endpoints +- Docker deployment with nginx reverse proxy +- uv for dependency management + +## Quick Start + +### Development (Local) + +```bash +cd backend +uv sync +uv run uvicorn app.main:app --reload +``` + +API documentation: http://localhost:8000/api/docs + +### Production (Docker) + +```bash +# Create environment file +cp backend/.env.example backend/.env +# Edit backend/.env if needed (defaults work for local development) + +# Start all services (nginx + backend + redis) +docker compose up -d + +# Check service health +curl http://localhost/api/v1/health + +# View logs +docker compose logs -f backend + +# Rebuild after code changes +docker compose up -d --build + +# Stop all services +docker compose down +``` + +Services: + +- nginx: http://localhost (reverse proxy) +- backend API: http://localhost:8000 (direct access) +- API docs: http://localhost/api/docs +- redis: localhost:6379 + +## API Endpoints + +### Health & Monitoring + +- `GET /api/v1/health` - Overall service health status +- `GET /api/v1/cache/health` - Redis cache connectivity check + +### Documentation + +- `GET /api/docs` - Interactive Swagger UI +- `GET /api/redoc` - ReDoc API documentation + +## Configuration + +Environment variables (see `.env.example`): + +```bash +# Redis +REDIS_URL=redis://localhost:6379/0 + +# Application +CORS_ORIGINS=http://localhost:3000,http://localhost +LOG_LEVEL=INFO +``` + +## Testing + +```bash +# Run e2e tests +npm run test:e2e + +# Or with Playwright directly +npx playwright test tests/e2e/03-api-health.spec.ts +``` + +## Architecture + +``` +nginx (port 80) + ├── /api/* → FastAPI backend (port 8000) + └── /* → Next.js static files + +FastAPI backend + └── Redis cache (port 6379) +``` diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 00000000..4c10750f --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1 @@ +# BRC Analytics Backend diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 00000000..28b07eff --- /dev/null +++ b/backend/app/api/__init__.py @@ -0,0 +1 @@ +# API package diff --git a/backend/app/api/v1/__init__.py b/backend/app/api/v1/__init__.py new file mode 100644 index 00000000..6c2f33cb --- /dev/null +++ b/backend/app/api/v1/__init__.py @@ -0,0 +1 @@ +# API v1 package diff --git a/backend/app/api/v1/cache.py b/backend/app/api/v1/cache.py new file mode 100644 index 00000000..82d8a30c --- /dev/null +++ b/backend/app/api/v1/cache.py @@ -0,0 +1,31 @@ +from fastapi import APIRouter, Depends, HTTPException + +from app.core.cache import CacheService +from app.core.dependencies import get_cache_service + +router = APIRouter() + + +@router.get("/health") +async def cache_health(cache: CacheService = Depends(get_cache_service)): + """Check if cache service is healthy""" + try: + # Try to set and get a test value + test_key = "health_check" + test_value = "ok" + + await cache.set(test_key, test_value, ttl=60) + result = await cache.get(test_key) + await cache.delete(test_key) + + if result == test_value: + return {"status": "healthy", "cache": "connected"} + else: + raise HTTPException( + status_code=503, detail="Cache service not responding correctly" + ) + + except Exception as e: + raise HTTPException( + status_code=503, detail=f"Cache service unhealthy: {str(e)}" + ) diff --git a/backend/app/api/v1/health.py b/backend/app/api/v1/health.py new file mode 100644 index 00000000..53bfd9ea --- /dev/null +++ b/backend/app/api/v1/health.py @@ -0,0 +1,16 @@ +from datetime import datetime + +from fastapi import APIRouter + +router = APIRouter() + + +@router.get("/health") +async def health_check(): + """Health check endpoint for monitoring system status""" + return { + "status": "healthy", + "version": "1.0.0", + "timestamp": datetime.utcnow().isoformat(), + "service": "BRC Analytics API", + } diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100644 index 00000000..d61a2551 --- /dev/null +++ b/backend/app/core/__init__.py @@ -0,0 +1 @@ +# Core package diff --git a/backend/app/core/cache.py b/backend/app/core/cache.py new file mode 100644 index 00000000..bcec835b --- /dev/null +++ b/backend/app/core/cache.py @@ -0,0 +1,118 @@ +import hashlib +import json +import logging +from datetime import timedelta +from typing import Any, Dict, Optional + +import redis.asyncio as redis + +logger = logging.getLogger(__name__) + + +class CacheService: + """Redis-based cache service with TTL support and key management""" + + def __init__(self, redis_url: str): + self.redis = redis.from_url(redis_url, decode_responses=True) + + async def get(self, key: str) -> Optional[Any]: + """Get a value from cache by key""" + try: + value = await self.redis.get(key) + if value: + return json.loads(value) + return None + except (redis.RedisError, json.JSONDecodeError) as e: + logger.error(f"Cache get error for key {key}: {e}") + return None + + async def set(self, key: str, value: Any, ttl: int = 3600) -> bool: + """Set a value in cache with TTL (time to live) in seconds""" + try: + serialized_value = json.dumps(value, default=str) + await self.redis.setex(key, ttl, serialized_value) + return True + except (redis.RedisError, TypeError) as e: + logger.error(f"Cache set error for key {key}: {e}") + return False + + async def delete(self, key: str) -> bool: + """Delete a key from cache""" + try: + result = await self.redis.delete(key) + return result > 0 + except redis.RedisError as e: + logger.error(f"Cache delete error for key {key}: {e}") + return False + + async def exists(self, key: str) -> bool: + """Check if key exists in cache""" + try: + return await self.redis.exists(key) > 0 + except redis.RedisError as e: + logger.error(f"Cache exists error for key {key}: {e}") + return False + + async def get_ttl(self, key: str) -> int: + """Get remaining TTL for a key (-1 if no TTL, -2 if key doesn't exist)""" + try: + return await self.redis.ttl(key) + except redis.RedisError as e: + logger.error(f"Cache TTL error for key {key}: {e}") + return -2 + + async def clear_pattern(self, pattern: str) -> int: + """Clear all keys matching a pattern""" + try: + keys = await self.redis.keys(pattern) + if keys: + return await self.redis.delete(*keys) + return 0 + except redis.RedisError as e: + logger.error(f"Cache clear pattern error for {pattern}: {e}") + return 0 + + async def get_stats(self) -> Dict[str, Any]: + """Get cache statistics""" + try: + info = await self.redis.info() + return { + "hits": info.get("keyspace_hits", 0), + "misses": info.get("keyspace_misses", 0), + "hit_rate": self._calculate_hit_rate(info), + "memory_used": info.get("used_memory_human", "0B"), + "memory_used_bytes": info.get("used_memory", 0), + "keys_count": await self.redis.dbsize(), + "connected_clients": info.get("connected_clients", 0), + } + except redis.RedisError as e: + logger.error(f"Cache stats error: {e}") + return {} + + def _calculate_hit_rate(self, info: Dict) -> float: + """Calculate cache hit rate from Redis info""" + hits = info.get("keyspace_hits", 0) + misses = info.get("keyspace_misses", 0) + total = hits + misses + return (hits / total) if total > 0 else 0.0 + + def make_key(self, prefix: str, params: Dict[str, Any]) -> str: + """Generate a cache key from prefix and parameters""" + # Sort parameters for consistent keys + param_str = json.dumps(params, sort_keys=True, default=str) + hash_val = hashlib.md5(param_str.encode()).hexdigest()[:16] + return f"{prefix}:{hash_val}" + + async def close(self): + """Close Redis connection""" + await self.redis.close() + + +# Cache TTL constants (in seconds) +class CacheTTL: + FIVE_MINUTES = 300 + ONE_HOUR = 3600 + SIX_HOURS = 21600 + ONE_DAY = 86400 + ONE_WEEK = 604800 + THIRTY_DAYS = 2592000 diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 00000000..eaa875b0 --- /dev/null +++ b/backend/app/core/config.py @@ -0,0 +1,34 @@ +import os +from functools import lru_cache +from typing import List + + +class Settings: + """Application settings loaded from environment variables""" + + # Redis settings + REDIS_URL: str = os.getenv("REDIS_URL", "redis://localhost:6379/0") + + # Database settings (for future use) + DATABASE_URL: str = os.getenv("DATABASE_URL", "") + + # CORS settings + CORS_ORIGINS: List[str] = os.getenv("CORS_ORIGINS", "http://localhost:3000").split( + "," + ) + + # Logging + LOG_LEVEL: str = os.getenv("LOG_LEVEL", "INFO") + + # Environment + ENVIRONMENT: str = os.getenv("ENVIRONMENT", "development") + + # Rate limiting + RATE_LIMIT_REQUESTS: int = int(os.getenv("RATE_LIMIT_REQUESTS", "100")) + RATE_LIMIT_WINDOW: int = int(os.getenv("RATE_LIMIT_WINDOW", "60")) # seconds + + +@lru_cache() +def get_settings() -> Settings: + """Get cached settings instance""" + return Settings() diff --git a/backend/app/core/dependencies.py b/backend/app/core/dependencies.py new file mode 100644 index 00000000..b49924b6 --- /dev/null +++ b/backend/app/core/dependencies.py @@ -0,0 +1,16 @@ +from functools import lru_cache + +from app.core.cache import CacheService +from app.core.config import get_settings + +# Global cache service instance +_cache_service = None + + +async def get_cache_service() -> CacheService: + """Dependency to get cache service instance""" + global _cache_service + if _cache_service is None: + settings = get_settings() + _cache_service = CacheService(settings.REDIS_URL) + return _cache_service diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 00000000..0beec7e0 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,31 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.api.v1 import cache, health +from app.core.config import get_settings + +settings = get_settings() + +app = FastAPI( + title="BRC Analytics API", + version="1.0.0", + docs_url="/api/docs", + redoc_url="/api/redoc", +) + +app.add_middleware( + CORSMiddleware, + allow_origins=settings.CORS_ORIGINS, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Include routers +app.include_router(health.router, prefix="/api/v1", tags=["health"]) +app.include_router(cache.router, prefix="/api/v1/cache", tags=["cache"]) + + +@app.get("/") +async def root(): + return {"message": "BRC Analytics API", "version": "1.0.0"} diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 00000000..a70b3029 --- /dev/null +++ b/backend/app/services/__init__.py @@ -0,0 +1 @@ +# Services package diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 00000000..c9474e35 --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,32 @@ +[project] +name = "brc-analytics-backend" +version = "0.1.0" +description = "FastAPI backend infrastructure for BRC Analytics" +authors = [ + {name = "BRC Team"} +] +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "fastapi>=0.116.1", + "uvicorn>=0.35.0", + "redis>=6.4.0", + "httpx>=0.28.1", + "pydantic>=2.11.7", + "python-dotenv>=1.1.1" +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.4.1", + "pytest-asyncio>=1.1.0", + "ruff>=0.13.0" +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["app"] + diff --git a/backend/ruff.toml b/backend/ruff.toml new file mode 100644 index 00000000..bf02cb1b --- /dev/null +++ b/backend/ruff.toml @@ -0,0 +1,12 @@ +# Ruff configuration for backend +# This prevents ruff from trying to parse poetry's pyproject.toml +target-version = "py312" +line-length = 88 + +[format] +quote-style = "double" +indent-style = "space" + +[lint] +select = ["E", "F", "I", "B"] +fixable = ["ALL"] diff --git a/backend/uv.lock b/backend/uv.lock new file mode 100644 index 00000000..a96f5410 --- /dev/null +++ b/backend/uv.lock @@ -0,0 +1,372 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "sniffio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/78/7d432127c41b50bccba979505f272c16cbcadcc33645d5fa3a738110ae75/anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4", size = 219094, upload-time = "2025-09-23T09:19:12.58Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097, upload-time = "2025-09-23T09:19:10.601Z" }, +] + +[[package]] +name = "brc-analytics-backend" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "fastapi" }, + { name = "httpx" }, + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "redis" }, + { name = "uvicorn" }, +] + +[package.optional-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "fastapi", specifier = ">=0.116.1" }, + { name = "httpx", specifier = ">=0.28.1" }, + { name = "pydantic", specifier = ">=2.11.7" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.4.1" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=1.1.0" }, + { name = "python-dotenv", specifier = ">=1.1.1" }, + { name = "redis", specifier = ">=6.4.0" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.13.0" }, + { name = "uvicorn", specifier = ">=0.35.0" }, +] +provides-extras = ["dev"] + +[[package]] +name = "certifi" +version = "2025.8.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/67/960ebe6bf230a96cda2e0abcf73af550ec4f090005363542f0765df162e0/certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407", size = 162386, upload-time = "2025-08-03T03:07:47.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/48/1549795ba7742c948d2ad169c1c8cdbae65bc450d6cd753d124b17c8cd32/certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5", size = 161216, upload-time = "2025-08-03T03:07:45.777Z" }, +] + +[[package]] +name = "click" +version = "8.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/61/de6cd827efad202d7057d93e0fed9294b96952e188f7384832791c7b2254/click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4", size = 276943, upload-time = "2025-09-18T17:32:23.696Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/d3/9dcc0f5797f070ec8edf30fbadfb200e71d9db6b84d211e3b2085a7589a0/click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc", size = 107295, upload-time = "2025-09-18T17:32:22.42Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "fastapi" +version = "0.118.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/3c/2b9345a6504e4055eaa490e0b41c10e338ad61d9aeaae41d97807873cdf2/fastapi-0.118.0.tar.gz", hash = "sha256:5e81654d98c4d2f53790a7d32d25a7353b30c81441be7d0958a26b5d761fa1c8", size = 310536, upload-time = "2025-09-29T03:37:23.126Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/54e2bdaad22ca91a59455251998d43094d5c3d3567c52c7c04774b3f43f2/fastapi-0.118.0-py3-none-any.whl", hash = "sha256:705137a61e2ef71019d2445b123aa8845bd97273c395b744d5a7dfe559056855", size = 97694, upload-time = "2025-09-29T03:37:21.338Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pydantic" +version = "2.11.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ff/5d/09a551ba512d7ca404d785072700d3f6727a02f6f3c24ecfd081c7cf0aa8/pydantic-2.11.9.tar.gz", hash = "sha256:6b8ffda597a14812a7975c90b82a8a2e777d9257aba3453f973acd3c032a18e2", size = 788495, upload-time = "2025-09-13T11:26:39.325Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/d3/108f2006987c58e76691d5ae5d200dd3e0f532cb4e5fa3560751c3a1feba/pydantic-2.11.9-py3-none-any.whl", hash = "sha256:c42dd626f5cfc1c6950ce6205ea58c93efa406da65f479dcb4029d5934857da2", size = 444855, upload-time = "2025-09-13T11:26:36.909Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.33.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195, upload-time = "2025-04-23T18:33:52.104Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/8a/2b41c97f554ec8c71f2a8a5f85cb56a8b0956addfe8b0efb5b3d77e8bdc3/pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc", size = 2009000, upload-time = "2025-04-23T18:31:25.863Z" }, + { url = "https://files.pythonhosted.org/packages/a1/02/6224312aacb3c8ecbaa959897af57181fb6cf3a3d7917fd44d0f2917e6f2/pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7", size = 1847996, upload-time = "2025-04-23T18:31:27.341Z" }, + { url = "https://files.pythonhosted.org/packages/d6/46/6dcdf084a523dbe0a0be59d054734b86a981726f221f4562aed313dbcb49/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025", size = 1880957, upload-time = "2025-04-23T18:31:28.956Z" }, + { url = "https://files.pythonhosted.org/packages/ec/6b/1ec2c03837ac00886ba8160ce041ce4e325b41d06a034adbef11339ae422/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011", size = 1964199, upload-time = "2025-04-23T18:31:31.025Z" }, + { url = "https://files.pythonhosted.org/packages/2d/1d/6bf34d6adb9debd9136bd197ca72642203ce9aaaa85cfcbfcf20f9696e83/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f", size = 2120296, upload-time = "2025-04-23T18:31:32.514Z" }, + { url = "https://files.pythonhosted.org/packages/e0/94/2bd0aaf5a591e974b32a9f7123f16637776c304471a0ab33cf263cf5591a/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88", size = 2676109, upload-time = "2025-04-23T18:31:33.958Z" }, + { url = "https://files.pythonhosted.org/packages/f9/41/4b043778cf9c4285d59742281a769eac371b9e47e35f98ad321349cc5d61/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1", size = 2002028, upload-time = "2025-04-23T18:31:39.095Z" }, + { url = "https://files.pythonhosted.org/packages/cb/d5/7bb781bf2748ce3d03af04d5c969fa1308880e1dca35a9bd94e1a96a922e/pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b", size = 2100044, upload-time = "2025-04-23T18:31:41.034Z" }, + { url = "https://files.pythonhosted.org/packages/fe/36/def5e53e1eb0ad896785702a5bbfd25eed546cdcf4087ad285021a90ed53/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1", size = 2058881, upload-time = "2025-04-23T18:31:42.757Z" }, + { url = "https://files.pythonhosted.org/packages/01/6c/57f8d70b2ee57fc3dc8b9610315949837fa8c11d86927b9bb044f8705419/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6", size = 2227034, upload-time = "2025-04-23T18:31:44.304Z" }, + { url = "https://files.pythonhosted.org/packages/27/b9/9c17f0396a82b3d5cbea4c24d742083422639e7bb1d5bf600e12cb176a13/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea", size = 2234187, upload-time = "2025-04-23T18:31:45.891Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6a/adf5734ffd52bf86d865093ad70b2ce543415e0e356f6cacabbc0d9ad910/pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290", size = 1892628, upload-time = "2025-04-23T18:31:47.819Z" }, + { url = "https://files.pythonhosted.org/packages/43/e4/5479fecb3606c1368d496a825d8411e126133c41224c1e7238be58b87d7e/pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2", size = 1955866, upload-time = "2025-04-23T18:31:49.635Z" }, + { url = "https://files.pythonhosted.org/packages/0d/24/8b11e8b3e2be9dd82df4b11408a67c61bb4dc4f8e11b5b0fc888b38118b5/pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab", size = 1888894, upload-time = "2025-04-23T18:31:51.609Z" }, + { url = "https://files.pythonhosted.org/packages/46/8c/99040727b41f56616573a28771b1bfa08a3d3fe74d3d513f01251f79f172/pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f", size = 2015688, upload-time = "2025-04-23T18:31:53.175Z" }, + { url = "https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6", size = 1844808, upload-time = "2025-04-23T18:31:54.79Z" }, + { url = "https://files.pythonhosted.org/packages/6f/5e/a0a7b8885c98889a18b6e376f344da1ef323d270b44edf8174d6bce4d622/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef", size = 1885580, upload-time = "2025-04-23T18:31:57.393Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2a/953581f343c7d11a304581156618c3f592435523dd9d79865903272c256a/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a", size = 1973859, upload-time = "2025-04-23T18:31:59.065Z" }, + { url = "https://files.pythonhosted.org/packages/e6/55/f1a813904771c03a3f97f676c62cca0c0a4138654107c1b61f19c644868b/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916", size = 2120810, upload-time = "2025-04-23T18:32:00.78Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c3/053389835a996e18853ba107a63caae0b9deb4a276c6b472931ea9ae6e48/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a", size = 2676498, upload-time = "2025-04-23T18:32:02.418Z" }, + { url = "https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d", size = 2000611, upload-time = "2025-04-23T18:32:04.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/a7/63ef2fed1837d1121a894d0ce88439fe3e3b3e48c7543b2a4479eb99c2bd/pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56", size = 2107924, upload-time = "2025-04-23T18:32:06.129Z" }, + { url = "https://files.pythonhosted.org/packages/04/8f/2551964ef045669801675f1cfc3b0d74147f4901c3ffa42be2ddb1f0efc4/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5", size = 2063196, upload-time = "2025-04-23T18:32:08.178Z" }, + { url = "https://files.pythonhosted.org/packages/26/bd/d9602777e77fc6dbb0c7db9ad356e9a985825547dce5ad1d30ee04903918/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e", size = 2236389, upload-time = "2025-04-23T18:32:10.242Z" }, + { url = "https://files.pythonhosted.org/packages/42/db/0e950daa7e2230423ab342ae918a794964b053bec24ba8af013fc7c94846/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162", size = 2239223, upload-time = "2025-04-23T18:32:12.382Z" }, + { url = "https://files.pythonhosted.org/packages/58/4d/4f937099c545a8a17eb52cb67fe0447fd9a373b348ccfa9a87f141eeb00f/pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849", size = 1900473, upload-time = "2025-04-23T18:32:14.034Z" }, + { url = "https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9", size = 1955269, upload-time = "2025-04-23T18:32:15.783Z" }, + { url = "https://files.pythonhosted.org/packages/f9/86/1beda0576969592f1497b4ce8e7bc8cbdf614c352426271b1b10d5f0aa64/pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9", size = 1893921, upload-time = "2025-04-23T18:32:18.473Z" }, + { url = "https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162, upload-time = "2025-04-23T18:32:20.188Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560, upload-time = "2025-04-23T18:32:22.354Z" }, + { url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777, upload-time = "2025-04-23T18:32:25.088Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pytest" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/86/9e3c5f48f7b7b638b216e4b9e645f54d199d7abbbab7a64a13b4e12ba10f/pytest_asyncio-1.2.0.tar.gz", hash = "sha256:c609a64a2a8768462d0c99811ddb8bd2583c33fd33cf7f21af1c142e824ffb57", size = 50119, upload-time = "2025-09-12T07:33:53.816Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/93/2fa34714b7a4ae72f2f8dad66ba17dd9a2c793220719e736dda28b7aec27/pytest_asyncio-1.2.0-py3-none-any.whl", hash = "sha256:8e17ae5e46d8e7efe51ab6494dd2010f4ca8dae51652aa3c8d55acf50bfb2e99", size = 15095, upload-time = "2025-09-12T07:33:52.639Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/b0/4bc07ccd3572a2f9df7e6782f52b0c6c90dcbb803ac4a167702d7d0dfe1e/python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab", size = 41978, upload-time = "2025-06-24T04:21:07.341Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556, upload-time = "2025-06-24T04:21:06.073Z" }, +] + +[[package]] +name = "redis" +version = "6.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/d6/e8b92798a5bd67d659d51a18170e91c16ac3b59738d91894651ee255ed49/redis-6.4.0.tar.gz", hash = "sha256:b01bc7282b8444e28ec36b261df5375183bb47a07eb9c603f284e89cbc5ef010", size = 4647399, upload-time = "2025-08-07T08:10:11.441Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/02/89e2ed7e85db6c93dfa9e8f691c5087df4e3551ab39081a4d7c6d1f90e05/redis-6.4.0-py3-none-any.whl", hash = "sha256:f0544fa9604264e9464cdf4814e7d4830f74b165d52f2a330a760a88dd248b7f", size = 279847, upload-time = "2025-08-07T08:10:09.84Z" }, +] + +[[package]] +name = "ruff" +version = "0.13.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/8e/f9f9ca747fea8e3ac954e3690d4698c9737c23b51731d02df999c150b1c9/ruff-0.13.3.tar.gz", hash = "sha256:5b0ba0db740eefdfbcce4299f49e9eaefc643d4d007749d77d047c2bab19908e", size = 5438533, upload-time = "2025-10-02T19:29:31.582Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/33/8f7163553481466a92656d35dea9331095122bb84cf98210bef597dd2ecd/ruff-0.13.3-py3-none-linux_armv6l.whl", hash = "sha256:311860a4c5e19189c89d035638f500c1e191d283d0cc2f1600c8c80d6dcd430c", size = 12484040, upload-time = "2025-10-02T19:28:49.199Z" }, + { url = "https://files.pythonhosted.org/packages/b0/b5/4a21a4922e5dd6845e91896b0d9ef493574cbe061ef7d00a73c61db531af/ruff-0.13.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:2bdad6512fb666b40fcadb65e33add2b040fc18a24997d2e47fee7d66f7fcae2", size = 13122975, upload-time = "2025-10-02T19:28:52.446Z" }, + { url = "https://files.pythonhosted.org/packages/40/90/15649af836d88c9f154e5be87e64ae7d2b1baa5a3ef317cb0c8fafcd882d/ruff-0.13.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fc6fa4637284708d6ed4e5e970d52fc3b76a557d7b4e85a53013d9d201d93286", size = 12346621, upload-time = "2025-10-02T19:28:54.712Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/bcbccb8141305f9a6d3f72549dd82d1134299177cc7eaf832599700f95a7/ruff-0.13.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c9e6469864f94a98f412f20ea143d547e4c652f45e44f369d7b74ee78185838", size = 12574408, upload-time = "2025-10-02T19:28:56.679Z" }, + { url = "https://files.pythonhosted.org/packages/ce/19/0f3681c941cdcfa2d110ce4515624c07a964dc315d3100d889fcad3bfc9e/ruff-0.13.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5bf62b705f319476c78891e0e97e965b21db468b3c999086de8ffb0d40fd2822", size = 12285330, upload-time = "2025-10-02T19:28:58.79Z" }, + { url = "https://files.pythonhosted.org/packages/10/f8/387976bf00d126b907bbd7725219257feea58650e6b055b29b224d8cb731/ruff-0.13.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:78cc1abed87ce40cb07ee0667ce99dbc766c9f519eabfd948ed87295d8737c60", size = 13980815, upload-time = "2025-10-02T19:29:01.577Z" }, + { url = "https://files.pythonhosted.org/packages/0c/a6/7c8ec09d62d5a406e2b17d159e4817b63c945a8b9188a771193b7e1cc0b5/ruff-0.13.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:4fb75e7c402d504f7a9a259e0442b96403fa4a7310ffe3588d11d7e170d2b1e3", size = 14987733, upload-time = "2025-10-02T19:29:04.036Z" }, + { url = "https://files.pythonhosted.org/packages/97/e5/f403a60a12258e0fd0c2195341cfa170726f254c788673495d86ab5a9a9d/ruff-0.13.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:17b951f9d9afb39330b2bdd2dd144ce1c1335881c277837ac1b50bfd99985ed3", size = 14439848, upload-time = "2025-10-02T19:29:06.684Z" }, + { url = "https://files.pythonhosted.org/packages/39/49/3de381343e89364c2334c9f3268b0349dc734fc18b2d99a302d0935c8345/ruff-0.13.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6052f8088728898e0a449f0dde8fafc7ed47e4d878168b211977e3e7e854f662", size = 13421890, upload-time = "2025-10-02T19:29:08.767Z" }, + { url = "https://files.pythonhosted.org/packages/ab/b5/c0feca27d45ae74185a6bacc399f5d8920ab82df2d732a17213fb86a2c4c/ruff-0.13.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc742c50f4ba72ce2a3be362bd359aef7d0d302bf7637a6f942eaa763bd292af", size = 13444870, upload-time = "2025-10-02T19:29:11.234Z" }, + { url = "https://files.pythonhosted.org/packages/50/a1/b655298a1f3fda4fdc7340c3f671a4b260b009068fbeb3e4e151e9e3e1bf/ruff-0.13.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:8e5640349493b378431637019366bbd73c927e515c9c1babfea3e932f5e68e1d", size = 13691599, upload-time = "2025-10-02T19:29:13.353Z" }, + { url = "https://files.pythonhosted.org/packages/32/b0/a8705065b2dafae007bcae21354e6e2e832e03eb077bb6c8e523c2becb92/ruff-0.13.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6b139f638a80eae7073c691a5dd8d581e0ba319540be97c343d60fb12949c8d0", size = 12421893, upload-time = "2025-10-02T19:29:15.668Z" }, + { url = "https://files.pythonhosted.org/packages/0d/1e/cbe7082588d025cddbb2f23e6dfef08b1a2ef6d6f8328584ad3015b5cebd/ruff-0.13.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:6b547def0a40054825de7cfa341039ebdfa51f3d4bfa6a0772940ed351d2746c", size = 12267220, upload-time = "2025-10-02T19:29:17.583Z" }, + { url = "https://files.pythonhosted.org/packages/a5/99/4086f9c43f85e0755996d09bdcb334b6fee9b1eabdf34e7d8b877fadf964/ruff-0.13.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9cc48a3564423915c93573f1981d57d101e617839bef38504f85f3677b3a0a3e", size = 13177818, upload-time = "2025-10-02T19:29:19.943Z" }, + { url = "https://files.pythonhosted.org/packages/9b/de/7b5db7e39947d9dc1c5f9f17b838ad6e680527d45288eeb568e860467010/ruff-0.13.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1a993b17ec03719c502881cb2d5f91771e8742f2ca6de740034433a97c561989", size = 13618715, upload-time = "2025-10-02T19:29:22.527Z" }, + { url = "https://files.pythonhosted.org/packages/28/d3/bb25ee567ce2f61ac52430cf99f446b0e6d49bdfa4188699ad005fdd16aa/ruff-0.13.3-py3-none-win32.whl", hash = "sha256:f14e0d1fe6460f07814d03c6e32e815bff411505178a1f539a38f6097d3e8ee3", size = 12334488, upload-time = "2025-10-02T19:29:24.782Z" }, + { url = "https://files.pythonhosted.org/packages/cf/49/12f5955818a1139eed288753479ba9d996f6ea0b101784bb1fe6977ec128/ruff-0.13.3-py3-none-win_amd64.whl", hash = "sha256:621e2e5812b691d4f244638d693e640f188bacbb9bc793ddd46837cea0503dd2", size = 13455262, upload-time = "2025-10-02T19:29:26.882Z" }, + { url = "https://files.pythonhosted.org/packages/fe/72/7b83242b26627a00e3af70d0394d68f8f02750d642567af12983031777fc/ruff-0.13.3-py3-none-win_arm64.whl", hash = "sha256:9e9e9d699841eaf4c2c798fa783df2fabc680b72059a02ca0ed81c460bc58330", size = 12538484, upload-time = "2025-10-02T19:29:28.951Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "starlette" +version = "0.48.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a7/a5/d6f429d43394057b67a6b5bbe6eae2f77a6bf7459d961fdb224bf206eee6/starlette-0.48.0.tar.gz", hash = "sha256:7e8cee469a8ab2352911528110ce9088fdc6a37d9876926e73da7ce4aa4c7a46", size = 2652949, upload-time = "2025-09-13T08:41:05.699Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/72/2db2f49247d0a18b4f1bb9a5a39a0162869acf235f3a96418363947b3d46/starlette-0.48.0-py3-none-any.whl", hash = "sha256:0764ca97b097582558ecb498132ed0c7d942f233f365b86ba37770e026510659", size = 73736, upload-time = "2025-09-13T08:41:03.869Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/57/1616c8274c3442d802621abf5deb230771c7a0fec9414cb6763900eb3868/uvicorn-0.37.0.tar.gz", hash = "sha256:4115c8add6d3fd536c8ee77f0e14a7fd2ebba939fed9b02583a97f80648f9e13", size = 80367, upload-time = "2025-09-23T13:33:47.486Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/cd/584a2ceb5532af99dd09e50919e3615ba99aa127e9850eafe5f31ddfdb9a/uvicorn-0.37.0-py3-none-any.whl", hash = "sha256:913b2b88672343739927ce381ff9e2ad62541f9f8289664fa1d1d3803fa2ce6c", size = 67976, upload-time = "2025-09-23T13:33:45.842Z" }, +] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..5998b6b5 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,58 @@ +services: + nginx: + image: nginx:alpine + ports: + - "80:80" + volumes: + - ./nginx.conf:/etc/nginx/conf.d/default.conf + - ./out:/usr/share/nginx/html + depends_on: + backend: + condition: service_healthy + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:80/api/v1/health"] + interval: 30s + timeout: 10s + retries: 3 + + backend: + build: + context: ./backend + dockerfile: Dockerfile + ports: + - "8000:8000" + env_file: + - ./backend/.env + environment: + # Only override what needs Docker networking + REDIS_URL: redis://redis:6379/0 + depends_on: + redis: + condition: service_healthy + restart: unless-stopped + extra_hosts: + - "host.docker.internal:host-gateway" + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/api/v1/health"] + interval: 30s + timeout: 10s + retries: 3 + + redis: + image: redis:7-alpine + ports: + - "6379:6379" + command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru + volumes: + - redis_data:/data + restart: unless-stopped + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 15s + timeout: 5s + retries: 3 + start_period: 10s + +volumes: + redis_data: diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 00000000..d4371d09 --- /dev/null +++ b/nginx.conf @@ -0,0 +1,66 @@ +upstream backend { + server backend:8000; +} + +server { + listen 80; + server_name localhost; + + # Gzip compression + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_types + application/json + application/javascript + text/css + text/javascript + text/plain + text/xml; + + # Security headers + add_header X-Content-Type-Options nosniff; + add_header X-Frame-Options DENY; + add_header X-XSS-Protection "1; mode=block"; + + # API routes - proxy to FastAPI backend + location /api { + proxy_pass http://backend; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + proxy_read_timeout 300; + proxy_connect_timeout 300; + proxy_send_timeout 300; + } + + # Static files - serve Next.js build output + location / { + root /usr/share/nginx/html; + try_files $uri $uri.html $uri/ /index.html; + + # Cache static assets + location ~* \.(css|js|jpg|jpeg|png|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # Cache HTML files for a shorter period + location ~* \.html$ { + expires 1h; + add_header Cache-Control "public"; + } + } + + # Health check endpoint + location /health { + access_log off; + return 200 "healthy\n"; + add_header Content-Type text/plain; + } +} \ No newline at end of file diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 00000000..a6b16332 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,39 @@ +import { defineConfig, devices } from "@playwright/test"; + +/** + * Playwright configuration for BRC Analytics tests + */ +export default defineConfig({ + forbidOnly: !!process.env.CI, + fullyParallel: false, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], + reporter: "html", + retries: process.env.CI ? 2 : 0, + testDir: "./tests/e2e", + use: { + baseURL: "http://localhost:3000", + screenshot: "only-on-failure", + trace: "on-first-retry", + video: "retain-on-failure", + }, + webServer: [ + { + command: "npm run dev", + reuseExistingServer: true, + timeout: 120 * 1000, + url: "http://localhost:3000", + }, + { + command: "docker-compose up", + reuseExistingServer: true, + timeout: 120 * 1000, + url: "http://localhost:8000/api/v1/health", + }, + ], + workers: 1, +}); diff --git a/pyproject.toml b/pyproject.toml index d2cd8cad..e69f26a1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,6 +2,7 @@ target-version = "py38" line-length = 88 indent-width = 4 +extend-exclude = ["backend/pyproject.toml", "backend/poetry.lock"] [tool.ruff.format] quote-style = "double" diff --git a/scripts/format-python.sh b/scripts/format-python.sh index 960f7ed5..93f7400b 100755 --- a/scripts/format-python.sh +++ b/scripts/format-python.sh @@ -2,11 +2,11 @@ # Format Python files using Ruff (Rust-based formatter) echo "Formatting Python files with Ruff..." -ruff format catalog/ +ruff format catalog/ backend/ # Sort imports with Ruff echo "Sorting imports with Ruff..." -ruff check --select I --fix catalog/ +ruff check --select I --fix catalog/ backend/ # Exit with Ruff's status code exit $? \ No newline at end of file diff --git a/tests/e2e/03-api-health.spec.ts b/tests/e2e/03-api-health.spec.ts new file mode 100644 index 00000000..614c6743 --- /dev/null +++ b/tests/e2e/03-api-health.spec.ts @@ -0,0 +1,45 @@ +import { test, expect } from "@playwright/test"; + +test.describe("BRC Analytics - API Infrastructure", () => { + test("backend API should be healthy", async ({ request }) => { + // Check backend health endpoint + const response = await request.get("http://localhost:8000/api/v1/health"); + expect(response.ok()).toBeTruthy(); + + const health = await response.json(); + expect(health.status).toBe("healthy"); + expect(health.service).toBe("BRC Analytics API"); + console.log("Backend health:", health); + }); + + test("cache health check should work", async ({ request }) => { + // Check cache health endpoint + const response = await request.get( + "http://localhost:8000/api/v1/cache/health" + ); + expect(response.ok()).toBeTruthy(); + + const health = await response.json(); + expect(health.status).toBe("healthy"); + expect(health.cache).toBe("connected"); + console.log("Cache health:", health); + }); + + test("API documentation should be accessible", async ({ page }) => { + // Navigate to API docs + await page.goto("http://localhost:8000/api/docs"); + + // Check that Swagger UI loaded + await expect(page.locator(".swagger-ui")).toBeVisible({ timeout: 10000 }); + + // Check for API title + const title = page.locator(".title"); + await expect(title).toContainText("BRC Analytics API"); + + // Screenshot the API docs + await page.screenshot({ + fullPage: true, + path: "tests/screenshots/api-docs.png", + }); + }); +}); From 9c43fdfdfcc9028c9f56eb69742a206505d4e710 Mon Sep 17 00:00:00 2001 From: Dannon Baker Date: Sun, 5 Oct 2025 12:40:50 -0400 Subject: [PATCH 02/10] feat: add version endpoint for API metadata Adds GET /api/v1/version endpoint that returns: - API version (from APP_VERSION env var) - Environment (development/production) - Service name This provides a simple way for the frontend to display version info and demonstrates the backend infrastructure is working. --- backend/README.md | 1 + backend/app/api/v1/version.py | 16 ++++++++++++++++ backend/app/core/config.py | 3 +++ backend/app/main.py | 3 ++- tests/e2e/03-api-health.spec.ts | 12 ++++++++++++ 5 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 backend/app/api/v1/version.py diff --git a/backend/README.md b/backend/README.md index 6f5a8d44..59dd3656 100644 --- a/backend/README.md +++ b/backend/README.md @@ -58,6 +58,7 @@ Services: - `GET /api/v1/health` - Overall service health status - `GET /api/v1/cache/health` - Redis cache connectivity check +- `GET /api/v1/version` - API version and environment information ### Documentation diff --git a/backend/app/api/v1/version.py b/backend/app/api/v1/version.py new file mode 100644 index 00000000..45845f33 --- /dev/null +++ b/backend/app/api/v1/version.py @@ -0,0 +1,16 @@ +from fastapi import APIRouter + +from app.core.config import get_settings + +router = APIRouter() +settings = get_settings() + + +@router.get("") +async def get_version(): + """Get API version and build information""" + return { + "version": settings.APP_VERSION, + "environment": settings.ENVIRONMENT, + "service": "BRC Analytics API", + } diff --git a/backend/app/core/config.py b/backend/app/core/config.py index eaa875b0..97a07dfc 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -6,6 +6,9 @@ class Settings: """Application settings loaded from environment variables""" + # Application + APP_VERSION: str = os.getenv("APP_VERSION", "1.0.0") + # Redis settings REDIS_URL: str = os.getenv("REDIS_URL", "redis://localhost:6379/0") diff --git a/backend/app/main.py b/backend/app/main.py index 0beec7e0..c5639b90 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,7 +1,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from app.api.v1 import cache, health +from app.api.v1 import cache, health, version from app.core.config import get_settings settings = get_settings() @@ -24,6 +24,7 @@ # Include routers app.include_router(health.router, prefix="/api/v1", tags=["health"]) app.include_router(cache.router, prefix="/api/v1/cache", tags=["cache"]) +app.include_router(version.router, prefix="/api/v1/version", tags=["version"]) @app.get("/") diff --git a/tests/e2e/03-api-health.spec.ts b/tests/e2e/03-api-health.spec.ts index 614c6743..d226146f 100644 --- a/tests/e2e/03-api-health.spec.ts +++ b/tests/e2e/03-api-health.spec.ts @@ -25,6 +25,18 @@ test.describe("BRC Analytics - API Infrastructure", () => { console.log("Cache health:", health); }); + test("version endpoint should return version info", async ({ request }) => { + // Check version endpoint + const response = await request.get("http://localhost:8000/api/v1/version"); + expect(response.ok()).toBeTruthy(); + + const version = await response.json(); + expect(version.version).toBeTruthy(); + expect(version.environment).toBeTruthy(); + expect(version.service).toBe("BRC Analytics API"); + console.log("API version:", version); + }); + test("API documentation should be accessible", async ({ page }) => { // Navigate to API docs await page.goto("http://localhost:8000/api/docs"); From e9fc72ecdba844c8779e63fb03e7ba64d739eb19 Mon Sep 17 00:00:00 2001 From: Dannon Baker Date: Sun, 5 Oct 2025 13:28:25 -0400 Subject: [PATCH 03/10] feat: sync backend version from package.json Use APP_VERSION build arg to inject version from package.json into backend container. Adds docker-build.sh script to automate this. All endpoints now use settings.APP_VERSION instead of hardcoded values. --- backend/Dockerfile | 4 ++++ backend/README.md | 3 +++ backend/app/api/v1/health.py | 5 ++++- backend/app/core/config.py | 2 +- backend/app/main.py | 4 ++-- docker-compose.yml | 2 ++ scripts/docker-build.sh | 8 ++++++++ 7 files changed, 24 insertions(+), 4 deletions(-) create mode 100755 scripts/docker-build.sh diff --git a/backend/Dockerfile b/backend/Dockerfile index def55cdc..2ac07f53 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -15,6 +15,10 @@ RUN uv sync --frozen --no-install-project # Production stage FROM python:3.12-slim as runtime +# Accept version as build argument +ARG APP_VERSION=0.15.0 +ENV APP_VERSION=${APP_VERSION} + # Install system dependencies RUN apt-get update && apt-get install -y \ curl \ diff --git a/backend/README.md b/backend/README.md index 59dd3656..0d8cb8ad 100644 --- a/backend/README.md +++ b/backend/README.md @@ -29,6 +29,9 @@ API documentation: http://localhost:8000/api/docs cp backend/.env.example backend/.env # Edit backend/.env if needed (defaults work for local development) +# Build with version from package.json +./scripts/docker-build.sh + # Start all services (nginx + backend + redis) docker compose up -d diff --git a/backend/app/api/v1/health.py b/backend/app/api/v1/health.py index 53bfd9ea..47ee9eca 100644 --- a/backend/app/api/v1/health.py +++ b/backend/app/api/v1/health.py @@ -2,7 +2,10 @@ from fastapi import APIRouter +from app.core.config import get_settings + router = APIRouter() +settings = get_settings() @router.get("/health") @@ -10,7 +13,7 @@ async def health_check(): """Health check endpoint for monitoring system status""" return { "status": "healthy", - "version": "1.0.0", + "version": settings.APP_VERSION, "timestamp": datetime.utcnow().isoformat(), "service": "BRC Analytics API", } diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 97a07dfc..ef69175e 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -7,7 +7,7 @@ class Settings: """Application settings loaded from environment variables""" # Application - APP_VERSION: str = os.getenv("APP_VERSION", "1.0.0") + APP_VERSION: str = os.getenv("APP_VERSION", "0.15.0") # Redis settings REDIS_URL: str = os.getenv("REDIS_URL", "redis://localhost:6379/0") diff --git a/backend/app/main.py b/backend/app/main.py index c5639b90..3d852146 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -8,7 +8,7 @@ app = FastAPI( title="BRC Analytics API", - version="1.0.0", + version=settings.APP_VERSION, docs_url="/api/docs", redoc_url="/api/redoc", ) @@ -29,4 +29,4 @@ @app.get("/") async def root(): - return {"message": "BRC Analytics API", "version": "1.0.0"} + return {"message": "BRC Analytics API", "version": settings.APP_VERSION} diff --git a/docker-compose.yml b/docker-compose.yml index 5998b6b5..eb0eced4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -20,6 +20,8 @@ services: build: context: ./backend dockerfile: Dockerfile + args: + APP_VERSION: ${APP_VERSION:-0.15.0} ports: - "8000:8000" env_file: diff --git a/scripts/docker-build.sh b/scripts/docker-build.sh new file mode 100755 index 00000000..1c00d03d --- /dev/null +++ b/scripts/docker-build.sh @@ -0,0 +1,8 @@ +#!/bin/bash + +# Build Docker images with version from package.json +VERSION=$(node -p "require('./package.json').version") +export APP_VERSION=$VERSION + +echo "Building with version: $VERSION" +docker compose build "$@" From d20521680dee8fd4a7dfef432cf1c54eaf8520c2 Mon Sep 17 00:00:00 2001 From: Dannon Baker Date: Mon, 6 Oct 2025 10:14:53 -0400 Subject: [PATCH 04/10] feat: add version display in footer --- .gitignore | 1 + .../Footer/components/Branding/branding.tsx | 2 + .../VersionDisplay/versionDisplay.tsx | 34 +++++++++++++++ app/hooks/useBackendVersion.ts | 24 +++++++++++ site-config/brc-analytics/local/.env | 2 + tests/e2e/04-version-display.spec.ts | 42 +++++++++++++++++++ 6 files changed, 105 insertions(+) create mode 100644 app/components/Layout/components/Footer/components/Branding/components/VersionDisplay/versionDisplay.tsx create mode 100644 app/hooks/useBackendVersion.ts create mode 100644 tests/e2e/04-version-display.spec.ts diff --git a/.gitignore b/.gitignore index 1c26dcfc..1c4efe3f 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,4 @@ backend/.env # python venv __pycache__ +playwright-report/ diff --git a/app/components/Layout/components/Footer/components/Branding/branding.tsx b/app/components/Layout/components/Footer/components/Branding/branding.tsx index c6e83178..52e0b0bd 100644 --- a/app/components/Layout/components/Footer/components/Branding/branding.tsx +++ b/app/components/Layout/components/Footer/components/Branding/branding.tsx @@ -3,6 +3,7 @@ import { ANCHOR_TARGET } from "@databiosphere/findable-ui/lib/components/Links/c import { Link } from "@databiosphere/findable-ui/lib/components/Links/components/Link/link"; import { Brands, FooterText, LargeBrand, SmallBrand } from "./branding.styles"; import { TYPOGRAPHY_PROPS } from "@databiosphere/findable-ui/lib/styles/common/mui/typography"; +import { VersionDisplay } from "./components/VersionDisplay/versionDisplay"; export const Branding = (): JSX.Element => { return ( @@ -54,6 +55,7 @@ export const Branding = (): JSX.Element => { url="https://www.niaid.nih.gov/research/bioinformatics-resource-centers" /> + ); }; diff --git a/app/components/Layout/components/Footer/components/Branding/components/VersionDisplay/versionDisplay.tsx b/app/components/Layout/components/Footer/components/Branding/components/VersionDisplay/versionDisplay.tsx new file mode 100644 index 00000000..b66059c3 --- /dev/null +++ b/app/components/Layout/components/Footer/components/Branding/components/VersionDisplay/versionDisplay.tsx @@ -0,0 +1,34 @@ +"use client"; + +import { Typography } from "@mui/material"; +import { useEffect, useState } from "react"; +import { TYPOGRAPHY_PROPS } from "@databiosphere/findable-ui/lib/styles/common/mui/typography"; + +const CLIENT_VERSION = process.env.NEXT_PUBLIC_VERSION || "0.15.0"; +const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || ""; + +export const VersionDisplay = (): JSX.Element => { + const [backendVersion, setBackendVersion] = useState(null); + + useEffect(() => { + if (!BACKEND_URL) { + // No backend URL configured, skip fetching + return; + } + + fetch(`${BACKEND_URL}/api/v1/version`) + .then((res) => res.json()) + .then((data) => setBackendVersion(data.version)) + .catch(() => setBackendVersion(null)); // Gracefully handle backend unavailable + }, []); + + return ( + + Client build: {CLIENT_VERSION} + {backendVersion && ` • Server revision: ${backendVersion}`} + + ); +}; diff --git a/app/hooks/useBackendVersion.ts b/app/hooks/useBackendVersion.ts new file mode 100644 index 00000000..bde3d887 --- /dev/null +++ b/app/hooks/useBackendVersion.ts @@ -0,0 +1,24 @@ +import { useEffect, useState } from "react"; + +interface BackendVersion { + environment: string; + service: string; + version: string; +} + +/** + * Hook to fetch backend version with graceful fallback. + * @returns Backend version string or null if backend is unavailable. + */ +export function useBackendVersion(): string | null { + const [version, setVersion] = useState(null); + + useEffect(() => { + fetch("/api/v1/version") + .then((res) => res.json()) + .then((data: BackendVersion) => setVersion(data.version)) + .catch(() => setVersion(null)); // Gracefully handle backend unavailable + }, []); + + return version; +} diff --git a/site-config/brc-analytics/local/.env b/site-config/brc-analytics/local/.env index cd69791b..507530a3 100644 --- a/site-config/brc-analytics/local/.env +++ b/site-config/brc-analytics/local/.env @@ -2,3 +2,5 @@ NEXT_PUBLIC_ENA_PROXY_DOMAIN="https://brc-analytics.dev.clevercanary.com" NEXT_PUBLIC_SITE_CONFIG='brc-analytics-local' NEXT_PUBLIC_GALAXY_INSTANCE_URL="https://test.galaxyproject.org" NEXT_PUBLIC_PLAUSIBLE_DOMAIN="brc-analytics.org" +NEXT_PUBLIC_GALAXY_ENV="TEST" +NEXT_PUBLIC_BACKEND_URL="http://localhost:8000" diff --git a/tests/e2e/04-version-display.spec.ts b/tests/e2e/04-version-display.spec.ts new file mode 100644 index 00000000..6244a3dd --- /dev/null +++ b/tests/e2e/04-version-display.spec.ts @@ -0,0 +1,42 @@ +import { expect, test } from "@playwright/test"; + +test.describe("Version Display", () => { + test("should show client version and backend version in footer", async ({ + page, + }) => { + await page.goto("http://localhost:3000"); + + // Wait for the footer to be visible + const footer = page.locator("footer"); + await expect(footer).toBeVisible(); + + // Check for client version (always present) + await expect(footer).toContainText("Client build:"); + + // Check for backend version (should appear after API call) + await expect(footer).toContainText("Server revision:", { timeout: 5000 }); + + // Verify both show 0.15.0 + const versionText = await footer.textContent(); + console.log("Version display:", versionText); + expect(versionText).toMatch(/Client build:.*0\.15\.0/); + expect(versionText).toMatch(/Server revision:.*0\.15\.0/); + }); + + test("should gracefully handle backend unavailable", async ({ page }) => { + // Block the API call to simulate backend unavailable + await page.route("**/api/v1/version", (route) => route.abort("failed")); + + await page.goto("http://localhost:3000"); + + const footer = page.locator("footer"); + await expect(footer).toBeVisible(); + + // Should still show client version + await expect(footer).toContainText("Client build:"); + + // Should NOT show server revision + const versionText = await footer.textContent(); + expect(versionText).not.toContain("Server revision:"); + }); +}); From bdda01dd11cacf78dc65f916481fed21a7d71f70 Mon Sep 17 00:00:00 2001 From: Dannon Baker Date: Mon, 6 Oct 2025 10:18:30 -0400 Subject: [PATCH 05/10] chore: remove unused useBackendVersion hook --- app/hooks/useBackendVersion.ts | 24 ------------------------ 1 file changed, 24 deletions(-) delete mode 100644 app/hooks/useBackendVersion.ts diff --git a/app/hooks/useBackendVersion.ts b/app/hooks/useBackendVersion.ts deleted file mode 100644 index bde3d887..00000000 --- a/app/hooks/useBackendVersion.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { useEffect, useState } from "react"; - -interface BackendVersion { - environment: string; - service: string; - version: string; -} - -/** - * Hook to fetch backend version with graceful fallback. - * @returns Backend version string or null if backend is unavailable. - */ -export function useBackendVersion(): string | null { - const [version, setVersion] = useState(null); - - useEffect(() => { - fetch("/api/v1/version") - .then((res) => res.json()) - .then((data: BackendVersion) => setVersion(data.version)) - .catch(() => setVersion(null)); // Gracefully handle backend unavailable - }, []); - - return version; -} From 7794cf6967165511df80260b1e70383b0937e367 Mon Sep 17 00:00:00 2001 From: Dannon Baker Date: Mon, 6 Oct 2025 10:45:04 -0400 Subject: [PATCH 06/10] chore: configure Jest to exclude e2e tests --- jest.config.js | 6 ++++++ package.json | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 jest.config.js diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 00000000..ce5b8787 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,6 @@ +// Jest was configured in 74d3967f (Sept 2024) but no Jest tests were ever written. +// This config excludes Playwright e2e tests which Jest cannot parse. +module.exports = { + testEnvironment: "jsdom", + testPathIgnorePatterns: ["/node_modules/", "/tests/e2e/"], +}; diff --git a/package.json b/package.json index 7ea45904..acbaac65 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "format": "prettier --write . --cache", "format:python": "./scripts/format-python.sh", "prepare": "husky", - "test": "jest --runInBand", + "test": "jest --runInBand --passWithNoTests", "build-brc-db": "esrun catalog/build/ts/build-catalog.ts", "build-ga2-db": "esrun catalog/ga2/build/ts/build-catalog.ts", "build-brc-from-ncbi": "python3 -m catalog.build.py.build_files_from_ncbi", From 8b5f5b9bad348ae1bf300b94b86bcfff62374e93 Mon Sep 17 00:00:00 2001 From: Dannon Baker Date: Thu, 23 Oct 2025 12:28:50 -0400 Subject: [PATCH 07/10] chore: remove duplicate VersionDisplay component Remove VersionDisplay component from footer branding as it duplicates the existing version chip functionality. The version chip already displays build date, version, and git commit info. Backend version display will be added to the existing version chip in a separate PR to avoid duplication. Addresses PR #892 feedback. --- .../Footer/components/Branding/branding.tsx | 2 - .../VersionDisplay/versionDisplay.tsx | 34 --------------- tests/e2e/04-version-display.spec.ts | 42 ------------------- 3 files changed, 78 deletions(-) delete mode 100644 app/components/Layout/components/Footer/components/Branding/components/VersionDisplay/versionDisplay.tsx delete mode 100644 tests/e2e/04-version-display.spec.ts diff --git a/app/components/Layout/components/Footer/components/Branding/branding.tsx b/app/components/Layout/components/Footer/components/Branding/branding.tsx index 52e0b0bd..c6e83178 100644 --- a/app/components/Layout/components/Footer/components/Branding/branding.tsx +++ b/app/components/Layout/components/Footer/components/Branding/branding.tsx @@ -3,7 +3,6 @@ import { ANCHOR_TARGET } from "@databiosphere/findable-ui/lib/components/Links/c import { Link } from "@databiosphere/findable-ui/lib/components/Links/components/Link/link"; import { Brands, FooterText, LargeBrand, SmallBrand } from "./branding.styles"; import { TYPOGRAPHY_PROPS } from "@databiosphere/findable-ui/lib/styles/common/mui/typography"; -import { VersionDisplay } from "./components/VersionDisplay/versionDisplay"; export const Branding = (): JSX.Element => { return ( @@ -55,7 +54,6 @@ export const Branding = (): JSX.Element => { url="https://www.niaid.nih.gov/research/bioinformatics-resource-centers" /> - ); }; diff --git a/app/components/Layout/components/Footer/components/Branding/components/VersionDisplay/versionDisplay.tsx b/app/components/Layout/components/Footer/components/Branding/components/VersionDisplay/versionDisplay.tsx deleted file mode 100644 index b66059c3..00000000 --- a/app/components/Layout/components/Footer/components/Branding/components/VersionDisplay/versionDisplay.tsx +++ /dev/null @@ -1,34 +0,0 @@ -"use client"; - -import { Typography } from "@mui/material"; -import { useEffect, useState } from "react"; -import { TYPOGRAPHY_PROPS } from "@databiosphere/findable-ui/lib/styles/common/mui/typography"; - -const CLIENT_VERSION = process.env.NEXT_PUBLIC_VERSION || "0.15.0"; -const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || ""; - -export const VersionDisplay = (): JSX.Element => { - const [backendVersion, setBackendVersion] = useState(null); - - useEffect(() => { - if (!BACKEND_URL) { - // No backend URL configured, skip fetching - return; - } - - fetch(`${BACKEND_URL}/api/v1/version`) - .then((res) => res.json()) - .then((data) => setBackendVersion(data.version)) - .catch(() => setBackendVersion(null)); // Gracefully handle backend unavailable - }, []); - - return ( - - Client build: {CLIENT_VERSION} - {backendVersion && ` • Server revision: ${backendVersion}`} - - ); -}; diff --git a/tests/e2e/04-version-display.spec.ts b/tests/e2e/04-version-display.spec.ts deleted file mode 100644 index 6244a3dd..00000000 --- a/tests/e2e/04-version-display.spec.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { expect, test } from "@playwright/test"; - -test.describe("Version Display", () => { - test("should show client version and backend version in footer", async ({ - page, - }) => { - await page.goto("http://localhost:3000"); - - // Wait for the footer to be visible - const footer = page.locator("footer"); - await expect(footer).toBeVisible(); - - // Check for client version (always present) - await expect(footer).toContainText("Client build:"); - - // Check for backend version (should appear after API call) - await expect(footer).toContainText("Server revision:", { timeout: 5000 }); - - // Verify both show 0.15.0 - const versionText = await footer.textContent(); - console.log("Version display:", versionText); - expect(versionText).toMatch(/Client build:.*0\.15\.0/); - expect(versionText).toMatch(/Server revision:.*0\.15\.0/); - }); - - test("should gracefully handle backend unavailable", async ({ page }) => { - // Block the API call to simulate backend unavailable - await page.route("**/api/v1/version", (route) => route.abort("failed")); - - await page.goto("http://localhost:3000"); - - const footer = page.locator("footer"); - await expect(footer).toBeVisible(); - - // Should still show client version - await expect(footer).toContainText("Client build:"); - - // Should NOT show server revision - const versionText = await footer.textContent(); - expect(versionText).not.toContain("Server revision:"); - }); -}); From fcb8d9a5fc2842bb2d6a82a22a536810dc23496f Mon Sep 17 00:00:00 2001 From: Dannon Baker Date: Thu, 23 Oct 2025 14:26:09 -0400 Subject: [PATCH 08/10] chore: reorganize Docker infrastructure into backend directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move Docker-related files into the backend directory to co-locate infrastructure with the backend code: - docker-compose.yml → backend/docker-compose.yml - nginx.conf → backend/nginx.conf - scripts/docker-build.sh → backend/docker-build.sh Update all path references in: - backend/README.md: Update build script path - backend/docker-compose.yml: Update context and volume paths - backend/docker-build.sh: Update package.json reference This keeps backend infrastructure self-contained and makes the deployment setup clearer. Addresses PR #892 feedback. --- backend/README.md | 6 +++--- {scripts => backend}/docker-build.sh | 2 +- docker-compose.yml => backend/docker-compose.yml | 6 +++--- nginx.conf => backend/nginx.conf | 0 4 files changed, 7 insertions(+), 7 deletions(-) rename {scripts => backend}/docker-build.sh (74%) rename docker-compose.yml => backend/docker-compose.yml (94%) rename nginx.conf => backend/nginx.conf (100%) diff --git a/backend/README.md b/backend/README.md index 0d8cb8ad..f36d3dc7 100644 --- a/backend/README.md +++ b/backend/README.md @@ -26,11 +26,11 @@ API documentation: http://localhost:8000/api/docs ```bash # Create environment file -cp backend/.env.example backend/.env -# Edit backend/.env if needed (defaults work for local development) +cp .env.example .env +# Edit .env if needed (defaults work for local development) # Build with version from package.json -./scripts/docker-build.sh +./docker-build.sh # Start all services (nginx + backend + redis) docker compose up -d diff --git a/scripts/docker-build.sh b/backend/docker-build.sh similarity index 74% rename from scripts/docker-build.sh rename to backend/docker-build.sh index 1c00d03d..e48fc1b0 100755 --- a/scripts/docker-build.sh +++ b/backend/docker-build.sh @@ -1,7 +1,7 @@ #!/bin/bash # Build Docker images with version from package.json -VERSION=$(node -p "require('./package.json').version") +VERSION=$(node -p "require('../package.json').version") export APP_VERSION=$VERSION echo "Building with version: $VERSION" diff --git a/docker-compose.yml b/backend/docker-compose.yml similarity index 94% rename from docker-compose.yml rename to backend/docker-compose.yml index eb0eced4..88b90b68 100644 --- a/docker-compose.yml +++ b/backend/docker-compose.yml @@ -5,7 +5,7 @@ services: - "80:80" volumes: - ./nginx.conf:/etc/nginx/conf.d/default.conf - - ./out:/usr/share/nginx/html + - ../out:/usr/share/nginx/html depends_on: backend: condition: service_healthy @@ -18,14 +18,14 @@ services: backend: build: - context: ./backend + context: . dockerfile: Dockerfile args: APP_VERSION: ${APP_VERSION:-0.15.0} ports: - "8000:8000" env_file: - - ./backend/.env + - .env environment: # Only override what needs Docker networking REDIS_URL: redis://redis:6379/0 diff --git a/nginx.conf b/backend/nginx.conf similarity index 100% rename from nginx.conf rename to backend/nginx.conf From 1cb8cb02ed6c61ca96df05870ee07863eb82b039 Mon Sep 17 00:00:00 2001 From: Dannon Baker Date: Tue, 28 Oct 2025 09:09:12 -0400 Subject: [PATCH 09/10] fix: secure Docker port exposure - only nginx publicly accessible Restrict backend and redis to Docker internal network only: - nginx: exposed on port 8080 (public-facing, port 80 already in use on host) - backend: no port mapping (only accessible via nginx within Docker network) - redis: no port mapping (only accessible within Docker network) This prevents direct external access to backend API and Redis while maintaining full functionality through the nginx reverse proxy. For local development debugging, backend can be accessed with: docker compose exec backend curl http://localhost:8000/api/v1/health Addresses security concern raised in PR #892 review. --- backend/docker-compose.yml | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml index 88b90b68..cf355afb 100644 --- a/backend/docker-compose.yml +++ b/backend/docker-compose.yml @@ -2,7 +2,7 @@ services: nginx: image: nginx:alpine ports: - - "80:80" + - "8080:80" volumes: - ./nginx.conf:/etc/nginx/conf.d/default.conf - ../out:/usr/share/nginx/html @@ -22,8 +22,8 @@ services: dockerfile: Dockerfile args: APP_VERSION: ${APP_VERSION:-0.15.0} - ports: - - "8000:8000" + # Port mapping removed - only accessible via nginx within Docker network + # For direct backend access during development, use: docker compose exec backend curl http://localhost:8000 env_file: - .env environment: @@ -43,8 +43,9 @@ services: redis: image: redis:7-alpine - ports: - - "6379:6379" + # Port mapping removed - only accessible within Docker network + # ports: + # - "127.0.0.1:6379:6379" command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru volumes: - redis_data:/data From 2ebe049a6651d2f2ebbfc3a9e1400feadd05539e Mon Sep 17 00:00:00 2001 From: Dannon Baker Date: Mon, 3 Nov 2025 09:47:00 -0500 Subject: [PATCH 10/10] chore: restructure backend with api service subdirectory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move FastAPI backend code into backend/api/ subdirectory to prepare for potential additional backend services in the future. Structure: backend/ ├── docker-compose.yml # Service orchestration ├── nginx.conf # Reverse proxy ├── docker-build.sh # Build script ├── README.md # Backend overview └── api/ # FastAPI REST API service ├── app/ # Application code ├── Dockerfile ├── pyproject.toml └── README.md # API-specific docs Updated: - docker-compose.yml: context and env_file paths point to ./api - README.md files: Updated paths and instructions - All imports and references work correctly with new structure Tested: Services build and run successfully with new structure. Addresses PR #892 feedback for organized directory structure. --- backend/README.md | 104 ++++++-------------- backend/{ => api}/.dockerignore | 0 backend/{ => api}/.env.example | 0 backend/{ => api}/Dockerfile | 0 backend/api/README.md | 107 +++++++++++++++++++++ backend/{ => api}/app/__init__.py | 0 backend/{ => api}/app/api/__init__.py | 0 backend/{ => api}/app/api/v1/__init__.py | 0 backend/{ => api}/app/api/v1/cache.py | 0 backend/{ => api}/app/api/v1/health.py | 0 backend/{ => api}/app/api/v1/version.py | 0 backend/{ => api}/app/core/__init__.py | 0 backend/{ => api}/app/core/cache.py | 0 backend/{ => api}/app/core/config.py | 0 backend/{ => api}/app/core/dependencies.py | 0 backend/{ => api}/app/main.py | 0 backend/{ => api}/app/services/__init__.py | 0 backend/{ => api}/pyproject.toml | 0 backend/{ => api}/ruff.toml | 0 backend/{ => api}/uv.lock | 0 backend/docker-compose.yml | 4 +- 21 files changed, 138 insertions(+), 77 deletions(-) rename backend/{ => api}/.dockerignore (100%) rename backend/{ => api}/.env.example (100%) rename backend/{ => api}/Dockerfile (100%) create mode 100644 backend/api/README.md rename backend/{ => api}/app/__init__.py (100%) rename backend/{ => api}/app/api/__init__.py (100%) rename backend/{ => api}/app/api/v1/__init__.py (100%) rename backend/{ => api}/app/api/v1/cache.py (100%) rename backend/{ => api}/app/api/v1/health.py (100%) rename backend/{ => api}/app/api/v1/version.py (100%) rename backend/{ => api}/app/core/__init__.py (100%) rename backend/{ => api}/app/core/cache.py (100%) rename backend/{ => api}/app/core/config.py (100%) rename backend/{ => api}/app/core/dependencies.py (100%) rename backend/{ => api}/app/main.py (100%) rename backend/{ => api}/app/services/__init__.py (100%) rename backend/{ => api}/pyproject.toml (100%) rename backend/{ => api}/ruff.toml (100%) rename backend/{ => api}/uv.lock (100%) diff --git a/backend/README.md b/backend/README.md index f36d3dc7..080cb6ac 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,103 +1,57 @@ # BRC Analytics Backend -FastAPI backend infrastructure for BRC Analytics. +Backend infrastructure for BRC Analytics, organized as a collection of services. -## Features +## Structure -- FastAPI REST API -- Redis caching with TTL support -- Health check endpoints -- Docker deployment with nginx reverse proxy -- uv for dependency management +``` +backend/ +├── docker-compose.yml # Service orchestration +├── nginx.conf # Reverse proxy configuration +├── docker-build.sh # Build script with version sync +│ +└── api/ # FastAPI REST API service + ├── app/ # Application code + ├── Dockerfile # API service container + ├── pyproject.toml # Python dependencies + └── README.md # API-specific documentation +``` ## Quick Start -### Development (Local) - ```bash cd backend -uv sync -uv run uvicorn app.main:app --reload -``` -API documentation: http://localhost:8000/api/docs - -### Production (Docker) - -```bash # Create environment file -cp .env.example .env -# Edit .env if needed (defaults work for local development) +cp api/.env.example api/.env # Build with version from package.json ./docker-build.sh -# Start all services (nginx + backend + redis) +# Start all services docker compose up -d -# Check service health -curl http://localhost/api/v1/health +# Check health +curl http://localhost:8080/api/v1/health # View logs -docker compose logs -f backend +docker compose logs -f -# Rebuild after code changes -docker compose up -d --build - -# Stop all services +# Stop services docker compose down ``` -Services: - -- nginx: http://localhost (reverse proxy) -- backend API: http://localhost:8000 (direct access) -- API docs: http://localhost/api/docs -- redis: localhost:6379 - -## API Endpoints +## Services -### Health & Monitoring +- **nginx** (port 8080): Reverse proxy, public-facing entry point +- **api**: FastAPI REST API (internal, accessed via nginx) +- **redis**: Cache layer (internal) -- `GET /api/v1/health` - Overall service health status -- `GET /api/v1/cache/health` - Redis cache connectivity check -- `GET /api/v1/version` - API version and environment information +See `api/README.md` for API-specific documentation. -### Documentation +## Port Configuration -- `GET /api/docs` - Interactive Swagger UI -- `GET /api/redoc` - ReDoc API documentation +For security, only nginx is exposed externally. Backend services (API, Redis) are only accessible within the Docker network. -## Configuration - -Environment variables (see `.env.example`): - -```bash -# Redis -REDIS_URL=redis://localhost:6379/0 - -# Application -CORS_ORIGINS=http://localhost:3000,http://localhost -LOG_LEVEL=INFO -``` - -## Testing - -```bash -# Run e2e tests -npm run test:e2e - -# Or with Playwright directly -npx playwright test tests/e2e/03-api-health.spec.ts -``` - -## Architecture - -``` -nginx (port 80) - ├── /api/* → FastAPI backend (port 8000) - └── /* → Next.js static files - -FastAPI backend - └── Redis cache (port 6379) -``` +- External access: `http://localhost:8080` → nginx → routes to services +- Direct backend access (dev only): `docker compose exec backend curl http://localhost:8000` diff --git a/backend/.dockerignore b/backend/api/.dockerignore similarity index 100% rename from backend/.dockerignore rename to backend/api/.dockerignore diff --git a/backend/.env.example b/backend/api/.env.example similarity index 100% rename from backend/.env.example rename to backend/api/.env.example diff --git a/backend/Dockerfile b/backend/api/Dockerfile similarity index 100% rename from backend/Dockerfile rename to backend/api/Dockerfile diff --git a/backend/api/README.md b/backend/api/README.md new file mode 100644 index 00000000..95a3c0fc --- /dev/null +++ b/backend/api/README.md @@ -0,0 +1,107 @@ +# BRC Analytics API Service + +FastAPI REST API service for BRC Analytics. + +## Features + +- FastAPI REST API +- Redis caching with TTL support +- Health check endpoints +- Docker deployment with nginx reverse proxy +- uv for dependency management + +## Quick Start + +### Development (Local) + +```bash +cd backend/api +uv sync +uv run uvicorn app.main:app --reload +``` + +API documentation: http://localhost:8000/api/docs + +### Production (Docker) + +Docker Compose orchestration is managed from the parent `/backend` directory. + +```bash +cd backend + +# Create environment file +cp api/.env.example api/.env +# Edit api/.env if needed (defaults work for local development) + +# Build with version from package.json +./docker-build.sh + +# Start all services (nginx + api + redis) +docker compose up -d + +# Check service health +curl http://localhost:8080/api/v1/health + +# View logs +docker compose logs -f backend + +# Rebuild after code changes +docker compose up -d --build + +# Stop all services +docker compose down +``` + +Services: + +- nginx: http://localhost:8080 (reverse proxy, public-facing) +- API service: internal only, accessible via nginx +- API docs: http://localhost:8080/api/docs +- redis: internal only + +## API Endpoints + +### Health & Monitoring + +- `GET /api/v1/health` - Overall service health status +- `GET /api/v1/cache/health` - Redis cache connectivity check +- `GET /api/v1/version` - API version and environment information + +### Documentation + +- `GET /api/docs` - Interactive Swagger UI +- `GET /api/redoc` - ReDoc API documentation + +## Configuration + +Environment variables (see `.env.example`): + +```bash +# Redis +REDIS_URL=redis://localhost:6379/0 + +# Application +CORS_ORIGINS=http://localhost:3000,http://localhost +LOG_LEVEL=INFO +``` + +## Testing + +```bash +# Run e2e tests +npm run test:e2e + +# Or with Playwright directly +npx playwright test tests/e2e/03-api-health.spec.ts +``` + +## Architecture + +``` +nginx (port 80) + ├── /api/* → FastAPI backend (port 8000) + └── /* → Next.js static files + +FastAPI backend + └── Redis cache (port 6379) +``` diff --git a/backend/app/__init__.py b/backend/api/app/__init__.py similarity index 100% rename from backend/app/__init__.py rename to backend/api/app/__init__.py diff --git a/backend/app/api/__init__.py b/backend/api/app/api/__init__.py similarity index 100% rename from backend/app/api/__init__.py rename to backend/api/app/api/__init__.py diff --git a/backend/app/api/v1/__init__.py b/backend/api/app/api/v1/__init__.py similarity index 100% rename from backend/app/api/v1/__init__.py rename to backend/api/app/api/v1/__init__.py diff --git a/backend/app/api/v1/cache.py b/backend/api/app/api/v1/cache.py similarity index 100% rename from backend/app/api/v1/cache.py rename to backend/api/app/api/v1/cache.py diff --git a/backend/app/api/v1/health.py b/backend/api/app/api/v1/health.py similarity index 100% rename from backend/app/api/v1/health.py rename to backend/api/app/api/v1/health.py diff --git a/backend/app/api/v1/version.py b/backend/api/app/api/v1/version.py similarity index 100% rename from backend/app/api/v1/version.py rename to backend/api/app/api/v1/version.py diff --git a/backend/app/core/__init__.py b/backend/api/app/core/__init__.py similarity index 100% rename from backend/app/core/__init__.py rename to backend/api/app/core/__init__.py diff --git a/backend/app/core/cache.py b/backend/api/app/core/cache.py similarity index 100% rename from backend/app/core/cache.py rename to backend/api/app/core/cache.py diff --git a/backend/app/core/config.py b/backend/api/app/core/config.py similarity index 100% rename from backend/app/core/config.py rename to backend/api/app/core/config.py diff --git a/backend/app/core/dependencies.py b/backend/api/app/core/dependencies.py similarity index 100% rename from backend/app/core/dependencies.py rename to backend/api/app/core/dependencies.py diff --git a/backend/app/main.py b/backend/api/app/main.py similarity index 100% rename from backend/app/main.py rename to backend/api/app/main.py diff --git a/backend/app/services/__init__.py b/backend/api/app/services/__init__.py similarity index 100% rename from backend/app/services/__init__.py rename to backend/api/app/services/__init__.py diff --git a/backend/pyproject.toml b/backend/api/pyproject.toml similarity index 100% rename from backend/pyproject.toml rename to backend/api/pyproject.toml diff --git a/backend/ruff.toml b/backend/api/ruff.toml similarity index 100% rename from backend/ruff.toml rename to backend/api/ruff.toml diff --git a/backend/uv.lock b/backend/api/uv.lock similarity index 100% rename from backend/uv.lock rename to backend/api/uv.lock diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml index cf355afb..cf9a5046 100644 --- a/backend/docker-compose.yml +++ b/backend/docker-compose.yml @@ -18,14 +18,14 @@ services: backend: build: - context: . + context: ./api dockerfile: Dockerfile args: APP_VERSION: ${APP_VERSION:-0.15.0} # Port mapping removed - only accessible via nginx within Docker network # For direct backend access during development, use: docker compose exec backend curl http://localhost:8000 env_file: - - .env + - ./api/.env environment: # Only override what needs Docker networking REDIS_URL: redis://redis:6379/0