feat: poblar scaffold inicial del shape rest-api/python
Reemplaza el placeholder mínimo previo por el scaffold canónico del golden path rest-api/python (entry point para tier free-trial): - code/app/main.py — FastAPI con /health (sin DB), /metrics, middleware correlation_id, settings Pydantic - code/tests/ — pytest + httpx TestClient (sin testcontainers, sin Docker) - helm/APP_NAME/ — chart minimal: Deployment, Service, IngressRoute, NetworkPolicy. SIN Job Alembic, SIN ServiceMonitor, SIN claim de BD - coralware-shape.yaml — shapeVersion 1.0.0, minTier free-trial, promotionPath -> web-backend-python cuando se requiera persistencia - .gitea/workflows/APP_NAME-build.yaml — invoca reusable workflow ADR-038 + job helm-deploy con KUBECONFIG_TENANT_B64 Diferencias con web-backend/python: sin alembic, sin BD, sin testcontainers, NetworkPolicy sin acceso a ns-addons. Placeholders __APP_NAME__, __TIER__, __TENANT_ID__, __TENANT_ID_NODASHES__ serán sustituidos server-side por repo-provisioner. El openspec/ del golden path NO se sincroniza al repo Gitea (gobernanza interna de Coralware). Refs: ADR-027 (Golden Paths), ADR-038 (CI), ADR-053 (referencia anatomía).
This commit is contained in:
26
code/Dockerfile
Normal file
26
code/Dockerfile
Normal file
@@ -0,0 +1,26 @@
|
||||
# Multi-stage para imagen final lean.
|
||||
# Stage 1: deps — instala dependencias Python en una venv aislada.
|
||||
# Stage 2: runtime — copia código y venv, corre como user no-root con FS read-only.
|
||||
|
||||
FROM python:3.12-slim AS deps
|
||||
WORKDIR /build
|
||||
COPY requirements.txt .
|
||||
RUN python -m venv /venv \
|
||||
&& /venv/bin/pip install --no-cache-dir --upgrade pip \
|
||||
&& /venv/bin/pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
FROM python:3.12-slim AS runtime
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=deps /venv /venv
|
||||
ENV PATH="/venv/bin:$PATH"
|
||||
|
||||
COPY app /app/app
|
||||
|
||||
RUN useradd -u 1001 -r appuser \
|
||||
&& chown -R appuser:appuser /app
|
||||
USER appuser
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--no-access-log"]
|
||||
0
code/app/__init__.py
Normal file
0
code/app/__init__.py
Normal file
94
code/app/main.py
Normal file
94
code/app/main.py
Normal file
@@ -0,0 +1,94 @@
|
||||
"""
|
||||
Aplicación FastAPI — entry point.
|
||||
|
||||
Endpoints obligatorios del shape rest-api/python (ver coralware-shape.yaml):
|
||||
- /health → liveness + readiness (siempre 200 si el proceso está vivo)
|
||||
- /docs → Swagger UI (auth opcional)
|
||||
- /metrics → Prometheus
|
||||
|
||||
Endpoints de dominio: agregar en app/routers/ y registrar en _register_routers().
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import Depends, FastAPI, HTTPException, Request, status
|
||||
from fastapi.responses import JSONResponse, Response
|
||||
from prometheus_client import CONTENT_TYPE_LATEST, generate_latest
|
||||
|
||||
from app.settings import settings
|
||||
|
||||
logging.basicConfig(
|
||||
level=getattr(logging, settings.LOG_LEVEL, logging.INFO),
|
||||
format="%(asctime)s [%(levelname)s] [cid=%(correlation_id)s] %(message)s",
|
||||
datefmt="%Y-%m-%dT%H:%M:%SZ",
|
||||
)
|
||||
|
||||
|
||||
class CorrelationIdFilter(logging.Filter):
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
if not hasattr(record, "correlation_id"):
|
||||
record.correlation_id = "-"
|
||||
return True
|
||||
|
||||
|
||||
logging.getLogger().addFilter(CorrelationIdFilter())
|
||||
log = logging.getLogger("__APP_NAME__")
|
||||
|
||||
|
||||
def _require_auth_dep() -> None:
|
||||
"""
|
||||
Dependency placeholder para /docs cuando OPENAPI_REQUIRE_AUTH=true.
|
||||
Implementación real: validar JWT contra KEYCLOAK_ISSUER (ADR-029 R-004).
|
||||
"""
|
||||
if not settings.OPENAPI_REQUIRE_AUTH:
|
||||
return
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Auth requerido — implementar validación JWT contra Keycloak",
|
||||
)
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
app = FastAPI(
|
||||
title="__APP_NAME__",
|
||||
version="0.1.0",
|
||||
dependencies=[Depends(_require_auth_dep)] if settings.OPENAPI_REQUIRE_AUTH else [],
|
||||
)
|
||||
|
||||
@app.middleware("http")
|
||||
async def correlation_id_middleware(request: Request, call_next):
|
||||
cid = request.headers.get(settings.CORRELATION_ID_HEADER) or _generate_cid()
|
||||
request.state.correlation_id = cid
|
||||
response = await call_next(request)
|
||||
response.headers[settings.CORRELATION_ID_HEADER] = cid
|
||||
return response
|
||||
|
||||
@app.get("/health", tags=["platform"])
|
||||
async def health() -> JSONResponse:
|
||||
return JSONResponse(status_code=200, content={"status": "ok"})
|
||||
|
||||
@app.get("/metrics", tags=["platform"])
|
||||
async def metrics() -> Response:
|
||||
return Response(content=generate_latest(), media_type=CONTENT_TYPE_LATEST)
|
||||
|
||||
_register_routers(app)
|
||||
return app
|
||||
|
||||
|
||||
def _generate_cid() -> str:
|
||||
"""Genera correlation_id si el cliente no envió uno."""
|
||||
import time
|
||||
import uuid
|
||||
|
||||
return f"__APP_NAME__-{int(time.time() * 1000)}-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
|
||||
def _register_routers(app: FastAPI) -> None:
|
||||
"""
|
||||
Registra los routers de dominio.
|
||||
Agrega aquí los include_router(...) conforme tu app crezca.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
app = create_app()
|
||||
19
code/app/settings.py
Normal file
19
code/app/settings.py
Normal file
@@ -0,0 +1,19 @@
|
||||
"""
|
||||
Configuración runtime via variables de entorno.
|
||||
Toda la configuración pasa por aquí — no leas env vars dispersos en otros módulos.
|
||||
"""
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=None, case_sensitive=True)
|
||||
|
||||
LOG_LEVEL: str = "INFO"
|
||||
CORRELATION_ID_HEADER: str = "X-Correlation-ID"
|
||||
|
||||
OPENAPI_REQUIRE_AUTH: bool = False
|
||||
KEYCLOAK_ISSUER: str = ""
|
||||
|
||||
|
||||
settings = Settings()
|
||||
36
code/pyproject.toml
Normal file
36
code/pyproject.toml
Normal file
@@ -0,0 +1,36 @@
|
||||
[project]
|
||||
name = "__APP_NAME__"
|
||||
version = "0.1.0"
|
||||
description = "REST API Python (FastAPI) — Golden Path rest-api/python"
|
||||
requires-python = ">=3.12"
|
||||
|
||||
[tool.black]
|
||||
line-length = 100
|
||||
target-version = ["py312"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py312"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "W", "I", "B", "UP", "S"]
|
||||
ignore = ["S101"]
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"tests/*" = ["S105", "S106"]
|
||||
|
||||
[tool.bandit]
|
||||
exclude_dirs = ["tests", ".venv"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
pythonpath = ["."]
|
||||
testpaths = ["tests"]
|
||||
addopts = "-v --strict-markers"
|
||||
|
||||
[tool.coverage.run]
|
||||
source = ["app"]
|
||||
omit = ["app/__init__.py", "tests/*"]
|
||||
|
||||
[tool.coverage.report]
|
||||
fail_under = 80
|
||||
show_missing = true
|
||||
4
code/requirements-dev.txt
Normal file
4
code/requirements-dev.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
-r requirements.txt
|
||||
-r ../../code/requirements-dev-base.txt
|
||||
|
||||
httpx==0.27.2
|
||||
6
code/requirements.txt
Normal file
6
code/requirements.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
fastapi==0.115.0
|
||||
uvicorn[standard]==0.32.0
|
||||
pydantic==2.9.2
|
||||
pydantic-settings==2.6.0
|
||||
prometheus-client==0.21.0
|
||||
python-jose[cryptography]==3.3.0
|
||||
0
code/tests/__init__.py
Normal file
0
code/tests/__init__.py
Normal file
21
code/tests/conftest.py
Normal file
21
code/tests/conftest.py
Normal file
@@ -0,0 +1,21 @@
|
||||
"""
|
||||
Fixtures para los tests del shape rest-api/python.
|
||||
|
||||
A diferencia de web-backend/python (que levanta MariaDB con testcontainers),
|
||||
este shape no tiene persistencia — los tests corren sin Docker.
|
||||
"""
|
||||
|
||||
from collections.abc import Iterator
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client() -> Iterator[TestClient]:
|
||||
"""TestClient con la app cargada. Sin BD, sin contenedores externos."""
|
||||
from app.main import create_app
|
||||
|
||||
app = create_app()
|
||||
with TestClient(app) as c:
|
||||
yield c
|
||||
26
code/tests/test_main.py
Normal file
26
code/tests/test_main.py
Normal file
@@ -0,0 +1,26 @@
|
||||
"""Cubre los endpoints obligatorios del shape: /health y /metrics."""
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
def test_health_returns_200_ok(client: TestClient) -> None:
|
||||
response = client.get("/health")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "ok"}
|
||||
|
||||
|
||||
def test_health_propagates_correlation_id(client: TestClient) -> None:
|
||||
cid = "test-cid-12345"
|
||||
response = client.get("/health", headers={"X-Correlation-ID": cid})
|
||||
assert response.headers.get("X-Correlation-ID") == cid
|
||||
|
||||
|
||||
def test_health_generates_correlation_id_when_absent(client: TestClient) -> None:
|
||||
response = client.get("/health")
|
||||
assert response.headers.get("X-Correlation-ID")
|
||||
|
||||
|
||||
def test_metrics_endpoint_serves_prometheus_format(client: TestClient) -> None:
|
||||
response = client.get("/metrics")
|
||||
assert response.status_code == 200
|
||||
assert "text/plain" in response.headers.get("content-type", "")
|
||||
Reference in New Issue
Block a user