init commit. Skeleton prepared

This commit is contained in:
2025-08-08 19:48:03 +09:00
commit d58302c2c8
127 changed files with 1329 additions and 0 deletions

119
.drone.yml Normal file
View File

@@ -0,0 +1,119 @@
---
kind: pipeline
type: docker
name: ci
trigger:
event: [ push, pull_request ]
steps:
- name: test-auth
image: python:3.12
environment:
PYTHONPATH: services/auth/src
commands:
- python -V
- pip install --no-cache-dir -r services/auth/requirements.txt
- python -m pytest -q services/auth/tests
- name: test-profiles
image: python:3.12
environment:
PYTHONPATH: services/profiles/src
commands:
- python -V
- pip install --no-cache-dir -r services/profiles/requirements.txt
- python -m pytest -q services/profiles/tests
- name: test-match
image: python:3.12
environment:
PYTHONPATH: services/match/src
commands:
- python -V
- pip install --no-cache-dir -r services/match/requirements.txt
- python -m pytest -q services/match/tests
- name: test-chat
image: python:3.12
environment:
PYTHONPATH: services/chat/src
commands:
- python -V
- pip install --no-cache-dir -r services/chat/requirements.txt
- python -m pytest -q services/chat/tests
- name: test-payments
image: python:3.12
environment:
PYTHONPATH: services/payments/src
commands:
- python -V
- pip install --no-cache-dir -r services/payments/requirements.txt
- python -m pytest -q services/payments/tests
- name: build-auth
image: plugins/docker
privileged: true
settings:
context: services/auth
dockerfile: services/auth/Dockerfile
repo: registry.example.com/your-namespace/marriage-auth
tags: [ latest ]
username:
from_secret: docker_username
password:
from_secret: docker_password
when:
event: [ push ]
- name: build-profiles
image: plugins/docker
privileged: true
settings:
context: services/profiles
dockerfile: services/profiles/Dockerfile
repo: registry.example.com/your-namespace/marriage-profiles
tags: [ latest ]
username:
from_secret: docker_username
password:
from_secret: docker_password
when:
event: [ push ]
- name: build-match
image: plugins/docker
privileged: true
settings:
context: services/match
dockerfile: services/match/Dockerfile
repo: registry.example.com/your-namespace/marriage-match
tags: [ latest ]
username:
from_secret: docker_username
password:
from_secret: docker_password
when:
event: [ push ]
- name: build-chat
image: plugins/docker
privileged: true
settings:
context: services/chat
dockerfile: services/chat/Dockerfile
repo: registry.example.com/your-namespace/marriage-chat
tags: [ latest ]
username:
from_secret: docker_username
password:
from_secret: docker_password
when:
event: [ push ]
- name: build-payments
image: plugins/docker
privileged: true
settings:
context: services/payments
dockerfile: services/payments/Dockerfile
repo: registry.example.com/your-namespace/marriage-payments
tags: [ latest ]
username:
from_secret: docker_username
password:
from_secret: docker_password
when:
event: [ push ]

3
.github/workflows/readme.md vendored Normal file
View File

@@ -0,0 +1,3 @@
# CI Note
This project uses Drone CI (.drone.yml).
This placeholder keeps the directory in the repo.

26
.gitignore vendored Normal file
View File

@@ -0,0 +1,26 @@
# Python
__pycache__/
*.py[cod]
*.so
*.egg-info/
.eggs/
.venv/
venv/
.env
.env.*
.pytest_cache/
.mypy_cache/
coverage.xml
htmlcov/
dist/
build/
# IDE / OS
.DS_Store
.idea/
.vscode/
# Docker
**/.python_packages/
**/.pytest_cache/
**/.ruff_cache/

15
README.md Normal file
View File

@@ -0,0 +1,15 @@
# Match Agency — Python Microservices Skeleton
**Stack**: Python (FastAPI) • PostgreSQL • SQLAlchemy 2.0 • Alembic • Docker Compose
**Gateway**: Nginx • **CI/CD**: Drone
## Quick Start
1) `cp .env.example .env`
2) `docker compose up --build`
3) Gateway: http://localhost:8080 (routes: /auth, /profiles, /match, /chat, /payments)
## Structure
- services/* — микросервисы (ООП-слои: repositories/services)
- infra/db/init — init-скрипты Postgres (создание БД под сервисы)
- infra/gateway — конфиг Nginx (API gateway)
- .drone.yml — CI/CD (pytest + docker build)

105
docker-compose.yml Normal file
View File

@@ -0,0 +1,105 @@
version: "3.9"
services:
postgres:
image: postgres:16
container_name: postgres
environment:
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
- ./infra/db/init:/docker-entrypoint-initdb.d
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-postgres}"]
interval: 5s
timeout: 5s
retries: 40
gateway:
image: nginx:alpine
container_name: gateway
depends_on:
- auth
- profiles
- match
- chat
- payments
ports:
- "8080:80"
volumes:
- ./infra/gateway/nginx.conf:/etc/nginx/conf.d/default.conf:ro
auth:
build:
context: ./services/auth
container_name: marriage_auth
env_file:
- .env
environment:
DATABASE_URL: ${AUTH_DATABASE_URL}
depends_on:
- postgres
ports:
- "${AUTH_PORT:-8001}:8000"
command: ./docker-entrypoint.sh
profiles:
build:
context: ./services/profiles
container_name: marriage_profiles
env_file:
- .env
environment:
DATABASE_URL: ${PROFILES_DATABASE_URL}
depends_on:
- postgres
ports:
- "${PROFILES_PORT:-8002}:8000"
command: ./docker-entrypoint.sh
match:
build:
context: ./services/match
container_name: marriage_match
env_file:
- .env
environment:
DATABASE_URL: ${MATCH_DATABASE_URL}
depends_on:
- postgres
ports:
- "${MATCH_PORT:-8003}:8000"
command: ./docker-entrypoint.sh
chat:
build:
context: ./services/chat
container_name: marriage_chat
env_file:
- .env
environment:
DATABASE_URL: ${CHAT_DATABASE_URL}
depends_on:
- postgres
ports:
- "${CHAT_PORT:-8004}:8000"
command: ./docker-entrypoint.sh
payments:
build:
context: ./services/payments
container_name: marriage_payments
env_file:
- .env
environment:
DATABASE_URL: ${PAYMENTS_DATABASE_URL}
depends_on:
- postgres
ports:
- "${PAYMENTS_PORT:-8005}:8000"
command: ./docker-entrypoint.sh
volumes:
pgdata:

View File

@@ -0,0 +1,6 @@
-- Executed once on fresh Postgres volume
CREATE DATABASE auth_db;
CREATE DATABASE profiles_db;
CREATE DATABASE match_db;
CREATE DATABASE chat_db;
CREATE DATABASE payments_db;

50
infra/gateway/nginx.conf Normal file
View File

@@ -0,0 +1,50 @@
server {
listen 80;
server_name _;
# Health of gateway itself
location = /health {
default_type application/json;
return 200 '{"status":"ok","gateway":"nginx"}';
}
location /auth/ {
proxy_pass http://auth:8000/;
proxy_http_version 1.1;
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;
}
location /profiles/ {
proxy_pass http://profiles:8000/;
proxy_http_version 1.1;
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;
}
location /match/ {
proxy_pass http://match:8000/;
proxy_http_version 1.1;
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;
}
location /chat/ {
proxy_pass http://chat:8000/;
proxy_http_version 1.1;
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;
}
location /payments/ {
proxy_pass http://payments:8000/;
proxy_http_version 1.1;
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;
}
}

17
services/auth/Dockerfile Normal file
View File

@@ -0,0 +1,17 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --upgrade pip \
&& pip install --no-cache-dir -r requirements.txt
COPY src ./src
COPY alembic.ini ./
COPY alembic ./alembic
COPY docker-entrypoint.sh ./
ENV PYTHONPATH=/app/src
EXPOSE 8000
CMD ["./docker-entrypoint.sh"]

35
services/auth/alembic.ini Normal file
View File

@@ -0,0 +1,35 @@
[alembic]
script_location = alembic
timezone = UTC
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers = console
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stdout,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s %(name)s %(message)s

View File

@@ -0,0 +1,55 @@
from __future__ import annotations
import os
import sys
from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
# Add ./src to sys.path
BASE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
SRC_DIR = os.path.join(BASE_DIR, "src")
if SRC_DIR not in sys.path:
sys.path.append(SRC_DIR)
from app.db.session import Base # noqa
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
DATABASE_URL = os.getenv("DATABASE_URL")
def run_migrations_offline() -> None:
url = DATABASE_URL
if not url:
raise RuntimeError("DATABASE_URL is not set")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
compare_type=True,
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
url = DATABASE_URL
if not url:
raise RuntimeError("DATABASE_URL is not set")
connectable = engine_from_config(
{"sqlalchemy.url": url},
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()

View File

View File

@@ -0,0 +1,6 @@
#!/usr/bin/env sh
set -e
# Run migrations (no-op if no revisions yet)
alembic -c alembic.ini upgrade head || true
# Start app
exec uvicorn app.main:app --host 0.0.0.0 --port 8000

View File

@@ -0,0 +1,10 @@
fastapi
uvicorn[standard]
SQLAlchemy>=2.0
psycopg2-binary
alembic
pydantic>=2
pydantic-settings
python-dotenv
httpx>=0.27
pytest

View File

View File

View File

@@ -0,0 +1,7 @@
from fastapi import APIRouter
router = APIRouter()
@router.get("/ping")
def ping():
return {"ping": "pong"}

View File

View File

@@ -0,0 +1,9 @@
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
database_url: str | None = None
class Config:
env_prefix = ""
env_file = ".env"
case_sensitive = False

View File

View File

@@ -0,0 +1,26 @@
import os
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, DeclarativeBase
from typing import Generator
DEFAULT_DB_URL = "postgresql+psycopg2://postgres:postgres@postgres:5432/auth_db"
class Base(DeclarativeBase):
pass
DATABASE_URL = os.getenv("DATABASE_URL", DEFAULT_DB_URL)
engine = create_engine(
DATABASE_URL,
pool_pre_ping=True,
future=True,
)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
def get_db() -> Generator:
db = SessionLocal()
try:
yield db
finally:
db.close()

View File

@@ -0,0 +1,11 @@
from fastapi import FastAPI
from .api.routes.ping import router as ping_router
app = FastAPI(title="AUTH Service")
@app.get("/health")
def health():
return {"status": "ok", "service": "auth"}
# v1 API
app.include_router(ping_router, prefix="/v1")

View File

View File

@@ -0,0 +1 @@
from app.db.session import Base # re-export for convenience

View File

@@ -0,0 +1,5 @@
from sqlalchemy.orm import Session
class BaseRepository:
def __init__(self, db: Session):
self.db = db

View File

@@ -0,0 +1,4 @@
from pydantic import BaseModel
class Message(BaseModel):
message: str

View File

@@ -0,0 +1,5 @@
from sqlalchemy.orm import Session
class BaseService:
def __init__(self, db: Session):
self.db = db

View File

@@ -0,0 +1,10 @@
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
def test_health():
r = client.get("/health")
assert r.status_code == 200
data = r.json()
assert data.get("status") == "ok"

17
services/chat/Dockerfile Normal file
View File

@@ -0,0 +1,17 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --upgrade pip \
&& pip install --no-cache-dir -r requirements.txt
COPY src ./src
COPY alembic.ini ./
COPY alembic ./alembic
COPY docker-entrypoint.sh ./
ENV PYTHONPATH=/app/src
EXPOSE 8000
CMD ["./docker-entrypoint.sh"]

35
services/chat/alembic.ini Normal file
View File

@@ -0,0 +1,35 @@
[alembic]
script_location = alembic
timezone = UTC
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers = console
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stdout,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s %(name)s %(message)s

View File

@@ -0,0 +1,55 @@
from __future__ import annotations
import os
import sys
from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
# Add ./src to sys.path
BASE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
SRC_DIR = os.path.join(BASE_DIR, "src")
if SRC_DIR not in sys.path:
sys.path.append(SRC_DIR)
from app.db.session import Base # noqa
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
DATABASE_URL = os.getenv("DATABASE_URL")
def run_migrations_offline() -> None:
url = DATABASE_URL
if not url:
raise RuntimeError("DATABASE_URL is not set")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
compare_type=True,
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
url = DATABASE_URL
if not url:
raise RuntimeError("DATABASE_URL is not set")
connectable = engine_from_config(
{"sqlalchemy.url": url},
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()

View File

View File

@@ -0,0 +1,6 @@
#!/usr/bin/env sh
set -e
# Run migrations (no-op if no revisions yet)
alembic -c alembic.ini upgrade head || true
# Start app
exec uvicorn app.main:app --host 0.0.0.0 --port 8000

View File

@@ -0,0 +1,10 @@
fastapi
uvicorn[standard]
SQLAlchemy>=2.0
psycopg2-binary
alembic
pydantic>=2
pydantic-settings
python-dotenv
httpx>=0.27
pytest

View File

View File

View File

@@ -0,0 +1,7 @@
from fastapi import APIRouter
router = APIRouter()
@router.get("/ping")
def ping():
return {"ping": "pong"}

View File

View File

@@ -0,0 +1,9 @@
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
database_url: str | None = None
class Config:
env_prefix = ""
env_file = ".env"
case_sensitive = False

View File

View File

@@ -0,0 +1,26 @@
import os
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, DeclarativeBase
from typing import Generator
DEFAULT_DB_URL = "postgresql+psycopg2://postgres:postgres@postgres:5432/chat_db"
class Base(DeclarativeBase):
pass
DATABASE_URL = os.getenv("DATABASE_URL", DEFAULT_DB_URL)
engine = create_engine(
DATABASE_URL,
pool_pre_ping=True,
future=True,
)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
def get_db() -> Generator:
db = SessionLocal()
try:
yield db
finally:
db.close()

View File

@@ -0,0 +1,11 @@
from fastapi import FastAPI
from .api.routes.ping import router as ping_router
app = FastAPI(title="CHAT Service")
@app.get("/health")
def health():
return {"status": "ok", "service": "chat"}
# v1 API
app.include_router(ping_router, prefix="/v1")

View File

View File

@@ -0,0 +1 @@
from app.db.session import Base # re-export for convenience

View File

@@ -0,0 +1,5 @@
from sqlalchemy.orm import Session
class BaseRepository:
def __init__(self, db: Session):
self.db = db

View File

@@ -0,0 +1,4 @@
from pydantic import BaseModel
class Message(BaseModel):
message: str

View File

@@ -0,0 +1,5 @@
from sqlalchemy.orm import Session
class BaseService:
def __init__(self, db: Session):
self.db = db

View File

@@ -0,0 +1,10 @@
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
def test_health():
r = client.get("/health")
assert r.status_code == 200
data = r.json()
assert data.get("status") == "ok"

17
services/match/Dockerfile Normal file
View File

@@ -0,0 +1,17 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --upgrade pip \
&& pip install --no-cache-dir -r requirements.txt
COPY src ./src
COPY alembic.ini ./
COPY alembic ./alembic
COPY docker-entrypoint.sh ./
ENV PYTHONPATH=/app/src
EXPOSE 8000
CMD ["./docker-entrypoint.sh"]

View File

@@ -0,0 +1,35 @@
[alembic]
script_location = alembic
timezone = UTC
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers = console
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stdout,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s %(name)s %(message)s

View File

@@ -0,0 +1,55 @@
from __future__ import annotations
import os
import sys
from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
# Add ./src to sys.path
BASE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
SRC_DIR = os.path.join(BASE_DIR, "src")
if SRC_DIR not in sys.path:
sys.path.append(SRC_DIR)
from app.db.session import Base # noqa
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
DATABASE_URL = os.getenv("DATABASE_URL")
def run_migrations_offline() -> None:
url = DATABASE_URL
if not url:
raise RuntimeError("DATABASE_URL is not set")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
compare_type=True,
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
url = DATABASE_URL
if not url:
raise RuntimeError("DATABASE_URL is not set")
connectable = engine_from_config(
{"sqlalchemy.url": url},
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()

View File

View File

@@ -0,0 +1,6 @@
#!/usr/bin/env sh
set -e
# Run migrations (no-op if no revisions yet)
alembic -c alembic.ini upgrade head || true
# Start app
exec uvicorn app.main:app --host 0.0.0.0 --port 8000

View File

@@ -0,0 +1,10 @@
fastapi
uvicorn[standard]
SQLAlchemy>=2.0
psycopg2-binary
alembic
pydantic>=2
pydantic-settings
python-dotenv
httpx>=0.27
pytest

View File

View File

View File

@@ -0,0 +1,7 @@
from fastapi import APIRouter
router = APIRouter()
@router.get("/ping")
def ping():
return {"ping": "pong"}

View File

View File

@@ -0,0 +1,9 @@
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
database_url: str | None = None
class Config:
env_prefix = ""
env_file = ".env"
case_sensitive = False

View File

View File

@@ -0,0 +1,26 @@
import os
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, DeclarativeBase
from typing import Generator
DEFAULT_DB_URL = "postgresql+psycopg2://postgres:postgres@postgres:5432/match_db"
class Base(DeclarativeBase):
pass
DATABASE_URL = os.getenv("DATABASE_URL", DEFAULT_DB_URL)
engine = create_engine(
DATABASE_URL,
pool_pre_ping=True,
future=True,
)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
def get_db() -> Generator:
db = SessionLocal()
try:
yield db
finally:
db.close()

View File

@@ -0,0 +1,11 @@
from fastapi import FastAPI
from .api.routes.ping import router as ping_router
app = FastAPI(title="MATCH Service")
@app.get("/health")
def health():
return {"status": "ok", "service": "match"}
# v1 API
app.include_router(ping_router, prefix="/v1")

View File

@@ -0,0 +1 @@
from app.db.session import Base # re-export for convenience

View File

@@ -0,0 +1,5 @@
from sqlalchemy.orm import Session
class BaseRepository:
def __init__(self, db: Session):
self.db = db

View File

@@ -0,0 +1,4 @@
from pydantic import BaseModel
class Message(BaseModel):
message: str

View File

@@ -0,0 +1,5 @@
from sqlalchemy.orm import Session
class BaseService:
def __init__(self, db: Session):
self.db = db

View File

@@ -0,0 +1,10 @@
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
def test_health():
r = client.get("/health")
assert r.status_code == 200
data = r.json()
assert data.get("status") == "ok"

View File

@@ -0,0 +1,17 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --upgrade pip \
&& pip install --no-cache-dir -r requirements.txt
COPY src ./src
COPY alembic.ini ./
COPY alembic ./alembic
COPY docker-entrypoint.sh ./
ENV PYTHONPATH=/app/src
EXPOSE 8000
CMD ["./docker-entrypoint.sh"]

View File

@@ -0,0 +1,35 @@
[alembic]
script_location = alembic
timezone = UTC
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers = console
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stdout,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s %(name)s %(message)s

View File

@@ -0,0 +1,55 @@
from __future__ import annotations
import os
import sys
from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
# Add ./src to sys.path
BASE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
SRC_DIR = os.path.join(BASE_DIR, "src")
if SRC_DIR not in sys.path:
sys.path.append(SRC_DIR)
from app.db.session import Base # noqa
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
DATABASE_URL = os.getenv("DATABASE_URL")
def run_migrations_offline() -> None:
url = DATABASE_URL
if not url:
raise RuntimeError("DATABASE_URL is not set")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
compare_type=True,
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
url = DATABASE_URL
if not url:
raise RuntimeError("DATABASE_URL is not set")
connectable = engine_from_config(
{"sqlalchemy.url": url},
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()

View File

@@ -0,0 +1,6 @@
#!/usr/bin/env sh
set -e
# Run migrations (no-op if no revisions yet)
alembic -c alembic.ini upgrade head || true
# Start app
exec uvicorn app.main:app --host 0.0.0.0 --port 8000

View File

@@ -0,0 +1,10 @@
fastapi
uvicorn[standard]
SQLAlchemy>=2.0
psycopg2-binary
alembic
pydantic>=2
pydantic-settings
python-dotenv
httpx>=0.27
pytest

View File

View File

@@ -0,0 +1,7 @@
from fastapi import APIRouter
router = APIRouter()
@router.get("/ping")
def ping():
return {"ping": "pong"}

View File

@@ -0,0 +1,9 @@
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
database_url: str | None = None
class Config:
env_prefix = ""
env_file = ".env"
case_sensitive = False

View File

View File

@@ -0,0 +1,26 @@
import os
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, DeclarativeBase
from typing import Generator
DEFAULT_DB_URL = "postgresql+psycopg2://postgres:postgres@postgres:5432/payments_db"
class Base(DeclarativeBase):
pass
DATABASE_URL = os.getenv("DATABASE_URL", DEFAULT_DB_URL)
engine = create_engine(
DATABASE_URL,
pool_pre_ping=True,
future=True,
)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
def get_db() -> Generator:
db = SessionLocal()
try:
yield db
finally:
db.close()

View File

@@ -0,0 +1,11 @@
from fastapi import FastAPI
from .api.routes.ping import router as ping_router
app = FastAPI(title="PAYMENTS Service")
@app.get("/health")
def health():
return {"status": "ok", "service": "payments"}
# v1 API
app.include_router(ping_router, prefix="/v1")

View File

@@ -0,0 +1 @@
from app.db.session import Base # re-export for convenience

View File

@@ -0,0 +1,5 @@
from sqlalchemy.orm import Session
class BaseRepository:
def __init__(self, db: Session):
self.db = db

View File

@@ -0,0 +1,4 @@
from pydantic import BaseModel
class Message(BaseModel):
message: str

Some files were not shown because too many files have changed in this diff Show More