diff --git a/.gitignore b/.gitignore index fa70ebdb..1c4efe3f 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,11 @@ npm-debug.log* # Build Dir /out +# Playwright test artifacts +/test-results +/tests/screenshots + # 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/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..2ac07f53 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,50 @@ +# 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 + +# 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 \ + && 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..0d8cb8ad --- /dev/null +++ b/backend/README.md @@ -0,0 +1,103 @@ +# 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) + +# Build with version from package.json +./scripts/docker-build.sh + +# 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 +- `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/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..47ee9eca --- /dev/null +++ b/backend/app/api/v1/health.py @@ -0,0 +1,19 @@ +from datetime import datetime + +from fastapi import APIRouter + +from app.core.config import get_settings + +router = APIRouter() +settings = get_settings() + + +@router.get("/health") +async def health_check(): + """Health check endpoint for monitoring system status""" + return { + "status": "healthy", + "version": settings.APP_VERSION, + "timestamp": datetime.utcnow().isoformat(), + "service": "BRC Analytics API", + } diff --git a/backend/app/api/v1/ncbi_links.py b/backend/app/api/v1/ncbi_links.py new file mode 100644 index 00000000..e89f66a4 --- /dev/null +++ b/backend/app/api/v1/ncbi_links.py @@ -0,0 +1,41 @@ +"""API endpoints for NCBI cross-linking.""" + +from fastapi import APIRouter, Depends + +from app.core.cache import CacheService, CacheTTL +from app.core.dependencies import get_cache_service +from app.services.ncbi_links_service import NCBILinksService + +router = APIRouter() + + +@router.get("/organism-links.json") +async def get_organism_links(cache: CacheService = Depends(get_cache_service)): + """Get organism links by taxonomy ID for NCBI cross-referencing""" + cache_key = "ncbi_links:organisms" + + cached = await cache.get(cache_key) + if cached is not None: + return cached + + service = NCBILinksService() + links = service.get_organism_links() + await cache.set(cache_key, links, ttl=CacheTTL.ONE_DAY) + + return links + + +@router.get("/assembly-links.json") +async def get_assembly_links(cache: CacheService = Depends(get_cache_service)): + """Get assembly links by accession for NCBI cross-referencing""" + cache_key = "ncbi_links:assemblies" + + cached = await cache.get(cache_key) + if cached is not None: + return cached + + service = NCBILinksService() + links = service.get_assembly_links() + await cache.set(cache_key, links, ttl=CacheTTL.ONE_DAY) + + return links 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/__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..c6dd2498 --- /dev/null +++ b/backend/app/core/config.py @@ -0,0 +1,40 @@ +import os +from functools import lru_cache +from typing import List + + +class Settings: + """Application settings loaded from environment variables""" + + # Application + APP_VERSION: str = os.getenv("APP_VERSION", "0.15.0") + + # 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 + + # Catalog path + CATALOG_PATH: str = os.getenv("CATALOG_PATH", "/catalog/output") + + +@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..75023dde --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,33 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.api.v1 import cache, health, ncbi_links, version +from app.core.config import get_settings + +settings = get_settings() + +app = FastAPI( + title="BRC Analytics API", + version=settings.APP_VERSION, + 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.include_router(version.router, prefix="/api/v1/version", tags=["version"]) +app.include_router(ncbi_links.router, prefix="/api/v1/links", tags=["ncbi-links"]) + + +@app.get("/") +async def root(): + return {"message": "BRC Analytics API", "version": settings.APP_VERSION} 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/app/services/ncbi_links_service.py b/backend/app/services/ncbi_links_service.py new file mode 100644 index 00000000..9ecfbbde --- /dev/null +++ b/backend/app/services/ncbi_links_service.py @@ -0,0 +1,74 @@ +"""Service for generating NCBI link data from BRC Analytics catalog.""" + +import json +import logging +from pathlib import Path +from typing import Any, Dict, List + +from app.core.config import get_settings + +logger = logging.getLogger(__name__) + + +class NCBILinksService: + """Service to generate link files for NCBI cross-referencing.""" + + def __init__(self, catalog_path: str | None = None): + settings = get_settings() + self.catalog_path = Path(catalog_path or settings.CATALOG_PATH) + self.base_url = "https://brc-analytics.org" + + def _load_json_file(self, filename: str) -> List[Dict[str, Any]]: + file_path = self.catalog_path / filename + try: + with open(file_path, "r") as f: + return json.load(f) + except FileNotFoundError: + logger.error(f"Catalog file not found: {file_path}") + return [] + except json.JSONDecodeError as e: + logger.error(f"Error parsing JSON from {file_path}: {e}") + return [] + + def get_organism_links(self) -> List[Dict[str, str]]: + organisms = self._load_json_file("organisms.json") + links = [] + + for org in organisms: + taxonomy_id = org.get("ncbiTaxonomyId") + if not taxonomy_id: + continue + + links.append( + { + "ncbiTaxonomyId": taxonomy_id, + "url": f"{self.base_url}/data/organisms/{taxonomy_id}", + "scientificName": org.get("taxonomicLevelSpecies"), + "commonName": org.get("commonName"), + } + ) + + logger.info(f"Generated {len(links)} organism links") + return links + + def get_assembly_links(self) -> List[Dict[str, str]]: + assemblies = self._load_json_file("assemblies.json") + links = [] + + for assembly in assemblies: + accession = assembly.get("accession") + if not accession: + continue + + url_accession = accession.replace(".", "_") + links.append( + { + "accession": accession, + "url": f"{self.base_url}/data/assemblies/{url_accession}", + "ncbiTaxonomyId": assembly.get("ncbiTaxonomyId"), + "scientificName": assembly.get("taxonomicLevelSpecies"), + } + ) + + logger.info(f"Generated {len(links)} assembly links") + return links 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..aec679f5 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,62 @@ +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 + args: + APP_VERSION: ${APP_VERSION:-0.15.0} + ports: + - "8000:8000" + env_file: + - ./backend/.env + environment: + # Only override what needs Docker networking + REDIS_URL: redis://redis:6379/0 + volumes: + - ./catalog:/catalog:ro + 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/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/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/package.json b/package.json index 2d9e5731..ee2f9067 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", 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/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 "$@" 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/site-config/brc-analytics/local/.env b/site-config/brc-analytics/local/.env index 11329b58..6cf1791e 100644 --- a/site-config/brc-analytics/local/.env +++ b/site-config/brc-analytics/local/.env @@ -1,3 +1,4 @@ NEXT_PUBLIC_ENA_PROXY_DOMAIN="https://brc-analytics.dev.clevercanary.com" NEXT_PUBLIC_SITE_CONFIG='brc-analytics-local' NEXT_PUBLIC_GALAXY_ENV="TEST" +NEXT_PUBLIC_BACKEND_URL="http://localhost:8000" diff --git a/tests/e2e/03-api-health.spec.ts b/tests/e2e/03-api-health.spec.ts new file mode 100644 index 00000000..d226146f --- /dev/null +++ b/tests/e2e/03-api-health.spec.ts @@ -0,0 +1,57 @@ +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("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"); + + // 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", + }); + }); +}); 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:"); + }); +});