feat: poblar scaffold inicial del shape rest-api/python
Some checks failed
__APP_NAME__ — build & deploy / build (push) Failing after 0s
__APP_NAME__ — build & deploy / helm-deploy (push) Has been skipped

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:
2026-05-06 17:57:04 -05:00
parent 78c4c0069b
commit 99544639e6
23 changed files with 719 additions and 32 deletions

26
code/Dockerfile Normal file
View 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
View File

94
code/app/main.py Normal file
View 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
View 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
View 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

View File

@@ -0,0 +1,4 @@
-r requirements.txt
-r ../../code/requirements-dev-base.txt
httpx==0.27.2

6
code/requirements.txt Normal file
View 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
View File

21
code/tests/conftest.py Normal file
View 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
View 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", "")