Api docs (SWAGGER, REDOC) available
This commit is contained in:
8
services/docs/Dockerfile
Normal file
8
services/docs/Dockerfile
Normal file
@@ -0,0 +1,8 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY main.py .
|
||||
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
127
services/docs/main.py
Normal file
127
services/docs/main.py
Normal file
@@ -0,0 +1,127 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Dict, Optional
|
||||
|
||||
import httpx
|
||||
from fastapi import FastAPI
|
||||
from fastapi.openapi.utils import get_openapi
|
||||
from fastapi.responses import JSONResponse, RedirectResponse
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
|
||||
log = logging.getLogger("docs")
|
||||
|
||||
app = FastAPI(title="Unified Marriage API Docs", version="1.0.0")
|
||||
|
||||
# Переопределяем OpenAPI, чтобы /docs использовал объединённую схему
|
||||
def custom_openapi():
|
||||
global _cached_openapi
|
||||
import asyncio
|
||||
if _cached_openapi is None or len((_cached_openapi.get("paths") or {})) == 0:
|
||||
_cached_openapi = asyncio.run(build_cache())
|
||||
return _cached_openapi
|
||||
|
||||
app.openapi = custom_openapi
|
||||
|
||||
# Используем ИМЕНА КОНТЕЙНЕРОВ и внутренний порт 8000
|
||||
MICROSERVICES: Dict[str, str] = {
|
||||
"auth": "http://marriage_auth:8000/openapi.json",
|
||||
"profiles": "http://marriage_profiles:8000/openapi.json",
|
||||
"match": "http://marriage_match:8000/openapi.json",
|
||||
"chat": "http://marriage_chat:8000/openapi.json",
|
||||
"payments": "http://marriage_payments:8000/openapi.json",
|
||||
}
|
||||
|
||||
_cached_openapi: Optional[dict] = None
|
||||
|
||||
async def fetch_spec(client: httpx.AsyncClient, name: str, url: str, retries: int = 6, delay: float = 2.0) -> Optional[dict]:
|
||||
for attempt in range(1, retries + 1):
|
||||
try:
|
||||
r = await client.get(url)
|
||||
r.raise_for_status()
|
||||
spec = r.json()
|
||||
log.info("Loaded OpenAPI from %s: %s paths", name, len((spec or {}).get("paths", {}) or {}))
|
||||
return spec
|
||||
except Exception as e:
|
||||
log.warning("Attempt %s/%s failed for %s: %s", attempt, retries, name, e)
|
||||
await asyncio.sleep(delay)
|
||||
log.error("Could not load OpenAPI from %s after %s attempts", name, retries)
|
||||
return None
|
||||
|
||||
def merge_specs(specs_by_name: Dict[str, Optional[dict]]) -> dict:
|
||||
merged_paths: Dict[str, dict] = {}
|
||||
merged_components = {"schemas": {}}
|
||||
|
||||
for name, spec in specs_by_name.items():
|
||||
if not spec:
|
||||
continue
|
||||
for path, methods in (spec.get("paths") or {}).items():
|
||||
merged_paths[f"/{name}{path}"] = methods
|
||||
merged_components["schemas"].update((spec.get("components") or {}).get("schemas", {}) or {})
|
||||
|
||||
openapi_schema = get_openapi(
|
||||
title="Unified Marriage API",
|
||||
version="1.0.0",
|
||||
description="Combined OpenAPI schema for all microservices",
|
||||
routes=[],
|
||||
)
|
||||
openapi_schema["servers"] = [{"url": "http://localhost:8080"}]
|
||||
openapi_schema["paths"] = merged_paths
|
||||
openapi_schema["components"] = merged_components
|
||||
return openapi_schema
|
||||
|
||||
async def build_cache() -> dict:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
tasks = [fetch_spec(client, name, url) for name, url in MICROSERVICES.items()]
|
||||
specs = await asyncio.gather(*tasks)
|
||||
|
||||
specs_by_name = dict(zip(MICROSERVICES.keys(), specs))
|
||||
merged = merge_specs(specs_by_name)
|
||||
log.info("Unified OpenAPI built: %s paths", len(merged.get("paths", {})))
|
||||
return merged
|
||||
|
||||
async def ensure_cache() -> dict:
|
||||
"""Гарантируем, что кэш непустой: если paths == 0 — пересобираем."""
|
||||
global _cached_openapi
|
||||
if _cached_openapi is None or len((_cached_openapi.get("paths") or {})) == 0:
|
||||
_cached_openapi = await build_cache()
|
||||
return _cached_openapi
|
||||
|
||||
@app.on_event("startup")
|
||||
async def _warmup_cache():
|
||||
"""Always rebuild cache on startup so Swagger has full data."""
|
||||
global _cached_openapi
|
||||
try:
|
||||
_cached_openapi = await build_cache()
|
||||
log.info("[startup] Cache built: %s paths", len((_cached_openapi.get("paths") or {})))
|
||||
except Exception as e:
|
||||
log.error("[startup] Failed to build cache: %s", e)
|
||||
@app.get("/", include_in_schema=False)
|
||||
def root():
|
||||
return RedirectResponse(url="/docs")
|
||||
|
||||
@app.get("/_health", include_in_schema=False)
|
||||
def health():
|
||||
return {"status": "ok", "cached_paths": len((_cached_openapi or {}).get("paths", {}))}
|
||||
|
||||
@app.get("/debug", include_in_schema=False)
|
||||
async def debug():
|
||||
out = {}
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
for name, url in MICROSERVICES.items():
|
||||
try:
|
||||
r = await client.get(url)
|
||||
out[name] = {"status": r.status_code, "len": len(r.text), "starts_with": r.text[:80]}
|
||||
except Exception as e:
|
||||
out[name] = {"error": str(e)}
|
||||
return out
|
||||
|
||||
@app.get("/refresh", include_in_schema=False)
|
||||
async def refresh():
|
||||
global _cached_openapi
|
||||
_cached_openapi = await build_cache()
|
||||
return {"refreshed": True, "paths": len(_cached_openapi.get("paths", {}))}
|
||||
|
||||
@app.get("/openapi.json", include_in_schema=False)
|
||||
async def unified_openapi():
|
||||
data = await ensure_cache()
|
||||
return JSONResponse(data)
|
||||
3
services/docs/requirements.txt
Normal file
3
services/docs/requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
fastapi
|
||||
uvicorn
|
||||
httpx
|
||||
@@ -4,15 +4,23 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.core.security import get_current_user, require_roles, UserClaims
|
||||
from app.schemas.payment import InvoiceCreate, InvoiceUpdate, InvoiceRead
|
||||
from app.models import Invoice
|
||||
from app.schemas.payment import InvoiceCreate, InvoiceUpdate, InvoiceRead, InvoiceOut
|
||||
from app.services.payment_service import PaymentService
|
||||
|
||||
router = APIRouter(prefix="/v1/invoices", tags=["payments"])
|
||||
|
||||
@router.post("", response_model=InvoiceRead, status_code=201)
|
||||
def create_invoice(payload: InvoiceCreate, db: Session = Depends(get_db),
|
||||
_: UserClaims = Depends(require_roles("ADMIN","MATCHMAKER"))):
|
||||
return PaymentService(db).create_invoice(**payload.model_dump(exclude_none=True))
|
||||
@router.post("", response_model=InvoiceOut, status_code=201)
|
||||
def create_invoice(payload: InvoiceCreate, db: Session = Depends(get_db)):
|
||||
inv = Invoice(
|
||||
client_id=payload.client_id, # UUID
|
||||
amount=payload.amount,
|
||||
currency=payload.currency,
|
||||
status="new",
|
||||
)
|
||||
db.add(inv)
|
||||
db.commit()
|
||||
db.refresh(inv)
|
||||
return inv
|
||||
|
||||
@router.get("", response_model=list[InvoiceRead])
|
||||
def list_invoices(client_id: str | None = None, status: str | None = None,
|
||||
|
||||
@@ -2,16 +2,17 @@ from __future__ import annotations
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from sqlalchemy import String, DateTime, Numeric
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.sql import func
|
||||
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
|
||||
from uuid import uuid4
|
||||
|
||||
from app.db.session import Base
|
||||
|
||||
class Invoice(Base):
|
||||
__tablename__ = "invoices"
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
client_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False)
|
||||
id = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid4)
|
||||
client_id = mapped_column(PG_UUID(as_uuid=True), nullable=False)
|
||||
amount: Mapped[float] = mapped_column(Numeric(12,2), nullable=False)
|
||||
currency: Mapped[str] = mapped_column(String(3), default="USD")
|
||||
status: Mapped[str] = mapped_column(String(16), default="pending") # pending/paid/canceled
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
from __future__ import annotations
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from uuid import UUID
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
class InvoiceCreate(BaseModel):
|
||||
client_id: str
|
||||
amount: float
|
||||
currency: str = "USD"
|
||||
client_id: UUID
|
||||
amount: int = Field(ge=0)
|
||||
currency: str
|
||||
description: Optional[str] = None
|
||||
|
||||
class InvoiceUpdate(BaseModel):
|
||||
@@ -15,10 +18,23 @@ class InvoiceUpdate(BaseModel):
|
||||
status: Optional[str] = None
|
||||
|
||||
class InvoiceRead(BaseModel):
|
||||
id: str
|
||||
client_id: str
|
||||
id: UUID
|
||||
client_id: UUID
|
||||
amount: float
|
||||
currency: str
|
||||
status: str
|
||||
description: Optional[str] = None
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class InvoiceOut(BaseModel):
|
||||
id: UUID
|
||||
client_id: UUID
|
||||
amount: int
|
||||
currency: str
|
||||
status: str
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
40
services/payments/src/app/schemas/payment.py.bak.1754729104
Normal file
40
services/payments/src/app/schemas/payment.py.bak.1754729104
Normal file
@@ -0,0 +1,40 @@
|
||||
from __future__ import annotations
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from uuid import UUID
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
class InvoiceCreate(BaseModel):
|
||||
client_id: UUID
|
||||
amount: int = Field(ge=0)
|
||||
currency: str
|
||||
description: Optional[str] = None
|
||||
|
||||
class InvoiceUpdate(BaseModel):
|
||||
amount: Optional[float] = None
|
||||
currency: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
|
||||
class InvoiceRead(BaseModel):
|
||||
id: str
|
||||
client_id: str
|
||||
amount: float
|
||||
currency: str
|
||||
status: str
|
||||
description: Optional[str] = None
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
|
||||
class InvoiceOut(BaseModel):
|
||||
id: UUID
|
||||
client_id: UUID
|
||||
amount: int
|
||||
currency: str
|
||||
status: str
|
||||
created_at: str | None = None
|
||||
# если есть другие UUID-поля — тоже как UUID
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
Reference in New Issue
Block a user