From 1a2cfec35c166387279611e6b8b8240fb64f6412 Mon Sep 17 00:00:00 2001 From: Aleksei Loguntsov Date: Wed, 29 Oct 2025 18:13:37 +0400 Subject: [PATCH 01/20] Initial commit: setup FastAPI microservice order_service --- .gitignore | 133 +++++++++++++++++++ .pre-commit-config.yaml | 53 ++++++++ README.md | 50 ++++++++ docker-compose.yml | 79 ++++++++++++ makefile | 28 ++++ order_service/Dockerfile | 51 ++++++++ order_service/pyproject.toml | 142 +++++++++++++++++++++ order_service/src/api/__init__.py | 6 + order_service/src/api/dependencies.py | 13 ++ order_service/src/api/health.py | 7 + order_service/src/api/orders.py | 60 +++++++++ order_service/src/db/__init__.py | 0 order_service/src/db/base.py | 14 ++ order_service/src/db/dependency.py | 7 + order_service/src/db/models/__init__.py | 5 + order_service/src/db/models/orders.py | 11 ++ order_service/src/main.py | 134 +++++++++++++++++++ order_service/src/models/__init__.py | 0 order_service/src/models/events.py | 21 +++ order_service/src/models/order_dto.py | 32 +++++ order_service/src/rabbit.py | 18 +++ order_service/src/settings.py | 32 +++++ order_service/src/tests/__init__.py | 0 order_service/src/tests/conftest.py | 20 +++ order_service/src/tests/test_health_api.py | 7 + order_service/src/tests/test_orders_api.py | 23 ++++ pyproject.toml | 6 + 27 files changed, 952 insertions(+) create mode 100644 .gitignore create mode 100644 .pre-commit-config.yaml create mode 100644 README.md create mode 100644 docker-compose.yml create mode 100644 makefile create mode 100644 order_service/Dockerfile create mode 100644 order_service/pyproject.toml create mode 100644 order_service/src/api/__init__.py create mode 100644 order_service/src/api/dependencies.py create mode 100644 order_service/src/api/health.py create mode 100644 order_service/src/api/orders.py create mode 100644 order_service/src/db/__init__.py create mode 100644 order_service/src/db/base.py create mode 100644 order_service/src/db/dependency.py create mode 100644 order_service/src/db/models/__init__.py create mode 100644 order_service/src/db/models/orders.py create mode 100644 order_service/src/main.py create mode 100644 order_service/src/models/__init__.py create mode 100644 order_service/src/models/events.py create mode 100644 order_service/src/models/order_dto.py create mode 100644 order_service/src/rabbit.py create mode 100644 order_service/src/settings.py create mode 100644 order_service/src/tests/__init__.py create mode 100644 order_service/src/tests/conftest.py create mode 100644 order_service/src/tests/test_health_api.py create mode 100644 order_service/src/tests/test_orders_api.py create mode 100644 pyproject.toml 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/README.md b/README.md new file mode 100644 index 0000000..20add22 --- /dev/null +++ b/README.md @@ -0,0 +1,50 @@ +# 🌀 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 / Poetry + +## 🚀 Run locally +```bash +cp .env.example .env +make up + +RabbitMQ Management UI → http://localhost:15672 +Login: user / pass + +--- + +## 🧩 common/shared_schemas.py + +(чтобы все сервисы могли использовать одни и те же структуры сообщений) + +```python +from pydantic import BaseModel +from datetime import datetime + +class OrderCreated(BaseModel): + event: str = "order_created" + order_id: int + user_id: int + amount: float + +class PaymentProcessed(BaseModel): + event: str = "payment_processed" + order_id: int + user_id: int + status: str + processed_at: datetime \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..375180a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,79 @@ +version: "3.9" + +x-env-common: &env_common + RABBITMQ_HOST: rabbitmq + RABBITMQ_USER: user + RABBITMQ_PASS: pass + +services: + # ───────────────────────────────────────────── + # 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 + + # ───────────────────────────────────────────── + # Order Service (FastAPI) + # ───────────────────────────────────────────── + order_service: + build: + context: ./order_service + dockerfile: Dockerfile + container_name: asyncflow_order + restart: unless-stopped + ports: + - "8081:8081" + env_file: + - .env + environment: + <<: *env_common + PORT: 8081 + LOG_LEVEL: info + depends_on: + rabbitmq: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8081/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 + +# ───────────────────────────────────────────── +# Networks & Volumes +# ───────────────────────────────────────────── +networks: + asyncflow_net: + driver: bridge + +volumes: + rabbitmq_data: + driver: local \ No newline at end of file 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..6b82b0a --- /dev/null +++ b/order_service/Dockerfile @@ -0,0 +1,51 @@ +# ───────────────────────────────────────────── +# 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 \ + PORT=8081 + +EXPOSE 8081 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD curl -fsS http://localhost:${PORT}/health || exit 1 + +CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8081"] \ No newline at end of file diff --git a/order_service/pyproject.toml b/order_service/pyproject.toml new file mode 100644 index 0000000..755a9bb --- /dev/null +++ b/order_service/pyproject.toml @@ -0,0 +1,142 @@ +[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", + + # Migrations + "alembic>=1.13,<2", + + # Utils + "python-dotenv>=1.0,<2", + "anyio>=4.4,<5", +] + +[project.optional-dependencies] +# если понадобятся дополнительные функции (например, e-mail, сжатие и т.п.), можно расширять +# "extras" = ["aiosmtplib>=3,<4"] + +# --- UV SETTINGS (чтобы можно было `uv run` и использовать группы зависимостей) --- +[tool.uv] +# пустой блок ок — uv читает [dependency-groups] ниже + +[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", # полезные стабы, если используешь dateutil (можно удалить) + + # Tests + "pytest>=8.3,<9", + "pytest-asyncio>=0.23,<1", + "httpx>=0.27,<1", + "pytest-cov>=5,<6", + "coverage[toml]>=7.6,<8", +] + +# --- RUFF (линтер + изорт) --- +[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 (сложность) + "PL", # pylint (часть правил) + "RUF", # ruff-specific +] +ignore = [ + "E501", # длину строки контролируем осознанно (или оставь включенной) +] +[tool.ruff.lint.per-file-ignores] +"tests/**" = ["S101"] # например, разрешить assert в тестах + +[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 (строгая типизация) --- +[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 + +# --- PYTEST --- +[tool.pytest.ini_options] +minversion = "8.0" +addopts = "-ra -q --strict-markers --disable-warnings --cov=src --cov-report=term-missing" +testpaths = ["tests"] +pythonpath = ["src"] +asyncio_mode = "auto" + +# --- COVERAGE --- +[tool.coverage.run] +branch = true +source = ["src"] + +[tool.coverage.report] +skip_empty = true +show_missing = true +fail_under = 80 + +# --- ALEMBIC (минимально, остальное в alembic.ini / env.py) --- +[tool.alembic] +# можно хранить общие пути тут для удобства, но основная конфигурация остаётся в alembic/ \ 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..1de8aad --- /dev/null +++ b/order_service/src/api/__init__.py @@ -0,0 +1,6 @@ +from .orders import router as orders_router +from .health import router as health_router + +api_router = APIRouter() +api_router.include_router(orders_router) +api_router.include_router(health_router) diff --git a/order_service/src/api/dependencies.py b/order_service/src/api/dependencies.py new file mode 100644 index 0000000..e766d0b --- /dev/null +++ b/order_service/src/api/dependencies.py @@ -0,0 +1,13 @@ +from fastapi import Request +from sqlalchemy.ext.asyncio import AsyncSession +import aio_pika + +from src.db.dependency import get_db as _get_db + +async def get_db() -> AsyncSession: + """Переиспользуем зависимость для БД.""" + return await _get_db().__anext__() # если хочешь унифицировать + +async def get_exchange(request: Request) -> aio_pika.abc.AbstractExchange: + """Достаём общий exchange из FastAPI state.""" + return request.app.state.amqp_exchange diff --git a/order_service/src/api/health.py b/order_service/src/api/health.py new file mode 100644 index 0000000..3aa94ca --- /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(): + 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..b0eff2e --- /dev/null +++ b/order_service/src/api/orders.py @@ -0,0 +1,60 @@ +from fastapi import APIRouter, Depends, status +from sqlalchemy.ext.asyncio import AsyncSession +from datetime import datetime +import aio_pika + +from src.db.dependency import get_db +from src.db.models.orders import Order +from src.models.order_dto import OrderCreate, OrderResponse +from src.models.events import OrderCreatedEvent +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) + await db.commit() + await db.refresh(new_order) + + # подготавливаем событие + event = { + "event": "order_created", + "order_id": new_order.id, + "user_id": new_order.user_id, + "amount": new_order.amount, + "created_at": datetime.utcnow().isoformat(), + } + + # публикуем в RabbitMQ + message = aio_pika.Message(body=str(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("", response_model=list[OrderResponse]) +async def list_orders(db: AsyncSession = Depends(get_db)): + """Возвращает список заказов (упрощённо).""" + result = await db.execute("SELECT id, created_at FROM orders ORDER BY id DESC") + rows = result.fetchall() + return [ + OrderResponse(order_id=row.id, created_at=row.created_at, message="OK") + for row in rows + ] 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..a119250 --- /dev/null +++ b/order_service/src/db/dependency.py @@ -0,0 +1,7 @@ +from typing import AsyncGenerator +from .base import async_session + +async def get_db() -> AsyncGenerator: + async with async_session() as session: + yield session + \ No newline at end of file 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..9da3032 --- /dev/null +++ b/order_service/src/db/models/orders.py @@ -0,0 +1,11 @@ +from sqlalchemy import Column, Integer, Float, DateTime, func +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) + 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..4f6d304 --- /dev/null +++ b/order_service/src/main.py @@ -0,0 +1,134 @@ +from __future__ import annotations + +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 # pydantic-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.*, чтобы использовать из зависимостей/роутеров. + """ + amqp_url = ( + f"amqp://{settings.rabbitmq_user}:{settings.rabbitmq_pass}" + f"@{settings.rabbitmq_host}:{settings.rabbitmq_port}/" + ) + logger.info("Connecting to RabbitMQ: %s", amqp_url.replace(settings.rabbitmq_pass, "******")) + + 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: RequestValidationError) -> JSONResponse: + logger.debug("Validation error: %s", exc.errors()) + return JSONResponse( + status_code=422, + content={"detail": exc.errors()}, + ) + + +# Можно добавить общий обработчик 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, prefix="/api") + + +# ── ПРИМЕЧАНИЕ ──────────────────────────────────────────────────────────────── +# Из обработчиков ты сможешь получить 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..8b0f052 --- /dev/null +++ b/order_service/src/models/order_dto.py @@ -0,0 +1,32 @@ +from datetime import datetime +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_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/rabbit.py b/order_service/src/rabbit.py new file mode 100644 index 0000000..5571c95 --- /dev/null +++ b/order_service/src/rabbit.py @@ -0,0 +1,18 @@ +import aio_pika +from settings import settings + + +async def get_connection(): + url = f"amqp://{settings.rabbitmq_user}:{settings.rabbitmq_pass}@{settings.rabbitmq_host}:{settings.rabbitmq_port}/" + return await aio_pika.connect_robust(url) + + +async def publish_message(event_name: str, message: dict): + connection = await get_connection() + async with connection: + channel = await connection.channel() + exchange = await channel.declare_exchange("asyncflow_exchange", aio_pika.ExchangeType.TOPIC) + await exchange.publish( + aio_pika.Message(body=str(message).encode()), + routing_key=event_name, + ) \ 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..fe56c1f --- /dev/null +++ b/order_service/src/settings.py @@ -0,0 +1,32 @@ +from pydantic_settings import BaseSettings +from typing import List, Optional + + +class Settings(BaseSettings): + # RabbitMQ + rabbitmq_user: str = "user" + rabbitmq_pass: str = "pass" + rabbitmq_host: str = "rabbitmq" + rabbitmq_port: int = 5672 + amqp_exchange: str = "asyncflow.exchange" + + db_user: str = "postgres" + db_pass: str = "postgres" + db_host: str = "db" + db_port: int = 5432 + db_name: str = "order_service" + + # CORS / Trusted hosts / Logs + cors_origins: Optional[List[str]] = None # CSV через переменную окружения не забудь парсить при необходимости + trusted_hosts: Optional[List[str]] = None + log_level: str = "INFO" + + @property + def database_url(self) -> str: + return f"postgresql+asyncpg://{self.db_user}:{self.db_pass}@{self.db_host}:{self.db_port}/{self.db_name}" + + class Config: + env_file = ".env" + env_nested_delimiter = "__" # удобно для списков/словарей через ENV + +settings = Settings() 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..421939e --- /dev/null +++ b/order_service/src/tests/conftest.py @@ -0,0 +1,20 @@ +import pytest +from httpx import AsyncClient +from unittest.mock import AsyncMock + +from src.main import app + + +@pytest.fixture +async def client(): + """Создаёт HTTP-клиент для FastAPI (без реального сервера).""" + async with AsyncClient(app=app, base_url="http://test") as ac: + yield ac + + +@pytest.fixture +def mock_exchange(monkeypatch): + """Мокаем RabbitMQ exchange, чтобы не слать реальные сообщения.""" + mock = AsyncMock() + monkeypatch.setattr("src.api.orders.get_exchange", lambda _: mock) + return mock \ 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..037e784 --- /dev/null +++ b/order_service/src/tests/test_health_api.py @@ -0,0 +1,7 @@ +import pytest + +@pytest.mark.asyncio +async def test_health_endpoint(client): + response = await client.get("/health") + assert response.status_code == 200 + assert response.json() == {"status": "ok"} \ 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..f4805ea --- /dev/null +++ b/order_service/src/tests/test_orders_api.py @@ -0,0 +1,23 @@ +import pytest + +@pytest.mark.asyncio +async def test_create_order_success(client, mock_exchange): + 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") + + # проверяем, что публикация события была вызвана + mock_exchange.publish.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_create_order_invalid_amount(client): + payload = {"user_id": 123, "amount": -10} + + response = await client.post("/api/orders", json=payload) + assert response.status_code == 422 # ошибка валидации \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..8501ebc --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,6 @@ +[project] +name = "asyncflow" +version = "0.1.0" +description = "Add your description here" +requires-python = ">=3.11" +dependencies = [] From 29dfde2e4290461e35e621ece764b3332eb5fe19 Mon Sep 17 00:00:00 2001 From: Aleksei Loguntsov Date: Wed, 29 Oct 2025 20:01:06 +0400 Subject: [PATCH 02/20] Initial --- order_service/pyproject.toml | 7 +- order_service/src/api/__init__.py | 3 +- order_service/src/api/dependencies.py | 10 +-- order_service/src/api/health.py | 2 +- order_service/src/api/orders.py | 22 ++++--- order_service/src/db/dependency.py | 7 +- order_service/src/main.py | 4 +- order_service/src/tests/conftest.py | 93 ++++++++++++++++++++++++--- 8 files changed, 109 insertions(+), 39 deletions(-) diff --git a/order_service/pyproject.toml b/order_service/pyproject.toml index 755a9bb..bd6374a 100644 --- a/order_service/pyproject.toml +++ b/order_service/pyproject.toml @@ -10,24 +10,21 @@ 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] diff --git a/order_service/src/api/__init__.py b/order_service/src/api/__init__.py index 1de8aad..650c6e3 100644 --- a/order_service/src/api/__init__.py +++ b/order_service/src/api/__init__.py @@ -1,6 +1,7 @@ +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) +api_router.include_router(orders_router, prefix="/api") api_router.include_router(health_router) diff --git a/order_service/src/api/dependencies.py b/order_service/src/api/dependencies.py index e766d0b..62dff2c 100644 --- a/order_service/src/api/dependencies.py +++ b/order_service/src/api/dependencies.py @@ -1,13 +1,9 @@ from fastapi import Request -from sqlalchemy.ext.asyncio import AsyncSession import aio_pika +from sqlalchemy.ext.asyncio import AsyncSession +from src.db.dependency import get_db -from src.db.dependency import get_db as _get_db - -async def get_db() -> AsyncSession: - """Переиспользуем зависимость для БД.""" - return await _get_db().__anext__() # если хочешь унифицировать async def get_exchange(request: Request) -> aio_pika.abc.AbstractExchange: """Достаём общий exchange из FastAPI state.""" - return request.app.state.amqp_exchange + 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 index 3aa94ca..bed0992 100644 --- a/order_service/src/api/health.py +++ b/order_service/src/api/health.py @@ -3,5 +3,5 @@ router = APIRouter(tags=["system"]) @router.get("/health") -async def 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 index b0eff2e..84d8a5f 100644 --- a/order_service/src/api/orders.py +++ b/order_service/src/api/orders.py @@ -1,15 +1,14 @@ from fastapi import APIRouter, Depends, status from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import text from datetime import datetime import aio_pika +import json -from src.db.dependency import get_db from src.db.models.orders import Order from src.models.order_dto import OrderCreate, OrderResponse -from src.models.events import OrderCreatedEvent from src.api.dependencies import get_db, get_exchange - router = APIRouter(prefix="/orders", tags=["orders"]) @@ -26,10 +25,14 @@ async def create_order( """Создаёт заказ и публикует событие order_created.""" new_order = Order(user_id=order_data.user_id, amount=order_data.amount) db.add(new_order) - await db.commit() - await db.refresh(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, @@ -38,8 +41,7 @@ async def create_order( "created_at": datetime.utcnow().isoformat(), } - # публикуем в RabbitMQ - message = aio_pika.Message(body=str(event).encode()) + message = aio_pika.Message(body=json.dumps(event).encode()) await exchange.publish(message, routing_key="order_created") return OrderResponse( @@ -52,9 +54,9 @@ async def create_order( @router.get("", response_model=list[OrderResponse]) async def list_orders(db: AsyncSession = Depends(get_db)): """Возвращает список заказов (упрощённо).""" - result = await db.execute("SELECT id, created_at FROM orders ORDER BY id DESC") + result = await db.execute(text("SELECT id, created_at FROM orders ORDER BY id DESC")) rows = result.fetchall() return [ OrderResponse(order_id=row.id, created_at=row.created_at, message="OK") for row in rows - ] + ] \ No newline at end of file diff --git a/order_service/src/db/dependency.py b/order_service/src/db/dependency.py index a119250..5e1ff50 100644 --- a/order_service/src/db/dependency.py +++ b/order_service/src/db/dependency.py @@ -1,7 +1,8 @@ from typing import AsyncGenerator -from .base import async_session +from src.db.base import async_session + async def get_db() -> AsyncGenerator: + """Создает и закрывает асинхронную сессию SQLAlchemy.""" async with async_session() as session: - yield session - \ No newline at end of file + yield session \ No newline at end of file diff --git a/order_service/src/main.py b/order_service/src/main.py index 4f6d304..60d3fd5 100644 --- a/order_service/src/main.py +++ b/order_service/src/main.py @@ -13,7 +13,7 @@ from starlette.requests import Request from starlette.responses import JSONResponse -from src.settings import settings # pydantic-settings +from src.settings import settings from src.api import api_router @@ -124,7 +124,7 @@ async def validation_exception_handler(_: Request, exc: RequestValidationError) # ── РОУТЕРЫ ─────────────────────────────────────────────────────────────────── # Здесь только подключение. Сами обработчики — в .api.* -app.include_router(api_router, prefix="/api") +app.include_router(api_router) # ── ПРИМЕЧАНИЕ ──────────────────────────────────────────────────────────────── diff --git a/order_service/src/tests/conftest.py b/order_service/src/tests/conftest.py index 421939e..99db96d 100644 --- a/order_service/src/tests/conftest.py +++ b/order_service/src/tests/conftest.py @@ -1,20 +1,93 @@ import pytest -from httpx import AsyncClient +from httpx import AsyncClient, ASGITransport +from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker +from sqlalchemy.pool import NullPool from unittest.mock import AsyncMock - +from src.db.base import Base from src.main import app +# ============================== +# 🚀 DATABASE FIXTURES (SQLite) +# ============================== + +@pytest.fixture(scope="session") +async def test_engine(): + """Создаёт in-memory SQLite движок один раз за сессию.""" + engine = create_async_engine( + "sqlite+aiosqlite:///:memory:", + echo=False, + poolclass=NullPool, + ) + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + yield engine + await engine.dispose() + + +@pytest.fixture() +async def db_session(test_engine): + """Создаёт новую async-сессию на тестовой базе.""" + async_session = async_sessionmaker( + bind=test_engine, + expire_on_commit=False, + autoflush=False, + autocommit=False, + ) + async with async_session() as session: + yield session + + +@pytest.fixture(autouse=True) +def override_db_dependency(monkeypatch, db_session): + """Переопределяет все get_db на SQLite-сессию.""" + async def _get_test_db(): + print("⚙️ Using TEST DB (SQLite)") + yield db_session + + # Подменяем во всех возможных местах + monkeypatch.setattr("src.db.dependency.get_db", _get_test_db) + monkeypatch.setattr("src.api.dependencies.get_db", _get_test_db) + monkeypatch.setattr("src.api.orders.get_db", _get_test_db) + + +# ============================== +# 🐇 RABBITMQ MOCK FIXTURES +# ============================== + +@pytest.fixture(autouse=True) +def mock_rabbit_connection(monkeypatch): + """Подменяет aio_pika.connect_robust, чтобы не коннектиться к реальному Rabbit.""" + mock_connect = AsyncMock(name="MockConnect") + mock_channel = AsyncMock(name="MockChannel") + mock_exchange = AsyncMock(name="MockExchange") + + mock_connect.channel.return_value = mock_channel + mock_channel.declare_exchange.return_value = mock_exchange + + monkeypatch.setattr("aio_pika.connect_robust", lambda *_, **__: mock_connect) + return mock_exchange + + +@pytest.fixture(autouse=True) +def mock_app_exchange(mock_rabbit_connection): + """Добавляет мокнутый exchange в app.state.""" + app.state.amqp_exchange = mock_rabbit_connection + return app.state.amqp_exchange + + +# ============================== +# 🌐 FASTAPI CLIENT FIXTURE +# ============================== + @pytest.fixture async def client(): - """Создаёт HTTP-клиент для FastAPI (без реального сервера).""" - async with AsyncClient(app=app, base_url="http://test") as ac: + """Создаёт HTTP-клиент с моками и тестовой БД.""" + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as ac: yield ac - @pytest.fixture -def mock_exchange(monkeypatch): - """Мокаем RabbitMQ exchange, чтобы не слать реальные сообщения.""" - mock = AsyncMock() - monkeypatch.setattr("src.api.orders.get_exchange", lambda _: mock) - return mock \ No newline at end of file +def mock_exchange(mock_rabbit_connection): + """Совместимая фикстура для старых тестов.""" + return mock_rabbit_connection From 43494f54d455705a7ef5479ab3d2b7d0073fb08e Mon Sep 17 00:00:00 2001 From: Aleksei Loguntsov Date: Thu, 30 Oct 2025 14:55:47 +0400 Subject: [PATCH 03/20] Initial --- .github/workflows/tests.yml | 50 +++++++++ README.md | 30 +----- order_service/src/db/dependency.py | 2 +- order_service/src/main.py | 10 +- order_service/src/models/order_dto.py | 5 + order_service/src/tests/conftest.py | 120 +++++++++++++-------- order_service/src/tests/test_orders_api.py | 13 ++- 7 files changed, 153 insertions(+), 77 deletions(-) create mode 100644 .github/workflows/tests.yml diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..ab1ba6c --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,50 @@ +name: 🧪 Run tests + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main, develop ] + +jobs: + test: + runs-on: ubuntu-latest + + strategy: + matrix: + python-version: ["3.12", "3.13"] + + 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 (новый менеджер пакетов) --- + - name: Install uv + run: pip install uv + + # --- Sync dependencies (с установкой dev-группы) --- + - name: Install dependencies + run: uv sync --group dev + + # --- Run pytest with coverage --- + - name: Run tests + env: + PYTHONPATH: . + run: | + uv run pytest -v --maxfail=1 --disable-warnings --cov=src --cov-report=term-missing --cov-fail-under=80 + + # --- (Optional) Upload coverage report artifact --- + - name: Upload coverage report + if: always() + uses: actions/upload-artifact@v4 + with: + name: coverage-report + path: ./.coverage \ No newline at end of file diff --git a/README.md b/README.md index 20add22..2a00ebc 100644 --- a/README.md +++ b/README.md @@ -18,33 +18,5 @@ Everything communicates through **RabbitMQ**. - Docker Compose - uv / Poetry -## 🚀 Run locally -```bash -cp .env.example .env -make up -RabbitMQ Management UI → http://localhost:15672 -Login: user / pass - ---- - -## 🧩 common/shared_schemas.py - -(чтобы все сервисы могли использовать одни и те же структуры сообщений) - -```python -from pydantic import BaseModel -from datetime import datetime - -class OrderCreated(BaseModel): - event: str = "order_created" - order_id: int - user_id: int - amount: float - -class PaymentProcessed(BaseModel): - event: str = "payment_processed" - order_id: int - user_id: int - status: str - processed_at: datetime \ No newline at end of file +![Tests](https://github.com///actions/workflows/tests.yml/badge.svg) diff --git a/order_service/src/db/dependency.py b/order_service/src/db/dependency.py index 5e1ff50..d94ec44 100644 --- a/order_service/src/db/dependency.py +++ b/order_service/src/db/dependency.py @@ -5,4 +5,4 @@ async def get_db() -> AsyncGenerator: """Создает и закрывает асинхронную сессию SQLAlchemy.""" async with async_session() as session: - yield session \ No newline at end of file + yield session diff --git a/order_service/src/main.py b/order_service/src/main.py index 60d3fd5..a8f8982 100644 --- a/order_service/src/main.py +++ b/order_service/src/main.py @@ -1,5 +1,5 @@ from __future__ import annotations - +from fastapi.encoders import jsonable_encoder import logging from contextlib import asynccontextmanager from typing import AsyncIterator, Optional, Sequence @@ -107,11 +107,13 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: # ── ГЛОБАЛЬНЫЕ ОБРАБОТЧИКИ ОШИБОК (без бизнес-логики) ───────────────────────── @app.exception_handler(RequestValidationError) -async def validation_exception_handler(_: Request, exc: RequestValidationError) -> JSONResponse: - logger.debug("Validation error: %s", exc.errors()) +async def validation_exception_handler(request, exc): return JSONResponse( status_code=422, - content={"detail": exc.errors()}, + content=jsonable_encoder({ + "detail": exc.errors(), + "body": exc.body, + }), ) diff --git a/order_service/src/models/order_dto.py b/order_service/src/models/order_dto.py index 8b0f052..6f3315c 100644 --- a/order_service/src/models/order_dto.py +++ b/order_service/src/models/order_dto.py @@ -1,4 +1,6 @@ from datetime import datetime +from decimal import Decimal + from pydantic import BaseModel, Field, condecimal # ─────────────────────────────────────────────────────────────── @@ -23,6 +25,9 @@ class OrderResponse(BaseModel): message: str = Field(default="OK", description="Описание результата операции") class Config: + json_encoders = { + Decimal: float, + } json_schema_extra = { "example": { "order_id": 101, diff --git a/order_service/src/tests/conftest.py b/order_service/src/tests/conftest.py index 99db96d..bed8329 100644 --- a/order_service/src/tests/conftest.py +++ b/order_service/src/tests/conftest.py @@ -1,93 +1,129 @@ +# conftest.py +import os import pytest from httpx import AsyncClient, ASGITransport -from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker -from sqlalchemy.pool import NullPool +from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession +from sqlalchemy.pool import StaticPool from unittest.mock import AsyncMock +from contextlib import asynccontextmanager + from src.db.base import Base from src.main import app +# именно эти объекты нам нужны для overrides +from src.api.dependencies import get_db, get_exchange +import aio_pika # ============================== -# 🚀 DATABASE FIXTURES (SQLite) +# 🚫 ОТКЛЮЧАЕМ 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(): - """Создаёт in-memory SQLite движок один раз за сессию.""" + # shared in-memory + StaticPool => одна БД на все коннекты процесса engine = create_async_engine( - "sqlite+aiosqlite:///:memory:", + "sqlite+aiosqlite:///:memory:?cache=shared", echo=False, - poolclass=NullPool, + poolclass=StaticPool, + connect_args={"uri": True}, ) + from sqlalchemy import text + async with engine.begin() as conn: + await conn.execute(text("PRAGMA foreign_keys=ON")) await conn.run_sync(Base.metadata.create_all) - yield engine - await engine.dispose() + try: + yield engine + finally: + await engine.dispose() @pytest.fixture() async def db_session(test_engine): - """Создаёт новую async-сессию на тестовой базе.""" - async_session = async_sessionmaker( + SessionTest = async_sessionmaker( bind=test_engine, expire_on_commit=False, autoflush=False, autocommit=False, + class_=AsyncSession, ) - async with async_session() as session: + async with SessionTest() as session: yield session +# ============================== +# 🧪 ПЕРЕОПРЕДЕЛЕНИЕ ЗАВИСИМОСТЕЙ FASTAPI +# ============================== @pytest.fixture(autouse=True) -def override_db_dependency(monkeypatch, db_session): - """Переопределяет все get_db на SQLite-сессию.""" +def override_db_and_exchange_dependencies(monkeypatch, db_session): + # 1) get_db через dependency_overrides async def _get_test_db(): - print("⚙️ Using TEST DB (SQLite)") yield db_session - # Подменяем во всех возможных местах - monkeypatch.setattr("src.db.dependency.get_db", _get_test_db) - monkeypatch.setattr("src.api.dependencies.get_db", _get_test_db) - monkeypatch.setattr("src.api.orders.get_db", _get_test_db) - - -# ============================== -# 🐇 RABBITMQ MOCK FIXTURES -# ============================== + app.dependency_overrides[get_db] = _get_test_db -@pytest.fixture(autouse=True) -def mock_rabbit_connection(monkeypatch): - """Подменяет aio_pika.connect_robust, чтобы не коннектиться к реальному Rabbit.""" + # 2) мок AMQP exchange через dependency_overrides(get_exchange) + mock_exchange = AsyncMock(name="MockExchange") + async def _get_test_exchange(): + return mock_exchange + + app.dependency_overrides[get_exchange] = _get_test_exchange + + # 3) на случай прямого использования SessionLocal из prod-кода — подменим его + # ТОЛЬКО если у тебя где-то есть импорт "from src.db.dependency import SessionLocal" + try: + from sqlalchemy.ext.asyncio import async_sessionmaker as _asm + SessionTest = _asm(bind=db_session.bind, expire_on_commit=False, class_=AsyncSession) + monkeypatch.setattr("src.db.dependency.SessionLocal", SessionTest, raising=False) + except Exception: + # если нет такого импорта/использования — тихо пропускаем + pass + + # 4) если код в старте приложения стучится в aio_pika.connect_robust — замокаем его на awaitable mock_connect = AsyncMock(name="MockConnect") mock_channel = AsyncMock(name="MockChannel") - mock_exchange = AsyncMock(name="MockExchange") - mock_connect.channel.return_value = mock_channel - mock_channel.declare_exchange.return_value = mock_exchange + monkeypatch.setattr("aio_pika.connect_robust", AsyncMock(return_value=mock_connect), raising=False) - monkeypatch.setattr("aio_pika.connect_robust", lambda *_, **__: mock_connect) - return mock_exchange + # ещё положим exchange в app.state на случай прямого доступа + app.state.amqp_exchange = mock_exchange + yield -@pytest.fixture(autouse=True) -def mock_app_exchange(mock_rabbit_connection): - """Добавляет мокнутый exchange в app.state.""" - app.state.amqp_exchange = mock_rabbit_connection - return app.state.amqp_exchange + # cleanup overrides + app.dependency_overrides.pop(get_db, None) + app.dependency_overrides.pop(get_exchange, None) # ============================== -# 🌐 FASTAPI CLIENT FIXTURE +# 🌐 HTTP-КЛИЕНТ БЕЗ LIFESPAN # ============================== - @pytest.fixture async def client(): - """Создаёт HTTP-клиент с моками и тестовой БД.""" + # жизненно важно: lifespan="off" — иначе стартовые коннекты улетят в прод transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as ac: yield ac + +# ============================== +# Совместимость со старыми тестами +# ============================== @pytest.fixture -def mock_exchange(mock_rabbit_connection): - """Совместимая фикстура для старых тестов.""" - return mock_rabbit_connection +def mock_exchange(): + # достаём, что положили в app.state + return app.state.amqp_exchange \ 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 index f4805ea..ecc7294 100644 --- a/order_service/src/tests/test_orders_api.py +++ b/order_service/src/tests/test_orders_api.py @@ -1,4 +1,9 @@ import pytest +from fastapi.testclient import TestClient +from src.main import app + +client_sync = TestClient(app) + @pytest.mark.asyncio async def test_create_order_success(client, mock_exchange): @@ -20,4 +25,10 @@ async def test_create_order_invalid_amount(client): payload = {"user_id": 123, "amount": -10} response = await client.post("/api/orders", json=payload) - assert response.status_code == 422 # ошибка валидации \ No newline at end of file + assert response.status_code == 422 # ошибка валидации + + +def test_validation_handler_jsonable(): + response = client_sync.post("/api/orders", json={"user_id": 1, "amount": -5}) + assert response.status_code == 422 + assert "detail" in response.json() From d5ea3fa92e09a6a48a5aac993bae89852453ad82 Mon Sep 17 00:00:00 2001 From: Aleksei Loguntsov Date: Thu, 30 Oct 2025 14:58:53 +0400 Subject: [PATCH 04/20] Initial --- pyproject.toml | 6 ------ 1 file changed, 6 deletions(-) delete mode 100644 pyproject.toml diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index 8501ebc..0000000 --- a/pyproject.toml +++ /dev/null @@ -1,6 +0,0 @@ -[project] -name = "asyncflow" -version = "0.1.0" -description = "Add your description here" -requires-python = ">=3.11" -dependencies = [] From 3a3c3e1e298117465914db101eab6bf22cc2f9ab Mon Sep 17 00:00:00 2001 From: Aleksei Loguntsov Date: Thu, 30 Oct 2025 15:30:56 +0400 Subject: [PATCH 05/20] Tests --- order_service/pyproject.toml | 24 +++- order_service/pytest.ini | 8 ++ order_service/scripts/install_dev.sh | 3 + order_service/setup.py | 9 ++ order_service/src/__init__.py | 1 + order_service/src/api/__init__.py | 3 +- order_service/src/api/orders.py | 32 ++++- order_service/src/db/models/orders.py | 3 +- order_service/src/tests/conftest.py | 142 ++++++++++----------- order_service/src/tests/test_health_api.py | 30 ++++- order_service/src/tests/test_orders_api.py | 78 ++++++++--- 11 files changed, 226 insertions(+), 107 deletions(-) create mode 100644 order_service/pytest.ini create mode 100644 order_service/scripts/install_dev.sh create mode 100644 order_service/setup.py create mode 100644 order_service/src/__init__.py diff --git a/order_service/pyproject.toml b/order_service/pyproject.toml index bd6374a..638870a 100644 --- a/order_service/pyproject.toml +++ b/order_service/pyproject.toml @@ -43,16 +43,32 @@ dev = [ # Typing "mypy>=1.10,<2", - "types-python-dateutil>=2.9.0.20240316", # полезные стабы, если используешь dateutil (можно удалить) + "types-python-dateutil>=2.9.0.20240316", # Tests "pytest>=8.3,<9", "pytest-asyncio>=0.23,<1", - "httpx>=0.27,<1", - "pytest-cov>=5,<6", - "coverage[toml]>=7.6,<8", + "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 = ["."] +testpaths = ["src/tests"] +python_files = ["test_*.py"] +asyncio_mode = "auto" + +[tool.coverage.run] +source = ["src"] +branch = true + # --- RUFF (линтер + изорт) --- [tool.ruff] target-version = "py312" 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 index 650c6e3..3366cac 100644 --- a/order_service/src/api/__init__.py +++ b/order_service/src/api/__init__.py @@ -4,4 +4,5 @@ api_router = APIRouter() api_router.include_router(orders_router, prefix="/api") -api_router.include_router(health_router) +# 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/orders.py b/order_service/src/api/orders.py index 84d8a5f..8df8bc4 100644 --- a/order_service/src/api/orders.py +++ b/order_service/src/api/orders.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends, status +from fastapi import APIRouter, Depends, status, HTTPException from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import text from datetime import datetime @@ -51,12 +51,34 @@ async def create_order( ) -@router.get("", response_model=list[OrderResponse]) +@router.get("") async def list_orders(db: AsyncSession = Depends(get_db)): """Возвращает список заказов (упрощённо).""" - result = await db.execute(text("SELECT id, created_at FROM orders ORDER BY id DESC")) + # 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 [ - OrderResponse(order_id=row.id, created_at=row.created_at, message="OK") + { + "id": row.id, + "user_id": row.user_id, + "amount": float(row.amount), + "status": row.status, + "created_at": row.created_at, + } for row in rows - ] \ No newline at end of file + ] + + +@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/models/orders.py b/order_service/src/db/models/orders.py index 9da3032..1a00c4c 100644 --- a/order_service/src/db/models/orders.py +++ b/order_service/src/db/models/orders.py @@ -1,4 +1,4 @@ -from sqlalchemy import Column, Integer, Float, DateTime, func +from sqlalchemy import Column, Integer, Float, DateTime, func, String from src.db.base import Base @@ -8,4 +8,5 @@ class Order(Base): 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/tests/conftest.py b/order_service/src/tests/conftest.py index bed8329..90d8df1 100644 --- a/order_service/src/tests/conftest.py +++ b/order_service/src/tests/conftest.py @@ -4,15 +4,28 @@ 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 +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 -# именно эти объекты нам нужны для overrides 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/СТАРТОВЫЕ КОННЕКТЫ @@ -34,7 +47,7 @@ async def _no_lifespan(_app): # ============================== @pytest.fixture(scope="session") async def test_engine(): - # shared in-memory + StaticPool => одна БД на все коннекты процесса + """Create a shared in-memory SQLite database engine for testing.""" engine = create_async_engine( "sqlite+aiosqlite:///:memory:?cache=shared", echo=False, @@ -43,87 +56,70 @@ async def test_engine(): ) from sqlalchemy import text - async with engine.begin() as conn: - await conn.execute(text("PRAGMA foreign_keys=ON")) - await conn.run_sync(Base.metadata.create_all) 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() +@pytest.fixture async def db_session(test_engine): - SessionTest = async_sessionmaker( - bind=test_engine, - expire_on_commit=False, - autoflush=False, - autocommit=False, - class_=AsyncSession, - ) - async with SessionTest() as session: + """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 -# ============================== -# 🧪 ПЕРЕОПРЕДЕЛЕНИЕ ЗАВИСИМОСТЕЙ FASTAPI -# ============================== -@pytest.fixture(autouse=True) -def override_db_and_exchange_dependencies(monkeypatch, db_session): - # 1) get_db через dependency_overrides - async def _get_test_db(): - yield db_session - - app.dependency_overrides[get_db] = _get_test_db - - # 2) мок AMQP exchange через dependency_overrides(get_exchange) - mock_exchange = AsyncMock(name="MockExchange") - async def _get_test_exchange(): - return mock_exchange - - app.dependency_overrides[get_exchange] = _get_test_exchange - - # 3) на случай прямого использования SessionLocal из prod-кода — подменим его - # ТОЛЬКО если у тебя где-то есть импорт "from src.db.dependency import SessionLocal" - try: - from sqlalchemy.ext.asyncio import async_sessionmaker as _asm - SessionTest = _asm(bind=db_session.bind, expire_on_commit=False, class_=AsyncSession) - monkeypatch.setattr("src.db.dependency.SessionLocal", SessionTest, raising=False) - except Exception: - # если нет такого импорта/использования — тихо пропускаем - pass - - # 4) если код в старте приложения стучится в aio_pika.connect_robust — замокаем его на awaitable - mock_connect = AsyncMock(name="MockConnect") - mock_channel = AsyncMock(name="MockChannel") - mock_connect.channel.return_value = mock_channel - monkeypatch.setattr("aio_pika.connect_robust", AsyncMock(return_value=mock_connect), raising=False) - - # ещё положим exchange в app.state на случай прямого доступа - app.state.amqp_exchange = mock_exchange - - yield - - # cleanup overrides - app.dependency_overrides.pop(get_db, None) - app.dependency_overrides.pop(get_exchange, None) +@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 -# ============================== -# 🌐 HTTP-КЛИЕНТ БЕЗ LIFESPAN -# ============================== @pytest.fixture -async def client(): - # жизненно важно: lifespan="off" — иначе стартовые коннекты улетят в прод - transport = ASGITransport(app=app) - async with AsyncClient(transport=transport, base_url="http://test") as ac: - yield ac +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 -# ============================== -# Совместимость со старыми тестами -# ============================== -@pytest.fixture -def mock_exchange(): - # достаём, что положили в app.state - return app.state.amqp_exchange \ No newline at end of file + 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 index 037e784..d5c41c5 100644 --- a/order_service/src/tests/test_health_api.py +++ b/order_service/src/tests/test_health_api.py @@ -1,7 +1,29 @@ import pytest +from sqlalchemy import text @pytest.mark.asyncio -async def test_health_endpoint(client): - response = await client.get("/health") - assert response.status_code == 200 - assert response.json() == {"status": "ok"} \ No newline at end of file +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 index ecc7294..2584f27 100644 --- a/order_service/src/tests/test_orders_api.py +++ b/order_service/src/tests/test_orders_api.py @@ -1,34 +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 -async def test_create_order_success(client, mock_exchange): - payload = {"user_id": 123, "amount": 99.9} +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) + 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") + assert response.status_code == 201 + data = response.json() + assert data["order_id"] > 0 + assert data["message"].startswith("Order created") - # проверяем, что публикация события была вызвана - mock_exchange.publish.assert_awaited_once() + # 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() -@pytest.mark.asyncio -async def test_create_order_invalid_amount(client): - payload = {"user_id": 123, "amount": -10} + 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 - response = await client.post("/api/orders", json=payload) - assert response.status_code == 422 # ошибка валидации + 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(): - response = client_sync.post("/api/orders", json={"user_id": 1, "amount": -5}) - assert response.status_code == 422 - assert "detail" in response.json() + 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() From 4180e45d5f379a3fae782aff7c1bd814c6ec9307 Mon Sep 17 00:00:00 2001 From: Aleksei Loguntsov Date: Thu, 30 Oct 2025 15:38:51 +0400 Subject: [PATCH 06/20] Tests --- .github/workflows/tests.yml | 60 +++++++++++++++++++++++++++++-------- 1 file changed, 47 insertions(+), 13 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ab1ba6c..af15daa 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -12,7 +12,8 @@ jobs: strategy: matrix: - python-version: ["3.12", "3.13"] + # Keep Dockerfile runtime Python (3.12.6) and also test against 3.11 + python-version: ["3.12.6", "3.11"] steps: # --- Checkout code --- @@ -26,25 +27,58 @@ jobs: python-version: ${{ matrix.python-version }} cache: 'pip' - # --- Install uv (новый менеджер пакетов) --- + # --- Install uv (as in Dockerfile) and sync dependencies with uv --- - name: Install uv - run: pip install uv + run: python -m pip install "uv==0.4.20" - # --- Sync dependencies (с установкой dev-группы) --- - - name: Install dependencies - run: uv sync --group dev + - 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- + + - name: Sync dependencies with uv + working-directory: order_service + run: | + uv sync --group dev --yes - # --- Run pytest with coverage --- - - name: Run tests + # --- Run pytest with coverage (via uv to match runtime) --- + - name: Run tests (pytest via uv) + working-directory: order_service env: - PYTHONPATH: . + 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: | - uv run pytest -v --maxfail=1 --disable-warnings --cov=src --cov-report=term-missing --cov-fail-under=80 + # 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 coverage report + - name: Upload test artifacts if: always() uses: actions/upload-artifact@v4 with: - name: coverage-report - path: ./.coverage \ No newline at end of file + name: test-reports-${{ matrix.python-version }} + path: order_service/test-reports \ No newline at end of file From 8405178b793cf9e1e2c16ffa34b9bf59cebb6700 Mon Sep 17 00:00:00 2001 From: Aleksei Loguntsov Date: Thu, 30 Oct 2025 15:48:04 +0400 Subject: [PATCH 07/20] CI tests workflow update --- .github/workflows/tests.yml | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index af15daa..b4f71f7 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -28,8 +28,14 @@ jobs: cache: 'pip' # --- Install uv (as in Dockerfile) and sync dependencies with uv --- - - name: Install uv - run: python -m pip install "uv==0.4.20" + - name: Install project and dev/test deps + working-directory: order_service + run: | + python -m pip install --upgrade pip setuptools wheel + # Install runtime dependencies from the project + python -m pip install . + # 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 - name: Cache pip uses: actions/cache@v4 @@ -42,11 +48,6 @@ jobs: ${{ runner.os }}-pip-${{ matrix.python-version }}- ${{ runner.os }}-pip- - - name: Sync dependencies with uv - working-directory: order_service - run: | - uv sync --group dev --yes - # --- Run pytest with coverage (via uv to match runtime) --- - name: Run tests (pytest via uv) working-directory: order_service From 77e47d37fb131a955ddc57568090c149cedc2ec1 Mon Sep 17 00:00:00 2001 From: Aleksei Loguntsov Date: Thu, 30 Oct 2025 15:53:08 +0400 Subject: [PATCH 08/20] TEsts --- .github/workflows/tests.yml | 4 ++-- order_service/pyproject.toml | 8 +++----- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b4f71f7..fa1c9e5 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -32,10 +32,10 @@ jobs: working-directory: order_service run: | python -m pip install --upgrade pip setuptools wheel - # Install runtime dependencies from the project - python -m pip install . # 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 diff --git a/order_service/pyproject.toml b/order_service/pyproject.toml index 638870a..73b3ab8 100644 --- a/order_service/pyproject.toml +++ b/order_service/pyproject.toml @@ -60,10 +60,12 @@ where = ["."] include = ["src*"] [tool.pytest.ini_options] -pythonpath = ["."] +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"] @@ -131,9 +133,6 @@ mypy_path = ["src"] [[tool.mypy.overrides]] module = "tests.*" strict = false - -# --- PYTEST --- -[tool.pytest.ini_options] minversion = "8.0" addopts = "-ra -q --strict-markers --disable-warnings --cov=src --cov-report=term-missing" testpaths = ["tests"] @@ -150,6 +149,5 @@ skip_empty = true show_missing = true fail_under = 80 -# --- ALEMBIC (минимально, остальное в alembic.ini / env.py) --- [tool.alembic] # можно хранить общие пути тут для удобства, но основная конфигурация остаётся в alembic/ \ No newline at end of file From c5af1af430abf242fb7e8bee06cf3979e6bd2de6 Mon Sep 17 00:00:00 2001 From: Aleksei Loguntsov Date: Thu, 30 Oct 2025 15:56:16 +0400 Subject: [PATCH 09/20] Tests --- order_service/pyproject.toml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/order_service/pyproject.toml b/order_service/pyproject.toml index 73b3ab8..effdb8b 100644 --- a/order_service/pyproject.toml +++ b/order_service/pyproject.toml @@ -139,11 +139,6 @@ testpaths = ["tests"] pythonpath = ["src"] asyncio_mode = "auto" -# --- COVERAGE --- -[tool.coverage.run] -branch = true -source = ["src"] - [tool.coverage.report] skip_empty = true show_missing = true From 85f87d7a33aa368c16d4d68a70c0c61959017282 Mon Sep 17 00:00:00 2001 From: Aleksei Loguntsov Date: Thu, 30 Oct 2025 16:57:32 +0400 Subject: [PATCH 10/20] Blah --- .github/chatmodes/Enter.chatmode.md | 5 + api_gateway/Dockerfile | 64 +++++++++ api_gateway/docker-entrypoint.sh | 24 ++++ api_gateway/pyproject.toml | 27 ++++ api_gateway/src/__init__.py | 0 api_gateway/src/api/__init__.py | 0 api_gateway/src/api/auth.py | 43 ++++++ api_gateway/src/api/orders.py | 45 ++++++ api_gateway/src/api/payments.py | 28 ++++ api_gateway/src/core/__init__.py | 0 api_gateway/src/core/config.py | 40 ++++++ api_gateway/src/core/schemas.py | 72 ++++++++++ api_gateway/src/core/security.py | 20 +++ api_gateway/src/core/services.py | 70 +++++++++ api_gateway/src/main.py | 75 ++++++++++ api_gateway/src/middleware/__init__.py | 0 api_gateway/src/middleware/auth.py | 62 ++++++++ api_gateway/src/schemas.py | 61 ++++++++ api_gateway/src/security.py | 20 +++ auth_service/Dockerfile | 34 +++++ .../versions/001_create_users_table.py | 32 +++++ auth_service/pyproject.toml | 30 ++++ auth_service/src/db/base.py | 15 ++ auth_service/src/db/models/users.py | 20 +++ auth_service/src/main.py | 136 ++++++++++++++++++ auth_service/src/schemas.py | 45 ++++++ auth_service/src/security.py | 84 +++++++++++ auth_service/src/settings.py | 36 +++++ billing_service/pyproject.toml | 125 ++++++++++++++++ .../src/consumers/order_consumer.py | 127 ++++++++++++++++ billing_service/src/db/base.py | 21 +++ billing_service/src/db/models/payments.py | 14 ++ billing_service/src/main.py | 85 +++++++++++ billing_service/src/models/events.py | 23 +++ billing_service/src/settings.py | 71 +++++++++ docker-compose.yml | 6 +- order_service/Dockerfile | 6 +- order_service/pyproject.toml | 26 ++-- order_service/src/rabbit.py | 18 --- order_service/src/settings.py | 96 ++++++++++--- 40 files changed, 1650 insertions(+), 56 deletions(-) create mode 100644 .github/chatmodes/Enter.chatmode.md create mode 100644 api_gateway/Dockerfile create mode 100644 api_gateway/docker-entrypoint.sh create mode 100644 api_gateway/pyproject.toml create mode 100644 api_gateway/src/__init__.py create mode 100644 api_gateway/src/api/__init__.py create mode 100644 api_gateway/src/api/auth.py create mode 100644 api_gateway/src/api/orders.py create mode 100644 api_gateway/src/api/payments.py create mode 100644 api_gateway/src/core/__init__.py create mode 100644 api_gateway/src/core/config.py create mode 100644 api_gateway/src/core/schemas.py create mode 100644 api_gateway/src/core/security.py create mode 100644 api_gateway/src/core/services.py create mode 100644 api_gateway/src/main.py create mode 100644 api_gateway/src/middleware/__init__.py create mode 100644 api_gateway/src/middleware/auth.py create mode 100644 api_gateway/src/schemas.py create mode 100644 api_gateway/src/security.py create mode 100644 auth_service/Dockerfile create mode 100644 auth_service/migrations/versions/001_create_users_table.py create mode 100644 auth_service/pyproject.toml create mode 100644 auth_service/src/db/base.py create mode 100644 auth_service/src/db/models/users.py create mode 100644 auth_service/src/main.py create mode 100644 auth_service/src/schemas.py create mode 100644 auth_service/src/security.py create mode 100644 auth_service/src/settings.py create mode 100644 billing_service/pyproject.toml create mode 100644 billing_service/src/consumers/order_consumer.py create mode 100644 billing_service/src/db/base.py create mode 100644 billing_service/src/db/models/payments.py create mode 100644 billing_service/src/main.py create mode 100644 billing_service/src/models/events.py create mode 100644 billing_service/src/settings.py delete mode 100644 order_service/src/rabbit.py 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/api_gateway/Dockerfile b/api_gateway/Dockerfile new file mode 100644 index 0000000..9bb3620 --- /dev/null +++ b/api_gateway/Dockerfile @@ -0,0 +1,64 @@ +# 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 + +# 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 poetry +RUN curl -sSL https://install.python-poetry.org | python3 - + +# Copy dependency files +COPY --chown=${APP_USER}:${APP_GROUP} pyproject.toml poetry.lock* ./ + +# Install dependencies +RUN poetry config virtualenvs.create false \ + && poetry install --no-dev --no-interaction --no-ansi + +# 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 +EXPOSE 9000 + +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:8000/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 ["python", "-m", "src.main"] \ 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..4ce6bbe --- /dev/null +++ b/api_gateway/docker-entrypoint.sh @@ -0,0 +1,24 @@ +#!/bin/sh +set -e + +# Create log directory if it doesn't exist +mkdir -p "${APP_HOME}/logs" + +# 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..6a631c3 --- /dev/null +++ b/api_gateway/pyproject.toml @@ -0,0 +1,27 @@ +[tool.poetry] +name = "api-gateway" +version = "0.1.0" +description = "API Gateway service for AsyncFlow" +authors = ["Aleksei Loguntsov"] + +[tool.poetry.dependencies] +python = "^3.11" +fastapi = "^0.104.1" +uvicorn = "^0.24.0" +httpx = "^0.25.1" # For making HTTP requests to other services +pydantic = "^2.4.2" +pydantic-settings = "^2.0.3" +python-jose = {extras = ["cryptography"], version = "^3.3.0"} +python-multipart = "^0.0.6" + +[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" + +[build-system] +requires = ["poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api" \ 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..ec9493c --- /dev/null +++ b/api_gateway/src/api/auth.py @@ -0,0 +1,43 @@ +from fastapi import APIRouter, Depends +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 + """ + 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..e69de29 diff --git a/api_gateway/src/core/config.py b/api_gateway/src/core/config.py new file mode 100644 index 0000000..82d9d55 --- /dev/null +++ b/api_gateway/src/core/config.py @@ -0,0 +1,40 @@ +from pydantic_settings import BaseSettings +from typing import Dict, List + + +class Settings(BaseSettings): + """API Gateway configuration settings.""" + + # Service host and port + HOST: str = "0.0.0.0" + PORT: int = 9000 + + # Service routes configuration + SERVICE_ROUTES: Dict[str, Dict[str, str]] = { + "auth": { + "host": "http://auth_service:9003", + "prefix": "auth", + "public_paths": ["/register", "/token"] + }, + "orders": { + "host": "http://order_service:9001", + "prefix": "orders", + "public_paths": [] + }, + "billing": { + "host": "http://billing_service:9002", + "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..581d51e --- /dev/null +++ b/api_gateway/src/core/services.py @@ -0,0 +1,70 @@ +from typing import Optional, Dict, Any +import httpx +from fastapi import Request, HTTPException, status +from fastapi.responses import StreamingResponse + +from core.config import settings + +http_client = httpx.AsyncClient() + + +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.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}" + + try: + # Forward the request with all original properties + headers = dict(request.headers) + headers.pop("host", None) # Remove host header + + # Add user info if available + if hasattr(request.state, "user"): + headers["X-User"] = request.state.user.get("sub") + + # Forward the request + response = await http_client.request( + method=request.method, + url=target_url, + headers=headers, + content=await request.body(), + params=request.query_params, + follow_redirects=True + ) + + # Stream the response back + return StreamingResponse( + response.aiter_raw(), + status_code=response.status_code, + headers=dict(response.headers), + media_type=response.headers.get("content-type") + ) + + except httpx.RequestError as e: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=f"Service unavailable: {str(e)}" + ) \ 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..7a54ef9 --- /dev/null +++ b/api_gateway/src/main.py @@ -0,0 +1,75 @@ +from fastapi import FastAPI, Request +from fastapi.middleware.cors import CORSMiddleware + +from core.config import settings +from core.services import forward_request +from middleware.auth import auth_middleware + +# 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="1.0.0" +) + +# Add CORS middleware +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # In production, replace with specific origins + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Add authentication middleware +app.middleware("http")(auth_middleware) + + +@app.get("/health") +async def health_check(): + """Health check endpoint.""" + return {"status": "healthy"} + + +@app.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"]) +async def api_gateway(request: Request, path: str): + """ + Main gateway route - forwards all requests to appropriate services. + + Path format: /{service}/{remaining_path} + Example: /orders/123 -> forwards to Order Service + """ + return await forward_request(request) + + +if __name__ == "__main__": + import uvicorn + uvicorn.run( + "main:app", + host=settings.HOST, + port=settings.PORT, + reload=True + ) +@app.get("/health", tags=["System"]) +async def health_check(): + """Health check endpoint.""" + return {"status": "healthy"} + + + + + +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..e69de29 diff --git a/api_gateway/src/middleware/auth.py b/api_gateway/src/middleware/auth.py new file mode 100644 index 0000000..e631ca1 --- /dev/null +++ b/api_gateway/src/middleware/auth.py @@ -0,0 +1,62 @@ +from fastapi import Request, HTTPException, status +from core.config import settings +from core.security import verify_token + + +def is_public_path(path: str) -> bool: + """Check if the path is public.""" + if path == "health": + return True + + path_parts = path.strip("/").split("/") + if len(path_parts) < 2: + return False + + service = path_parts[0] + if service not in settings.SERVICE_ROUTES: + return False + + service_config = settings.SERVICE_ROUTES[service] + remaining_path = "/" + "/".join(path_parts[1:]) + 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): + 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/schemas.py b/api_gateway/src/schemas.py new file mode 100644 index 0000000..d9a0a03 --- /dev/null +++ b/api_gateway/src/schemas.py @@ -0,0 +1,61 @@ +from pydantic import BaseModel, EmailStr +from typing import Optional, List, Dict +from datetime import datetime + + +# Auth Models +class UserCreate(BaseModel): + """User registration request model.""" + email: EmailStr + username: str + password: str + + +class Token(BaseModel): + """Token response model.""" + access_token: str + token_type: str + + +class User(BaseModel): + """User response model.""" + id: int + email: EmailStr + username: str + is_active: bool + created_at: datetime + + +# Order Models +class OrderCreate(BaseModel): + """Order creation request model.""" + items: List[Dict[str, Any]] + shipping_address: str + + +class Order(BaseModel): + """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(BaseModel): + """Payment creation request model.""" + order_id: int + amount: float + payment_method: str + + +class Payment(BaseModel): + """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/security.py b/api_gateway/src/security.py new file mode 100644 index 0000000..fced2a8 --- /dev/null +++ b/api_gateway/src/security.py @@ -0,0 +1,20 @@ +from fastapi import HTTPException, status +from jose import JWTError, jwt +from settings 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/auth_service/Dockerfile b/auth_service/Dockerfile new file mode 100644 index 0000000..7734193 --- /dev/null +++ b/auth_service/Dockerfile @@ -0,0 +1,34 @@ +FROM python:3.11-slim + +WORKDIR /app + +# Install poetry +RUN pip install poetry + +# Copy project files +COPY pyproject.toml poetry.lock* ./ +COPY alembic.ini ./ +COPY migrations/ ./migrations/ +COPY src/ ./src/ + +# Install dependencies +RUN poetry config virtualenvs.create false \ + && poetry install --no-dev --no-interaction --no-ansi + +# Expose the port +EXPOSE 9003 + +# Create startup script +RUN echo '#!/bin/sh\n\ +# Wait for database\n\ +while ! /app/start.sh \ + && chmod +x /app/start.sh + +# Run the application +CMD ["/app/start.sh"] \ 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..368a8c6 --- /dev/null +++ b/auth_service/migrations/versions/001_create_users_table.py @@ -0,0 +1,32 @@ +"""create users table + +Revision ID: 001 +Create Date: 2023-10-30 12:00:00.000000 +""" +from alembic import op +import sqlalchemy as sa + + +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..38ac8df --- /dev/null +++ b/auth_service/pyproject.toml @@ -0,0 +1,30 @@ +[tool.poetry] +name = "auth-service" +version = "0.1.0" +description = "Authentication service for AsyncFlow" +authors = ["Aleksei Loguntsov"] + +[tool.poetry.dependencies] +python = "^3.11" +fastapi = "^0.104.1" +uvicorn = "^0.24.0" +pydantic = "^2.4.2" +pydantic-settings = "^2.0.3" +python-jose = {extras = ["cryptography"], version = "^3.3.0"} +passlib = {extras = ["bcrypt"], version = "^1.7.4"} +sqlalchemy = "^2.0.23" +alembic = "^1.12.1" +psycopg2-binary = "^2.9.9" +python-multipart = "^0.0.6" + +[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" + +[build-system] +requires = ["poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api" \ 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..ae82591 --- /dev/null +++ b/auth_service/src/db/base.py @@ -0,0 +1,15 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from settings import settings + +engine = create_engine(settings.database_url) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + +def get_db(): + """Get database session.""" + db = SessionLocal() + try: + yield db + finally: + db.close() \ 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..4d5b521 --- /dev/null +++ b/auth_service/src/main.py @@ -0,0 +1,136 @@ +from datetime import timedelta +from typing import List +from fastapi import FastAPI, Depends, HTTPException, status +from fastapi.security import OAuth2PasswordRequestForm +from sqlalchemy.orm import Session + +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("/auth/register", response_model=schemas.User) +async def register_user( + user_data: schemas.UserCreate, + db: Session = Depends(get_db) +): + """Register a new user.""" + # Check if user exists + if db.query(User).filter(User.email == user_data.email).first(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Email already registered" + ) + if db.query(User).filter(User.username == user_data.username).first(): + 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) + db.commit() + 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: Session = Depends(get_db) +): + """Login to get access token.""" + user = db.query(User).filter(User.username == form_data.username).first() + 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: Session = Depends(get_db) +): + """Update current user information.""" + if user_update.email and user_update.email != current_user.email: + if db.query(User).filter(User.email == user_update.email).first(): + 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: + if db.query(User).filter(User.username == user_update.username).first(): + 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.commit() + 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: Session = 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" + ) + + users = db.query(User).offset(skip).limit(limit).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..5ca3da9 --- /dev/null +++ b/auth_service/src/security.py @@ -0,0 +1,84 @@ +from datetime import datetime, timedelta +from typing import Optional +from jose import JWTError, jwt +from passlib.context import CryptContext +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +from sqlalchemy.orm import Session + +from settings import settings +from db.base import get_db +from db.models.users import User + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/token") + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + """Verify a password against its hash.""" + return pwd_context.verify(plain_password, hashed_password) + + +def get_password_hash(password: str) -> str: + """Generate password hash.""" + return pwd_context.hash(password) + + +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: Session = Depends(get_db) +) -> 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 + + user = db.query(User).filter(User.username == username).first() + 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..258a035 --- /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://{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/docker-compose.yml b/docker-compose.yml index 375180a..3062baa 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -40,18 +40,18 @@ services: container_name: asyncflow_order restart: unless-stopped ports: - - "8081:8081" + - "9001:9001" env_file: - .env environment: <<: *env_common - PORT: 8081 + PORT: 9001 LOG_LEVEL: info depends_on: rabbitmq: condition: service_healthy healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8081/health"] + test: ["CMD", "curl", "-f", "http://localhost:9001/health"] interval: 20s timeout: 5s retries: 5 diff --git a/order_service/Dockerfile b/order_service/Dockerfile index 6b82b0a..130dc85 100644 --- a/order_service/Dockerfile +++ b/order_service/Dockerfile @@ -41,11 +41,11 @@ ENV PYTHONUNBUFFERED=1 \ PYTHONDONTWRITEBYTECODE=1 \ UV_SYSTEM_PYTHON=1 \ PYTHONPATH=/app \ - PORT=8081 + PORT=9001 -EXPOSE 8081 +EXPOSE 9001 HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ CMD curl -fsS http://localhost:${PORT}/health || exit 1 -CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8081"] \ No newline at end of file +CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "9001"] \ No newline at end of file diff --git a/order_service/pyproject.toml b/order_service/pyproject.toml index effdb8b..210be3f 100644 --- a/order_service/pyproject.toml +++ b/order_service/pyproject.toml @@ -28,12 +28,12 @@ dependencies = [ ] [project.optional-dependencies] -# если понадобятся дополнительные функции (например, e-mail, сжатие и т.п.), можно расширять +# Additional features can be added here if needed (e.g., email, compression) # "extras" = ["aiosmtplib>=3,<4"] -# --- UV SETTINGS (чтобы можно было `uv run` и использовать группы зависимостей) --- +# --- UV SETTINGS --- [tool.uv] -# пустой блок ок — uv читает [dependency-groups] ниже +# Empty block - uv reads dependency-groups below [dependency-groups] dev = [ @@ -71,7 +71,7 @@ addopts = "-ra -q --strict-markers --disable-warnings --cov=src --cov-report=ter source = ["src"] branch = true -# --- RUFF (линтер + изорт) --- +# --- RUFF (linter + import sorter) --- [tool.ruff] target-version = "py312" line-length = 100 @@ -79,19 +79,19 @@ fix = true unsafe-fixes = false select = [ "E", "F", # pycodestyle / pyflakes - "I", # isort (импорт-упорядочивание) + "I", # isort "UP", # pyupgrade "B", # bugbear "SIM", # simplify - "C90", # mccabe (сложность) - "PL", # pylint (часть правил) + "C90", # mccabe complexity + "PL", # pylint rules "RUF", # ruff-specific ] ignore = [ - "E501", # длину строки контролируем осознанно (или оставь включенной) + "E501", # line length control ] [tool.ruff.lint.per-file-ignores] -"tests/**" = ["S101"] # например, разрешить assert в тестах +"tests/**" = ["S101"] # allow asserts in tests [tool.ruff.format] preview = true @@ -104,7 +104,7 @@ target-version = ["py312"] line-length = 100 skip-string-normalization = true -# --- MYPY (строгая типизация) --- +# --- MYPY (strict typing) --- [tool.mypy] python_version = "3.12" strict = true @@ -126,10 +126,10 @@ plugins = [ "sqlalchemy.ext.mypy.plugin", ] -# где искать исходники +# Source code location mypy_path = ["src"] -# можно гибко ослаблять правила на тесты/скрипты +# Relax rules for tests [[tool.mypy.overrides]] module = "tests.*" strict = false @@ -145,4 +145,4 @@ show_missing = true fail_under = 80 [tool.alembic] -# можно хранить общие пути тут для удобства, но основная конфигурация остаётся в alembic/ \ No newline at end of file +# Common paths can be stored here, main config stays in alembic/ \ No newline at end of file diff --git a/order_service/src/rabbit.py b/order_service/src/rabbit.py deleted file mode 100644 index 5571c95..0000000 --- a/order_service/src/rabbit.py +++ /dev/null @@ -1,18 +0,0 @@ -import aio_pika -from settings import settings - - -async def get_connection(): - url = f"amqp://{settings.rabbitmq_user}:{settings.rabbitmq_pass}@{settings.rabbitmq_host}:{settings.rabbitmq_port}/" - return await aio_pika.connect_robust(url) - - -async def publish_message(event_name: str, message: dict): - connection = await get_connection() - async with connection: - channel = await connection.channel() - exchange = await channel.declare_exchange("asyncflow_exchange", aio_pika.ExchangeType.TOPIC) - await exchange.publish( - aio_pika.Message(body=str(message).encode()), - routing_key=event_name, - ) \ No newline at end of file diff --git a/order_service/src/settings.py b/order_service/src/settings.py index fe56c1f..91eb253 100644 --- a/order_service/src/settings.py +++ b/order_service/src/settings.py @@ -1,32 +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): - # RabbitMQ - rabbitmq_user: str = "user" - rabbitmq_pass: str = "pass" - rabbitmq_host: str = "rabbitmq" - rabbitmq_port: int = 5672 - amqp_exchange: str = "asyncflow.exchange" - - db_user: str = "postgres" - db_pass: str = "postgres" - db_host: str = "db" - db_port: int = 5432 - db_name: str = "order_service" - - # CORS / Trusted hosts / Logs - cors_origins: Optional[List[str]] = None # CSV через переменную окружения не забудь парсить при необходимости - trusted_hosts: Optional[List[str]] = None - log_level: str = "INFO" + """ + 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: - return f"postgresql+asyncpg://{self.db_user}:{self.db_pass}@{self.db_host}:{self.db_port}/{self.db_name}" + """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 = "__" # удобно для списков/словарей через ENV + env_nested_delimiter = "__" + case_sensitive = True +# Global settings instance settings = Settings() + +# Validate settings on import +settings.model_validate(settings.model_dump()) From c2768b3718a8515a25b30fa5136bacb5866bed5c Mon Sep 17 00:00:00 2001 From: Aleksei Loguntsov Date: Thu, 30 Oct 2025 17:16:42 +0400 Subject: [PATCH 11/20] Add development guide and implement metrics and rate limiting middleware --- DEVELOPMENT.md | 189 +++++++++++++++++++++++ README.md | 2 +- api_gateway/Dockerfile | 16 +- api_gateway/pyproject.toml | 134 +++++++++++++--- api_gateway/src/core/config.py | 32 +++- api_gateway/src/core/metrics.py | 47 ++++++ api_gateway/src/main.py | 143 ++++++++++++++--- api_gateway/src/middleware/rate_limit.py | 49 ++++++ api_gateway/src/schemas.py | 61 -------- api_gateway/src/security.py | 20 --- auth_service/Dockerfile | 14 +- docker-compose.yml | 4 +- order_service/Dockerfile | 8 +- 13 files changed, 567 insertions(+), 152 deletions(-) create mode 100644 DEVELOPMENT.md create mode 100644 api_gateway/src/core/metrics.py create mode 100644 api_gateway/src/middleware/rate_limit.py delete mode 100644 api_gateway/src/schemas.py delete mode 100644 api_gateway/src/security.py 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 index 2a00ebc..505d698 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Everything communicates through **RabbitMQ**. - aio-pika - RabbitMQ - Docker Compose -- uv / Poetry +- 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 index 9bb3620..d380ab5 100644 --- a/api_gateway/Dockerfile +++ b/api_gateway/Dockerfile @@ -24,15 +24,14 @@ RUN apt-get update \ && mkdir -p ${APP_HOME}/logs \ && chown -R ${APP_USER}:${APP_GROUP} ${APP_HOME} -# Install poetry -RUN curl -sSL https://install.python-poetry.org | python3 - +# 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 poetry.lock* ./ +COPY --chown=${APP_USER}:${APP_GROUP} pyproject.toml ./ # Install dependencies -RUN poetry config virtualenvs.create false \ - && poetry install --no-dev --no-interaction --no-ansi +RUN uv pip install --system --no-cache --compile . # Copy application code COPY --chown=${APP_USER}:${APP_GROUP} src/ ./src/ @@ -44,11 +43,14 @@ USER ${APP_USER} VOLUME ["${APP_HOME}/logs"] # Expose the port -EXPOSE 9000 +# 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:8000/health || exit 1 + 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/ diff --git a/api_gateway/pyproject.toml b/api_gateway/pyproject.toml index 6a631c3..5befd69 100644 --- a/api_gateway/pyproject.toml +++ b/api_gateway/pyproject.toml @@ -1,27 +1,117 @@ -[tool.poetry] +[project] name = "api-gateway" version = "0.1.0" description = "API Gateway service for AsyncFlow" -authors = ["Aleksei Loguntsov"] - -[tool.poetry.dependencies] -python = "^3.11" -fastapi = "^0.104.1" -uvicorn = "^0.24.0" -httpx = "^0.25.1" # For making HTTP requests to other services -pydantic = "^2.4.2" -pydantic-settings = "^2.0.3" -python-jose = {extras = ["cryptography"], version = "^3.3.0"} -python-multipart = "^0.0.6" - -[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" +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 = ["poetry-core>=1.0.0"] -build-backend = "poetry.core.masonry.api" \ No newline at end of file +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/core/config.py b/api_gateway/src/core/config.py index 82d9d55..a84f45b 100644 --- a/api_gateway/src/core/config.py +++ b/api_gateway/src/core/config.py @@ -1,4 +1,5 @@ from pydantic_settings import BaseSettings +from pydantic import Field from typing import Dict, List @@ -7,22 +8,45 @@ class Settings(BaseSettings): # Service host and port HOST: str = "0.0.0.0" - PORT: int = 9000 + 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") # Service routes configuration SERVICE_ROUTES: Dict[str, Dict[str, str]] = { "auth": { - "host": "http://auth_service:9003", + "host": f"http://auth_service:{Field(9003, env='AUTH_SERVICE_PORT')}", "prefix": "auth", "public_paths": ["/register", "/token"] }, "orders": { - "host": "http://order_service:9001", + "host": f"http://order_service:{Field(9001, env='ORDER_SERVICE_PORT')}", "prefix": "orders", "public_paths": [] }, "billing": { - "host": "http://billing_service:9002", + "host": f"http://billing_service:{Field(9002, env='BILLING_SERVICE_PORT')}", "prefix": "billing", "public_paths": [] } diff --git a/api_gateway/src/core/metrics.py b/api_gateway/src/core/metrics.py new file mode 100644 index 0000000..2cbe9f5 --- /dev/null +++ b/api_gateway/src/core/metrics.py @@ -0,0 +1,47 @@ +"""Gateway metrics collection and reporting.""" +from prometheus_client import Counter, Histogram, generate_latest +from fastapi import Request +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: + async def __call__(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() \ No newline at end of file diff --git a/api_gateway/src/main.py b/api_gateway/src/main.py index 7a54ef9..45993c8 100644 --- a/api_gateway/src/main.py +++ b/api_gateway/src/main.py @@ -1,9 +1,39 @@ -from fastapi import FastAPI, Request +import logging +import datetime +import uuid +from contextlib import asynccontextmanager +from typing import Dict, Any + +import uvicorn +from fastapi import FastAPI, Request, Response, HTTPException from fastapi.middleware.cors import CORSMiddleware +from fastapi.middleware.gzip import GZipMiddleware +from fastapi.responses import JSONResponse +import httpx +import structlog from core.config import settings from core.services import forward_request +from core.metrics import MetricsMiddleware, get_metrics from middleware.auth import auth_middleware +from middleware.rate_limit 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( @@ -16,51 +46,114 @@ * 📦 Order Service (/orders/*) * 💳 Billing Service (/billing/*) """, - version="1.0.0" + 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=["*"], # In production, replace with specific origins - allow_credentials=True, + 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") +@app.get("/health", tags=["System"]) async def health_check(): - """Health check endpoint.""" - return {"status": "healthy"} + """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 +@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("/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"]) -async def api_gateway(request: Request, path: str): +@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: /{service}/{remaining_path} - Example: /orders/123 -> forwards to Order Service + Path format: /api/{version}/{service}/{remaining_path} + Example: /api/v1/orders/123 -> forwards to Order Service """ - return await forward_request(request) - - -if __name__ == "__main__": - import uvicorn - uvicorn.run( - "main:app", - host=settings.HOST, - port=settings.PORT, - reload=True + 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, ) -@app.get("/health", tags=["System"]) -async def health_check(): - """Health check endpoint.""" - return {"status": "healthy"} + + 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 diff --git a/api_gateway/src/middleware/rate_limit.py b/api_gateway/src/middleware/rate_limit.py new file mode 100644 index 0000000..496040b --- /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, Tuple +import time + +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/api_gateway/src/schemas.py b/api_gateway/src/schemas.py deleted file mode 100644 index d9a0a03..0000000 --- a/api_gateway/src/schemas.py +++ /dev/null @@ -1,61 +0,0 @@ -from pydantic import BaseModel, EmailStr -from typing import Optional, List, Dict -from datetime import datetime - - -# Auth Models -class UserCreate(BaseModel): - """User registration request model.""" - email: EmailStr - username: str - password: str - - -class Token(BaseModel): - """Token response model.""" - access_token: str - token_type: str - - -class User(BaseModel): - """User response model.""" - id: int - email: EmailStr - username: str - is_active: bool - created_at: datetime - - -# Order Models -class OrderCreate(BaseModel): - """Order creation request model.""" - items: List[Dict[str, Any]] - shipping_address: str - - -class Order(BaseModel): - """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(BaseModel): - """Payment creation request model.""" - order_id: int - amount: float - payment_method: str - - -class Payment(BaseModel): - """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/security.py b/api_gateway/src/security.py deleted file mode 100644 index fced2a8..0000000 --- a/api_gateway/src/security.py +++ /dev/null @@ -1,20 +0,0 @@ -from fastapi import HTTPException, status -from jose import JWTError, jwt -from settings 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/auth_service/Dockerfile b/auth_service/Dockerfile index 7734193..8009ad3 100644 --- a/auth_service/Dockerfile +++ b/auth_service/Dockerfile @@ -2,21 +2,23 @@ FROM python:3.11-slim WORKDIR /app -# Install poetry -RUN pip install poetry +# Install uv for faster package installation +RUN pip install --no-cache-dir uv==0.4.20 # Copy project files -COPY pyproject.toml poetry.lock* ./ +COPY pyproject.toml ./ COPY alembic.ini ./ COPY migrations/ ./migrations/ COPY src/ ./src/ # Install dependencies -RUN poetry config virtualenvs.create false \ - && poetry install --no-dev --no-interaction --no-ansi +RUN uv pip install --system --no-cache --compile . + +# Default port (can be overridden) +ENV AUTH_SERVICE_PORT=9003 # Expose the port -EXPOSE 9003 +EXPOSE ${AUTH_SERVICE_PORT} # Create startup script RUN echo '#!/bin/sh\n\ diff --git a/docker-compose.yml b/docker-compose.yml index 3062baa..43d44b8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -40,12 +40,12 @@ services: container_name: asyncflow_order restart: unless-stopped ports: - - "9001:9001" + - "${ORDER_SERVICE_PORT:-9001}:${ORDER_SERVICE_PORT:-9001}" env_file: - .env environment: <<: *env_common - PORT: 9001 + ORDER_SERVICE_PORT: ${ORDER_SERVICE_PORT:-9001} LOG_LEVEL: info depends_on: rabbitmq: diff --git a/order_service/Dockerfile b/order_service/Dockerfile index 130dc85..0a5fc45 100644 --- a/order_service/Dockerfile +++ b/order_service/Dockerfile @@ -41,11 +41,11 @@ ENV PYTHONUNBUFFERED=1 \ PYTHONDONTWRITEBYTECODE=1 \ UV_SYSTEM_PYTHON=1 \ PYTHONPATH=/app \ - PORT=9001 + ORDER_SERVICE_PORT=9001 -EXPOSE 9001 +EXPOSE ${ORDER_SERVICE_PORT} HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ - CMD curl -fsS http://localhost:${PORT}/health || exit 1 + CMD curl -fsS http://localhost:${ORDER_SERVICE_PORT}/health || exit 1 -CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "9001"] \ No newline at end of file +CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "${ORDER_SERVICE_PORT}"] \ No newline at end of file From 58554a2b7d1a3d5c07b64af64177efaf93d01ade Mon Sep 17 00:00:00 2001 From: Aleksei Loguntsov Date: Thu, 30 Oct 2025 17:24:29 +0400 Subject: [PATCH 12/20] Foo --- api_gateway/src/core/config.py | 41 +++++++------ api_gateway/src/core/services.py | 102 ++++++++++++++++++++----------- auth_service/Dockerfile | 24 +++++--- 3 files changed, 105 insertions(+), 62 deletions(-) diff --git a/api_gateway/src/core/config.py b/api_gateway/src/core/config.py index a84f45b..4dc404b 100644 --- a/api_gateway/src/core/config.py +++ b/api_gateway/src/core/config.py @@ -33,24 +33,31 @@ class Settings(BaseSettings): CORS_ORIGINS: List[str] = Field(["*"], env="CORS_ORIGINS") CORS_ALLOW_CREDENTIALS: bool = Field(True, env="CORS_ALLOW_CREDENTIALS") - # Service routes configuration - SERVICE_ROUTES: Dict[str, Dict[str, str]] = { - "auth": { - "host": f"http://auth_service:{Field(9003, env='AUTH_SERVICE_PORT')}", - "prefix": "auth", - "public_paths": ["/register", "/token"] - }, - "orders": { - "host": f"http://order_service:{Field(9001, env='ORDER_SERVICE_PORT')}", - "prefix": "orders", - "public_paths": [] - }, - "billing": { - "host": f"http://billing_service:{Field(9002, env='BILLING_SERVICE_PORT')}", - "prefix": "billing", - "public_paths": [] + # 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 diff --git a/api_gateway/src/core/services.py b/api_gateway/src/core/services.py index 581d51e..e972e32 100644 --- a/api_gateway/src/core/services.py +++ b/api_gateway/src/core/services.py @@ -1,12 +1,11 @@ from typing import Optional, Dict, Any +import asyncio import httpx from fastapi import Request, HTTPException, status from fastapi.responses import StreamingResponse from core.config import settings -http_client = httpx.AsyncClient() - async def forward_request(request: Request) -> Any: """ @@ -18,7 +17,20 @@ async def forward_request(request: Request) -> Any: 3. Streams response back to client 4. Preserves headers and status codes """ - path = request.url.path.strip("/") + 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: @@ -36,35 +48,55 @@ async def forward_request(request: Request) -> Any: forwarding_path = "/" + "/".join(path_parts[1:]) if len(path_parts) > 1 else "/" target_url = f"{service_host}{forwarding_path}" - try: - # Forward the request with all original properties - headers = dict(request.headers) - headers.pop("host", None) # Remove host header - - # Add user info if available - if hasattr(request.state, "user"): - headers["X-User"] = request.state.user.get("sub") - - # Forward the request - response = await http_client.request( - method=request.method, - url=target_url, - headers=headers, - content=await request.body(), - params=request.query_params, - follow_redirects=True - ) - - # Stream the response back - return StreamingResponse( - response.aiter_raw(), - status_code=response.status_code, - headers=dict(response.headers), - media_type=response.headers.get("content-type") - ) - - except httpx.RequestError as e: - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail=f"Service unavailable: {str(e)}" - ) \ No newline at end of file + # 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( + response.aiter_raw(), + 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/auth_service/Dockerfile b/auth_service/Dockerfile index 8009ad3..fc059f0 100644 --- a/auth_service/Dockerfile +++ b/auth_service/Dockerfile @@ -20,17 +20,21 @@ ENV AUTH_SERVICE_PORT=9003 # Expose the port EXPOSE ${AUTH_SERVICE_PORT} -# Create startup script -RUN echo '#!/bin/sh\n\ -# Wait for database\n\ -while ! /app/start.sh \ - && chmod +x /app/start.sh +# Create startup script +RUN cat <<'EOF' > /app/start.sh +#!/bin/sh +# Wait for database +while ! Date: Thu, 30 Oct 2025 17:32:21 +0400 Subject: [PATCH 13/20] asd --- api_gateway/src/main.py | 3 -- docker-compose.yml | 82 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 81 insertions(+), 4 deletions(-) diff --git a/api_gateway/src/main.py b/api_gateway/src/main.py index 45993c8..1bbf18c 100644 --- a/api_gateway/src/main.py +++ b/api_gateway/src/main.py @@ -1,14 +1,11 @@ -import logging import datetime import uuid from contextlib import asynccontextmanager -from typing import Dict, Any import uvicorn from fastapi import FastAPI, Request, Response, HTTPException from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.gzip import GZipMiddleware -from fastapi.responses import JSONResponse import httpx import structlog diff --git a/docker-compose.yml b/docker-compose.yml index 43d44b8..d27c91a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -30,6 +30,66 @@ services: 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 + depends_on: + order_service: + condition: service_started + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:${API_GATEWAY_PORT:-9000}/health"] + interval: 20s + timeout: 5s + retries: 5 + start_period: 10s + networks: + - asyncflow_net + + # ───────────────────────────────────────────── + # 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} + 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 + # ───────────────────────────────────────────── # Order Service (FastAPI) # ───────────────────────────────────────────── @@ -51,7 +111,7 @@ services: rabbitmq: condition: service_healthy healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:9001/health"] + test: ["CMD", "curl", "-f", "http://localhost:${ORDER_SERVICE_PORT:-9001}/api/health"] interval: 20s timeout: 5s retries: 5 @@ -67,6 +127,26 @@ services: 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 + LOG_LEVEL: info + depends_on: + rabbitmq: + condition: service_healthy + networks: + - asyncflow_net + # ───────────────────────────────────────────── # Networks & Volumes # ───────────────────────────────────────────── From 1bc7a56da6f0793d67224cf69b2a4bb0ccb74372 Mon Sep 17 00:00:00 2001 From: Aleksei Loguntsov Date: Thu, 30 Oct 2025 17:41:25 +0400 Subject: [PATCH 14/20] Front --- QUICKSTART.md | 51 ++++++++++ api_gateway/Dockerfile | 2 +- auth_service/Dockerfile | 29 +++--- docker-compose.yml | 23 +++++ frontend/.gitignore | 30 ++++++ frontend/Dockerfile | 39 ++++++++ frontend/index.html | 13 +++ frontend/nginx.conf | 45 +++++++++ frontend/package.json | 28 ++++++ frontend/src/App.css | 216 ++++++++++++++++++++++++++++++++++++++++ frontend/src/App.jsx | 111 +++++++++++++++++++++ frontend/src/index.css | 20 ++++ frontend/src/main.jsx | 10 ++ frontend/vite.config.js | 20 ++++ 14 files changed, 621 insertions(+), 16 deletions(-) create mode 100644 QUICKSTART.md create mode 100644 frontend/.gitignore create mode 100644 frontend/Dockerfile create mode 100644 frontend/index.html create mode 100644 frontend/nginx.conf create mode 100644 frontend/package.json create mode 100644 frontend/src/App.css create mode 100644 frontend/src/App.jsx create mode 100644 frontend/src/index.css create mode 100644 frontend/src/main.jsx create mode 100644 frontend/vite.config.js diff --git a/QUICKSTART.md b/QUICKSTART.md new file mode 100644 index 0000000..1b851d4 --- /dev/null +++ b/QUICKSTART.md @@ -0,0 +1,51 @@ +# AsyncFlow - Quick Start + +Launch the index page with basic API: + +```bash +# 1. Copy environment configuration +cp .env.example .env + +# 2. Build and start services (minimal setup: frontend + gateway + order_service + rabbitmq) +docker compose up --build frontend api_gateway order_service rabbitmq + +# 3. Access the app +# Frontend: http://localhost:3000 +# API Gateway health: http://localhost:9000/health +# API v1 health: http://localhost:9000/api/v1/health (used by frontend) +``` + +## What You'll See + +- **React Frontend** on port 3000 with AsyncFlow branding +- **Live health check** showing gateway and services status +- **Feature cards** and tech stack display + +## Services Running + +1. **Frontend (React + Nginx)** - Port 3000 +2. **API Gateway (FastAPI)** - Port 9000 +3. **Order Service (FastAPI)** - Port 9001 +4. **RabbitMQ** - Ports 5672 (AMQP) & 15672 (Management UI) + +## Troubleshooting + +If health check fails: +```bash +# Check individual service health +curl http://localhost:9000/health +curl http://localhost:9001/api/health + +# View logs +docker compose logs -f api_gateway +docker compose logs -f order_service +``` + +## Optional: Full Stack + +To run all services including auth and billing: +```bash +docker compose up --build +``` + +Note: Auth service requires Postgres. Add to docker-compose.yml if needed. diff --git a/api_gateway/Dockerfile b/api_gateway/Dockerfile index d380ab5..32045b0 100644 --- a/api_gateway/Dockerfile +++ b/api_gateway/Dockerfile @@ -63,4 +63,4 @@ ENV APP_ENV=production \ LOG_DIR=${APP_HOME}/logs ENTRYPOINT ["docker-entrypoint.sh"] -CMD ["python", "-m", "src.main"] \ No newline at end of file +CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "9000"] \ No newline at end of file diff --git a/auth_service/Dockerfile b/auth_service/Dockerfile index fc059f0..c467582 100644 --- a/auth_service/Dockerfile +++ b/auth_service/Dockerfile @@ -20,21 +20,20 @@ ENV AUTH_SERVICE_PORT=9003 # Expose the port EXPOSE ${AUTH_SERVICE_PORT} -alembic upgrade head\n\ -# Create startup script -RUN cat <<'EOF' > /app/start.sh -#!/bin/sh -# Wait for database -while ! /app/start.sh && \ + echo 'set -e' >> /app/start.sh && \ + echo 'echo "Waiting for database..."' >> /app/start.sh && \ + echo 'while ! nc -z ${POSTGRES_HOST:-postgres} ${POSTGRES_PORT:-5432}; do sleep 1; done' >> /app/start.sh && \ + echo 'echo "Database is ready!"' >> /app/start.sh && \ + echo 'echo "Running migrations..."' >> /app/start.sh && \ + echo 'alembic upgrade head' >> /app/start.sh && \ + echo 'echo "Starting Auth Service on port ${AUTH_SERVICE_PORT}..."' >> /app/start.sh && \ + echo 'exec uvicorn src.main:app --host 0.0.0.0 --port ${AUTH_SERVICE_PORT}' >> /app/start.sh && \ + chmod +x /app/start.sh + +# Install netcat for health checks +RUN apt-get update && apt-get install -y netcat-openbsd && rm -rf /var/lib/apt/lists/* # Run the application CMD ["/app/start.sh"] \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index d27c91a..8bdb803 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -30,6 +30,29 @@ services: 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) # ───────────────────────────────────────────── 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..3775bf7 --- /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 +RUN npm ci --only=production + +# 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..ea23973 --- /dev/null +++ b/frontend/src/App.css @@ -0,0 +1,216 @@ +.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); +} + +@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..07a47a1 --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,111 @@ +import { useState, useEffect } from 'react' +import './App.css' +import axios from 'axios' + +function App() { + const [health, setHealth] = useState(null) + const [loading, setLoading] = useState(true) + + 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() + }, []) + + 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 +
+
+
+ +
+

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, + } +}) From 9470546033ba7d8a69fe841c61131fdb8dad174a Mon Sep 17 00:00:00 2001 From: Aleksei Loguntsov Date: Thu, 30 Oct 2025 18:22:59 +0400 Subject: [PATCH 15/20] asd --- api_gateway/Dockerfile | 6 ++++++ api_gateway/docker-entrypoint.sh | 5 +++-- api_gateway/src/main.py | 5 +++++ docker-compose.yml | 7 +++---- frontend/Dockerfile | 4 ++-- 5 files changed, 19 insertions(+), 8 deletions(-) diff --git a/api_gateway/Dockerfile b/api_gateway/Dockerfile index 32045b0..766b00a 100644 --- a/api_gateway/Dockerfile +++ b/api_gateway/Dockerfile @@ -10,6 +10,12 @@ ARG APP_HOME=/app 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} diff --git a/api_gateway/docker-entrypoint.sh b/api_gateway/docker-entrypoint.sh index 4ce6bbe..c0248e3 100644 --- a/api_gateway/docker-entrypoint.sh +++ b/api_gateway/docker-entrypoint.sh @@ -1,8 +1,9 @@ #!/bin/sh set -e -# Create log directory if it doesn't exist -mkdir -p "${APP_HOME}/logs" +# 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() { diff --git a/api_gateway/src/main.py b/api_gateway/src/main.py index 1bbf18c..fa01157 100644 --- a/api_gateway/src/main.py +++ b/api_gateway/src/main.py @@ -104,6 +104,11 @@ async def health_check(): 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.""" diff --git a/docker-compose.yml b/docker-compose.yml index 8bdb803..1df800b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -73,9 +73,6 @@ services: ORDER_SERVICE_PORT: ${ORDER_SERVICE_PORT:-9001} BILLING_SERVICE_PORT: ${BILLING_SERVICE_PORT:-9002} LOG_LEVEL: info - depends_on: - order_service: - condition: service_started healthcheck: test: ["CMD", "curl", "-f", "http://localhost:${API_GATEWAY_PORT:-9000}/health"] interval: 20s @@ -83,7 +80,9 @@ services: retries: 5 start_period: 10s networks: - - asyncflow_net + asyncflow_net: + aliases: + - api_gateway # ───────────────────────────────────────────── # Auth Service (FastAPI) diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 3775bf7..32ec486 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -8,8 +8,8 @@ WORKDIR /app # Copy package files COPY package*.json ./ -# Install dependencies -RUN npm ci --only=production +# Install dependencies (including devDependencies for build) +RUN npm install # Copy source code COPY . . From f612675907aa27055296a7c8d47c3bf11ed9024a Mon Sep 17 00:00:00 2001 From: Aleksei Loguntsov Date: Thu, 30 Oct 2025 20:51:52 +0400 Subject: [PATCH 16/20] g --- QUICKSTART.md | 51 ------------ api_gateway/src/core/__init__.py | 7 ++ api_gateway/src/main.py | 2 +- api_gateway/src/middleware/auth.py | 53 ++++++++++--- .../src/{core => middleware}/metrics.py | 13 ++- auth_service/Dockerfile | 42 ++++++---- auth_service/docker-entrypoint.sh | 39 +++++++++ auth_service/migrations/env.py | 79 +++++++++++++++++++ .../versions/001_create_users_table.py | 7 ++ auth_service/pyproject.toml | 8 +- auth_service/src/db/base.py | 26 +++--- db/init/01-init.sql | 35 ++++++++ docker-compose.yml | 62 +++++++++++++++ order_service/Dockerfile | 3 +- order_service/src/main.py | 10 ++- 15 files changed, 338 insertions(+), 99 deletions(-) delete mode 100644 QUICKSTART.md rename api_gateway/src/{core => middleware}/metrics.py (82%) create mode 100644 auth_service/docker-entrypoint.sh create mode 100644 auth_service/migrations/env.py create mode 100644 db/init/01-init.sql diff --git a/QUICKSTART.md b/QUICKSTART.md deleted file mode 100644 index 1b851d4..0000000 --- a/QUICKSTART.md +++ /dev/null @@ -1,51 +0,0 @@ -# AsyncFlow - Quick Start - -Launch the index page with basic API: - -```bash -# 1. Copy environment configuration -cp .env.example .env - -# 2. Build and start services (minimal setup: frontend + gateway + order_service + rabbitmq) -docker compose up --build frontend api_gateway order_service rabbitmq - -# 3. Access the app -# Frontend: http://localhost:3000 -# API Gateway health: http://localhost:9000/health -# API v1 health: http://localhost:9000/api/v1/health (used by frontend) -``` - -## What You'll See - -- **React Frontend** on port 3000 with AsyncFlow branding -- **Live health check** showing gateway and services status -- **Feature cards** and tech stack display - -## Services Running - -1. **Frontend (React + Nginx)** - Port 3000 -2. **API Gateway (FastAPI)** - Port 9000 -3. **Order Service (FastAPI)** - Port 9001 -4. **RabbitMQ** - Ports 5672 (AMQP) & 15672 (Management UI) - -## Troubleshooting - -If health check fails: -```bash -# Check individual service health -curl http://localhost:9000/health -curl http://localhost:9001/api/health - -# View logs -docker compose logs -f api_gateway -docker compose logs -f order_service -``` - -## Optional: Full Stack - -To run all services including auth and billing: -```bash -docker compose up --build -``` - -Note: Auth service requires Postgres. Add to docker-compose.yml if needed. diff --git a/api_gateway/src/core/__init__.py b/api_gateway/src/core/__init__.py index e69de29..0b9fef4 100644 --- a/api_gateway/src/core/__init__.py +++ 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/main.py b/api_gateway/src/main.py index fa01157..54bf2be 100644 --- a/api_gateway/src/main.py +++ b/api_gateway/src/main.py @@ -11,7 +11,7 @@ from core.config import settings from core.services import forward_request -from core.metrics import MetricsMiddleware, get_metrics +from middleware.metrics import MetricsMiddleware, get_metrics from middleware.auth import auth_middleware from middleware.rate_limit import rate_limit_middleware diff --git a/api_gateway/src/middleware/auth.py b/api_gateway/src/middleware/auth.py index e631ca1..e9d1242 100644 --- a/api_gateway/src/middleware/auth.py +++ b/api_gateway/src/middleware/auth.py @@ -2,22 +2,53 @@ 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. -def is_public_path(path: str) -> bool: - """Check if the path is public.""" - if path == "health": + 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 - - path_parts = path.strip("/").split("/") - if len(path_parts) < 2: + + 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 - - service = path_parts[0] + + # 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] - remaining_path = "/" + "/".join(path_parts[1:]) return remaining_path in service_config["public_paths"] @@ -26,7 +57,7 @@ async def auth_middleware(request: Request, call_next): path = request.url.path # Skip authentication for public paths - if is_public_path(path): + if is_public_path(path, request.method): return await call_next(request) # Get token from header diff --git a/api_gateway/src/core/metrics.py b/api_gateway/src/middleware/metrics.py similarity index 82% rename from api_gateway/src/core/metrics.py rename to api_gateway/src/middleware/metrics.py index 2cbe9f5..b4a2a06 100644 --- a/api_gateway/src/core/metrics.py +++ b/api_gateway/src/middleware/metrics.py @@ -1,6 +1,7 @@ """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 @@ -22,12 +23,15 @@ ['service', 'error_type'] ) -class MetricsMiddleware: - async def __call__(self, request: Request, call_next): + +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) @@ -42,6 +46,7 @@ async def __call__(self, request: Request, call_next): finally: REQUEST_LATENCY.labels(service=service).observe(time.time() - start_time) + def get_metrics(): """Get current metrics in Prometheus format.""" - return generate_latest() \ No newline at end of file + return generate_latest() diff --git a/auth_service/Dockerfile b/auth_service/Dockerfile index c467582..94e292c 100644 --- a/auth_service/Dockerfile +++ b/auth_service/Dockerfile @@ -2,16 +2,21 @@ 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 project files +# Copy dependency definitions and project files COPY pyproject.toml ./ COPY alembic.ini ./ COPY migrations/ ./migrations/ COPY src/ ./src/ -# Install dependencies +# Install dependencies directly from pyproject.toml using uv RUN uv pip install --system --no-cache --compile . # Default port (can be overridden) @@ -20,20 +25,23 @@ ENV AUTH_SERVICE_PORT=9003 # Expose the port EXPOSE ${AUTH_SERVICE_PORT} -# Create startup script using separate RUN commands for better Docker layer caching -RUN echo '#!/bin/sh' > /app/start.sh && \ - echo 'set -e' >> /app/start.sh && \ - echo 'echo "Waiting for database..."' >> /app/start.sh && \ - echo 'while ! nc -z ${POSTGRES_HOST:-postgres} ${POSTGRES_PORT:-5432}; do sleep 1; done' >> /app/start.sh && \ - echo 'echo "Database is ready!"' >> /app/start.sh && \ - echo 'echo "Running migrations..."' >> /app/start.sh && \ - echo 'alembic upgrade head' >> /app/start.sh && \ - echo 'echo "Starting Auth Service on port ${AUTH_SERVICE_PORT}..."' >> /app/start.sh && \ - echo 'exec uvicorn src.main:app --host 0.0.0.0 --port ${AUTH_SERVICE_PORT}' >> /app/start.sh && \ - chmod +x /app/start.sh - -# Install netcat for health checks -RUN apt-get update && apt-get install -y netcat-openbsd && rm -rf /var/lib/apt/lists/* +# 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 -CMD ["/app/start.sh"] \ No newline at end of file +ENTRYPOINT ["docker-entrypoint.sh"] +CMD ["uvicorn", "src.main:app"] \ 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/migrations/env.py b/auth_service/migrations/env.py new file mode 100644 index 0000000..b0ebbbe --- /dev/null +++ b/auth_service/migrations/env.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +from logging.config import fileConfig + +from sqlalchemy import engine_from_config, pool +import os +from alembic import context + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# If you use metadata for autogenerate, import it here. +# from db.models.users import Base +# target_metadata = Base.metadata +# For this project we rely on explicit migrations, so metadata is optional. +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://{user}:{password}@{host}:{port}/{db}" + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + """ + + # Prefer URL from environment variables to avoid ini interpolation issues + 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 run_migrations_online() -> None: + """Run migrations in 'online' mode.""" + + # Build engine config purely from env, avoiding reading .ini sections + connectable = engine_from_config( + {"sqlalchemy.url": _build_db_url_from_env()}, + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata, compare_type=True) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/auth_service/migrations/versions/001_create_users_table.py b/auth_service/migrations/versions/001_create_users_table.py index 368a8c6..b170369 100644 --- a/auth_service/migrations/versions/001_create_users_table.py +++ b/auth_service/migrations/versions/001_create_users_table.py @@ -1,11 +1,18 @@ """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( diff --git a/auth_service/pyproject.toml b/auth_service/pyproject.toml index 38ac8df..da5b9c0 100644 --- a/auth_service/pyproject.toml +++ b/auth_service/pyproject.toml @@ -3,12 +3,15 @@ name = "auth-service" version = "0.1.0" description = "Authentication service for AsyncFlow" authors = ["Aleksei Loguntsov"] +packages = [ + { include = "*", from = "src" } # Corrected the path to avoid nested src/src +] [tool.poetry.dependencies] python = "^3.11" fastapi = "^0.104.1" -uvicorn = "^0.24.0" -pydantic = "^2.4.2" +uvicorn = {extras = ["standard"], version = "^0.24.0"} # Added standard extras for uvicorn +pydantic = "^2.4.2" # Ensure the correct version of pydantic is specified pydantic-settings = "^2.0.3" python-jose = {extras = ["cryptography"], version = "^3.3.0"} passlib = {extras = ["bcrypt"], version = "^1.7.4"} @@ -16,6 +19,7 @@ sqlalchemy = "^2.0.23" alembic = "^1.12.1" psycopg2-binary = "^2.9.9" python-multipart = "^0.0.6" +email-validator = "^2.1.0" [tool.poetry.group.dev.dependencies] pytest = "^7.4.3" diff --git a/auth_service/src/db/base.py b/auth_service/src/db/base.py index ae82591..7f21be0 100644 --- a/auth_service/src/db/base.py +++ b/auth_service/src/db/base.py @@ -1,15 +1,21 @@ -from sqlalchemy import create_engine +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine from sqlalchemy.orm import sessionmaker from settings import settings -engine = create_engine(settings.database_url) -SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) +DATABASE_URL = settings.database_url +# Create an async engine +engine = create_async_engine(DATABASE_URL, echo=True) -def get_db(): - """Get database session.""" - db = SessionLocal() - try: - yield db - finally: - db.close() \ No newline at end of file +# Create an async sessionmaker +async_session = sessionmaker( + bind=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 \ 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 index 1df800b..3d5fba4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,6 +7,47 @@ x-env-common: &env_common 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: @@ -102,6 +143,8 @@ services: 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"] @@ -111,6 +154,9 @@ services: start_period: 10s networks: - asyncflow_net + depends_on: + postgres: + condition: service_healthy # ───────────────────────────────────────────── # Order Service (FastAPI) @@ -128,10 +174,17 @@ services: 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 @@ -162,10 +215,17 @@ services: - .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 @@ -178,4 +238,6 @@ networks: volumes: rabbitmq_data: + driver: local + postgres_data: driver: local \ No newline at end of file diff --git a/order_service/Dockerfile b/order_service/Dockerfile index 0a5fc45..736345a 100644 --- a/order_service/Dockerfile +++ b/order_service/Dockerfile @@ -48,4 +48,5 @@ 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 -CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "${ORDER_SERVICE_PORT}"] \ No newline at end of file +# 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/src/main.py b/order_service/src/main.py index a8f8982..2ed0f13 100644 --- a/order_service/src/main.py +++ b/order_service/src/main.py @@ -32,11 +32,17 @@ 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}:{settings.rabbitmq_pass}" + f"amqp://{settings.rabbitmq_user}:{pass_value}" f"@{settings.rabbitmq_host}:{settings.rabbitmq_port}/" ) - logger.info("Connecting to RabbitMQ: %s", amqp_url.replace(settings.rabbitmq_pass, "******")) + logger.info("Connecting to RabbitMQ: %s", amqp_url.replace(pass_value, "******")) connection: Optional[aio_pika.RobustConnection] = None channel: Optional[aio_pika.abc.AbstractChannel] = None From 421ca419b9538dc1ca04b4e45ca201889046559f Mon Sep 17 00:00:00 2001 From: Aleksei Loguntsov Date: Sun, 2 Nov 2025 11:50:34 +0400 Subject: [PATCH 17/20] sdfsd --- auth_service/src/db/base.py | 16 ++++++++++---- auth_service/src/main.py | 43 ++++++++++++++++++++++-------------- auth_service/src/security.py | 16 +++++++++----- frontend/src/main.jsx | 38 +++++++++++++++++++++++-------- 4 files changed, 78 insertions(+), 35 deletions(-) diff --git a/auth_service/src/db/base.py b/auth_service/src/db/base.py index 7f21be0..732e383 100644 --- a/auth_service/src/db/base.py +++ b/auth_service/src/db/base.py @@ -1,15 +1,18 @@ -from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine +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 -engine = create_async_engine(DATABASE_URL, echo=True) +async_engine: AsyncEngine = create_async_engine( + DATABASE_URL, + echo=True, # Enable SQL query logging +) # Create an async sessionmaker async_session = sessionmaker( - bind=engine, + bind=async_engine, class_=AsyncSession, expire_on_commit=False ) @@ -18,4 +21,9 @@ # Dependency to get the async database session async def get_db(): async with async_session() as session: - yield session \ No newline at end of file + 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/main.py b/auth_service/src/main.py index 4d5b521..923ac6d 100644 --- a/auth_service/src/main.py +++ b/auth_service/src/main.py @@ -2,11 +2,12 @@ from typing import List from fastapi import FastAPI, Depends, HTTPException, status from fastapi.security import OAuth2PasswordRequestForm -from sqlalchemy.orm import Session +from sqlalchemy.ext.asyncio import AsyncSession +import sqlalchemy as sa import security from settings import settings -from db.base import get_db +from db.base import get_db, async_session from db.models.users import User import schemas @@ -16,21 +17,28 @@ @app.post("/auth/register", response_model=schemas.User) async def register_user( user_data: schemas.UserCreate, - db: Session = Depends(get_db) + db: AsyncSession = Depends(get_db) # Use async session ): """Register a new user.""" # Check if user exists - if db.query(User).filter(User.email == user_data.email).first(): + 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" ) - if db.query(User).filter(User.username == user_data.username).first(): + + 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( @@ -38,34 +46,37 @@ async def register_user( username=user_data.username, hashed_password=hashed_password ) - + db.add(db_user) - db.commit() - db.refresh(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: Session = Depends(get_db) + db: AsyncSession = Depends(get_db) # Use async session ): """Login to get access token.""" - user = db.query(User).filter(User.username == form_data.username).first() + 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"} @@ -81,7 +92,7 @@ async def read_users_me( async def update_user_me( user_update: schemas.UserUpdate, current_user: User = Depends(security.get_current_active_user), - db: Session = Depends(get_db) + db: AsyncSession = Depends(get_db) ): """Update current user information.""" if user_update.email and user_update.email != current_user.email: @@ -117,7 +128,7 @@ async def read_users( skip: int = 0, limit: int = 100, current_user: User = Depends(security.get_current_active_user), - db: Session = Depends(get_db) + db: AsyncSession = Depends(get_db) ): """Get list of users (requires superuser).""" if not current_user.is_superuser: diff --git a/auth_service/src/security.py b/auth_service/src/security.py index 5ca3da9..40b9886 100644 --- a/auth_service/src/security.py +++ b/auth_service/src/security.py @@ -4,7 +4,8 @@ from passlib.context import CryptContext from fastapi import Depends, HTTPException, status from fastapi.security import OAuth2PasswordBearer -from sqlalchemy.orm import Session +from sqlalchemy.ext.asyncio import AsyncSession +import sqlalchemy as sa from settings import settings from db.base import get_db @@ -44,7 +45,7 @@ def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) - async def get_current_user( token: str = Depends(oauth2_scheme), - db: Session = Depends(get_db) + db: AsyncSession = Depends(get_db) # Use AsyncSession ) -> User: """Get current user from JWT token.""" credentials_exception = HTTPException( @@ -52,7 +53,7 @@ async def get_current_user( detail="Could not validate credentials", headers={"WWW-Authenticate": "Bearer"}, ) - + try: payload = jwt.decode( token, @@ -64,11 +65,14 @@ async def get_current_user( raise credentials_exception except JWTError: raise credentials_exception - - user = db.query(User).filter(User.username == username).first() + + result = await db.execute( + sa.select(User).where(User.username == username) + ) + user = result.scalar() if user is None: raise credentials_exception - + return user diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index 54b39dd..d7f093e 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -1,10 +1,30 @@ -import React from 'react' -import ReactDOM from 'react-dom/client' -import App from './App.jsx' -import './index.css' +import React from 'react'; +import ReactDOM from 'react-dom'; +import App from './App'; +import { BrowserRouter as Router, Route, Routes, Link } from 'react-router-dom'; +import './index.css'; -ReactDOM.createRoot(document.getElementById('root')).render( - - - , -) +function MainPage() { + return ( +
+

Welcome to AsyncFlow

+

Your one-stop solution for managing orders and billing.

+ + + +
+ ); +} + +function AppRouter() { + return ( + + + } /> + Sign In Page} /> + + + ); +} + +ReactDOM.render(, document.getElementById('root')); From bfdba16165becef4d8f2192297ad62b63f5add39 Mon Sep 17 00:00:00 2001 From: Aleksei Loguntsov Date: Sun, 2 Nov 2025 12:54:57 +0400 Subject: [PATCH 18/20] asd --- api_gateway/src/api/auth.py | 5 +- api_gateway/src/core/services.py | 2 +- api_gateway/src/middleware/metrics.py | 9 ++ api_gateway/src/middleware/rate_limit.py | 4 +- auth_service/Dockerfile | 1 + auth_service/README.md | 2 + auth_service/migrations/env.py | 55 +++------ auth_service/pyproject.toml | 62 ++++++++-- auth_service/src/main.py | 26 ++-- auth_service/src/settings.py | 2 +- frontend/src/App.css | 90 ++++++++++++++ frontend/src/App.jsx | 151 +++++++++++++++++++++++ frontend/src/main.jsx | 38 ++---- 13 files changed, 348 insertions(+), 99 deletions(-) create mode 100644 auth_service/README.md diff --git a/api_gateway/src/api/auth.py b/api_gateway/src/api/auth.py index ec9493c..f876e6f 100644 --- a/api_gateway/src/api/auth.py +++ b/api_gateway/src/api/auth.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, Response from fastapi.security import OAuth2PasswordRequestForm from core.schemas import User, UserCreate, Token @@ -15,7 +15,8 @@ async def register(user_data: UserCreate): - Requires email, username, and password - Returns created user information """ - return await ServiceClient.forward_request("auth", "register", "POST", user_data.dict()) + return Response(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) diff --git a/api_gateway/src/core/services.py b/api_gateway/src/core/services.py index e972e32..a5ccaa5 100644 --- a/api_gateway/src/core/services.py +++ b/api_gateway/src/core/services.py @@ -1,4 +1,4 @@ -from typing import Optional, Dict, Any +from typing import Optional, Any import asyncio import httpx from fastapi import Request, HTTPException, status diff --git a/api_gateway/src/middleware/metrics.py b/api_gateway/src/middleware/metrics.py index b4a2a06..37e4f4d 100644 --- a/api_gateway/src/middleware/metrics.py +++ b/api_gateway/src/middleware/metrics.py @@ -35,6 +35,15 @@ async def dispatch(self, request: Request, call_next): start_time = time.time() try: response = await call_next(request) + + + # Ensure response body is not consumed multiple times + if isinstance(response, StreamingResponse): + original_body = b"".join([chunk async for chunk in response.body_iterator]) + response.body_iterator = iter([original_body]) + response.headers["Content-Length"] = str(len(original_body)) + + REQUEST_COUNT.labels(service=service, status=response.status_code).inc() return response except Exception as e: diff --git a/api_gateway/src/middleware/rate_limit.py b/api_gateway/src/middleware/rate_limit.py index 496040b..29187e7 100644 --- a/api_gateway/src/middleware/rate_limit.py +++ b/api_gateway/src/middleware/rate_limit.py @@ -1,8 +1,8 @@ """Rate limiting middleware.""" from fastapi import Request, HTTPException from datetime import datetime, timedelta -from typing import Dict, Tuple -import time +from typing import Dict + class RateLimiter: def __init__(self, requests_per_minute: int = 60): diff --git a/auth_service/Dockerfile b/auth_service/Dockerfile index 94e292c..52386b7 100644 --- a/auth_service/Dockerfile +++ b/auth_service/Dockerfile @@ -13,6 +13,7 @@ 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/ 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/migrations/env.py b/auth_service/migrations/env.py index b0ebbbe..5904b36 100644 --- a/auth_service/migrations/env.py +++ b/auth_service/migrations/env.py @@ -1,24 +1,16 @@ from __future__ import annotations - +import asyncio +import os from logging.config import fileConfig -from sqlalchemy import engine_from_config, pool -import os +from sqlalchemy import pool +from sqlalchemy.ext.asyncio import create_async_engine from alembic import context -# this is the Alembic Config object, which provides -# access to the values within the .ini file in use. config = context.config - -# Interpret the config file for Python logging. -# This line sets up loggers basically. if config.config_file_name is not None: fileConfig(config.config_file_name) -# If you use metadata for autogenerate, import it here. -# from db.models.users import Base -# target_metadata = Base.metadata -# For this project we rely on explicit migrations, so metadata is optional. target_metadata = None @@ -28,22 +20,10 @@ def _build_db_url_from_env() -> str: host = os.getenv("POSTGRES_HOST", "postgres") port = os.getenv("POSTGRES_PORT", "5432") db = os.getenv("POSTGRES_DB", "auth_db") - return f"postgresql://{user}:{password}@{host}:{port}/{db}" + return f"postgresql+asyncpg://{user}:{password}@{host}:{port}/{db}" def run_migrations_offline() -> None: - """Run migrations in 'offline' mode. - - This configures the context with just a URL - and not an Engine, though an Engine is acceptable - here as well. By skipping the Engine creation - we don't even need a DBAPI to be available. - - Calls to context.execute() here emit the given string to the - script output. - """ - - # Prefer URL from environment variables to avoid ini interpolation issues url = _build_db_url_from_env() context.configure( url=url, @@ -51,29 +31,24 @@ def run_migrations_offline() -> None: literal_binds=True, compare_type=True, ) - with context.begin_transaction(): context.run_migrations() -def run_migrations_online() -> None: - """Run migrations in 'online' mode.""" - - # Build engine config purely from env, avoiding reading .ini sections - connectable = engine_from_config( - {"sqlalchemy.url": _build_db_url_from_env()}, - prefix="sqlalchemy.", - poolclass=pool.NullPool, - ) +def do_run_migrations(connection): + context.configure(connection=connection, target_metadata=target_metadata, compare_type=True) + with context.begin_transaction(): + context.run_migrations() - with connectable.connect() as 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: - run_migrations_online() + asyncio.run(run_migrations_online()) \ No newline at end of file diff --git a/auth_service/pyproject.toml b/auth_service/pyproject.toml index da5b9c0..1641007 100644 --- a/auth_service/pyproject.toml +++ b/auth_service/pyproject.toml @@ -3,23 +3,33 @@ name = "auth-service" version = "0.1.0" description = "Authentication service for AsyncFlow" authors = ["Aleksei Loguntsov"] -packages = [ - { include = "*", from = "src" } # Corrected the path to avoid nested src/src -] +readme = "README.md" +packages = [{ include = "*", from = "src" }] [tool.poetry.dependencies] python = "^3.11" -fastapi = "^0.104.1" -uvicorn = {extras = ["standard"], version = "^0.24.0"} # Added standard extras for uvicorn -pydantic = "^2.4.2" # Ensure the correct version of pydantic is specified + +# --- Web Framework --- +fastapi = ">=0.111.1" +uvicorn = { extras = ["standard"], version = "^0.24.0" } + +# --- Data & Validation --- +pydantic = "^2.4.2" pydantic-settings = "^2.0.3" -python-jose = {extras = ["cryptography"], version = "^3.3.0"} -passlib = {extras = ["bcrypt"], version = "^1.7.4"} -sqlalchemy = "^2.0.23" -alembic = "^1.12.1" -psycopg2-binary = "^2.9.9" -python-multipart = "^0.0.6" 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" } + +# --- 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" @@ -28,7 +38,33 @@ 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" \ No newline at end of file +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/main.py b/auth_service/src/main.py index 923ac6d..3e1cddd 100644 --- a/auth_service/src/main.py +++ b/auth_service/src/main.py @@ -96,30 +96,33 @@ async def update_user_me( ): """Update current user information.""" if user_update.email and user_update.email != current_user.email: - if db.query(User).filter(User.email == user_update.email).first(): + 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: - if db.query(User).filter(User.username == user_update.username).first(): + 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.commit() - db.refresh(current_user) - + + db.add(current_user) + await db.commit() + await db.refresh(current_user) + return current_user @@ -136,8 +139,9 @@ async def read_users( status_code=status.HTTP_403_FORBIDDEN, detail="Not enough permissions" ) - - users = db.query(User).offset(skip).limit(limit).all() + + result = await db.execute(sa.select(User).offset(skip).limit(limit)) + users = result.scalars().all() return users diff --git a/auth_service/src/settings.py b/auth_service/src/settings.py index 258a035..0d47ebc 100644 --- a/auth_service/src/settings.py +++ b/auth_service/src/settings.py @@ -24,7 +24,7 @@ class Settings(BaseSettings): def database_url(self) -> str: """Get the database URL.""" return ( - f"postgresql://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}@" + f"postgresql+asyncpg://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}@" f"{self.POSTGRES_HOST}:{self.POSTGRES_PORT}/{self.POSTGRES_DB}" ) diff --git a/frontend/src/App.css b/frontend/src/App.css index ea23973..4286974 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -201,6 +201,96 @@ 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; diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 07a47a1..c2c7942 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -5,6 +5,12 @@ 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: '', password: '' }) + const [showAuthForm, setShowAuthForm] = useState(false) + const [authData, setAuthData] = useState({ username: '', password: '' }) useEffect(() => { const checkHealth = async () => { @@ -20,6 +26,56 @@ function App() { checkHealth() }, []) + 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() + 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 (
@@ -101,6 +157,101 @@ function App() { +
+ + +
+ + {showSignIn && ( +
+ +

Sign In

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

Sign Up

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

Authentication

+
+ + + +
+
+ )} +

AsyncFlow © 2025 | Built with ❤️ for microservices architecture

diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index d7f093e..54b39dd 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -1,30 +1,10 @@ -import React from 'react'; -import ReactDOM from 'react-dom'; -import App from './App'; -import { BrowserRouter as Router, Route, Routes, Link } from 'react-router-dom'; -import './index.css'; +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App.jsx' +import './index.css' -function MainPage() { - return ( -
-

Welcome to AsyncFlow

-

Your one-stop solution for managing orders and billing.

- - - -
- ); -} - -function AppRouter() { - return ( - - - } /> - Sign In Page
} /> - - - ); -} - -ReactDOM.render(, document.getElementById('root')); +ReactDOM.createRoot(document.getElementById('root')).render( + + + , +) From ef68cacbc76b0dc917807ddf8fc4f34824ec4fe7 Mon Sep 17 00:00:00 2001 From: Aleksei Loguntsov Date: Sun, 2 Nov 2025 13:42:23 +0400 Subject: [PATCH 19/20] asd --- api_gateway/src/main.py | 24 ++++++++++++------------ api_gateway/src/middleware/metrics.py | 8 -------- 2 files changed, 12 insertions(+), 20 deletions(-) diff --git a/api_gateway/src/main.py b/api_gateway/src/main.py index 54bf2be..092a200 100644 --- a/api_gateway/src/main.py +++ b/api_gateway/src/main.py @@ -50,27 +50,27 @@ async def lifespan(app: FastAPI): ) # Add CORS middleware -app.add_middleware( - CORSMiddleware, - allow_origins=settings.CORS_ORIGINS, - allow_credentials=settings.CORS_ALLOW_CREDENTIALS, - allow_methods=["*"], - allow_headers=["*"], -) +# 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) +# if settings.ENABLE_METRICS: +# app.add_middleware(MetricsMiddleware) # Add rate limiting if enabled -if settings.RATE_LIMIT_ENABLED: - app.middleware("http")(rate_limit_middleware) +# if settings.RATE_LIMIT_ENABLED: + # app.middleware("http")(rate_limit_middleware) # Add authentication middleware -app.middleware("http")(auth_middleware) +# app.middleware("http")(auth_middleware) @app.get("/health", tags=["System"]) diff --git a/api_gateway/src/middleware/metrics.py b/api_gateway/src/middleware/metrics.py index 37e4f4d..8689fc9 100644 --- a/api_gateway/src/middleware/metrics.py +++ b/api_gateway/src/middleware/metrics.py @@ -36,14 +36,6 @@ async def dispatch(self, request: Request, call_next): try: response = await call_next(request) - - # Ensure response body is not consumed multiple times - if isinstance(response, StreamingResponse): - original_body = b"".join([chunk async for chunk in response.body_iterator]) - response.body_iterator = iter([original_body]) - response.headers["Content-Length"] = str(len(original_body)) - - REQUEST_COUNT.labels(service=service, status=response.status_code).inc() return response except Exception as e: From 9b164b0a183cf104c835a82e448119e95a101367 Mon Sep 17 00:00:00 2001 From: Aleksei Loguntsov Date: Mon, 3 Nov 2025 12:15:16 +0400 Subject: [PATCH 20/20] asd --- api_gateway/src/api/auth.py | 9 ++++- api_gateway/src/core/services.py | 4 +-- api_gateway/src/main.py | 34 +++++++++--------- api_gateway/src/middleware/__init__.py | 11 ++++++ auth_service/.python-version | 1 + auth_service/main.py | 6 ++++ auth_service/pyproject.toml | 1 + auth_service/src/main.py | 4 +-- auth_service/src/security.py | 50 ++++++++++++++++++++++---- frontend/src/App.jsx | 24 ++++++++++++- 10 files changed, 113 insertions(+), 31 deletions(-) create mode 100644 auth_service/.python-version create mode 100644 auth_service/main.py diff --git a/api_gateway/src/api/auth.py b/api_gateway/src/api/auth.py index f876e6f..8bd5cb7 100644 --- a/api_gateway/src/api/auth.py +++ b/api_gateway/src/api/auth.py @@ -15,7 +15,14 @@ async def register(user_data: UserCreate): - Requires email, username, and password - Returns created user information """ - return Response(status_code=201, content={"message": "User registered successfully"}) + 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()) diff --git a/api_gateway/src/core/services.py b/api_gateway/src/core/services.py index a5ccaa5..da02c46 100644 --- a/api_gateway/src/core/services.py +++ b/api_gateway/src/core/services.py @@ -78,9 +78,9 @@ async def forward_request(request: Request) -> Any: follow_redirects=True, timeout=settings.DEFAULT_TIMEOUT, ) - + return StreamingResponse( - response.aiter_raw(), + content=bytes(response.content or b""), status_code=response.status_code, headers=dict(response.headers), media_type=response.headers.get("content-type") diff --git a/api_gateway/src/main.py b/api_gateway/src/main.py index 092a200..6c2561f 100644 --- a/api_gateway/src/main.py +++ b/api_gateway/src/main.py @@ -11,9 +11,9 @@ from core.config import settings from core.services import forward_request -from middleware.metrics import MetricsMiddleware, get_metrics -from middleware.auth import auth_middleware -from middleware.rate_limit import rate_limit_middleware +from middleware import MetricsMiddleware, get_metrics +from middleware import auth_middleware +from middleware import rate_limit_middleware # Configure structured logging structlog.configure( @@ -50,27 +50,27 @@ async def lifespan(app: FastAPI): ) # Add CORS middleware -# app.add_middleware( -# CORSMiddleware, -# allow_origins=settings.CORS_ORIGINS, -# allow_credentials=settings.CORS_ALLOW_CREDENTIALS, -# allow_methods=["*"], -# allow_headers=["*"], -# ) +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) +if settings.ENABLE_METRICS: + app.add_middleware(MetricsMiddleware) # Add rate limiting if enabled -# if settings.RATE_LIMIT_ENABLED: - # app.middleware("http")(rate_limit_middleware) +if settings.RATE_LIMIT_ENABLED: + app.middleware("http")(rate_limit_middleware) # Add authentication middleware -# app.middleware("http")(auth_middleware) +app.middleware("http")(auth_middleware) @app.get("/health", tags=["System"]) @@ -142,6 +142,7 @@ async def api_gateway(request: Request, version: str, path: str): try: response = await forward_request(request) + logger.info( "request_completed", request_id=request_id, @@ -158,9 +159,6 @@ async def api_gateway(request: Request, version: str, path: str): raise - - - if __name__ == "__main__": uvicorn.run( "main:app", diff --git a/api_gateway/src/middleware/__init__.py b/api_gateway/src/middleware/__init__.py index e69de29..eeb32f2 100644 --- a/api_gateway/src/middleware/__init__.py +++ 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/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/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/pyproject.toml b/auth_service/pyproject.toml index 1641007..12f17b1 100644 --- a/auth_service/pyproject.toml +++ b/auth_service/pyproject.toml @@ -22,6 +22,7 @@ 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"] } diff --git a/auth_service/src/main.py b/auth_service/src/main.py index 3e1cddd..34014a1 100644 --- a/auth_service/src/main.py +++ b/auth_service/src/main.py @@ -7,14 +7,14 @@ import security from settings import settings -from db.base import get_db, async_session +from db.base import get_db from db.models.users import User import schemas app = FastAPI(title="AsyncFlow Auth Service") -@app.post("/auth/register", response_model=schemas.User) +@app.post("/register", response_model=schemas.User) async def register_user( user_data: schemas.UserCreate, db: AsyncSession = Depends(get_db) # Use async session diff --git a/auth_service/src/security.py b/auth_service/src/security.py index 40b9886..fa012aa 100644 --- a/auth_service/src/security.py +++ b/auth_service/src/security.py @@ -1,30 +1,66 @@ +from __future__ import annotations + from datetime import datetime, timedelta from typing import Optional + +import logging from jose import JWTError, jwt -from passlib.context import CryptContext 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 -pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") -oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/token") +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", +) -def verify_password(plain_password: str, hashed_password: str) -> bool: - """Verify a password against its hash.""" - return pwd_context.verify(plain_password, hashed_password) +# Корректный относительный URL для токена (через ваш /auth/token endpoint) +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/token") +# --- +# Хэширование и верификация +# --- def get_password_hash(password: str) -> str: - """Generate password hash.""" + """ + Возвращает хэш пароля, используя 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() diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index c2c7942..a4cf4b9 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -8,7 +8,7 @@ function App() { const [showSignIn, setShowSignIn] = useState(false) const [showSignUp, setShowSignUp] = useState(false) const [signInData, setSignInData] = useState({ username: '', password: '' }) - const [signUpData, setSignUpData] = useState({ username: '', password: '' }) + const [signUpData, setSignUpData] = useState({ username: '', email: '', password: '' }) const [showAuthForm, setShowAuthForm] = useState(false) const [authData, setAuthData] = useState({ username: '', password: '' }) @@ -23,7 +23,11 @@ function App() { 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) @@ -57,6 +61,14 @@ function App() { 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}`) @@ -207,6 +219,16 @@ function App() { placeholder="Choose a username" /> +