diff --git a/.github/chatmodes/Enter.chatmode.md b/.github/chatmodes/Enter.chatmode.md new file mode 100644 index 0000000..2807cf3 --- /dev/null +++ b/.github/chatmodes/Enter.chatmode.md @@ -0,0 +1,5 @@ +--- +description: 'Description of the custom chat mode.' +tools: [] +--- +Define the purpose of this chat mode and how AI should behave: response style, available tools, focus areas, and any mode-specific instructions or constraints. \ No newline at end of file diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..fa1c9e5 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,85 @@ +name: ๐Ÿงช Run tests + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main, develop ] + +jobs: + test: + runs-on: ubuntu-latest + + strategy: + matrix: + # Keep Dockerfile runtime Python (3.12.6) and also test against 3.11 + python-version: ["3.12.6", "3.11"] + + steps: + # --- Checkout code --- + - name: Checkout repository + uses: actions/checkout@v4 + + # --- Setup Python --- + - name: Setup Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: 'pip' + + # --- Install uv (as in Dockerfile) and sync dependencies with uv --- + - name: Install project and dev/test deps + working-directory: order_service + run: | + python -m pip install --upgrade pip setuptools wheel + # Install test & dev tools required for CI runs (matches order_service dependency-groups:dev) + python -m pip install httpx pytest pytest-asyncio pytest-cov ruff black mypy types-python-dateutil + # Install project in editable mode with all deps (-e for source mapping in coverage) + python -m pip install -e . + + - name: Cache pip + uses: actions/cache@v4 + with: + path: | + ~/.cache/pip + ~/.cache/pypoetry + key: ${{ runner.os }}-pip-${{ matrix.python-version }}-${{ hashFiles('order_service/pyproject.toml') }} + restore-keys: | + ${{ runner.os }}-pip-${{ matrix.python-version }}- + ${{ runner.os }}-pip- + + # --- Run pytest with coverage (via uv to match runtime) --- + - name: Run tests (pytest via uv) + working-directory: order_service + env: + PYTHONPATH: ./ + run: | + mkdir -p test-reports + uv run pytest -v --maxfail=1 --disable-warnings \ + --junitxml=test-reports/junit-${{ matrix.python-version }}.xml \ + --cov=src --cov-report=xml:test-reports/coverage-${{ matrix.python-version }}.xml --cov-report=term-missing --cov-fail-under=80 || true + + - name: Fail if tests failed + if: always() + run: | + # If any junit reports contain failures, exit non-zero + set -e + grep -R "failures=\|errors=" test-reports || true + # Let the job fail based on exit code from pytest above + if [ -f test-reports/junit-${{ matrix.python-version }}.xml ]; then + # simple check: look for failures attribute > 0 + failures=$(xmllint --xpath 'string(//testsuite/@failures)' test-reports/junit-${{ matrix.python-version }}.xml || echo 0) + errors=$(xmllint --xpath 'string(//testsuite/@errors)' test-reports/junit-${{ matrix.python-version }}.xml || echo 0) + if [ "${failures}" != "0" ] || [ "${errors}" != "0" ]; then + echo "Tests reported failures or errors (failures=${failures}, errors=${errors})" + exit 1 + fi + fi + + # --- (Optional) Upload coverage report artifact --- + - name: Upload test artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-reports-${{ matrix.python-version }} + path: order_service/test-reports \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9253666 --- /dev/null +++ b/.gitignore @@ -0,0 +1,133 @@ +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# Python / Build Artifacts +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +__pycache__/ +*.py[cod] +*.pyo +*.pyd +*.so +*.egg +*.egg-info/ +.eggs/ +dist/ +build/ +pip-wheel-metadata/ +.installed.cfg + +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# Environment / Dependency Managers +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +.venv/ +venv/ +env/ +ENV/ +.uv/ +.venv-*/ +poetry.lock +uv.lock +Pipfile +Pipfile.lock + +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# IDE / Editor Configs +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +.vscode/ +.idea/ +*.swp +*.swo +*.bak + +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# Logs / Temp Files +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +*.log +*.tmp +*.temp +*.out +nohup.out +coverage.xml +htmlcov/ +.cache/ +.pytest_cache/ +.tox/ +.mypy_cache/ +ruff_cache/ +.dmypy.json +.pyre/ +pytestdebug.log + +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# Databases / Runtime Files +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +*.db +*.sqlite3 +*.sqlite +alembic.ini +alembic/versions/*.pyc +alembic/versions/__pycache__/ +alembic/versions/*.log + +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# Docker / Compose +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# (ะธะณะฝะพั€ะธะผ build-ะฐั€ั‚ะตั„ะฐะบั‚ั‹, ะฝะพ ะฝะต Dockerfile) +**/__pycache__/ +**/.pytest_cache/ +**/.mypy_cache/ +**/Dockerfile.old +**/docker-compose.override.yml +**/.dockerignore +docker-compose.override.yml +.docker/ +tmp/ + +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# Environment Files / Secrets +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +.env +.env.* +!.env.example # ะฝะพ ะฟั€ะธะผะตั€ ะบะพะฝั„ะธะณัƒั€ะฐั†ะธะธ ะพัั‚ะฐะฒะปัะตะผ ะฒ ั€ะตะฟะพะทะธั‚ะพั€ะธะธ +*.pem +*.key +*.crt +*.csr +*.pub +secrets/ +**/secrets.yaml +**/secrets.json + +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# Coverage / Reports +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +.coverage +coverage.json +cov_html/ +reports/ +logs/ +test-results.xml + +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# Jupyter / Notebooks (ะตัะปะธ ะฒะดั€ัƒะณ) +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +.ipynb_checkpoints/ +*.ipynb + +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# OS Specific / Misc +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +.DS_Store +Thumbs.db +desktop.ini +*.orig +*.rej + +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# Project specific (AsyncFlow) +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# ะ˜ะณะฝะพั€ะธะผ ะปะพะบะฐะปัŒะฝั‹ะต ะดะฐะฝะฝั‹ะต ัะตั€ะฒะธัะพะฒ +order_service/data/ +billing_service/data/ +notification_service/data/ + +# ะ˜ะณะฝะพั€ะธะผ ะปะพะบะฐะปัŒะฝั‹ะต RabbitMQ volume data +rabbitmq_data/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..4da878c --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,53 @@ +repos: + # ---- Ruff (ะปะธะฝั‚ะตั€ + ั„ะพั€ะผะฐั‚ั‚ะตั€) ---- + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.6.8 + hooks: + - id: ruff + name: ruff (all services) + args: [--fix] + - id: ruff-format + name: ruff format (all services) + + # ---- Black (ั„ะพั€ะผะฐั‚ะธั€ะพะฒะฐะฝะธะต, ะตัะปะธ ั…ะพั‡ะตัˆัŒ ัั‚ั€ะพะณะธะน ัั‚ะธะปัŒ) ---- + - repo: https://github.com/psf/black + rev: 24.8.0 + hooks: + - id: black + name: black (all services) + language_version: python3.12 + additional_dependencies: [black>=24.8.0] + + # ---- Mypy (ั‚ะธะฟะธะทะฐั†ะธั) ---- + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.10.0 + hooks: + - id: mypy + name: mypy (all services) + language_version: python3.12 + additional_dependencies: + - pydantic>=2.7,<3 + - pydantic-settings>=2.3,<3 + - sqlalchemy>=2.0,<3 + - aio-pika>=9,<10 + - fastapi>=0.115,<1 + # ะฟั€ะพะฒะตั€ัะตะผ ะฒัะต ัะตั€ะฒะธัั‹ ั€ะฐะทะพะผ + args: [ + "--pretty", + "--show-error-codes", + "--namespace-packages", + "order_service/src", + "billing_service/src", + "notification_service/src", + ] + + # ---- Pytest (ะพะฟั†ะธะพะฝะฐะปัŒะฝะพ: ะฑั‹ัั‚ั€ั‹ะต ั‚ะตัั‚ั‹) ---- + - repo: local + hooks: + - id: pytest + name: pytest (order_service) + entry: uv run pytest -q --maxfail=1 --disable-warnings -x + language: system + pass_filenames: false + always_run: true + stages: [commit] \ No newline at end of file diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 0000000..7d070ec --- /dev/null +++ b/DEVELOPMENT.md @@ -0,0 +1,189 @@ +# Development Guide + +## ๐Ÿ› ๏ธ Development Setup + +### Prerequisites +- Python 3.11 or later +- Docker and Docker Compose +- uv (fast Python package installer) + +### Initial Setup + +1. Install uv: +```bash +pip install uv +``` + +2. Clone the repository: +```bash +git clone git@github.com:loguntsovae/asyncflow.git +cd asyncflow +``` + +3. Create and activate a virtual environment: +```bash +python -m venv .venv +source .venv/bin/activate # On Windows: .venv\Scripts\activate +``` + +4. Install dependencies (with dev tools): +```bash +# Install base dependencies +uv pip install -e . +# Install development dependencies +uv pip install -e ".[dev]" +``` + +5. Copy environment example and adjust: +```bash +cp .env.example .env +# Edit .env with your settings +``` + +## ๐Ÿš€ Development Workflow + +### Running Services Locally + +1. Start all services: +```bash +make up +``` + +2. Start specific service: +```bash +docker compose up api_gateway +``` + +3. View logs: +```bash +make logs +# Or for specific service: +docker compose logs -f api_gateway +``` + +### Quality Checks + +Run all checks: +```bash +make qa # Runs format, lint, typecheck, and test +``` + +Individual checks: +```bash +make format # Format code with ruff +make lint # Check code with ruff +make typecheck # Run mypy type checking +make test # Run pytest suite +``` + +### Testing + +Run tests: +```bash +# All tests +pytest + +# Specific test file +pytest src/tests/test_orders_api.py + +# With coverage +pytest --cov=src +``` + +### Adding Dependencies + +1. Add runtime dependency: +```bash +uv pip install package_name +``` + +2. Add development dependency: +```bash +uv pip install --group dev package_name +``` + +3. Update pyproject.toml accordingly: +```toml +[project] +dependencies = [ + "package_name>=1.0.0,<2.0" +] + +[dependency-groups] +dev = [ + "dev-package>=1.0.0,<2.0" +] +``` + +## ๐Ÿ“ฆ Building and Deployment + +### Building Docker Images + +```bash +# Build all services +docker compose build + +# Build specific service +docker compose build api_gateway +``` + +### Running Tests in Docker + +```bash +# Build test image +docker build -t asyncflow-test -f order_service/Dockerfile . + +# Run tests +docker run --rm asyncflow-test pytest +``` + +### Production Deployment + +1. Set production configuration: +```bash +cp .env.example .env.prod +# Edit .env.prod with production settings +``` + +2. Deploy with Docker Compose: +```bash +docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d +``` + +## ๐Ÿ” Monitoring & Debugging + +### Metrics + +Access service metrics: +- API Gateway: http://localhost:9000/metrics +- Order Service: http://localhost:9001/metrics + +### Health Checks + +Check service health: +- API Gateway: http://localhost:9000/health +- Order Service: http://localhost:9001/health + +### Logging + +View structured logs: +```bash +# All services +docker compose logs -f | jq . + +# Specific service +docker compose logs -f api_gateway | jq . +``` + +## ๐Ÿ“š Documentation + +- API Documentation: http://localhost:9000/api/v1/docs +- ReDoc Interface: http://localhost:9000/api/v1/redoc + +## โšก Performance Tips + +1. Use `uv` for faster dependency installation +2. Keep Docker images minimal with multi-stage builds +3. Enable response compression for larger payloads +4. Use connection pooling for database and HTTP clients +5. Monitor and optimize based on metrics \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..505d698 --- /dev/null +++ b/README.md @@ -0,0 +1,22 @@ +# ๐ŸŒ€ AsyncFlow + +**Event-driven microservice system built with FastAPI + RabbitMQ** + +## ๐Ÿงฉ Overview +AsyncFlow is a demo of event-driven architecture: +- **Order Service** creates and publishes events. +- **Billing Service** processes payments asynchronously. +- **Notification Service** reacts to processed payments. + +Everything communicates through **RabbitMQ**. + +## โš™๏ธ Stack +- Python 3.12+ +- FastAPI +- aio-pika +- RabbitMQ +- Docker Compose +- uv (Fast Python Package Installer) + + +![Tests](https://github.com///actions/workflows/tests.yml/badge.svg) diff --git a/api_gateway/Dockerfile b/api_gateway/Dockerfile new file mode 100644 index 0000000..766b00a --- /dev/null +++ b/api_gateway/Dockerfile @@ -0,0 +1,72 @@ +# syntax=docker/dockerfile:1 +FROM python:3.11-slim as builder + +# Build arguments +ARG APP_USER=appuser +ARG APP_GROUP=appgroup +ARG APP_HOME=/app + +# Prevent Python from writing pyc files and from buffering stdout/stderr +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +# Ensure APP_HOME is available at runtime (not just build-time) +ENV APP_HOME=${APP_HOME} + +# Make src packages importable as top-level (e.g., `from core import ...`) +ENV PYTHONPATH=${APP_HOME}/src + +# Set work directory +WORKDIR ${APP_HOME} + +# Install system dependencies and create user +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + curl \ + build-essential \ + && rm -rf /var/lib/apt/lists/* \ + && groupadd -r ${APP_GROUP} \ + && useradd -r -g ${APP_GROUP} -d ${APP_HOME} ${APP_USER} \ + && mkdir -p ${APP_HOME}/logs \ + && chown -R ${APP_USER}:${APP_GROUP} ${APP_HOME} + +# Install uv for faster package installation +RUN pip install --no-cache-dir uv==0.4.20 + +# Copy dependency files +COPY --chown=${APP_USER}:${APP_GROUP} pyproject.toml ./ + +# Install dependencies +RUN uv pip install --system --no-cache --compile . + +# Copy application code +COPY --chown=${APP_USER}:${APP_GROUP} src/ ./src/ + +# Create a non-root user to run the application +USER ${APP_USER} + +# Create volume for logs +VOLUME ["${APP_HOME}/logs"] + +# Expose the port +# Default port (can be overridden) +ENV API_GATEWAY_PORT=9000 + +EXPOSE ${API_GATEWAY_PORT} + +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:${API_GATEWAY_PORT}/health || exit 1 + +# Set entry point script +COPY --chown=${APP_USER}:${APP_GROUP} docker-entrypoint.sh /usr/local/bin/ +RUN chmod +x /usr/local/bin/docker-entrypoint.sh + +# Set environment variables for the application +ENV APP_ENV=production \ + LOG_LEVEL=info \ + LOG_FORMAT=json \ + LOG_DIR=${APP_HOME}/logs + +ENTRYPOINT ["docker-entrypoint.sh"] +CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "9000"] \ No newline at end of file diff --git a/api_gateway/docker-entrypoint.sh b/api_gateway/docker-entrypoint.sh new file mode 100644 index 0000000..c0248e3 --- /dev/null +++ b/api_gateway/docker-entrypoint.sh @@ -0,0 +1,25 @@ +#!/bin/sh +set -e + +# Create log directory if it doesn't exist (fallback to /app/logs) +LOG_DIR_PATH="${LOG_DIR:-${APP_HOME:-/app}/logs}" +mkdir -p "$LOG_DIR_PATH" + +# Wait for dependent services if needed +wait_for_service() { + host="$1" + port="$2" + echo "Waiting for $host:$port..." + while ! nc -z "$host" "$port"; do + sleep 1 + done + echo "$host:$port is available" +} + +# Example: Wait for auth service +if [ -n "$AUTH_SERVICE_HOST" ] && [ -n "$AUTH_SERVICE_PORT" ]; then + wait_for_service "$AUTH_SERVICE_HOST" "$AUTH_SERVICE_PORT" +fi + +# Run the application +exec "$@" \ No newline at end of file diff --git a/api_gateway/pyproject.toml b/api_gateway/pyproject.toml new file mode 100644 index 0000000..5befd69 --- /dev/null +++ b/api_gateway/pyproject.toml @@ -0,0 +1,117 @@ +[project] +name = "api-gateway" +version = "0.1.0" +description = "API Gateway service for AsyncFlow" +authors = [ + {name = "Aleksei Loguntsov"} +] +requires-python = ">=3.11" +readme = "README.md" +license = {text = "Apache-2.0"} + +dependencies = [ + "fastapi>=0.104.1,<1.0", + "uvicorn[standard]>=0.24.0,<1.0", + "httpx>=0.25.1,<1.0", + "pydantic>=2.4.2,<3", + "pydantic-settings>=2.0.3,<3", + "python-jose[cryptography]>=3.3.0,<4", + "python-multipart>=0.0.6,<1.0", + "structlog>=24.1.0,<25", + "prometheus-client>=0.19.0,<1.0", + "orjson>=3.9.10,<4" +] + +[tool.uv] +# Empty block - uv reads dependency-groups below + +[dependency-groups] +dev = [ + # Lint/Format + "ruff>=0.6,<1", + "black>=24.8,<25", + + # Typing + "mypy>=1.10,<2", + "types-python-dateutil>=2.9.0.20240316", + + # Tests + "pytest>=8.3,<9", + "pytest-asyncio>=0.23,<1", + "pytest-cov>=4.1,<5", + "httpx>=0.25.1,<1.0" # For testing HTTP client +] + +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[tool.setuptools.packages.find] +where = ["."] +include = ["src*"] + +# Testing configuration +[tool.pytest.ini_options] +pythonpath = ["src"] +testpaths = ["src/tests"] +python_files = ["test_*.py"] +asyncio_mode = "auto" +minversion = "8.0" +addopts = "-ra -q --strict-markers --disable-warnings --cov=src --cov-report=term-missing" + +[tool.coverage.run] +source = ["src"] +branch = true + +[tool.ruff] +target-version = "py311" +line-length = 100 +fix = true +unsafe-fixes = false +select = [ + "E", "F", # pycodestyle / pyflakes + "I", # isort + "UP", # pyupgrade + "B", # bugbear + "SIM", # simplify + "C90", # mccabe complexity + "PL", # pylint rules + "RUF", # ruff-specific +] +ignore = [ + "E501", # line length control +] + +[tool.ruff.lint.per-file-ignores] +"tests/**" = ["S101"] # allow asserts in tests + +[tool.ruff.format] +preview = true +quote-style = "double" +indent-style = "space" + +[tool.mypy] +python_version = "3.11" +strict = true +warn_unused_ignores = true +warn_redundant_casts = true +warn_unreachable = true +disallow_any_generics = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_defs = true +disallow_incomplete_defs = true +check_untyped_defs = true +no_implicit_optional = true +show_error_codes = true +pretty = true + +plugins = ["pydantic.mypy"] + +# Source code location +mypy_path = ["src"] + +# Relax rules for tests +[[tool.mypy.overrides]] +module = "tests.*" +strict = false \ No newline at end of file diff --git a/api_gateway/src/__init__.py b/api_gateway/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api_gateway/src/api/__init__.py b/api_gateway/src/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api_gateway/src/api/auth.py b/api_gateway/src/api/auth.py new file mode 100644 index 0000000..8bd5cb7 --- /dev/null +++ b/api_gateway/src/api/auth.py @@ -0,0 +1,51 @@ +from fastapi import APIRouter, Depends, Response +from fastapi.security import OAuth2PasswordRequestForm + +from core.schemas import User, UserCreate, Token +from core.services import ServiceClient + +router = APIRouter(prefix="/auth", tags=["Authentication"]) + + +@router.post("/register", response_model=User) +async def register(user_data: UserCreate): + """ + Register a new user. + + - Requires email, username, and password + - Returns created user information + """ + raise NotImplementedError("User registration is handled by the auth service.") + + from fastapi.responses import JSONResponse + + return JSONResponse( + status_code=201, + content={"message": "User registered successfully"} + ) + # return await ServiceClient.forward_request("auth", "register", "POST", user_data.dict()) + + +@router.post("/token", response_model=Token) +async def login(form_data: OAuth2PasswordRequestForm = Depends()): + """ + Login to get access token. + + - Requires username and password + - Returns JWT access token + """ + return await ServiceClient.forward_request( + "auth", "token", "POST", + {"username": form_data.username, "password": form_data.password} + ) + + +@router.get("/me", response_model=User) +async def get_user_me(token: str): + """ + Get current user information. + + - Requires authentication + - Returns user profile + """ + return await ServiceClient.forward_request("auth", "me", "GET", None, token) \ No newline at end of file diff --git a/api_gateway/src/api/orders.py b/api_gateway/src/api/orders.py new file mode 100644 index 0000000..fdb661c --- /dev/null +++ b/api_gateway/src/api/orders.py @@ -0,0 +1,45 @@ +from fastapi import APIRouter +from typing import List +from core.schemas import Order, OrderCreate +from core.services import ServiceClient + +router = APIRouter(prefix="/orders", tags=["Orders"]) + + +@router.post("", response_model=Order) +async def create_order(order: OrderCreate, token: str): + """ + Create a new order. + + - Requires authentication + - Accepts list of items and shipping address + - Returns created order details + """ + return await ServiceClient.forward_request("orders", "", "POST", order.dict(), token) + + +@router.get("", response_model=List[Order]) +async def list_orders(skip: int = 0, limit: int = 10, token: str = None): + """ + List all orders. + + - Requires authentication + - Supports pagination + - Returns list of orders + """ + return await ServiceClient.forward_request( + "orders", "", "GET", + params={"skip": skip, "limit": limit}, + token=token + ) + + +@router.get("/{order_id}", response_model=Order) +async def get_order(order_id: int, token: str): + """ + Get order details by ID. + + - Requires authentication + - Returns order details + """ + return await ServiceClient.forward_request("orders", str(order_id), "GET", None, token) \ No newline at end of file diff --git a/api_gateway/src/api/payments.py b/api_gateway/src/api/payments.py new file mode 100644 index 0000000..a02a4ce --- /dev/null +++ b/api_gateway/src/api/payments.py @@ -0,0 +1,28 @@ +from fastapi import APIRouter +from core.schemas import Payment, PaymentCreate +from core.services import ServiceClient + +router = APIRouter(prefix="/payments", tags=["Payments"]) + + +@router.post("", response_model=Payment) +async def create_payment(payment: PaymentCreate, token: str): + """ + Create a new payment. + + - Requires authentication + - Processes payment for an order + - Returns payment details + """ + return await ServiceClient.forward_request("billing", "payments", "POST", payment.dict(), token) + + +@router.get("/{payment_id}", response_model=Payment) +async def get_payment(payment_id: int, token: str): + """ + Get payment details by ID. + + - Requires authentication + - Returns payment information + """ + return await ServiceClient.forward_request("billing", f"payments/{payment_id}", "GET", None, token) \ No newline at end of file diff --git a/api_gateway/src/core/__init__.py b/api_gateway/src/core/__init__.py new file mode 100644 index 0000000..0b9fef4 --- /dev/null +++ b/api_gateway/src/core/__init__.py @@ -0,0 +1,7 @@ +""" +Core package initialization. + +Note: Metrics have moved to `middleware.metrics`. This module remains for backward compatibility, +but `core.metrics` is intentionally not re-exported to avoid duplicate definitions. +""" + diff --git a/api_gateway/src/core/config.py b/api_gateway/src/core/config.py new file mode 100644 index 0000000..4dc404b --- /dev/null +++ b/api_gateway/src/core/config.py @@ -0,0 +1,71 @@ +from pydantic_settings import BaseSettings +from pydantic import Field +from typing import Dict, List + + +class Settings(BaseSettings): + """API Gateway configuration settings.""" + + # Service host and port + HOST: str = "0.0.0.0" + PORT: int = Field(9000, env="API_GATEWAY_PORT") + + # API Version + API_VERSION: str = Field("v1", env="API_VERSION") + + # Rate limiting + RATE_LIMIT_ENABLED: bool = Field(True, env="RATE_LIMIT_ENABLED") + RATE_LIMIT_REQUESTS_PER_MINUTE: int = Field(60, env="RATE_LIMIT_REQUESTS_PER_MINUTE") + + # Request timeouts (seconds) + DEFAULT_TIMEOUT: float = Field(30.0, env="DEFAULT_TIMEOUT") + LONG_POLLING_TIMEOUT: float = Field(90.0, env="LONG_POLLING_TIMEOUT") + + # Metrics + ENABLE_METRICS: bool = Field(True, env="ENABLE_METRICS") + METRICS_PATH: str = Field("/metrics", env="METRICS_PATH") + + # Logging + LOG_LEVEL: str = Field("INFO", env="LOG_LEVEL") + LOG_FORMAT: str = Field("json", env="LOG_FORMAT") + + # CORS Settings + CORS_ORIGINS: List[str] = Field(["*"], env="CORS_ORIGINS") + CORS_ALLOW_CREDENTIALS: bool = Field(True, env="CORS_ALLOW_CREDENTIALS") + + # Per-service ports (can be overridden via env) + AUTH_SERVICE_PORT: int = Field(9003, env="AUTH_SERVICE_PORT") + ORDER_SERVICE_PORT: int = Field(9001, env="ORDER_SERVICE_PORT") + BILLING_SERVICE_PORT: int = Field(9002, env="BILLING_SERVICE_PORT") + + # Service routes configuration (computed from ports so Field() isn't used inside strings) + @property + def SERVICE_ROUTES(self) -> Dict[str, Dict[str, str]]: + return { + "auth": { + "host": f"http://auth_service:{self.AUTH_SERVICE_PORT}", + "prefix": "auth", + "public_paths": ["/register", "/token"] + }, + "orders": { + "host": f"http://order_service:{self.ORDER_SERVICE_PORT}", + "prefix": "orders", + "public_paths": [] + }, + "billing": { + "host": f"http://billing_service:{self.BILLING_SERVICE_PORT}", + "prefix": "billing", + "public_paths": [] + } + } + + # JWT configuration + JWT_SECRET_KEY: str = "your-secret-key" # Should match auth service + JWT_ALGORITHM: str = "HS256" + + class Config: + env_file = ".env" + case_sensitive = True + + +settings = Settings() \ No newline at end of file diff --git a/api_gateway/src/core/schemas.py b/api_gateway/src/core/schemas.py new file mode 100644 index 0000000..a3e15db --- /dev/null +++ b/api_gateway/src/core/schemas.py @@ -0,0 +1,72 @@ +from pydantic import BaseModel, EmailStr +from typing import Optional, List, Dict, Any +from datetime import datetime + + +# Base Models +class BaseSchema(BaseModel): + """Base schema with common configuration.""" + + class Config: + from_attributes = True + json_encoders = { + datetime: lambda v: v.isoformat() + } + + +# Auth Models +class UserCreate(BaseSchema): + """User registration request model.""" + email: EmailStr + username: str + password: str + + +class Token(BaseSchema): + """Token response model.""" + access_token: str + token_type: str + + +class User(BaseSchema): + """User response model.""" + id: int + email: EmailStr + username: str + is_active: bool + created_at: datetime + + +# Order Models +class OrderCreate(BaseSchema): + """Order creation request model.""" + items: List[Dict[str, Any]] + shipping_address: str + + +class Order(BaseSchema): + """Order response model.""" + id: int + status: str + items: List[Dict[str, Any]] + shipping_address: str + created_at: datetime + updated_at: Optional[datetime] + + +# Payment Models +class PaymentCreate(BaseSchema): + """Payment creation request model.""" + order_id: int + amount: float + payment_method: str + + +class Payment(BaseSchema): + """Payment response model.""" + id: int + order_id: int + amount: float + status: str + payment_method: str + created_at: datetime \ No newline at end of file diff --git a/api_gateway/src/core/security.py b/api_gateway/src/core/security.py new file mode 100644 index 0000000..479fdcd --- /dev/null +++ b/api_gateway/src/core/security.py @@ -0,0 +1,20 @@ +from fastapi import HTTPException, status +from jose import JWTError, jwt +from core.config import settings + + +async def verify_token(token: str) -> dict: + """Verify JWT token and return payload.""" + try: + payload = jwt.decode( + token, + settings.JWT_SECRET_KEY, + algorithms=[settings.JWT_ALGORITHM] + ) + return payload + except JWTError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid authentication credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) \ No newline at end of file diff --git a/api_gateway/src/core/services.py b/api_gateway/src/core/services.py new file mode 100644 index 0000000..da02c46 --- /dev/null +++ b/api_gateway/src/core/services.py @@ -0,0 +1,102 @@ +from typing import Optional, Any +import asyncio +import httpx +from fastapi import Request, HTTPException, status +from fastapi.responses import StreamingResponse + +from core.config import settings + + +async def forward_request(request: Request) -> Any: + """ + Forward request to appropriate service. + + This function: + 1. Extracts service name from path + 2. Forwards request to appropriate service + 3. Streams response back to client + 4. Preserves headers and status codes + """ + path = request.url.path + # If gateway exposes versioned API under /api/{version}/..., strip that prefix + api_prefix = f"/api/{settings.API_VERSION}/" + if path.startswith(api_prefix): + path = path[len(api_prefix):] + else: + # Also allow bare /api/... (no version) by stripping first two segments if present + if path.startswith("/api/"): + # remove leading /api/{something}/ + parts = path.strip("/").split("/") + if len(parts) >= 2: + path = "/" + "/".join(parts[2:]) if len(parts) > 2 else "/" + + path = path.strip("/") + path_parts = path.split("/") + + if not path_parts: + raise HTTPException(status_code=404, detail="Invalid path") + + service_name = path_parts[0] + if service_name not in settings.SERVICE_ROUTES: + raise HTTPException(status_code=404, detail="Service not found") + + # Get service configuration + service_config = settings.SERVICE_ROUTES[service_name] + service_host = service_config["host"] + + # Remove service prefix from path for forwarding + forwarding_path = "/" + "/".join(path_parts[1:]) if len(path_parts) > 1 else "/" + target_url = f"{service_host}{forwarding_path}" + + # Build headers to forward + headers = dict(request.headers) + headers.pop("host", None) + if hasattr(request.state, "user"): + headers["X-User"] = request.state.user.get("sub") + + # Prefer app-scoped http client created in lifespan; fall back to a local client + client: Optional[httpx.AsyncClient] = getattr(request.app.state, "http_client", None) + + # Simple retry/backoff for transient errors + max_attempts = 3 + backoff = 0.25 + last_exc = None + + for attempt in range(1, max_attempts + 1): + created_local_client = False + try: + if client is None: + client = httpx.AsyncClient(timeout=settings.DEFAULT_TIMEOUT) + created_local_client = True + + response = await client.request( + method=request.method, + url=target_url, + headers=headers, + content=await request.body(), + params=request.query_params, + follow_redirects=True, + timeout=settings.DEFAULT_TIMEOUT, + ) + + return StreamingResponse( + content=bytes(response.content or b""), + status_code=response.status_code, + headers=dict(response.headers), + media_type=response.headers.get("content-type") + ) + + except (httpx.RequestError, httpx.TimeoutException) as e: + last_exc = e + # transient error -> retry with backoff + if attempt < max_attempts: + await asyncio.sleep(backoff * attempt) + continue + # final attempt failed + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=f"Service unavailable: {str(e)}" + ) + finally: + if created_local_client and client is not None: + await client.aclose() \ No newline at end of file diff --git a/api_gateway/src/main.py b/api_gateway/src/main.py new file mode 100644 index 0000000..6c2561f --- /dev/null +++ b/api_gateway/src/main.py @@ -0,0 +1,168 @@ +import datetime +import uuid +from contextlib import asynccontextmanager + +import uvicorn +from fastapi import FastAPI, Request, Response, HTTPException +from fastapi.middleware.cors import CORSMiddleware +from fastapi.middleware.gzip import GZipMiddleware +import httpx +import structlog + +from core.config import settings +from core.services import forward_request +from middleware import MetricsMiddleware, get_metrics +from middleware import auth_middleware +from middleware import rate_limit_middleware + +# Configure structured logging +structlog.configure( + processors=[ + structlog.processors.TimeStamper(fmt="iso"), + structlog.processors.JSONRenderer(), + ] +) +logger = structlog.get_logger() + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Application lifecycle management.""" + # Create HTTP client pool + app.state.http_client = httpx.AsyncClient(timeout=settings.DEFAULT_TIMEOUT) + yield + await app.state.http_client.aclose() + +# Create FastAPI application +app = FastAPI( + title="AsyncFlow API Gateway", + description=""" + AsyncFlow API Gateway - Unified interface for microservices. + + Available Services: + * ๐Ÿ”’ Auth Service (/auth/*) + * ๐Ÿ“ฆ Order Service (/orders/*) + * ๐Ÿ’ณ Billing Service (/billing/*) + """, + version=settings.API_VERSION, + docs_url=f"/api/{settings.API_VERSION}/docs", + redoc_url=f"/api/{settings.API_VERSION}/redoc", + lifespan=lifespan +) + +# Add CORS middleware +app.add_middleware( + CORSMiddleware, + allow_origins=settings.CORS_ORIGINS, + allow_credentials=settings.CORS_ALLOW_CREDENTIALS, + allow_methods=["*"], + allow_headers=["*"], +) + +# Add GZip compression +app.add_middleware(GZipMiddleware, minimum_size=1000) + +# Add metrics middleware if enabled +if settings.ENABLE_METRICS: + app.add_middleware(MetricsMiddleware) + +# Add rate limiting if enabled +if settings.RATE_LIMIT_ENABLED: + app.middleware("http")(rate_limit_middleware) + +# Add authentication middleware +app.middleware("http")(auth_middleware) + + +@app.get("/health", tags=["System"]) +async def health_check(): + """Enhanced health check endpoint with service status.""" + health_info = { + "status": "healthy", + "timestamp": datetime.datetime.utcnow().isoformat(), + "version": settings.API_VERSION, + "services": {} + } + + # Check each service + for service_name, config in settings.SERVICE_ROUTES.items(): + try: + async with httpx.AsyncClient(timeout=2.0) as client: + response = await client.get(f"{config['host']}/health") + health_info["services"][service_name] = { + "status": "up" if response.status_code == 200 else "degraded", + "latency_ms": int(response.elapsed.total_seconds() * 1000) + } + except Exception as e: + health_info["services"][service_name] = { + "status": "down", + "error": str(e) + } + if all(svc.get("status") == "down" for svc in health_info["services"].values()): + health_info["status"] = "unhealthy" + else: + health_info["status"] = "degraded" + + return health_info + +# Backward/forward-compatible alias: expose health under versioned path as well +@app.get(f"/api/{settings.API_VERSION}/health", tags=["System"]) +async def health_check_versioned(): + return await health_check() + +@app.get("/metrics", tags=["System"], include_in_schema=settings.ENABLE_METRICS) +async def metrics(): + """Prometheus metrics endpoint.""" + if not settings.ENABLE_METRICS: + raise HTTPException(status_code=404) + return Response(content=get_metrics(), media_type="text/plain") + +@app.api_route("/api/{version}/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"]) +async def api_gateway(request: Request, version: str, path: str): + """ + Main gateway route - forwards all requests to appropriate services. + + Path format: /api/{version}/{service}/{remaining_path} + Example: /api/v1/orders/123 -> forwards to Order Service + """ + if version != settings.API_VERSION: + raise HTTPException( + status_code=404, + detail=f"API version {version} not found. Current version: {settings.API_VERSION}" + ) + + # Add request context for logging + request_id = str(uuid.uuid4()) + logger.info( + "incoming_request", + request_id=request_id, + method=request.method, + path=request.url.path, + client_ip=request.client.host if request.client else None, + ) + + try: + response = await forward_request(request) + + logger.info( + "request_completed", + request_id=request_id, + status_code=response.status_code + ) + return response + except Exception as e: + logger.error( + "request_failed", + request_id=request_id, + error=str(e), + error_type=type(e).__name__ + ) + raise + + +if __name__ == "__main__": + uvicorn.run( + "main:app", + host=settings.HOST, + port=settings.PORT, + reload=True + ) \ No newline at end of file diff --git a/api_gateway/src/middleware/__init__.py b/api_gateway/src/middleware/__init__.py new file mode 100644 index 0000000..eeb32f2 --- /dev/null +++ b/api_gateway/src/middleware/__init__.py @@ -0,0 +1,11 @@ +from .auth import auth_middleware +from .metrics import MetricsMiddleware, get_metrics +from .rate_limit import rate_limit_middleware + +__all__ = [ + "auth_middleware", + "MetricsMiddleware", + "get_metrics", + "rate_limit_middleware", + "LoggingMiddleware", +] diff --git a/api_gateway/src/middleware/auth.py b/api_gateway/src/middleware/auth.py new file mode 100644 index 0000000..e9d1242 --- /dev/null +++ b/api_gateway/src/middleware/auth.py @@ -0,0 +1,93 @@ +from fastapi import Request, HTTPException, status +from core.config import settings +from core.security import verify_token + +def is_public_path(path: str, method: str) -> bool: + """Check if the path is public or should bypass auth. + + Supports both bare and versioned API prefixes (e.g., /api/v1/...). + Also allows health and docs endpoints, and CORS preflight. + """ + # Allow CORS preflight + if method.upper() == "OPTIONS": + return True + + normalized = path.strip("/") + + # Health endpoints + if normalized == "health" or normalized == f"api/{settings.API_VERSION}/health": + return True + + # Docs and OpenAPI + docs_candidates = { + "docs", + "redoc", + "openapi.json", + f"api/{settings.API_VERSION}/docs", + f"api/{settings.API_VERSION}/redoc", + f"api/{settings.API_VERSION}/openapi.json", + } + if normalized in docs_candidates: + return True + + # Determine service and remaining path with optional versioned prefix + parts = normalized.split("/") if normalized else [] + if not parts: + return False + + # Handle /api/{version}/{service}/... + idx = 0 + if len(parts) >= 2 and parts[0] == "api" and parts[1] == settings.API_VERSION: + idx = 2 + + if len(parts) <= idx: + return False + + service = parts[idx] + if service not in settings.SERVICE_ROUTES: + return False + + remaining_path = "/" + "/".join(parts[idx + 1:]) if len(parts) > idx + 1 else "/" + service_config = settings.SERVICE_ROUTES[service] + return remaining_path in service_config["public_paths"] + + +async def auth_middleware(request: Request, call_next): + """Authenticate requests before routing.""" + path = request.url.path + + # Skip authentication for public paths + if is_public_path(path, request.method): + return await call_next(request) + + # Get token from header + auth_header = request.headers.get("Authorization") + if not auth_header: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Missing authentication token", + headers={"WWW-Authenticate": "Bearer"}, + ) + + try: + scheme, token = auth_header.split() + if scheme.lower() != "bearer": + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid authentication scheme", + headers={"WWW-Authenticate": "Bearer"}, + ) + + # Verify token + payload = await verify_token(token) + + # Add user info to request state + request.state.user = payload + return await call_next(request) + + except ValueError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid authentication format", + headers={"WWW-Authenticate": "Bearer"}, + ) \ No newline at end of file diff --git a/api_gateway/src/middleware/metrics.py b/api_gateway/src/middleware/metrics.py new file mode 100644 index 0000000..8689fc9 --- /dev/null +++ b/api_gateway/src/middleware/metrics.py @@ -0,0 +1,53 @@ +"""Gateway metrics collection and reporting.""" +from prometheus_client import Counter, Histogram, generate_latest +from fastapi import Request +from starlette.middleware.base import BaseHTTPMiddleware +import time + +# Define metrics +REQUEST_COUNT = Counter( + 'gateway_requests_total', + 'Total count of requests by service and status', + ['service', 'status'] +) + +REQUEST_LATENCY = Histogram( + 'gateway_request_latency_seconds', + 'Request latency by service', + ['service'] +) + +UPSTREAM_ERRORS = Counter( + 'gateway_upstream_errors_total', + 'Total count of upstream service errors', + ['service', 'error_type'] +) + + +class MetricsMiddleware(BaseHTTPMiddleware): + """Starlette-compatible middleware for collecting Prometheus metrics.""" + + async def dispatch(self, request: Request, call_next): + # Extract service from path + path_parts = request.url.path.strip("/").split("/") + service = path_parts[0] if path_parts else "unknown" + + start_time = time.time() + try: + response = await call_next(request) + + REQUEST_COUNT.labels(service=service, status=response.status_code).inc() + return response + except Exception as e: + UPSTREAM_ERRORS.labels( + service=service, + error_type=type(e).__name__ + ).inc() + raise + finally: + REQUEST_LATENCY.labels(service=service).observe(time.time() - start_time) + + +def get_metrics(): + """Get current metrics in Prometheus format.""" + return generate_latest() diff --git a/api_gateway/src/middleware/rate_limit.py b/api_gateway/src/middleware/rate_limit.py new file mode 100644 index 0000000..29187e7 --- /dev/null +++ b/api_gateway/src/middleware/rate_limit.py @@ -0,0 +1,49 @@ +"""Rate limiting middleware.""" +from fastapi import Request, HTTPException +from datetime import datetime, timedelta +from typing import Dict + + +class RateLimiter: + def __init__(self, requests_per_minute: int = 60): + self.requests_per_minute = requests_per_minute + self.requests: Dict[str, list] = {} + + def is_allowed(self, client_ip: str) -> bool: + now = datetime.now() + minute_ago = now - timedelta(minutes=1) + + # Clean old requests + self.requests[client_ip] = [ + req_time for req_time in self.requests.get(client_ip, []) + if req_time > minute_ago + ] + + # Check rate limit + if len(self.requests.get(client_ip, [])) >= self.requests_per_minute: + return False + + # Add new request + if client_ip not in self.requests: + self.requests[client_ip] = [] + self.requests[client_ip].append(now) + return True + +rate_limiter = RateLimiter() + +async def rate_limit_middleware(request: Request, call_next): + """Limit requests per client IP.""" + client_ip = request.client.host if request.client else "unknown" + + # Skip rate limiting for health check + if request.url.path == "/health": + return await call_next(request) + + if not rate_limiter.is_allowed(client_ip): + raise HTTPException( + status_code=429, + detail="Too many requests", + headers={"Retry-After": "60"} + ) + + return await call_next(request) \ No newline at end of file diff --git a/auth_service/.python-version b/auth_service/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/auth_service/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/auth_service/Dockerfile b/auth_service/Dockerfile new file mode 100644 index 0000000..52386b7 --- /dev/null +++ b/auth_service/Dockerfile @@ -0,0 +1,48 @@ +FROM python:3.11-slim + +WORKDIR /app + +# Runtime Python settings +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PYTHONPATH=/app/src + +# Install uv for faster package installation +RUN pip install --no-cache-dir uv==0.4.20 + +# Copy dependency definitions and project files +COPY pyproject.toml ./ +COPY alembic.ini ./ +COPY README.md ./ +COPY migrations/ ./migrations/ +COPY src/ ./src/ + +# Install dependencies directly from pyproject.toml using uv +RUN uv pip install --system --no-cache --compile . + +# Default port (can be overridden) +ENV AUTH_SERVICE_PORT=9003 + +# Expose the port +EXPOSE ${AUTH_SERVICE_PORT} + +# Copy entrypoint script +COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh +RUN chmod +x /usr/local/bin/docker-entrypoint.sh + +# Install tools used by entrypoint and healthcheck +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + netcat-openbsd \ + wget \ + curl \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# Health check +HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ + CMD wget -qO- http://localhost:${AUTH_SERVICE_PORT}/health >/dev/null || exit 1 + +# Run the application +ENTRYPOINT ["docker-entrypoint.sh"] +CMD ["uvicorn", "src.main:app"] \ No newline at end of file diff --git a/auth_service/README.md b/auth_service/README.md new file mode 100644 index 0000000..5f89d49 --- /dev/null +++ b/auth_service/README.md @@ -0,0 +1,2 @@ +# AsyncFlow Auth Service +Lightweight FastAPI authentication microservice with async SQLAlchemy and JWT-based auth. \ No newline at end of file diff --git a/auth_service/docker-entrypoint.sh b/auth_service/docker-entrypoint.sh new file mode 100644 index 0000000..962875c --- /dev/null +++ b/auth_service/docker-entrypoint.sh @@ -0,0 +1,39 @@ +#!/bin/sh +set -euo pipefail + +# Defaults (can be overridden by env) +: "${POSTGRES_HOST:=postgres}" +: "${POSTGRES_PORT:=5432}" +: "${AUTH_SERVICE_PORT:=9003}" + +log() { + printf "[auth-entrypoint] %s\n" "$*" +} + +wait_for_port() { + host="$1"; port="$2"; name="${3:-service}" + log "Waiting for $name at ${host}:${port}..." + until nc -z "$host" "$port"; do + sleep 1 + done + log "$name is available" +} + +# Wait for Postgres +wait_for_port "$POSTGRES_HOST" "$POSTGRES_PORT" "Postgres" + +# Run migrations +log "Running Alembic migrations..." +# Ensure alembic uses the bundled alembic.ini in /app +cd /app +alembic upgrade head +log "Migrations complete" + +# Exec the application +log "Starting Auth Service on port ${AUTH_SERVICE_PORT}" +if [ "$#" -gt 0 ] && [ "$1" = "uvicorn" ]; then + shift + exec uvicorn "$@" --host 0.0.0.0 --port "${AUTH_SERVICE_PORT}" +else + exec "$@" +fi diff --git a/auth_service/main.py b/auth_service/main.py new file mode 100644 index 0000000..af26751 --- /dev/null +++ b/auth_service/main.py @@ -0,0 +1,6 @@ +def main(): + print("Hello from auth-service!") + + +if __name__ == "__main__": + main() diff --git a/auth_service/migrations/env.py b/auth_service/migrations/env.py new file mode 100644 index 0000000..5904b36 --- /dev/null +++ b/auth_service/migrations/env.py @@ -0,0 +1,54 @@ +from __future__ import annotations +import asyncio +import os +from logging.config import fileConfig + +from sqlalchemy import pool +from sqlalchemy.ext.asyncio import create_async_engine +from alembic import context + +config = context.config +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = None + + +def _build_db_url_from_env() -> str: + user = os.getenv("POSTGRES_USER", "postgres") + password = os.getenv("POSTGRES_PASSWORD", "postgres") + host = os.getenv("POSTGRES_HOST", "postgres") + port = os.getenv("POSTGRES_PORT", "5432") + db = os.getenv("POSTGRES_DB", "auth_db") + return f"postgresql+asyncpg://{user}:{password}@{host}:{port}/{db}" + + +def run_migrations_offline() -> None: + url = _build_db_url_from_env() + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + compare_type=True, + ) + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection): + context.configure(connection=connection, target_metadata=target_metadata, compare_type=True) + with context.begin_transaction(): + context.run_migrations() + + +async def run_migrations_online() -> None: + connectable = create_async_engine(_build_db_url_from_env(), poolclass=pool.NullPool) + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + await connectable.dispose() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + asyncio.run(run_migrations_online()) \ No newline at end of file diff --git a/auth_service/migrations/versions/001_create_users_table.py b/auth_service/migrations/versions/001_create_users_table.py new file mode 100644 index 0000000..b170369 --- /dev/null +++ b/auth_service/migrations/versions/001_create_users_table.py @@ -0,0 +1,39 @@ +"""create users table + +Revision ID: 001 +Revises: +Create Date: 2023-10-30 12:00:00.000000 +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = '001' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + 'users', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('email', sa.String(), nullable=False), + sa.Column('username', sa.String(), nullable=False), + sa.Column('hashed_password', sa.String(), nullable=False), + sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'), + sa.Column('is_superuser', sa.Boolean(), nullable=False, server_default='false'), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()')), + sa.Column('updated_at', sa.DateTime(timezone=True), onupdate=sa.text('now()')), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True) + op.create_index(op.f('ix_users_id'), 'users', ['id'], unique=False) + op.create_index(op.f('ix_users_username'), 'users', ['username'], unique=True) + + +def downgrade(): + op.drop_index(op.f('ix_users_username'), table_name='users') + op.drop_index(op.f('ix_users_id'), table_name='users') + op.drop_index(op.f('ix_users_email'), table_name='users') + op.drop_table('users') \ No newline at end of file diff --git a/auth_service/pyproject.toml b/auth_service/pyproject.toml new file mode 100644 index 0000000..12f17b1 --- /dev/null +++ b/auth_service/pyproject.toml @@ -0,0 +1,71 @@ +[tool.poetry] +name = "auth-service" +version = "0.1.0" +description = "Authentication service for AsyncFlow" +authors = ["Aleksei Loguntsov"] +readme = "README.md" +packages = [{ include = "*", from = "src" }] + +[tool.poetry.dependencies] +python = "^3.11" + +# --- Web Framework --- +fastapi = ">=0.111.1" +uvicorn = { extras = ["standard"], version = "^0.24.0" } + +# --- Data & Validation --- +pydantic = "^2.4.2" +pydantic-settings = "^2.0.3" +email-validator = "^2.1.0" +python-multipart = "^0.0.6" + +# --- Security & Auth --- +python-jose = { extras = ["cryptography"], version = "^3.3.0" } +passlib = { extras = ["bcrypt"], version = "^1.7.4" } +bcrypt = { version = "^4.3.0" } + +# --- Database & ORM --- +sqlalchemy = { version = "^2.0.23", extras = ["asyncio"] } +alembic = "^1.12.1" +asyncpg = "^0.27.0" + +# --- Misc --- +# Add here only essential runtime libs + +[tool.poetry.group.dev.dependencies] +pytest = "^7.4.3" +pytest-asyncio = "^0.21.1" +pytest-cov = "^4.1.0" +black = "^23.10.1" +isort = "^5.12.0" +flake8 = "^6.1.0" +mypy = "^1.10.0" +types-python-jose = "^3.3.4" # typing hints for jose +types-passlib = "^1.7.7.12" # typing hints for passlib + +[build-system] +requires = ["poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api" + +# --- Optional tools configuration --- + +[tool.black] +line-length = 88 +target-version = ["py311"] +exclude = ''' +/( + \.venv + | build + | dist + | migrations +)/ +''' + +[tool.isort] +profile = "black" +line_length = 88 +skip = ["migrations", ".venv"] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +addopts = "-v --maxfail=1 --disable-warnings --cov=src" \ No newline at end of file diff --git a/auth_service/src/db/base.py b/auth_service/src/db/base.py new file mode 100644 index 0000000..732e383 --- /dev/null +++ b/auth_service/src/db/base.py @@ -0,0 +1,29 @@ +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, create_async_engine +from sqlalchemy.orm import sessionmaker +from settings import settings + +DATABASE_URL = settings.database_url + +# Create an async engine +async_engine: AsyncEngine = create_async_engine( + DATABASE_URL, + echo=True, # Enable SQL query logging +) + +# Create an async sessionmaker +async_session = sessionmaker( + bind=async_engine, + class_=AsyncSession, + expire_on_commit=False +) + + +# Dependency to get the async database session +async def get_db(): + async with async_session() as session: + yield session + + +# Dependency to get the async engine +async def get_async_engine(): + return async_engine \ No newline at end of file diff --git a/auth_service/src/db/models/users.py b/auth_service/src/db/models/users.py new file mode 100644 index 0000000..4fb50cf --- /dev/null +++ b/auth_service/src/db/models/users.py @@ -0,0 +1,20 @@ +from sqlalchemy import Column, Integer, String, Boolean, DateTime +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.sql import func + +Base = declarative_base() + + +class User(Base): + """User model for authentication.""" + + __tablename__ = "users" + + id = Column(Integer, primary_key=True, index=True) + email = Column(String, unique=True, index=True, nullable=False) + username = Column(String, unique=True, index=True, nullable=False) + hashed_password = Column(String, nullable=False) + is_active = Column(Boolean, default=True) + is_superuser = Column(Boolean, default=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) \ No newline at end of file diff --git a/auth_service/src/main.py b/auth_service/src/main.py new file mode 100644 index 0000000..34014a1 --- /dev/null +++ b/auth_service/src/main.py @@ -0,0 +1,151 @@ +from datetime import timedelta +from typing import List +from fastapi import FastAPI, Depends, HTTPException, status +from fastapi.security import OAuth2PasswordRequestForm +from sqlalchemy.ext.asyncio import AsyncSession +import sqlalchemy as sa + +import security +from settings import settings +from db.base import get_db +from db.models.users import User +import schemas + +app = FastAPI(title="AsyncFlow Auth Service") + + +@app.post("/register", response_model=schemas.User) +async def register_user( + user_data: schemas.UserCreate, + db: AsyncSession = Depends(get_db) # Use async session +): + """Register a new user.""" + # Check if user exists + result = await db.execute( + sa.select(User).where(User.email == user_data.email) + ) + if result.scalar(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Email already registered" + ) + + result = await db.execute( + sa.select(User).where(User.username == user_data.username) + ) + if result.scalar(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Username already taken" + ) + + # Create new user + hashed_password = security.get_password_hash(user_data.password) + db_user = User( + email=user_data.email, + username=user_data.username, + hashed_password=hashed_password + ) + + db.add(db_user) + await db.commit() + await db.refresh(db_user) + + return db_user + + +@app.post("/auth/token", response_model=schemas.Token) +async def login_for_access_token( + form_data: OAuth2PasswordRequestForm = Depends(), + db: AsyncSession = Depends(get_db) # Use async session +): + """Login to get access token.""" + result = await db.execute( + sa.select(User).where(User.username == form_data.username) + ) + user = result.scalar() + if not user or not security.verify_password(form_data.password, user.hashed_password): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect username or password", + headers={"WWW-Authenticate": "Bearer"}, + ) + + access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + access_token = security.create_access_token( + data={"sub": user.username}, + expires_delta=access_token_expires + ) + + return {"access_token": access_token, "token_type": "bearer"} + + +@app.get("/auth/me", response_model=schemas.User) +async def read_users_me( + current_user: User = Depends(security.get_current_active_user) +): + """Get current user information.""" + return current_user + + +@app.put("/auth/me", response_model=schemas.User) +async def update_user_me( + user_update: schemas.UserUpdate, + current_user: User = Depends(security.get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """Update current user information.""" + if user_update.email and user_update.email != current_user.email: + result = await db.execute(sa.select(User).where(User.email == user_update.email)) + if result.scalar(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Email already registered" + ) + current_user.email = user_update.email + + if user_update.username and user_update.username != current_user.username: + result = await db.execute(sa.select(User).where(User.username == user_update.username)) + if result.scalar(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Username already taken" + ) + current_user.username = user_update.username + + if user_update.password: + current_user.hashed_password = security.get_password_hash(user_update.password) + + if user_update.is_active is not None: + current_user.is_active = user_update.is_active + + db.add(current_user) + await db.commit() + await db.refresh(current_user) + + return current_user + + +@app.get("/auth/users", response_model=List[schemas.User]) +async def read_users( + skip: int = 0, + limit: int = 100, + current_user: User = Depends(security.get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """Get list of users (requires superuser).""" + if not current_user.is_superuser: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions" + ) + + result = await db.execute(sa.select(User).offset(skip).limit(limit)) + users = result.scalars().all() + return users + + +@app.get("/health") +async def health_check(): + """Health check endpoint.""" + return {"status": "healthy"} \ No newline at end of file diff --git a/auth_service/src/schemas.py b/auth_service/src/schemas.py new file mode 100644 index 0000000..c9ea2cd --- /dev/null +++ b/auth_service/src/schemas.py @@ -0,0 +1,45 @@ +from pydantic import BaseModel, EmailStr +from typing import Optional +from datetime import datetime + + +class UserBase(BaseModel): + """Base user schema.""" + email: EmailStr + username: str + + +class UserCreate(UserBase): + """User creation schema.""" + password: str + + +class UserUpdate(BaseModel): + """User update schema.""" + email: Optional[EmailStr] = None + username: Optional[str] = None + password: Optional[str] = None + is_active: Optional[bool] = None + + +class User(UserBase): + """User response schema.""" + id: int + is_active: bool + is_superuser: bool + created_at: datetime + updated_at: Optional[datetime] = None + + class Config: + from_attributes = True + + +class Token(BaseModel): + """Token response schema.""" + access_token: str + token_type: str + + +class TokenData(BaseModel): + """Token payload schema.""" + username: Optional[str] = None \ No newline at end of file diff --git a/auth_service/src/security.py b/auth_service/src/security.py new file mode 100644 index 0000000..fa012aa --- /dev/null +++ b/auth_service/src/security.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +from datetime import datetime, timedelta +from typing import Optional + +import logging +from jose import JWTError, jwt +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +from sqlalchemy.ext.asyncio import AsyncSession +import sqlalchemy as sa +from passlib.context import CryptContext + +from settings import settings +from db.base import get_db +from db.models.users import User + +logger = logging.getLogger(__name__) +logger.setLevel(logging.INFO) + +# --- +# ะŸะฐั€ะพะปัŒะฝั‹ะน ะบะพะฝั‚ะตะบัั‚: +# - ะพัะฝะพะฒะฝะฐั ัั…ะตะผะฐ: bcrypt_sha256 (ัะฝะธะผะฐะตั‚ ะปะธะผะธั‚ 72 ะฑะฐะนั‚ะฐ) +# - legacy: bcrypt (ั‡ั‚ะพะฑั‹ ะฟั€ะพะฒะตั€ัั‚ัŒ ัƒะถะต ัะพั…ั€ะฐะฝั‘ะฝะฝั‹ะต ั…ััˆะธ) +# --- +pwd_context = CryptContext( + schemes=["bcrypt_sha256", "bcrypt"], + default="bcrypt_sha256", + deprecated="auto", +) + +# ะšะพั€ั€ะตะบั‚ะฝั‹ะน ะพั‚ะฝะพัะธั‚ะตะปัŒะฝั‹ะน URL ะดะปั ั‚ะพะบะตะฝะฐ (ั‡ะตั€ะตะท ะฒะฐัˆ /auth/token endpoint) +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/token") + +# --- +# ะฅััˆะธั€ะพะฒะฐะฝะธะต ะธ ะฒะตั€ะธั„ะธะบะฐั†ะธั +# --- + +def get_password_hash(password: str) -> str: + """ + ะ’ะพะทะฒั€ะฐั‰ะฐะตั‚ ั…ััˆ ะฟะฐั€ะพะปั, ะธัะฟะพะปัŒะทัƒั bcrypt_sha256 (ะฟะพ ัƒะผะพะปั‡ะฐะฝะธัŽ). + ะ’ะะ˜ะœะะะ˜ะ•: ะฟะฐั€ะพะปัŒ ะฝะต ะปะพะณะธั€ัƒะตะผ. + """ + if not isinstance(password, str): + raise TypeError("password must be a str") + # passlib ัะฐะผ ะฒัะต ะบะพั€ั€ะตะบั‚ะฝะพ ะบะพะดะธั€ัƒะตั‚; ะปะธะผะธั‚ั‹ 72 ะฑะฐะนั‚ะฐ ะดะปั 'bcrypt' ะฝะฐั ะฝะต ะบะฐัะฐัŽั‚ัั, + # ั‚.ะบ. ะพัะฝะพะฒะฝะฐั ัั…ะตะผะฐ 'bcrypt_sha256' ะฟั€ะตะดะฒะฐั€ะธั‚ะตะปัŒะฝะพ ั…ััˆะธั€ัƒะตั‚ ะฟะฐั€ะพะปัŒ SHA-256. + return pwd_context.hash(password) + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + """ + ะŸั€ะพะฒะตั€ัะตั‚ ะฟะฐั€ะพะปัŒ ะฟั€ะพั‚ะธะฒ ั…ััˆะฐ (ะฟะพะดะดะตั€ะถะธะฒะฐะตั‚ ะธ bcrypt_sha256, ะธ legacy bcrypt). + """ + if not isinstance(plain_password, str) or not isinstance(hashed_password, str): + return False + try: + return pwd_context.verify(plain_password, hashed_password) + except Exception as exc: + logger.warning("Password verification failed: %s", exc.__class__.__name__) + return False + + +def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str: + """Create JWT access token.""" + to_encode = data.copy() + + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(minutes=15) + + to_encode.update({"exp": expire}) + encoded_jwt = jwt.encode( + to_encode, + settings.JWT_SECRET_KEY, + algorithm=settings.JWT_ALGORITHM + ) + return encoded_jwt + + +async def get_current_user( + token: str = Depends(oauth2_scheme), + db: AsyncSession = Depends(get_db) # Use AsyncSession +) -> User: + """Get current user from JWT token.""" + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + + try: + payload = jwt.decode( + token, + settings.JWT_SECRET_KEY, + algorithms=[settings.JWT_ALGORITHM] + ) + username: str = payload.get("sub") + if username is None: + raise credentials_exception + except JWTError: + raise credentials_exception + + result = await db.execute( + sa.select(User).where(User.username == username) + ) + user = result.scalar() + if user is None: + raise credentials_exception + + return user + + +async def get_current_active_user( + current_user: User = Depends(get_current_user) +) -> User: + """Get current active user.""" + if not current_user.is_active: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Inactive user" + ) + return current_user \ No newline at end of file diff --git a/auth_service/src/settings.py b/auth_service/src/settings.py new file mode 100644 index 0000000..0d47ebc --- /dev/null +++ b/auth_service/src/settings.py @@ -0,0 +1,36 @@ +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + """Authentication service configuration settings.""" + + # Service configuration + HOST: str = "0.0.0.0" + PORT: int = 9003 + + # Database configuration + POSTGRES_USER: str = "postgres" + POSTGRES_PASSWORD: str = "postgres" + POSTGRES_HOST: str = "postgres" + POSTGRES_PORT: int = 5434 # Using a different port to avoid conflicts + POSTGRES_DB: str = "auth_db" + + # JWT configuration + JWT_SECRET_KEY: str = "your-secret-key" # Should be overridden in production + JWT_ALGORITHM: str = "HS256" + ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 + + @property + def database_url(self) -> str: + """Get the database URL.""" + return ( + f"postgresql+asyncpg://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}@" + f"{self.POSTGRES_HOST}:{self.POSTGRES_PORT}/{self.POSTGRES_DB}" + ) + + class Config: + env_file = ".env" + case_sensitive = True + + +settings = Settings() \ No newline at end of file diff --git a/billing_service/pyproject.toml b/billing_service/pyproject.toml new file mode 100644 index 0000000..39f89dc --- /dev/null +++ b/billing_service/pyproject.toml @@ -0,0 +1,125 @@ +[project] +name = "billing_service" +version = "0.1.0" +description = "AsyncFlow ยท Billing Service (RabbitMQ + Async SQLAlchemy)" +readme = "README.md" +requires-python = ">=3.12" + +# --- RUNTIME DEPENDENCIES --- +dependencies = [ + # Messaging / RabbitMQ + "aio-pika>=9.4,<10", + # Settings / Validation + "pydantic>=2.7,<3", + "pydantic-settings>=2.3,<3", + # Database (async SQLAlchemy + asyncpg) + "sqlalchemy>=2.0,<3", + "asyncpg>=0.29,<1.0", + "greenlet>=3.2.4", + # Migrations + "alembic>=1.13,<2", + # Utils + "python-dotenv>=1.0,<2", + "anyio>=4.4,<5", + "aiosqlite>=0.21.0", +] + +[dependency-groups] +dev = [ + # Lint/Format + "ruff>=0.6,<1", + "black>=24.8,<25", + + # Typing + "mypy>=1.10,<2", + "types-python-dateutil>=2.9.0.20240316", + + # Tests + "pytest>=8.3,<9", + "pytest-asyncio>=0.23,<1", + "pytest-cov>=4.1,<5" +] + +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[tool.setuptools.packages.find] +where = ["."] +include = ["src*"] + +[tool.pytest.ini_options] +pythonpath = ["src"] +testpaths = ["src/tests"] +python_files = ["test_*.py"] +asyncio_mode = "auto" +minversion = "8.0" +addopts = "-ra -q --strict-markers --disable-warnings --cov=src --cov-report=term-missing" + +[tool.coverage.run] +source = ["src"] +branch = true + +[tool.ruff] +target-version = "py312" +line-length = 100 +fix = true +unsafe-fixes = false +select = [ + "E", "F", # pycodestyle / pyflakes + "I", # isort + "UP", # pyupgrade + "B", # bugbear + "SIM", # simplify + "C90", # mccabe complexity + "PL", # pylint rules + "RUF", # ruff-specific +] +ignore = [ + "E501", # line length control +] + +[tool.ruff.lint.per-file-ignores] +"tests/**" = ["S101"] # allow asserts in tests + +[tool.ruff.format] +preview = true +quote-style = "double" +indent-style = "space" + +[tool.black] +target-version = ["py312"] +line-length = 100 +skip-string-normalization = true + +[tool.mypy] +python_version = "3.12" +strict = true +warn_unused_ignores = true +warn_redundant_casts = true +warn_unreachable = true +disallow_any_generics = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_defs = true +disallow_incomplete_defs = true +check_untyped_defs = true +no_implicit_optional = true +show_error_codes = true +pretty = true + +plugins = [ + "pydantic.mypy", + "sqlalchemy.ext.mypy.plugin", +] + +mypy_path = ["src"] + +[[tool.mypy.overrides]] +module = "tests.*" +strict = false + +[tool.coverage.report] +skip_empty = true +show_missing = true +fail_under = 80 \ No newline at end of file diff --git a/billing_service/src/consumers/order_consumer.py b/billing_service/src/consumers/order_consumer.py new file mode 100644 index 0000000..44713b9 --- /dev/null +++ b/billing_service/src/consumers/order_consumer.py @@ -0,0 +1,127 @@ +import json +import logging +from datetime import datetime +from typing import Optional + +import aio_pika +from aio_pika import IncomingMessage +from sqlalchemy.ext.asyncio import AsyncSession + +from src.db.models.payments import Payment +from src.models.events import OrderCreatedEvent, PaymentProcessedEvent +from src.db.base import async_session + + +logger = logging.getLogger(__name__) + + +class OrderConsumer: + """Consumes order_created events and processes payments.""" + + def __init__(self, exchange: aio_pika.abc.AbstractExchange): + self.exchange = exchange + + async def setup(self) -> None: + """Setup queue and bind to exchange.""" + channel = self.exchange.channel + + # Declare queue + queue = await channel.declare_queue( + "billing_orders_queue", + durable=True, + auto_delete=False + ) + + # Bind to order_created events + await queue.bind( + exchange=self.exchange, + routing_key="order_created" + ) + + # Start consuming + await queue.consume(self.process_message) + logger.info("Order consumer ready") + + async def process_message(self, message: IncomingMessage) -> None: + """Process incoming order_created event.""" + async with message.process(): + try: + # Parse event + event_data = json.loads(message.body.decode()) + order_event = OrderCreatedEvent(**event_data) + + # Process payment + payment_id = await self.process_payment(order_event) + + if payment_id: + # Publish result + await self.publish_result( + order_id=order_event.order_id, + user_id=order_event.user_id, + payment_id=payment_id, + amount=order_event.amount, + status="completed" + ) + logger.info(f"Payment processed: order_id={order_event.order_id}") + else: + logger.error(f"Payment failed: order_id={order_event.order_id}") + + except Exception as e: + logger.exception(f"Error processing message: {e}") + # Requeue if needed (depends on your retry strategy) + # await message.reject(requeue=True) + + async def process_payment(self, order: OrderCreatedEvent) -> Optional[int]: + """Process payment for order and return payment_id if successful.""" + async with async_session() as session: + try: + # Create payment record + payment = Payment( + order_id=order.order_id, + user_id=order.user_id, + amount=float(order.amount), + status="processing" + ) + session.add(payment) + await session.flush() + + # TODO: Add actual payment processing logic here + # For now, simulate success + payment.status = "completed" + payment.processed_at = datetime.utcnow() + + await session.commit() + return payment.id + + except Exception as e: + logger.exception(f"Payment processing error: {e}") + await session.rollback() + return None + + async def publish_result( + self, + order_id: int, + user_id: int, + payment_id: int, + amount: float, + status: str + ) -> None: + """Publish payment_processed event.""" + event = PaymentProcessedEvent( + order_id=order_id, + user_id=user_id, + payment_id=payment_id, + amount=amount, + status=status, + processed_at=datetime.utcnow() + ) + + message = aio_pika.Message( + body=json.dumps(event.model_dump()).encode(), + delivery_mode=aio_pika.DeliveryMode.PERSISTENT + ) + + await self.exchange.publish( + message, + routing_key="payment_processed" + ) \ No newline at end of file diff --git a/billing_service/src/db/base.py b/billing_service/src/db/base.py new file mode 100644 index 0000000..ad9ccda --- /dev/null +++ b/billing_service/src/db/base.py @@ -0,0 +1,21 @@ +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker +from sqlalchemy.orm import DeclarativeBase + +from src.settings import settings + +engine = create_async_engine( + settings.database_url, + echo=False, + future=True, + pool_size=settings.db_pool_size +) + +async_session = async_sessionmaker( + engine, + expire_on_commit=False, + class_=AsyncSession +) + +class Base(DeclarativeBase): + """Base class for all models.""" + pass \ No newline at end of file diff --git a/billing_service/src/db/models/payments.py b/billing_service/src/db/models/payments.py new file mode 100644 index 0000000..227ee35 --- /dev/null +++ b/billing_service/src/db/models/payments.py @@ -0,0 +1,14 @@ +from sqlalchemy import Column, Integer, Float, DateTime, func, String, ForeignKey +from src.db.base import Base + + +class Payment(Base): + __tablename__ = "payments" + + id = Column(Integer, primary_key=True, index=True) + order_id = Column(Integer, nullable=False, index=True) + user_id = Column(Integer, nullable=False) + amount = Column(Float, nullable=False) + status = Column(String(32), nullable=False, server_default="pending") + processed_at = Column(DateTime(timezone=True), nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) \ No newline at end of file diff --git a/billing_service/src/main.py b/billing_service/src/main.py new file mode 100644 index 0000000..cc3a0d9 --- /dev/null +++ b/billing_service/src/main.py @@ -0,0 +1,85 @@ +import asyncio +import logging +from contextlib import asynccontextmanager +from typing import AsyncIterator + +import aio_pika +from src.settings import settings +from src.consumers.order_consumer import OrderConsumer + + +# Setup logging +logger = logging.getLogger("asyncflow.billing_service") +logging.basicConfig( + level=getattr(logging, settings.log_level.upper(), logging.INFO), + format="%(asctime)s | %(levelname)s | %(name)s | %(message)s", +) + + +@asynccontextmanager +async def get_rabbitmq() -> AsyncIterator[aio_pika.abc.AbstractExchange]: + """Setup RabbitMQ connection, channel and exchange.""" + # Build connection URL + amqp_url = ( + f"amqp://{settings.rabbitmq_user}:{settings.rabbitmq_pass.get_secret_value()}" + f"@{settings.rabbitmq_host}:{settings.rabbitmq_port}/" + ) + logger.info( + "Connecting to RabbitMQ: %s", + amqp_url.replace(settings.rabbitmq_pass.get_secret_value(), "******") + ) + + connection = None + channel = None + exchange = None + + try: + # Connect and setup exchange + connection = await aio_pika.connect_robust(amqp_url) + channel = await connection.channel() + exchange = await channel.declare_exchange( + name=settings.amqp_exchange, + type=aio_pika.ExchangeType.TOPIC, + durable=True + ) + + logger.info("RabbitMQ connected") + yield exchange + + finally: + # Cleanup + try: + if channel and not channel.is_closed: + await channel.close() + except Exception as e: + logger.warning(f"Channel close warning: {e}") + + try: + if connection and not connection.is_closed: + await connection.close() + except Exception as e: + logger.warning(f"Connection close warning: {e}") + + logger.info("RabbitMQ connection closed") + + +async def main() -> None: + """Main service entry point.""" + async with get_rabbitmq() as exchange: + # Setup consumer + consumer = OrderConsumer(exchange) + await consumer.setup() + + try: + # Keep service running + while True: + await asyncio.sleep(3600) # 1 hour + except asyncio.CancelledError: + logger.info("Shutdown requested") + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except KeyboardInterrupt: + logger.info("Service stopped") \ No newline at end of file diff --git a/billing_service/src/models/events.py b/billing_service/src/models/events.py new file mode 100644 index 0000000..63179ef --- /dev/null +++ b/billing_service/src/models/events.py @@ -0,0 +1,23 @@ +from pydantic import BaseModel +from datetime import datetime +from decimal import Decimal + + +class OrderCreatedEvent(BaseModel): + """Event received from Order Service when order is created.""" + event: str = "order_created" + order_id: int + user_id: int + amount: Decimal + created_at: datetime + + +class PaymentProcessedEvent(BaseModel): + """Event published when payment is processed (success/failed).""" + event: str = "payment_processed" + order_id: int + user_id: int + payment_id: int + amount: Decimal + status: str + processed_at: datetime \ No newline at end of file diff --git a/billing_service/src/settings.py b/billing_service/src/settings.py new file mode 100644 index 0000000..8cc13d6 --- /dev/null +++ b/billing_service/src/settings.py @@ -0,0 +1,71 @@ +from pydantic import Field, validator, SecretStr +from pydantic_settings import BaseSettings +from typing import List, Optional +from enum import Enum + + +class LogLevel(str, Enum): + """Valid log levels.""" + DEBUG = "DEBUG" + INFO = "INFO" + WARNING = "WARNING" + ERROR = "ERROR" + CRITICAL = "CRITICAL" + + +class Settings(BaseSettings): + """Billing Service settings with environment variable support.""" + # Service + service_name: str = Field("billing_service", description="Service name for logs and tracing") + environment: str = Field("development", description="Deployment environment") + + # RabbitMQ + rabbitmq_user: str = Field("user", description="RabbitMQ username") + rabbitmq_pass: SecretStr = Field("pass", description="RabbitMQ password") + rabbitmq_host: str = Field("rabbitmq", description="RabbitMQ hostname") + rabbitmq_port: int = Field(5672, ge=1, le=65535, description="RabbitMQ port") + amqp_exchange: str = Field( + "asyncflow.exchange", + description="RabbitMQ exchange name for event publishing" + ) + + # Database + db_user: str = Field("postgres", description="Database username") + db_pass: SecretStr = Field("postgres", description="Database password") + db_host: str = Field("db", description="Database hostname") + db_port: int = Field(5434, ge=1, le=65535, description="Database port") + db_name: str = Field("billing_service", description="Database name") + db_pool_size: int = Field(5, ge=1, le=20, description="Database connection pool size") + db_use_ssl: bool = Field(False, description="Enable SSL for database connection") + + # Logging + log_level: LogLevel = Field( + LogLevel.INFO, + description="Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)" + ) + + @property + def database_url(self) -> str: + """Build SQLAlchemy database URL.""" + # Use in-memory SQLite for testing + if self.environment == "test" or not self.db_host: + return "sqlite+aiosqlite:///:memory:" + + # Build PostgreSQL URL for production/development + url = f"postgresql+asyncpg://{self.db_user}:{self.db_pass.get_secret_value()}" + url += f"@{self.db_host}:{self.db_port}/{self.db_name}" + if self.db_use_ssl: + url += "?ssl=true" + return url + + class Config: + env_file = ".env" + env_nested_delimiter = "__" + case_sensitive = True + + +# Global settings instance +settings = Settings() + +# Validate settings on import +settings.model_validate(settings.model_dump()) \ No newline at end of file diff --git a/db/init/01-init.sql b/db/init/01-init.sql new file mode 100644 index 0000000..d2f7fda --- /dev/null +++ b/db/init/01-init.sql @@ -0,0 +1,35 @@ +-- Create application roles (dev defaults) +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT FROM pg_catalog.pg_roles WHERE rolname = 'auth_user') THEN + CREATE ROLE auth_user LOGIN PASSWORD 'postgres'; + END IF; + IF NOT EXISTS ( + SELECT FROM pg_catalog.pg_roles WHERE rolname = 'orders_user') THEN + CREATE ROLE orders_user LOGIN PASSWORD 'postgres'; + END IF; + IF NOT EXISTS ( + SELECT FROM pg_catalog.pg_roles WHERE rolname = 'billing_user') THEN + CREATE ROLE billing_user LOGIN PASSWORD 'postgres'; + END IF; +END$$; + +-- Create databases owned by respective roles +-- Note: entrypoint runs this only on first init, so IF NOT EXISTS is not required +CREATE DATABASE auth_db OWNER auth_user; +CREATE DATABASE orders_db OWNER orders_user; +CREATE DATABASE billing_db OWNER billing_user; + +-- Adjust schema ownership and basic privileges +\connect auth_db +ALTER SCHEMA public OWNER TO auth_user; +GRANT ALL PRIVILEGES ON DATABASE auth_db TO auth_user; + +\connect orders_db +ALTER SCHEMA public OWNER TO orders_user; +GRANT ALL PRIVILEGES ON DATABASE orders_db TO orders_user; + +\connect billing_db +ALTER SCHEMA public OWNER TO billing_user; +GRANT ALL PRIVILEGES ON DATABASE billing_db TO billing_user; diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..3d5fba4 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,243 @@ +version: "3.9" + +x-env-common: &env_common + RABBITMQ_HOST: rabbitmq + RABBITMQ_USER: user + RABBITMQ_PASS: pass + +services: + # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + # PostgreSQL Database (shared) + # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + postgres: + image: postgres:16-alpine + container_name: asyncflow_postgres + restart: unless-stopped + environment: + POSTGRES_USER: ${POSTGRES_USER:-postgres} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} + # POSTGRES_DB can be omitted since we create multiple DBs via init script + ports: + - "${POSTGRES_PORT_PUBLIC:-5432}:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + - ./db/init:/docker-entrypoint-initdb.d:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres}"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - asyncflow_net + + # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + # pgAdmin 4 (optional GUI) + # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + pgadmin: + image: dpage/pgadmin4:8.12 + container_name: asyncflow_pgadmin + restart: unless-stopped + environment: + PGADMIN_DEFAULT_EMAIL: ${PGADMIN_DEFAULT_EMAIL:-admin@example.com} + PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_DEFAULT_PASSWORD:-admin} + ports: + - "${PGADMIN_PORT:-5050}:80" + depends_on: + postgres: + condition: service_healthy + networks: + - asyncflow_net + # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + # RabbitMQ Broker + # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + rabbitmq: + image: rabbitmq:3.13-management + container_name: asyncflow_rabbitmq + hostname: rabbitmq + restart: unless-stopped + ports: + - "5672:5672" # AMQP protocol + - "15672:15672" # Management UI + environment: + RABBITMQ_DEFAULT_USER: ${RABBITMQ_USER:-user} + RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASS:-pass} + healthcheck: + test: ["CMD", "rabbitmq-diagnostics", "ping"] + interval: 20s + timeout: 5s + retries: 5 + volumes: + - rabbitmq_data:/var/lib/rabbitmq + networks: + - asyncflow_net + + # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + # Frontend (React + Nginx) + # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + container_name: asyncflow_frontend + restart: unless-stopped + ports: + - "${FRONTEND_PORT:-3000}:80" + depends_on: + api_gateway: + condition: service_started + healthcheck: + test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:80/"] + interval: 20s + timeout: 5s + retries: 3 + start_period: 10s + networks: + - asyncflow_net + + # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + # API Gateway (FastAPI) + # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + api_gateway: + build: + context: ./api_gateway + dockerfile: Dockerfile + container_name: asyncflow_api_gateway + restart: unless-stopped + ports: + - "${API_GATEWAY_PORT:-9000}:${API_GATEWAY_PORT:-9000}" + env_file: + - .env + environment: + <<: *env_common + API_GATEWAY_PORT: ${API_GATEWAY_PORT:-9000} + AUTH_SERVICE_PORT: ${AUTH_SERVICE_PORT:-9003} + ORDER_SERVICE_PORT: ${ORDER_SERVICE_PORT:-9001} + BILLING_SERVICE_PORT: ${BILLING_SERVICE_PORT:-9002} + LOG_LEVEL: info + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:${API_GATEWAY_PORT:-9000}/health"] + interval: 20s + timeout: 5s + retries: 5 + start_period: 10s + networks: + asyncflow_net: + aliases: + - api_gateway + + # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + # Auth Service (FastAPI) + # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + auth_service: + build: + context: ./auth_service + dockerfile: Dockerfile + container_name: asyncflow_auth + restart: unless-stopped + ports: + - "${AUTH_SERVICE_PORT:-9003}:${AUTH_SERVICE_PORT:-9003}" + env_file: + - .env + environment: + <<: *env_common + AUTH_SERVICE_PORT: ${AUTH_SERVICE_PORT:-9003} + POSTGRES_HOST: ${POSTGRES_HOST:-postgres} + POSTGRES_PORT: ${POSTGRES_PORT:-5432} + POSTGRES_USER: ${POSTGRES_USER:-postgres} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} + LOG_LEVEL: info + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:${AUTH_SERVICE_PORT:-9003}/health"] + interval: 20s + timeout: 5s + retries: 5 + start_period: 10s + networks: + - asyncflow_net + depends_on: + postgres: + condition: service_healthy + + # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + # Order Service (FastAPI) + # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + order_service: + build: + context: ./order_service + dockerfile: Dockerfile + container_name: asyncflow_order + restart: unless-stopped + ports: + - "${ORDER_SERVICE_PORT:-9001}:${ORDER_SERVICE_PORT:-9001}" + env_file: + - .env + environment: + <<: *env_common + ORDER_SERVICE_PORT: ${ORDER_SERVICE_PORT:-9001} + DB_HOST: ${POSTGRES_HOST:-postgres} + DB_PORT: ${POSTGRES_PORT:-5432} + DB_USER: ${POSTGRES_USER:-postgres} + DB_PASS: ${POSTGRES_PASSWORD:-postgres} + DB_NAME: ${ORDER_SERVICE_DB:-orders_db} + LOG_LEVEL: info + depends_on: + rabbitmq: + condition: service_healthy + postgres: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:${ORDER_SERVICE_PORT:-9001}/api/health"] + interval: 20s + timeout: 5s + retries: 5 + start_period: 20s + networks: + - asyncflow_net + deploy: + resources: + limits: + cpus: "0.50" + memory: 512M + reservations: + cpus: "0.25" + memory: 256M + + # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + # Billing Service (consumer) + # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + billing_service: + build: + context: ./billing_service + dockerfile: Dockerfile + container_name: asyncflow_billing + restart: unless-stopped + env_file: + - .env + environment: + <<: *env_common + DB_HOST: ${POSTGRES_HOST:-postgres} + DB_PORT: ${POSTGRES_PORT:-5432} + DB_USER: ${POSTGRES_USER:-postgres} + DB_PASS: ${POSTGRES_PASSWORD:-postgres} + DB_NAME: ${BILLING_SERVICE_DB:-billing_db} + LOG_LEVEL: info + depends_on: + rabbitmq: + condition: service_healthy + postgres: + condition: service_healthy + networks: + - asyncflow_net + +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# Networks & Volumes +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +networks: + asyncflow_net: + driver: bridge + +volumes: + rabbitmq_data: + driver: local + postgres_data: + driver: local \ No newline at end of file diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..8286515 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,30 @@ +# Dependencies +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +# Build output +dist/ +dist-ssr/ +*.local + +# Editor +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Environment +.env +.env.local +.env.development.local +.env.test.local +.env.production.local diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..32ec486 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,39 @@ +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# Stage 1: Build the React app +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +FROM node:20-alpine AS builder + +WORKDIR /app + +# Copy package files +COPY package*.json ./ + +# Install dependencies (including devDependencies for build) +RUN npm install + +# Copy source code +COPY . . + +# Build the app +RUN npm run build + +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# Stage 2: Serve with nginx +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +FROM nginx:1.25-alpine + +# Copy built files from builder stage +COPY --from=builder /app/dist /usr/share/nginx/html + +# Copy nginx configuration +COPY nginx.conf /etc/nginx/conf.d/default.conf + +# Expose port +EXPOSE 80 + +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD wget --quiet --tries=1 --spider http://localhost:80/ || exit 1 + +# Start nginx +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..fb81245 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + AsyncFlow - Event-Driven Microservices + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..e31ce7a --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,45 @@ +server { + listen 80; + server_name localhost; + + root /usr/share/nginx/html; + index index.html; + + # Gzip compression + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json; + + # Security headers + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + + # API proxy to gateway + location /api/ { + proxy_pass http://api_gateway:9000; + 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; + } + + # SPA routing - serve index.html for all routes + location / { + try_files $uri $uri/ /index.html; + } + + # Cache static assets + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # Error pages + error_page 404 /index.html; +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..560beef --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,28 @@ +{ + "name": "asyncflow-frontend", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0" + }, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.20.0", + "axios": "^1.6.2" + }, + "devDependencies": { + "@types/react": "^18.2.43", + "@types/react-dom": "^18.2.17", + "@vitejs/plugin-react": "^4.2.1", + "vite": "^5.0.8", + "eslint": "^8.55.0", + "eslint-plugin-react": "^7.33.2", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-react-refresh": "^0.4.5" + } +} diff --git a/frontend/src/App.css b/frontend/src/App.css new file mode 100644 index 0000000..4286974 --- /dev/null +++ b/frontend/src/App.css @@ -0,0 +1,306 @@ +.app { + min-height: 100vh; + display: flex; + flex-direction: column; +} + +.header { + background: rgba(255, 255, 255, 0.95); + padding: 2rem; + text-align: center; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); +} + +.header h1 { + font-size: 3rem; + color: #667eea; + margin-bottom: 0.5rem; +} + +.subtitle { + font-size: 1.2rem; + color: #666; +} + +.main { + flex: 1; + max-width: 1200px; + margin: 2rem auto; + padding: 0 2rem; + width: 100%; +} + +.hero { + background: white; + padding: 3rem; + border-radius: 12px; + text-align: center; + margin-bottom: 2rem; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1); +} + +.hero h2 { + font-size: 2.5rem; + color: #333; + margin-bottom: 1rem; +} + +.hero p { + font-size: 1.2rem; + color: #666; +} + +.status-card { + background: white; + padding: 2rem; + border-radius: 12px; + margin-bottom: 2rem; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1); +} + +.status-card h3 { + font-size: 1.8rem; + margin-bottom: 1.5rem; + color: #333; +} + +.loading { + text-align: center; + color: #666; + font-size: 1.1rem; +} + +.health-status { + display: flex; + align-items: flex-start; + gap: 1.5rem; +} + +.status-indicator { + font-size: 3rem; +} + +.status-details { + flex: 1; +} + +.status-details p { + font-size: 1.1rem; + margin-bottom: 1rem; +} + +.services { + margin-top: 1rem; +} + +.services h4 { + font-size: 1.2rem; + margin-bottom: 0.5rem; + color: #555; +} + +.services ul { + list-style: none; + padding-left: 0; +} + +.services li { + padding: 0.5rem 0; + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 1rem; +} + +.service-status { + font-size: 1.2rem; +} + +.latency { + color: #888; + font-size: 0.9rem; +} + +.features { + background: white; + padding: 2rem; + border-radius: 12px; + margin-bottom: 2rem; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1); +} + +.features h3 { + font-size: 1.8rem; + margin-bottom: 1.5rem; + color: #333; +} + +.feature-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 1.5rem; +} + +.feature-card { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + padding: 1.5rem; + border-radius: 8px; + transition: transform 0.3s ease, box-shadow 0.3s ease; +} + +.feature-card:hover { + transform: translateY(-5px); + box-shadow: 0 8px 25px rgba(102, 126, 234, 0.4); +} + +.feature-card h4 { + font-size: 1.4rem; + margin-bottom: 0.5rem; +} + +.feature-card p { + font-size: 1rem; + opacity: 0.9; +} + +.tech-stack { + background: white; + padding: 2rem; + border-radius: 12px; + margin-bottom: 2rem; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1); +} + +.tech-stack h3 { + font-size: 1.8rem; + margin-bottom: 1.5rem; + color: #333; +} + +.tech-tags { + display: flex; + flex-wrap: wrap; + gap: 1rem; +} + +.tag { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + padding: 0.5rem 1rem; + border-radius: 20px; + font-size: 0.9rem; + font-weight: 500; +} + +.footer { + background: rgba(255, 255, 255, 0.95); + padding: 1.5rem; + text-align: center; + color: #666; + box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.1); +} + +.auth-button { + background-color: #4CAF50; + color: white; + border: none; + border-radius: 25px; + padding: 10px 20px; + margin: 5px; + cursor: pointer; + font-size: 16px; + font-weight: bold; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + transition: all 0.3s ease; +} + +.auth-button:hover { + background-color: #45a049; + transform: scale(1.05); +} + +.auth-button:active { + background-color: #3e8e41; + transform: scale(0.95); +} + +.modal { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background-color: white; + border-radius: 10px; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); + padding: 20px; + z-index: 1000; + width: 300px; +} + +.modal h3 { + margin-top: 0; + font-size: 20px; + text-align: center; +} + +.modal form { + display: flex; + flex-direction: column; + gap: 15px; +} + +.modal input { + padding: 10px; + border: 1px solid #ccc; + border-radius: 5px; + font-size: 14px; +} + +.modal button { + background-color: #007BFF; + color: white; + border: none; + border-radius: 5px; + padding: 10px; + cursor: pointer; + font-size: 14px; + transition: background-color 0.3s ease; +} + +.modal button:hover { + background-color: #0056b3; +} + +.modal button:active { + background-color: #003f7f; +} + +.modal-close { + background: none; + border: none; + color: #aaa; + font-size: 20px; + position: absolute; + top: 10px; + right: 10px; + cursor: pointer; +} + +.modal-close:hover { + color: #000; +} + +@media (max-width: 768px) { + .header h1 { + font-size: 2rem; + } + + .hero h2 { + font-size: 1.8rem; + } + + .feature-grid { + grid-template-columns: 1fr; + } +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..a4cf4b9 --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,284 @@ +import { useState, useEffect } from 'react' +import './App.css' +import axios from 'axios' + +function App() { + const [health, setHealth] = useState(null) + const [loading, setLoading] = useState(true) + const [showSignIn, setShowSignIn] = useState(false) + const [showSignUp, setShowSignUp] = useState(false) + const [signInData, setSignInData] = useState({ username: '', password: '' }) + const [signUpData, setSignUpData] = useState({ username: '', email: '', password: '' }) + const [showAuthForm, setShowAuthForm] = useState(false) + const [authData, setAuthData] = useState({ username: '', password: '' }) + + useEffect(() => { + const checkHealth = async () => { + try { + const response = await axios.get('/api/v1/health') + setHealth(response.data) + } catch (error) { + setHealth({ status: 'error', message: error.message }) + } finally { + setLoading(false) + } + } + + checkHealth() + const interval = setInterval(checkHealth, 10 * 60 * 1000) // Check health every 10 minutes + + return () => clearInterval(interval) // Cleanup interval on component unmount + }, []) + + const toggleSignIn = () => setShowSignIn(!showSignIn) + const toggleSignUp = () => setShowSignUp(!showSignUp) + const toggleAuthForm = () => setShowAuthForm(!showAuthForm) + + const handleSignInChange = (e) => { + const { name, value } = e.target + setSignInData((prev) => ({ ...prev, [name]: value })) + } + + const handleSignUpChange = (e) => { + const { name, value } = e.target + setSignUpData((prev) => ({ ...prev, [name]: value })) + } + + const handleAuthChange = (e) => { + const { name, value } = e.target + setAuthData((prev) => ({ ...prev, [name]: value })) + } + + const handleSignInSubmit = async (e) => { + e.preventDefault() + try { + const response = await axios.post('/api/v1/auth/login', signInData) + alert(`Login successful: ${response.data.message}`) + } catch (error) { + alert(`Login failed: ${error.response?.data?.message || error.message}`) + } + } + + const handleSignUpSubmit = async (e) => { + e.preventDefault() + if (!signUpData.username || !signUpData.email || !signUpData.password) { + alert('All fields are required!') + return + } + if (signUpData.password.length < 6) { + alert('Password must be at least 6 characters long!') + return + } + try { + const response = await axios.post('/api/v1/auth/register', signUpData) + alert(`Registration successful: ${response.data.message}`) + } catch (error) { + alert(`Registration failed: ${error.response?.data?.message || error.message}`) + } + } + + const handleAuthSubmit = async (e) => { + e.preventDefault() + try { + const response = await axios.post('/api/v1/auth/login', authData) + alert(`Login successful: ${response.data.message}`) + toggleAuthForm() + } catch (error) { + alert(`Login failed: ${error.response?.data?.message || error.message}`) + } + } + + return ( +
+
+

๐ŸŒ€ AsyncFlow

+

Event-Driven Microservices Platform

+
+ +
+
+

Welcome to AsyncFlow

+

A modern event-driven architecture demo built with FastAPI + RabbitMQ

+
+ +
+

System Status

+ {loading ? ( +

Checking services...

+ ) : ( +
+
+ {health?.status === 'healthy' ? 'โœ…' : 'โŒ'} +
+
+

Gateway: {health?.status || 'Unknown'}

+ {health?.services && ( +
+

Services:

+
    + {Object.entries(health.services).map(([name, info]) => ( +
  • + + {info.status === 'up' ? '๐ŸŸข' : info.status === 'degraded' ? '๐ŸŸก' : '๐Ÿ”ด'} + + {name}: {info.status} + {info.latency_ms && ({info.latency_ms}ms)} +
  • + ))} +
+
+ )} +
+
+ )} +
+ +
+

Features

+
+
+

๐Ÿ”’ Authentication

+

JWT-based auth service with user management

+
+
+

๐Ÿ“ฆ Orders

+

Order management with event publishing

+
+
+

๐Ÿ’ณ Billing

+

Async payment processing via RabbitMQ

+
+
+

๐ŸŒ API Gateway

+

Unified REST API with rate limiting & metrics

+
+
+
+ +
+

Technology Stack

+
+ Python 3.12 + FastAPI + RabbitMQ + PostgreSQL + Docker + React + Vite +
+
+
+ +
+ + +
+ + {showSignIn && ( +
+ +

Sign In

+
+ + + +
+
+ )} + + {showSignUp && ( +
+ +

Sign Up

+
+ + + + +
+
+ )} + + {showAuthForm && ( +
+ +

Authentication

+
+ + + +
+
+ )} + +
+

AsyncFlow ยฉ 2025 | Built with โค๏ธ for microservices architecture

+
+
+ ) +} + +export default App diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..f85a24d --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,20 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', + 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', + sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + min-height: 100vh; + color: #333; +} + +#root { + min-height: 100vh; +} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx new file mode 100644 index 0000000..54b39dd --- /dev/null +++ b/frontend/src/main.jsx @@ -0,0 +1,10 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App.jsx' +import './index.css' + +ReactDOM.createRoot(document.getElementById('root')).render( + + + , +) diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..92f5d0a --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,20 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + server: { + host: '0.0.0.0', + port: 3000, + proxy: { + '/api': { + target: process.env.VITE_API_URL || 'http://api_gateway:9000', + changeOrigin: true, + } + } + }, + build: { + outDir: 'dist', + sourcemap: false, + } +}) diff --git a/makefile b/makefile new file mode 100644 index 0000000..5ee773e --- /dev/null +++ b/makefile @@ -0,0 +1,28 @@ +# ===== AsyncFlow Makefile ===== +up: + docker compose up --build + +down: + docker compose down -v + +logs: + docker compose logs -f + +restart: + docker compose down -v && docker compose up --build + +clean: + find . -type d -name "__pycache__" -exec rm -rf {} + + +# quality +lint: + uv run ruff check . +format: + uv run ruff format . +typecheck: + uv run mypy . + +test: + uv run pytest + +qa: format lint typecheck test \ No newline at end of file diff --git a/order_service/Dockerfile b/order_service/Dockerfile new file mode 100644 index 0000000..736345a --- /dev/null +++ b/order_service/Dockerfile @@ -0,0 +1,52 @@ +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# Stage 1 โ€” Builder (ัƒัั‚ะฐะฝะพะฒะบะฐ ะทะฐะฒะธัะธะผะพัั‚ะตะน) +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +FROM python:3.12.6-slim-bookworm AS builder + +# ะฃัั‚ะฐะฝะฐะฒะปะธะฒะฐะตะผ ัะธัั‚ะตะผะฝั‹ะต ะฟะฐะบะตั‚ั‹, ะฝะตะพะฑั…ะพะดะธะผั‹ะต ะดะปั ัะฑะพั€ะบะธ ะทะฐะฒะธัะธะผะพัั‚ะตะน (ะฝะฐะฟั€ะธะผะตั€, asyncpg) +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential curl ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# ะฃัั‚ะฐะฝะฐะฒะปะธะฒะฐะตะผ uv (ะฑั‹ัั‚ั€ะฐั ะฐะปัŒั‚ะตั€ะฝะฐั‚ะธะฒะฐ pip/poetry) +RUN pip install --no-cache-dir uv==0.4.20 + +WORKDIR /build + +# ะšะพะฟะธั€ัƒะตะผ ั‚ะพะปัŒะบะพ pyproject.toml โ€” ะดะปั ะบััˆะธั€ะพะฒะฐะฝะธั ะทะฐะฒะธัะธะผะพัั‚ะตะน +COPY pyproject.toml ./ + +# ะฃัั‚ะฐะฝะฐะฒะปะธะฒะฐะตะผ ะทะฐะฒะธัะธะผะพัั‚ะธ ะฒ ัะธัั‚ะตะผะฝั‹ะน Python +RUN uv pip install --system --no-cache --compile . + + +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# Stage 2 โ€” Runtime +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +FROM python:3.12.6-slim-bookworm AS runtime + +RUN useradd --create-home --shell /bin/bash appuser + +WORKDIR /app +RUN rm -rf /app/.venv +COPY --chown=appuser:appuser . . + +# โฌ‡๏ธ ะดะพะฑะฐะฒะปัะตะผ ะบะพะฟะธั€ะพะฒะฐะฝะธะต bin +COPY --from=builder /usr/local/lib/python3.12 /usr/local/lib/python3.12 +COPY --from=builder /usr/local/bin /usr/local/bin + +USER appuser + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + UV_SYSTEM_PYTHON=1 \ + PYTHONPATH=/app \ + ORDER_SERVICE_PORT=9001 + +EXPOSE ${ORDER_SERVICE_PORT} + +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD curl -fsS http://localhost:${ORDER_SERVICE_PORT}/health || exit 1 + +# Use shell form to expand environment variable in the port argument +CMD ["/bin/sh", "-c", "uvicorn src.main:app --host 0.0.0.0 --port ${ORDER_SERVICE_PORT}"] \ No newline at end of file diff --git a/order_service/pyproject.toml b/order_service/pyproject.toml new file mode 100644 index 0000000..210be3f --- /dev/null +++ b/order_service/pyproject.toml @@ -0,0 +1,148 @@ +[project] +name = "order_service" +version = "0.1.0" +description = "AsyncFlow ยท Order Service (FastAPI + RabbitMQ + Async SQLAlchemy)" +readme = "README.md" +requires-python = ">=3.12" + +# --- RUNTIME DEPENDENCIES --- +dependencies = [ + # Web + "fastapi>=0.115,<1.0", + "uvicorn[standard]>=0.30,<1.0", + # Messaging / RabbitMQ + "aio-pika>=9.4,<10", + # Settings / Validation + "pydantic>=2.7,<3", + "pydantic-settings>=2.3,<3", + # Database (async SQLAlchemy + asyncpg) + "sqlalchemy>=2.0,<3", + "asyncpg>=0.29,<1.0", + "greenlet>=3.2.4", + # Migrations + "alembic>=1.13,<2", + # Utils + "python-dotenv>=1.0,<2", + "anyio>=4.4,<5", + "aiosqlite>=0.21.0", +] + +[project.optional-dependencies] +# Additional features can be added here if needed (e.g., email, compression) +# "extras" = ["aiosmtplib>=3,<4"] + +# --- UV SETTINGS --- +[tool.uv] +# Empty block - uv reads dependency-groups below + +[dependency-groups] +dev = [ + # Lint/Format + "ruff>=0.6,<1", + "black>=24.8,<25", + + # Typing + "mypy>=1.10,<2", + "types-python-dateutil>=2.9.0.20240316", + + # Tests + "pytest>=8.3,<9", + "pytest-asyncio>=0.23,<1", + "pytest-cov>=4.1,<5" +] + +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[tool.setuptools.packages.find] +where = ["."] +include = ["src*"] + +[tool.pytest.ini_options] +pythonpath = ["src"] +testpaths = ["src/tests"] +python_files = ["test_*.py"] +asyncio_mode = "auto" +minversion = "8.0" +addopts = "-ra -q --strict-markers --disable-warnings --cov=src --cov-report=term-missing" + +[tool.coverage.run] +source = ["src"] +branch = true + +# --- RUFF (linter + import sorter) --- +[tool.ruff] +target-version = "py312" +line-length = 100 +fix = true +unsafe-fixes = false +select = [ + "E", "F", # pycodestyle / pyflakes + "I", # isort + "UP", # pyupgrade + "B", # bugbear + "SIM", # simplify + "C90", # mccabe complexity + "PL", # pylint rules + "RUF", # ruff-specific +] +ignore = [ + "E501", # line length control +] +[tool.ruff.lint.per-file-ignores] +"tests/**" = ["S101"] # allow asserts in tests + +[tool.ruff.format] +preview = true +quote-style = "double" +indent-style = "space" + +# --- BLACK (ั„ะพั€ะผะฐั‚ั‚ะตั€) --- +[tool.black] +target-version = ["py312"] +line-length = 100 +skip-string-normalization = true + +# --- MYPY (strict typing) --- +[tool.mypy] +python_version = "3.12" +strict = true +warn_unused_ignores = true +warn_redundant_casts = true +warn_unreachable = true +disallow_any_generics = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_defs = true +disallow_incomplete_defs = true +check_untyped_defs = true +no_implicit_optional = true +show_error_codes = true +pretty = true + +plugins = [ + "pydantic.mypy", + "sqlalchemy.ext.mypy.plugin", +] + +# Source code location +mypy_path = ["src"] + +# Relax rules for tests +[[tool.mypy.overrides]] +module = "tests.*" +strict = false +minversion = "8.0" +addopts = "-ra -q --strict-markers --disable-warnings --cov=src --cov-report=term-missing" +testpaths = ["tests"] +pythonpath = ["src"] +asyncio_mode = "auto" + +[tool.coverage.report] +skip_empty = true +show_missing = true +fail_under = 80 + +[tool.alembic] +# Common paths can be stored here, main config stays in alembic/ \ No newline at end of file diff --git a/order_service/pytest.ini b/order_service/pytest.ini new file mode 100644 index 0000000..7fb3d78 --- /dev/null +++ b/order_service/pytest.ini @@ -0,0 +1,8 @@ +[pytest] +testpaths = src/tests +python_files = test_*.py +python_classes = Test* +python_functions = test_* +addopts = -v -ra +asyncio_mode = auto +pythonpath = . \ No newline at end of file diff --git a/order_service/scripts/install_dev.sh b/order_service/scripts/install_dev.sh new file mode 100644 index 0000000..3c3335b --- /dev/null +++ b/order_service/scripts/install_dev.sh @@ -0,0 +1,3 @@ +#!/bin/sh +# Install the package in development mode +pip install -e . \ No newline at end of file diff --git a/order_service/setup.py b/order_service/setup.py new file mode 100644 index 0000000..863e027 --- /dev/null +++ b/order_service/setup.py @@ -0,0 +1,9 @@ +from setuptools import setup, find_packages + +setup( + name="order_service", + version="0.1.0", + packages=find_packages(), + package_dir={"": "."}, + python_requires=">=3.11", +) \ No newline at end of file diff --git a/order_service/src/__init__.py b/order_service/src/__init__.py new file mode 100644 index 0000000..7678b0e --- /dev/null +++ b/order_service/src/__init__.py @@ -0,0 +1 @@ +"""Order Service Package.""" \ No newline at end of file diff --git a/order_service/src/api/__init__.py b/order_service/src/api/__init__.py new file mode 100644 index 0000000..3366cac --- /dev/null +++ b/order_service/src/api/__init__.py @@ -0,0 +1,8 @@ +from fastapi import APIRouter +from .orders import router as orders_router +from .health import router as health_router + +api_router = APIRouter() +api_router.include_router(orders_router, prefix="/api") +# Mount health endpoints under /api so tests use /api/health +api_router.include_router(health_router, prefix="/api") diff --git a/order_service/src/api/dependencies.py b/order_service/src/api/dependencies.py new file mode 100644 index 0000000..62dff2c --- /dev/null +++ b/order_service/src/api/dependencies.py @@ -0,0 +1,9 @@ +from fastapi import Request +import aio_pika +from sqlalchemy.ext.asyncio import AsyncSession +from src.db.dependency import get_db + + +async def get_exchange(request: Request) -> aio_pika.abc.AbstractExchange: + """ะ”ะพัั‚ะฐั‘ะผ ะพะฑั‰ะธะน exchange ะธะท FastAPI state.""" + return request.app.state.amqp_exchange \ No newline at end of file diff --git a/order_service/src/api/health.py b/order_service/src/api/health.py new file mode 100644 index 0000000..bed0992 --- /dev/null +++ b/order_service/src/api/health.py @@ -0,0 +1,7 @@ +from fastapi import APIRouter + +router = APIRouter(tags=["system"]) + +@router.get("/health") +async def health_check(): + return {"status": "ok"} \ No newline at end of file diff --git a/order_service/src/api/orders.py b/order_service/src/api/orders.py new file mode 100644 index 0000000..8df8bc4 --- /dev/null +++ b/order_service/src/api/orders.py @@ -0,0 +1,84 @@ +from fastapi import APIRouter, Depends, status, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import text +from datetime import datetime +import aio_pika +import json + +from src.db.models.orders import Order +from src.models.order_dto import OrderCreate, OrderResponse +from src.api.dependencies import get_db, get_exchange + +router = APIRouter(prefix="/orders", tags=["orders"]) + + +@router.post( + "", + response_model=OrderResponse, + status_code=status.HTTP_201_CREATED, +) +async def create_order( + order_data: OrderCreate, + db: AsyncSession = Depends(get_db), + exchange: aio_pika.abc.AbstractExchange = Depends(get_exchange), +): + """ะกะพะทะดะฐั‘ั‚ ะทะฐะบะฐะท ะธ ะฟัƒะฑะปะธะบัƒะตั‚ ัะพะฑั‹ั‚ะธะต order_created.""" + new_order = Order(user_id=order_data.user_id, amount=order_data.amount) + db.add(new_order) + + try: + await db.commit() + await db.refresh(new_order) + except Exception: + await db.rollback() + raise + + event = { + "event": "order_created", + "order_id": new_order.id, + "user_id": new_order.user_id, + "amount": new_order.amount, + "created_at": datetime.utcnow().isoformat(), + } + + message = aio_pika.Message(body=json.dumps(event).encode()) + await exchange.publish(message, routing_key="order_created") + + return OrderResponse( + order_id=new_order.id, + created_at=new_order.created_at, + message="Order created and event published", + ) + + +@router.get("") +async def list_orders(db: AsyncSession = Depends(get_db)): + """ะ’ะพะทะฒั€ะฐั‰ะฐะตั‚ ัะฟะธัะพะบ ะทะฐะบะฐะทะพะฒ (ัƒะฟั€ะพั‰ั‘ะฝะฝะพ).""" + # Return simplified order list with basic fields + result = await db.execute(text("SELECT id, user_id, amount, status, created_at FROM orders ORDER BY id DESC")) + rows = result.fetchall() + return [ + { + "id": row.id, + "user_id": row.user_id, + "amount": float(row.amount), + "status": row.status, + "created_at": row.created_at, + } + for row in rows + ] + + +@router.get("/{order_id}") +async def get_order(order_id: int, db: AsyncSession = Depends(get_db)): + """Get a single order by id.""" + order = await db.get(Order, order_id) + if order is None: + raise HTTPException(status_code=404, detail="Order not found") + return { + "id": order.id, + "user_id": order.user_id, + "amount": float(order.amount), + "status": order.status, + "created_at": order.created_at, + } \ No newline at end of file diff --git a/order_service/src/db/__init__.py b/order_service/src/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/order_service/src/db/base.py b/order_service/src/db/base.py new file mode 100644 index 0000000..58f14d2 --- /dev/null +++ b/order_service/src/db/base.py @@ -0,0 +1,14 @@ +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker +from sqlalchemy.orm import DeclarativeBase +from src.settings import settings + + +engine = create_async_engine(settings.database_url, echo=False, future=True) + +async_session = async_sessionmaker( + engine, expire_on_commit=False, class_=AsyncSession +) + +class Base(DeclarativeBase): + """Base class for all models.""" + pass diff --git a/order_service/src/db/dependency.py b/order_service/src/db/dependency.py new file mode 100644 index 0000000..d94ec44 --- /dev/null +++ b/order_service/src/db/dependency.py @@ -0,0 +1,8 @@ +from typing import AsyncGenerator +from src.db.base import async_session + + +async def get_db() -> AsyncGenerator: + """ะกะพะทะดะฐะตั‚ ะธ ะทะฐะบั€ั‹ะฒะฐะตั‚ ะฐัะธะฝั…ั€ะพะฝะฝัƒัŽ ัะตััะธัŽ SQLAlchemy.""" + async with async_session() as session: + yield session diff --git a/order_service/src/db/models/__init__.py b/order_service/src/db/models/__init__.py new file mode 100644 index 0000000..4a63189 --- /dev/null +++ b/order_service/src/db/models/__init__.py @@ -0,0 +1,5 @@ +from .orders import Order + +__all__ = [ + "Order", +] \ No newline at end of file diff --git a/order_service/src/db/models/orders.py b/order_service/src/db/models/orders.py new file mode 100644 index 0000000..1a00c4c --- /dev/null +++ b/order_service/src/db/models/orders.py @@ -0,0 +1,12 @@ +from sqlalchemy import Column, Integer, Float, DateTime, func, String +from src.db.base import Base + + +class Order(Base): + __tablename__ = "orders" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, nullable=False) + amount = Column(Float, nullable=False) + status = Column(String(length=32), nullable=False, server_default="pending") + created_at = Column(DateTime(timezone=True), server_default=func.now()) diff --git a/order_service/src/main.py b/order_service/src/main.py new file mode 100644 index 0000000..2ed0f13 --- /dev/null +++ b/order_service/src/main.py @@ -0,0 +1,142 @@ +from __future__ import annotations +from fastapi.encoders import jsonable_encoder +import logging +from contextlib import asynccontextmanager +from typing import AsyncIterator, Optional, Sequence + +import aio_pika +from fastapi import FastAPI +from fastapi.exceptions import RequestValidationError +from fastapi.middleware.cors import CORSMiddleware +from fastapi.middleware.gzip import GZipMiddleware +from starlette.middleware.trustedhost import TrustedHostMiddleware +from starlette.requests import Request +from starlette.responses import JSONResponse + +from src.settings import settings +from src.api import api_router + + +# โ”€โ”€ ะ›ะžะ“ะ˜ะ ะžะ’ะะะ˜ะ• โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +logger = logging.getLogger("asyncflow.order_service") +logging.basicConfig( + level=getattr(logging, settings.log_level.upper(), logging.INFO), + format="%(asctime)s | %(levelname)s | %(name)s | %(message)s", +) + + +# โ”€โ”€ LIFESPAN: ะฟะพะดะบะปัŽั‡ะตะฝะธะต ะบ RabbitMQ ะฝะฐ ัั‚ะฐั€ั‚ะต, ะทะฐะบั€ั‹ั‚ะธะต ะฝะฐ ะพัั‚ะฐะฝะพะฒะบะต โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +@asynccontextmanager +async def lifespan(app: FastAPI) -> AsyncIterator[None]: + """ + ะ˜ะฝะธั†ะธะฐะปะธะทะธั€ัƒะตะผ ัะพะตะดะธะฝะตะฝะธะต ั RabbitMQ (robust), ะบะฐะฝะฐะป ะธ ะพะฑะผะตะฝะฝะธะบ. + ะšะปะฐะดั‘ะผ ะฒัั‘ ะฒ app.state.*, ั‡ั‚ะพะฑั‹ ะธัะฟะพะปัŒะทะพะฒะฐั‚ัŒ ะธะท ะทะฐะฒะธัะธะผะพัั‚ะตะน/ั€ะพัƒั‚ะตั€ะพะฒ. + """ + # Build AMQP URL using the real secret value; mask in logs + try: + pass_value = settings.rabbitmq_pass.get_secret_value() # pydantic SecretStr + except AttributeError: + pass_value = str(settings.rabbitmq_pass) + + amqp_url = ( + f"amqp://{settings.rabbitmq_user}:{pass_value}" + f"@{settings.rabbitmq_host}:{settings.rabbitmq_port}/" + ) + logger.info("Connecting to RabbitMQ: %s", amqp_url.replace(pass_value, "******")) + + connection: Optional[aio_pika.RobustConnection] = None + channel: Optional[aio_pika.abc.AbstractChannel] = None + exchange: Optional[aio_pika.abc.AbstractExchange] = None + + try: + connection = await aio_pika.connect_robust(amqp_url) + channel = await connection.channel() + # durable topic exchange โ€” ั‡ั‚ะพะฑั‹ ะฟะตั€ะตะถะธะฒะฐะป ั€ะตัั‚ะฐั€ั‚ั‹ ะฑั€ะพะบะตั€ะฐ + exchange = await channel.declare_exchange( + name=settings.amqp_exchange, + type=aio_pika.ExchangeType.TOPIC, + durable=True, + ) + + app.state.amqp_connection = connection + app.state.amqp_channel = channel + app.state.amqp_exchange = exchange + + logger.info("RabbitMQ connected. Exchange '%s' ready.", settings.amqp_exchange) + yield + + finally: + # ะ—ะฐะบั€ั‹ะฒะฐะตะผ ั€ะตััƒั€ัั‹ ะฐะบะบัƒั€ะฐั‚ะฝะพ + try: + if channel and not channel.is_closed: + await channel.close() + except Exception as e: + logger.warning("Channel close warning: %s", e) + + try: + if connection and not connection.is_closed: + await connection.close() + except Exception as e: + logger.warning("Connection close warning: %s", e) + + logger.info("RabbitMQ connection closed.") + + +# โ”€โ”€ ะŸะ ะ˜ะ›ะžะ–ะ•ะะ˜ะ• โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +app = FastAPI( + title="AsyncFlow - Order Service", + version="0.1.0", + docs_url="/docs", + redoc_url="/redoc", + lifespan=lifespan, +) + + +# โ”€โ”€ MIDDLEWARE โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# GZip ะดะปั ะพั‚ะฒะตั‚ะพะฒ (ัะบะพะฝะพะผะธั‚ ั‚ั€ะฐั„ะธะบ) +app.add_middleware(GZipMiddleware, minimum_size=800) + +# Trusted hosts (ะพะฟั†ะธะพะฝะฐะปัŒะฝะพ โ€” ะตัะปะธ ั…ะพั‡ะตัˆัŒ ะพะณั€ะฐะฝะธั‡ะธั‚ัŒ Host header) +if settings.trusted_hosts: + app.add_middleware(TrustedHostMiddleware, allowed_hosts=settings.trusted_hosts) + +# CORS (ะธะท ะฝะฐัั‚ั€ะพะตะบ; ะฟะพ ัƒะผะพะปั‡ะฐะฝะธัŽ โ€” ั‚ะพะปัŒะบะพ localhost) +cors_origins: Sequence[str] = settings.cors_origins or ["http://localhost", "http://localhost:3000"] +app.add_middleware( + CORSMiddleware, + allow_origins=list(cors_origins), + allow_credentials=True, + allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], + allow_headers=["*"], +) + + +# โ”€โ”€ ะ“ะ›ะžะ‘ะะ›ะฌะะซะ• ะžะ‘ะ ะะ‘ะžะขะงะ˜ะšะ˜ ะžะจะ˜ะ‘ะžะš (ะฑะตะท ะฑะธะทะฝะตั-ะปะพะณะธะบะธ) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +@app.exception_handler(RequestValidationError) +async def validation_exception_handler(request, exc): + return JSONResponse( + status_code=422, + content=jsonable_encoder({ + "detail": exc.errors(), + "body": exc.body, + }), + ) + + +# ะœะพะถะฝะพ ะดะพะฑะฐะฒะธั‚ัŒ ะพะฑั‰ะธะน ะพะฑั€ะฐะฑะพั‚ั‡ะธะบ HTTPException ะธ unexpected Exception ะฟั€ะธ ะถะตะปะฐะฝะธะธ: +# from fastapi import HTTPException +# @app.exception_handler(HTTPException) +# async def http_exception_handler(_: Request, exc: HTTPException) -> JSONResponse: +# return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail}) + + +# โ”€โ”€ ะ ะžะฃะขะ•ะ ะซ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# ะ—ะดะตััŒ ั‚ะพะปัŒะบะพ ะฟะพะดะบะปัŽั‡ะตะฝะธะต. ะกะฐะผะธ ะพะฑั€ะฐะฑะพั‚ั‡ะธะบะธ โ€” ะฒ .api.* +app.include_router(api_router) + + +# โ”€โ”€ ะŸะ ะ˜ะœะ•ะงะะะ˜ะ• โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# ะ˜ะท ะพะฑั€ะฐะฑะพั‚ั‡ะธะบะพะฒ ั‚ั‹ ัะผะพะถะตัˆัŒ ะฟะพะปัƒั‡ะธั‚ัŒ exchange ั‚ะฐะบ: +# exchange: aio_pika.abc.AbstractExchange = request.app.state.amqp_exchange +# ะธ ะฟัƒะฑะปะธะบะพะฒะฐั‚ัŒ ัะพะฑั‹ั‚ะธั: +# await exchange.publish(aio_pika.Message(body=payload), routing_key="order_created") \ No newline at end of file diff --git a/order_service/src/models/__init__.py b/order_service/src/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/order_service/src/models/events.py b/order_service/src/models/events.py new file mode 100644 index 0000000..bc5b7cb --- /dev/null +++ b/order_service/src/models/events.py @@ -0,0 +1,21 @@ +from pydantic import BaseModel +from datetime import datetime +from decimal import Decimal + + +class OrderCreatedEvent(BaseModel): + """ะกะพะฑั‹ั‚ะธะต, ะฟัƒะฑะปะธะบัƒะตะผะพะต ะฒ RabbitMQ ะฟั€ะธ ัะพะทะดะฐะฝะธะธ ะทะฐะบะฐะทะฐ.""" + event: str = "order_created" + order_id: int + user_id: int + amount: Decimal + created_at: datetime + + +class PaymentProcessedEvent(BaseModel): + """ะกะพะฑั‹ั‚ะธะต, ะบะพั‚ะพั€ะพะต ะฟัƒะฑะปะธะบัƒะตั‚ billing_service ะฟะพัะปะต ัƒัะฟะตัˆะฝะพะน ะพะฟะปะฐั‚ั‹.""" + event: str = "payment_processed" + order_id: int + user_id: int + status: str + processed_at: datetime \ No newline at end of file diff --git a/order_service/src/models/order_dto.py b/order_service/src/models/order_dto.py new file mode 100644 index 0000000..6f3315c --- /dev/null +++ b/order_service/src/models/order_dto.py @@ -0,0 +1,37 @@ +from datetime import datetime +from decimal import Decimal + +from pydantic import BaseModel, Field, condecimal + +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# ๐Ÿงฉ API-ะผะพะดะตะปะธ: ะทะฐะฟั€ะพัั‹ ะธ ะพั‚ะฒะตั‚ั‹ ะดะปั Order Service +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +class OrderBase(BaseModel): + """ะžะฑั‰ะธะต ะฟะพะปั, ะบะพั‚ะพั€ั‹ะต ะผะพะณัƒั‚ ะฟะตั€ะตะธัะฟะพะปัŒะทะพะฒะฐั‚ัŒัั.""" + user_id: int = Field(..., ge=1, description="ID ะฟะพะปัŒะทะพะฒะฐั‚ะตะปั") + amount: condecimal(gt=0, max_digits=10, decimal_places=2) = Field(..., description="ะกัƒะผะผะฐ ะทะฐะบะฐะทะฐ") + + +class OrderCreate(OrderBase): + """ะœะพะดะตะปัŒ ะฒั…ะพะดะฝั‹ั… ะดะฐะฝะฝั‹ั… ะฟั€ะธ ัะพะทะดะฐะฝะธะธ ะทะฐะบะฐะทะฐ.""" + pass + + +class OrderResponse(BaseModel): + """ะžั‚ะฒะตั‚ API ะฟั€ะธ ัะพะทะดะฐะฝะธะธ ะธะปะธ ะทะฐะฟั€ะพัะต ะทะฐะบะฐะทะฐ.""" + order_id: int = Field(..., description="ะฃะฝะธะบะฐะปัŒะฝั‹ะน ะธะดะตะฝั‚ะธั„ะธะบะฐั‚ะพั€ ะทะฐะบะฐะทะฐ") + created_at: datetime = Field(..., description="ะ”ะฐั‚ะฐ ะธ ะฒั€ะตะผั ัะพะทะดะฐะฝะธั ะทะฐะบะฐะทะฐ (UTC)") + message: str = Field(default="OK", description="ะžะฟะธัะฐะฝะธะต ั€ะตะทัƒะปัŒั‚ะฐั‚ะฐ ะพะฟะตั€ะฐั†ะธะธ") + + class Config: + json_encoders = { + Decimal: float, + } + json_schema_extra = { + "example": { + "order_id": 101, + "created_at": "2025-10-29T12:34:56.789Z", + "message": "Order created and event published", + } + } \ No newline at end of file diff --git a/order_service/src/settings.py b/order_service/src/settings.py new file mode 100644 index 0000000..91eb253 --- /dev/null +++ b/order_service/src/settings.py @@ -0,0 +1,90 @@ +from pydantic import Field, validator, SecretStr +from pydantic_settings import BaseSettings +from typing import List, Optional +from enum import Enum + + +class LogLevel(str, Enum): + """Valid log levels.""" + DEBUG = "DEBUG" + INFO = "INFO" + WARNING = "WARNING" + ERROR = "ERROR" + CRITICAL = "CRITICAL" + + +class Settings(BaseSettings): + """ + Application settings with environment variable support. + All settings can be overridden using environment variables. + """ + # Service Settings + service_name: str = Field("order_service", description="Service name for logs and tracing") + environment: str = Field("development", description="Deployment environment") + + # RabbitMQ Settings + rabbitmq_user: str = Field("user", description="RabbitMQ username") + rabbitmq_pass: SecretStr = Field("pass", description="RabbitMQ password") + rabbitmq_host: str = Field("rabbitmq", description="RabbitMQ hostname") + rabbitmq_port: int = Field(5672, ge=1, le=65535, description="RabbitMQ port") + amqp_exchange: str = Field( + "asyncflow.exchange", + description="RabbitMQ exchange name for event publishing" + ) + + # Database Settings + db_user: str = Field("postgres", description="Database username") + db_pass: SecretStr = Field("postgres", description="Database password") + db_host: str = Field("db", description="Database hostname") + db_port: int = Field(5432, ge=1, le=65535, description="Database port") + db_name: str = Field("order_service", description="Database name") + db_pool_size: int = Field(5, ge=1, le=20, description="Database connection pool size") + db_use_ssl: bool = Field(False, description="Enable SSL for database connection") + + # Security Settings + cors_origins: Optional[List[str]] = Field( + None, + description="Allowed CORS origins. Set via CORS_ORIGINS env var as comma-separated list" + ) + trusted_hosts: Optional[List[str]] = Field( + None, + description="Trusted host patterns. Set via TRUSTED_HOSTS env var as comma-separated list" + ) + + # Logging + log_level: LogLevel = Field( + LogLevel.INFO, + description="Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)" + ) + + @validator("cors_origins", "trusted_hosts", pre=True) + def parse_list_from_str(cls, v): + """Parse comma-separated string into list.""" + if isinstance(v, str): + return [i.strip() for i in v.split(",") if i.strip()] + return v + + @property + def database_url(self) -> str: + """Build SQLAlchemy database URL.""" + # Use in-memory SQLite for testing if no host is set + if self.environment == "test" or not self.db_host: + return "sqlite+aiosqlite:///:memory:" + + # Build PostgreSQL URL for production/development + url = f"postgresql+asyncpg://{self.db_user}:{self.db_pass.get_secret_value()}" + url += f"@{self.db_host}:{self.db_port}/{self.db_name}" + if self.db_use_ssl: + url += "?ssl=true" + return url + + class Config: + env_file = ".env" + env_nested_delimiter = "__" + case_sensitive = True + +# Global settings instance +settings = Settings() + +# Validate settings on import +settings.model_validate(settings.model_dump()) diff --git a/order_service/src/tests/__init__.py b/order_service/src/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/order_service/src/tests/conftest.py b/order_service/src/tests/conftest.py new file mode 100644 index 0000000..90d8df1 --- /dev/null +++ b/order_service/src/tests/conftest.py @@ -0,0 +1,125 @@ +# conftest.py +import os +import pytest +from httpx import AsyncClient, ASGITransport +from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession +from sqlalchemy.pool import StaticPool +from unittest.mock import AsyncMock, patch +from contextlib import asynccontextmanager +import datetime + +import sys +from pathlib import Path + +# Add the project root to Python path +project_root = str(Path(__file__).parent.parent.parent) +if project_root not in sys.path: + sys.path.insert(0, project_root) + +from src.db.base import Base +from src.main import app +from src.api.dependencies import get_db, get_exchange +from src.db.models.orders import Order +from src.models.order_dto import OrderCreate +import aio_pika + +# Provide a default mock exchange on app.state so sync TestClient can access it +app.state.amqp_exchange = AsyncMock(spec=aio_pika.Exchange) + + +# ============================== +# ๐Ÿšซ ะžะขะšะ›ะฎะงะะ•ะœ LIFESPAN/ะกะขะะ ะขะžะ’ะซะ• ะšะžะะะ•ะšะขะซ +# ============================== +# ะ’ะฐั€ะธะฐะฝั‚ 1: ะฟะพะปะฝะพัั‚ัŒัŽ ะฒั‹ะบะปัŽั‡ะธะผ lifespan ัƒ httpx-ั‚ั€ะฐะฝัะฟะพั€ั‚ะฐ (ัะผ. fixture client) +# ะ’ะฐั€ะธะฐะฝั‚ 2 (ะดะพะฟ): ะฟะตั€ะตัั‚ั€ะฐั…ะพะฒะบะฐ โ€” ะทะฐะผะตะฝะธะผ lifespan ะบะพะฝั‚ะตะบัั‚ ะฝะฐ ะฟัƒัั‚ะพะน + +@asynccontextmanager +async def _no_lifespan(_app): + # ะฝะธั‡ะตะณะพ ะฝะต ะดะตะปะฐะตะผ ะฝะฐ ัั‚ะฐั€ั‚ะต/ะพัั‚ะฐะฝะพะฒะบะต + yield + +# ะ•ัะปะธ ะฒ app ัƒะถะต ัƒัั‚ะฐะฝะพะฒะปะตะฝ ะดั€ัƒะณะพะน lifespan, ะฟะตั€ะตะพะฟั€ะตะดะตะปะธะผ: +app.router.lifespan_context = _no_lifespan + + +# ============================== +# ๐Ÿš€ SQLITE ENGINE (shared in-memory) +# ============================== +@pytest.fixture(scope="session") +async def test_engine(): + """Create a shared in-memory SQLite database engine for testing.""" + engine = create_async_engine( + "sqlite+aiosqlite:///:memory:?cache=shared", + echo=False, + poolclass=StaticPool, + connect_args={"uri": True}, + ) + from sqlalchemy import text + + try: + async with engine.begin() as conn: + await conn.execute(text("PRAGMA foreign_keys=ON")) + await conn.run_sync(Base.metadata.create_all) + + yield engine + + finally: + # Clean up database and dispose engine + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + await engine.dispose() + +@pytest.fixture +async def db_session(test_engine): + """Create a fresh database session for each test.""" + async_session = async_sessionmaker(test_engine, expire_on_commit=False) + async with async_session() as session: + yield session + await session.rollback() + +@pytest.fixture +async def sample_order(db_session): + """Create a sample order for testing.""" + order = Order( + user_id=1, + amount=100.0, + created_at=datetime.datetime.utcnow(), + status="pending" + ) + db_session.add(order) + await db_session.commit() + await db_session.refresh(order) + return order + +@pytest.fixture +def sample_order_create(): + """Create a sample OrderCreate DTO for testing.""" + return OrderCreate(user_id=1, amount=100.0) + +@pytest.fixture +async def mock_exchange(): + """Mock RabbitMQ exchange for testing.""" + mock = AsyncMock(spec=aio_pika.Exchange) + mock.publish = AsyncMock() + return mock + +@pytest.fixture +async def client(db_session, mock_exchange): + """Test client with mocked dependencies.""" + # Override dependencies + async def override_get_db(): + yield db_session + + async def override_get_exchange(): + return mock_exchange + + app.dependency_overrides[get_db] = override_get_db + app.dependency_overrides[get_exchange] = override_get_exchange + # Use ASGITransport so the client talks to the FastAPI app without network + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as test_client: + try: + yield test_client + finally: + # Clear dependency overrides after test + app.dependency_overrides.clear() \ No newline at end of file diff --git a/order_service/src/tests/test_health_api.py b/order_service/src/tests/test_health_api.py new file mode 100644 index 0000000..d5c41c5 --- /dev/null +++ b/order_service/src/tests/test_health_api.py @@ -0,0 +1,29 @@ +import pytest +from sqlalchemy import text + +@pytest.mark.asyncio +class TestHealthAPI: + async def test_health_endpoint(self, client): + response = await client.get("/api/health") + assert response.status_code == 200 + assert response.json() == {"status": "ok"} + + async def test_health_check_with_db(self, client, db_session): + """Test health check endpoint with database connection.""" + # First check the health endpoint + response = await client.get("/api/health") + assert response.status_code == 200 + assert response.json() == {"status": "ok"} + + # Verify database connection is working + result = await db_session.execute(text("SELECT 1")) + assert result.scalar() == 1 + + async def test_health_check_with_rabbitmq(self, client, mock_exchange): + """Test health check with RabbitMQ mock.""" + response = await client.get("/api/health") + assert response.status_code == 200 + assert response.json() == {"status": "ok"} + + # Verify RabbitMQ mock is available + assert mock_exchange is not None \ No newline at end of file diff --git a/order_service/src/tests/test_orders_api.py b/order_service/src/tests/test_orders_api.py new file mode 100644 index 0000000..2584f27 --- /dev/null +++ b/order_service/src/tests/test_orders_api.py @@ -0,0 +1,74 @@ +import pytest +from fastapi.testclient import TestClient +from src.main import app +from src.db.models.orders import Order + +client_sync = TestClient(app) + +@pytest.mark.asyncio +class TestOrdersAPI: + async def test_create_order_success(self, client, mock_exchange, db_session): + payload = {"user_id": 123, "amount": 99.9} + + response = await client.post("/api/orders", json=payload) + + assert response.status_code == 201 + data = response.json() + assert data["order_id"] > 0 + assert data["message"].startswith("Order created") + + # Verify database state + order = await db_session.get(Order, data["order_id"]) + assert order is not None + assert order.user_id == payload["user_id"] + assert float(order.amount) == payload["amount"] + assert order.status == "pending" + + # Verify event publication + mock_exchange.publish.assert_awaited_once() + + async def test_create_order_invalid_amount(self, client): + payload = {"user_id": 123, "amount": -10} + response = await client.post("/api/orders", json=payload) + assert response.status_code == 422 + + async def test_create_order_zero_amount(self, client): + payload = {"user_id": 123, "amount": 0} + response = await client.post("/api/orders", json=payload) + assert response.status_code == 422 + + async def test_create_order_missing_fields(self, client): + response = await client.post("/api/orders", json={}) + assert response.status_code == 422 + errors = response.json()["detail"] + # Validation errors include the field location in 'loc' + assert any("user_id" in str(err["loc"]) for err in errors) + assert any("amount" in str(err["loc"]) for err in errors) + + async def test_get_order(self, client, sample_order): + response = await client.get(f"/api/orders/{sample_order.id}") + assert response.status_code == 200 + data = response.json() + assert data["id"] == sample_order.id + assert data["user_id"] == sample_order.user_id + assert float(data["amount"]) == float(sample_order.amount) + assert data["status"] == sample_order.status + + async def test_get_nonexistent_order(self, client): + response = await client.get("/api/orders/99999") + assert response.status_code == 404 + + async def test_list_orders(self, client, sample_order): + response = await client.get("/api/orders") + assert response.status_code == 200 + data = response.json() + assert isinstance(data, list) + assert len(data) > 0 + order = data[0] + assert order["id"] == sample_order.id + assert order["user_id"] == sample_order.user_id + + def test_validation_handler_jsonable(self): + response = client_sync.post("/api/orders", json={"user_id": 1, "amount": -5}) + assert response.status_code == 422 + assert "detail" in response.json()