chuopycore-cli 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- chuopycore_cli-1.0.0/MANIFEST.in +1 -0
- chuopycore_cli-1.0.0/PKG-INFO +7 -0
- chuopycore_cli-1.0.0/README.md +75 -0
- chuopycore_cli-1.0.0/chuopycore_cli.egg-info/PKG-INFO +7 -0
- chuopycore_cli-1.0.0/chuopycore_cli.egg-info/SOURCES.txt +30 -0
- chuopycore_cli-1.0.0/chuopycore_cli.egg-info/dependency_links.txt +1 -0
- chuopycore_cli-1.0.0/chuopycore_cli.egg-info/entry_points.txt +2 -0
- chuopycore_cli-1.0.0/chuopycore_cli.egg-info/requires.txt +3 -0
- chuopycore_cli-1.0.0/chuopycore_cli.egg-info/top_level.txt +1 -0
- chuopycore_cli-1.0.0/pycore/__init__.py +0 -0
- chuopycore_cli-1.0.0/pycore/cli.py +106 -0
- chuopycore_cli-1.0.0/pycore/templates/.env.jinja +35 -0
- chuopycore_cli-1.0.0/pycore/templates/.gitignore.jinja +44 -0
- chuopycore_cli-1.0.0/pycore/templates/Dockerfile.jinja +21 -0
- chuopycore_cli-1.0.0/pycore/templates/alembic.ini.jinja +40 -0
- chuopycore_cli-1.0.0/pycore/templates/app/api/routers.py.jinja +29 -0
- chuopycore_cli-1.0.0/pycore/templates/app/core/exceptions.py.jinja +30 -0
- chuopycore_cli-1.0.0/pycore/templates/app/core/logging.py.jinja +91 -0
- chuopycore_cli-1.0.0/pycore/templates/app/core/security.py.jinja +23 -0
- chuopycore_cli-1.0.0/pycore/templates/app/core/settings.py.jinja +142 -0
- chuopycore_cli-1.0.0/pycore/templates/app/db/database.py.jinja +71 -0
- chuopycore_cli-1.0.0/pycore/templates/app/db/models/models.py.jinja +16 -0
- chuopycore_cli-1.0.0/pycore/templates/app/main.py.jinja +58 -0
- chuopycore_cli-1.0.0/pycore/templates/app/schemas/schema.py.jinja +21 -0
- chuopycore_cli-1.0.0/pycore/templates/app/services/service.py.jinja +25 -0
- chuopycore_cli-1.0.0/pycore/templates/docker-compose.yml.jinja +108 -0
- chuopycore_cli-1.0.0/pycore/templates/requirements.txt.jinja +31 -0
- chuopycore_cli-1.0.0/pycore/templates/startup.sh.jinja +23 -0
- chuopycore_cli-1.0.0/pycore/templates/structure.json +0 -0
- chuopycore_cli-1.0.0/pycore/templates/tests/test_main.py.jinja +9 -0
- chuopycore_cli-1.0.0/setup.cfg +4 -0
- chuopycore_cli-1.0.0/setup.py +18 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
recursive-include pycore/templates *
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# chuopycore-cli 🚀
|
|
2
|
+
|
|
3
|
+
`chuopycore-cli` es una potente herramienta de interfaz de línea de comandos (CLI) escrita en Python, diseñada para acelerar el desarrollo de microservicios basados en **FastAPI**. Genera automáticamente una arquitectura profesional, escalable y lista para producción en segundos.
|
|
4
|
+
|
|
5
|
+
## ✨ Características
|
|
6
|
+
|
|
7
|
+
- **Scaffolding Profesional**: Estructura de carpetas optimizada para mantenibilidad.
|
|
8
|
+
- **FastAPI Core**: Configuración avanzada con soporte para `lifespan` y middlewares.
|
|
9
|
+
- **Docker-Ready**: Orquestación completa con Docker Compose.
|
|
10
|
+
- **Arquitectura de DB Avanzada**:
|
|
11
|
+
- PostgreSQL con replicación **Master/Replica**.
|
|
12
|
+
- **PGBouncer** integrado para gestión eficiente de pool de conexiones.
|
|
13
|
+
- **Migraciones**: Configuración de **Alembic** lista para usar.
|
|
14
|
+
- **Gestión de Configuración**: Basada en `pydantic-settings` para manejo robusto de variables de entorno.
|
|
15
|
+
- **Scripts de Automatización**: Script `startup.sh` que gestiona esperas de DB y ejecuciones de migraciones automáticas.
|
|
16
|
+
|
|
17
|
+
## 🛠️ Instalación
|
|
18
|
+
|
|
19
|
+
Para instalar la herramienta localmente en modo desarrollo:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install -e .
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Esto registrará el comando `chuopycore` en tu sistema.
|
|
26
|
+
|
|
27
|
+
## 🚀 Uso Académico y Práctico
|
|
28
|
+
|
|
29
|
+
Para generar un nuevo microservicio, simplemente ejecuta:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
chuopycore init mi-nuevo-servicio
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
### Opciones
|
|
36
|
+
|
|
37
|
+
- `--db`, `-d`: Incluye toda la infraestructura de base de datos (PostgreSQL, PGBouncer, SQLAlchemy, Alembic).
|
|
38
|
+
|
|
39
|
+
**Ejemplo de creación con base de datos:**
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
chuopycore init facturacion-service --db
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## 📂 Estructura del Proyecto Generado
|
|
46
|
+
|
|
47
|
+
El microservicio resultante seguirá este estándar:
|
|
48
|
+
|
|
49
|
+
```text
|
|
50
|
+
facturacion-service/
|
|
51
|
+
├── app/
|
|
52
|
+
│ ├── api/ # Routers y controladores
|
|
53
|
+
│ ├── core/ # Configuración, excepciones y logs
|
|
54
|
+
│ ├── db/ # Modelos y conexión a base de datos
|
|
55
|
+
│ ├── schemas/ # Modelos Pydantic
|
|
56
|
+
│ └── services/ # Lógica de negocio
|
|
57
|
+
├── alembic/ # Historial de migraciones
|
|
58
|
+
├── scripts/ # Scripts de utilidad
|
|
59
|
+
├── tests/ # Pruebas unitarias e integración
|
|
60
|
+
├── .env # Variables de entorno
|
|
61
|
+
├── dockerfile # Construcción de imagen Docker
|
|
62
|
+
├── docker-compose.yml # Orquestación de servicios
|
|
63
|
+
└── startup.sh # Script de inicialización
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## 📝 Requisitos
|
|
67
|
+
|
|
68
|
+
- Python 3.10+
|
|
69
|
+
- Jinja2
|
|
70
|
+
- Typer
|
|
71
|
+
- Rich
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
Desarrollado con ❤️ para la estandarización de microservicios.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
MANIFEST.in
|
|
2
|
+
README.md
|
|
3
|
+
setup.py
|
|
4
|
+
chuopycore_cli.egg-info/PKG-INFO
|
|
5
|
+
chuopycore_cli.egg-info/SOURCES.txt
|
|
6
|
+
chuopycore_cli.egg-info/dependency_links.txt
|
|
7
|
+
chuopycore_cli.egg-info/entry_points.txt
|
|
8
|
+
chuopycore_cli.egg-info/requires.txt
|
|
9
|
+
chuopycore_cli.egg-info/top_level.txt
|
|
10
|
+
pycore/__init__.py
|
|
11
|
+
pycore/cli.py
|
|
12
|
+
pycore/templates/.env.jinja
|
|
13
|
+
pycore/templates/.gitignore.jinja
|
|
14
|
+
pycore/templates/Dockerfile.jinja
|
|
15
|
+
pycore/templates/alembic.ini.jinja
|
|
16
|
+
pycore/templates/docker-compose.yml.jinja
|
|
17
|
+
pycore/templates/requirements.txt.jinja
|
|
18
|
+
pycore/templates/startup.sh.jinja
|
|
19
|
+
pycore/templates/structure.json
|
|
20
|
+
pycore/templates/app/main.py.jinja
|
|
21
|
+
pycore/templates/app/api/routers.py.jinja
|
|
22
|
+
pycore/templates/app/core/exceptions.py.jinja
|
|
23
|
+
pycore/templates/app/core/logging.py.jinja
|
|
24
|
+
pycore/templates/app/core/security.py.jinja
|
|
25
|
+
pycore/templates/app/core/settings.py.jinja
|
|
26
|
+
pycore/templates/app/db/database.py.jinja
|
|
27
|
+
pycore/templates/app/db/models/models.py.jinja
|
|
28
|
+
pycore/templates/app/schemas/schema.py.jinja
|
|
29
|
+
pycore/templates/app/services/service.py.jinja
|
|
30
|
+
pycore/templates/tests/test_main.py.jinja
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pycore
|
|
File without changes
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
import os
|
|
3
|
+
from jinja2 import Environment, FileSystemLoader
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
|
|
6
|
+
app = typer.Typer()
|
|
7
|
+
console = Console()
|
|
8
|
+
|
|
9
|
+
# Configuración de Jinja
|
|
10
|
+
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
11
|
+
TEMPLATE_DIR = os.path.join(BASE_DIR, 'templates')
|
|
12
|
+
env = Environment(loader=FileSystemLoader(TEMPLATE_DIR))
|
|
13
|
+
|
|
14
|
+
def create_file(template_path, context, output_path):
|
|
15
|
+
"""Renderiza el template y lo guarda."""
|
|
16
|
+
try:
|
|
17
|
+
# Si el template está en subcarpeta, jinja lo busca relativo al root de templates
|
|
18
|
+
template = env.get_template(template_path)
|
|
19
|
+
content = template.render(context)
|
|
20
|
+
|
|
21
|
+
dir_name = os.path.dirname(output_path)
|
|
22
|
+
if dir_name:
|
|
23
|
+
os.makedirs(dir_name, exist_ok=True)
|
|
24
|
+
|
|
25
|
+
with open(output_path, 'w', encoding='utf-8') as f:
|
|
26
|
+
f.write(content)
|
|
27
|
+
except Exception as e:
|
|
28
|
+
console.print(f"[red]Error procesando {template_path}: {e}[/red]")
|
|
29
|
+
|
|
30
|
+
@app.command()
|
|
31
|
+
def init(
|
|
32
|
+
name: str = typer.Argument(..., help="Nombre del proyecto"),
|
|
33
|
+
db: bool = typer.Option(False, "--db", "-d", help="Incluir Base de Datos (SQLAlchemy/Alembic)")
|
|
34
|
+
):
|
|
35
|
+
"""Genera la arquitectura base."""
|
|
36
|
+
base_path = os.path.join(os.getcwd(), name)
|
|
37
|
+
|
|
38
|
+
if os.path.exists(base_path):
|
|
39
|
+
console.print(f"[bold red]❌ La carpeta {name} ya existe.[/bold red]")
|
|
40
|
+
raise typer.Exit()
|
|
41
|
+
|
|
42
|
+
console.print(f"🚀 Creando arquitectura en: [bold cyan]{name}[/bold cyan]")
|
|
43
|
+
|
|
44
|
+
# 1. Crear directorios vacíos necesarios (Estructura Base)
|
|
45
|
+
dirs = [
|
|
46
|
+
"alembic",
|
|
47
|
+
"app/api",
|
|
48
|
+
"app/core",
|
|
49
|
+
"app/db/models",
|
|
50
|
+
"app/schemas",
|
|
51
|
+
"app/services",
|
|
52
|
+
"scripts",
|
|
53
|
+
"tests",
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
for d in dirs:
|
|
57
|
+
os.makedirs(os.path.join(base_path, d), exist_ok=True)
|
|
58
|
+
|
|
59
|
+
# 2. Contexto para las plantillas
|
|
60
|
+
context = {
|
|
61
|
+
"project_name": name,
|
|
62
|
+
"with_db": db
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
# 3. Mapeo de Templates -> Archivo Destino
|
|
66
|
+
files_map = {
|
|
67
|
+
".gitignore.jinja": ".gitignore",
|
|
68
|
+
".env.jinja": ".env",
|
|
69
|
+
"requirements.txt.jinja": "requirements.txt",
|
|
70
|
+
"Dockerfile.jinja": "dockerfile",
|
|
71
|
+
"docker-compose.yml.jinja": "docker-compose.yml",
|
|
72
|
+
"startup.sh.jinja": "startup.sh",
|
|
73
|
+
"alembic.ini.jinja": "alembic.ini",
|
|
74
|
+
|
|
75
|
+
# App Code
|
|
76
|
+
"app/main.py.jinja": "app/main.py",
|
|
77
|
+
"app/api/routers.py.jinja": "app/api/routers.py",
|
|
78
|
+
"app/core/settings.py.jinja": "app/core/settings.py",
|
|
79
|
+
"app/core/exceptions.py.jinja": "app/core/exceptions.py",
|
|
80
|
+
"app/core/logging.py.jinja": "app/core/logging.py",
|
|
81
|
+
"app/core/security.py.jinja": "app/core/security.py",
|
|
82
|
+
"app/db/models/models.py.jinja": "app/db/models/models.py",
|
|
83
|
+
"app/schemas/schema.py.jinja": "app/schemas/schema.py",
|
|
84
|
+
"app/services/service.py.jinja": "app/services/service.py",
|
|
85
|
+
"tests/test_main.py.jinja": "tests/test_main.py",
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if db:
|
|
89
|
+
files_map["app/db/database.py.jinja"] = "app/db/database.py"
|
|
90
|
+
|
|
91
|
+
# 4. Generación
|
|
92
|
+
for template, dest in files_map.items():
|
|
93
|
+
create_file(template, context, os.path.join(base_path, dest))
|
|
94
|
+
|
|
95
|
+
# Crear __init__.py necesarios
|
|
96
|
+
init_dirs = ["app", "app/api", "app/core", "app/db", "app/db/models", "app/schemas", "app/services"]
|
|
97
|
+
for d in init_dirs:
|
|
98
|
+
with open(os.path.join(base_path, d, "__init__.py"), 'w') as f:
|
|
99
|
+
pass
|
|
100
|
+
|
|
101
|
+
console.print(f"\n✅ [green]Microservicio {name} creado con éxito.[/green]")
|
|
102
|
+
if db:
|
|
103
|
+
console.print(" 📦 Incluye configuración de SQLAlchemy y Alembic.")
|
|
104
|
+
|
|
105
|
+
if __name__ == "__main__":
|
|
106
|
+
app()
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
ENV=DEV
|
|
2
|
+
|
|
3
|
+
PROJECT_FLAG=Project: {{ project_name }}
|
|
4
|
+
ADMIN_KEY=GENERIC_ADMIN_KEY_CHANGE_ME
|
|
5
|
+
{{ project_name | upper | replace('-', '_') }}_API_KEY=GENERIC_API_KEY_CHANGE_ME
|
|
6
|
+
|
|
7
|
+
API_HOST=0.0.0.0
|
|
8
|
+
API_PORT=8000
|
|
9
|
+
API_DEBUG=True
|
|
10
|
+
API_WORKERS=1
|
|
11
|
+
ALGORITHM=HS256
|
|
12
|
+
ACCESS_TOKEN_EXPIRE_MINUTES=30
|
|
13
|
+
SECRET_KEY=CHANGE_THIS_TO_A_SECURE_SECRET_KEY
|
|
14
|
+
|
|
15
|
+
# Security Settings
|
|
16
|
+
ENABLE_RATE_LIMITING=True
|
|
17
|
+
MAX_LOGIN_ATTEMPTS=5
|
|
18
|
+
RATE_LIMIT_WINDOW=60
|
|
19
|
+
|
|
20
|
+
# Logging Settings
|
|
21
|
+
LOG_LEVEL=INFO
|
|
22
|
+
ENABLE_SECURITY_LOGGING=True
|
|
23
|
+
|
|
24
|
+
# Database Settings
|
|
25
|
+
POSTGRES_USER=postgres
|
|
26
|
+
POSTGRES_PASSWORD=postgres
|
|
27
|
+
POSTGRES_DB={{ project_name | replace('-', '_') }}_db
|
|
28
|
+
POSTGRES_PORT=5432
|
|
29
|
+
POSTGRES_HOST=localhost
|
|
30
|
+
|
|
31
|
+
# CQRS (Opcional, apuntan a master por defecto)
|
|
32
|
+
POSTGRES_WRITER_HOST=localhost
|
|
33
|
+
POSTGRES_WRITER_PORT=5432
|
|
34
|
+
POSTGRES_READER_HOST=localhost
|
|
35
|
+
POSTGRES_READER_PORT=5432
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
*.so
|
|
6
|
+
.Python
|
|
7
|
+
env/
|
|
8
|
+
build/
|
|
9
|
+
develop-eggs/
|
|
10
|
+
dist/
|
|
11
|
+
downloads/
|
|
12
|
+
eggs/
|
|
13
|
+
.eggs/
|
|
14
|
+
lib/
|
|
15
|
+
lib64/
|
|
16
|
+
parts/
|
|
17
|
+
sdist/
|
|
18
|
+
var/
|
|
19
|
+
wheels/
|
|
20
|
+
*.egg-info/
|
|
21
|
+
.installed.cfg
|
|
22
|
+
*.egg
|
|
23
|
+
|
|
24
|
+
# Microservice specific
|
|
25
|
+
.env
|
|
26
|
+
.venv
|
|
27
|
+
venv/
|
|
28
|
+
ENV/
|
|
29
|
+
security.log
|
|
30
|
+
*.db
|
|
31
|
+
*.sqlite3
|
|
32
|
+
|
|
33
|
+
# Docker
|
|
34
|
+
.docker
|
|
35
|
+
|
|
36
|
+
# IDEs
|
|
37
|
+
.vscode/
|
|
38
|
+
.idea/
|
|
39
|
+
*.swp
|
|
40
|
+
*.swo
|
|
41
|
+
|
|
42
|
+
# OS
|
|
43
|
+
.DS_Store
|
|
44
|
+
Thumbs.db
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
FROM python:3.11-slim
|
|
2
|
+
WORKDIR /code
|
|
3
|
+
|
|
4
|
+
RUN apt-get update && apt-get install -y \
|
|
5
|
+
curl \
|
|
6
|
+
postgresql-client \
|
|
7
|
+
&& apt-get clean
|
|
8
|
+
|
|
9
|
+
COPY ./requirements.txt /code/requirements.txt
|
|
10
|
+
RUN pip install --no-cache-dir --upgrade pip && \
|
|
11
|
+
pip install --no-cache-dir -r /code/requirements.txt
|
|
12
|
+
|
|
13
|
+
COPY ./app /code/app
|
|
14
|
+
COPY ./alembic /code/alembic
|
|
15
|
+
COPY ./alembic.ini /code/alembic.ini
|
|
16
|
+
COPY ./startup.sh /code/startup.sh
|
|
17
|
+
|
|
18
|
+
RUN sed -i 's/\r$//' /code/startup.sh && chmod +x /code/startup.sh
|
|
19
|
+
|
|
20
|
+
EXPOSE 8000
|
|
21
|
+
ENTRYPOINT ["/code/startup.sh"]
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
[alembic]
|
|
2
|
+
script_location = alembic
|
|
3
|
+
prepend_sys_path = .
|
|
4
|
+
version_path_separator = os
|
|
5
|
+
|
|
6
|
+
sqlalchemy.url = postgresql://%(POSTGRES_USER)s:%(POSTGRES_PASSWORD)s@%(POSTGRES_HOST)s:%(POSTGRES_PORT)s/%(POSTGRES_DB)s
|
|
7
|
+
|
|
8
|
+
[loggers]
|
|
9
|
+
keys = root,sqlalchemy,alembic
|
|
10
|
+
|
|
11
|
+
[handlers]
|
|
12
|
+
keys = console
|
|
13
|
+
|
|
14
|
+
[formatters]
|
|
15
|
+
keys = generic
|
|
16
|
+
|
|
17
|
+
[logger_root]
|
|
18
|
+
level = WARN
|
|
19
|
+
handlers = console
|
|
20
|
+
qualname =
|
|
21
|
+
|
|
22
|
+
[logger_sqlalchemy]
|
|
23
|
+
level = WARN
|
|
24
|
+
handlers =
|
|
25
|
+
qualname = sqlalchemy.engine
|
|
26
|
+
|
|
27
|
+
[logger_alembic]
|
|
28
|
+
level = INFO
|
|
29
|
+
handlers =
|
|
30
|
+
qualname = alembic
|
|
31
|
+
|
|
32
|
+
[handler_console]
|
|
33
|
+
class = StreamHandler
|
|
34
|
+
args = (sys.stderr,)
|
|
35
|
+
level = NOTSET
|
|
36
|
+
formatter = generic
|
|
37
|
+
|
|
38
|
+
[formatter_generic]
|
|
39
|
+
format = %(levelname)-5.5s [%(name)s] %(message)s
|
|
40
|
+
datefmt = %H:%M:%S
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from fastapi import APIRouter, Depends, HTTPException, status
|
|
2
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
3
|
+
from typing import List, Any
|
|
4
|
+
|
|
5
|
+
from app.db.database import get_db, get_read_db
|
|
6
|
+
from app.core.security import get_api_key
|
|
7
|
+
|
|
8
|
+
router = APIRouter(
|
|
9
|
+
prefix="/items",
|
|
10
|
+
tags=["Items"],
|
|
11
|
+
dependencies=[Depends(get_api_key)]
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
@router.get("", response_model=List[Any])
|
|
15
|
+
async def list_items(
|
|
16
|
+
skip: int = 0,
|
|
17
|
+
limit: int = 100,
|
|
18
|
+
db: AsyncSession = Depends(get_read_db)
|
|
19
|
+
):
|
|
20
|
+
"""Obtiene la lista de elementos (Ejemplo)."""
|
|
21
|
+
return []
|
|
22
|
+
|
|
23
|
+
@router.post("", status_code=status.HTTP_201_CREATED)
|
|
24
|
+
async def create_item(
|
|
25
|
+
item_data: Any,
|
|
26
|
+
db: AsyncSession = Depends(get_db)
|
|
27
|
+
):
|
|
28
|
+
"""Crea un nuevo elemento (Ejemplo)."""
|
|
29
|
+
return {"message": "Item creado"}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
class ServiceError(Exception):
|
|
2
|
+
"""Excepción base para los errores del microservicio {{ project_name }}."""
|
|
3
|
+
pass
|
|
4
|
+
|
|
5
|
+
class AuthenticationError(ServiceError):
|
|
6
|
+
"""Lanzada cuando la autenticación falla."""
|
|
7
|
+
pass
|
|
8
|
+
|
|
9
|
+
class TokenError(AuthenticationError):
|
|
10
|
+
"""Lanzada cuando hay problemas con los tokens."""
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
class DatabaseError(ServiceError):
|
|
14
|
+
"""Lanzada cuando las operaciones de base de datos fallan."""
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
class APIError(ServiceError):
|
|
18
|
+
"""Lanzada cuando las llamadas a la API fallan."""
|
|
19
|
+
def __init__(self, status_code: int, message: str):
|
|
20
|
+
self.status_code = status_code
|
|
21
|
+
self.message = message
|
|
22
|
+
super().__init__(f"API Error {status_code}: {message}")
|
|
23
|
+
|
|
24
|
+
class DataValidationError(ServiceError):
|
|
25
|
+
"""Lanzada cuando la validación de datos falla."""
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
class ResourceNotFoundError(ServiceError):
|
|
29
|
+
"""Lanzada cuando un recurso solicitado no se encuentra."""
|
|
30
|
+
pass
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import html
|
|
2
|
+
import logging
|
|
3
|
+
import requests
|
|
4
|
+
import traceback
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
|
|
7
|
+
from .settings import settings
|
|
8
|
+
|
|
9
|
+
class TelegramHandler(logging.Handler):
|
|
10
|
+
def __init__(self) -> None:
|
|
11
|
+
self.token = settings.TELEGRAM_TOKEN
|
|
12
|
+
self.chat_ids = settings.ADMINISTRATOR_IDS
|
|
13
|
+
self.project_flag = settings.PROJECT_FLAG
|
|
14
|
+
self.api_url = f"https://api.telegram.org/bot{self.token}/sendMessage"
|
|
15
|
+
super().__init__()
|
|
16
|
+
|
|
17
|
+
def send_message(self, message: str):
|
|
18
|
+
try:
|
|
19
|
+
project_flag_text = f"[{self.project_flag}]\n" if self.project_flag else ""
|
|
20
|
+
full_message = project_flag_text + message
|
|
21
|
+
for chat_id in self.chat_ids:
|
|
22
|
+
payload = {
|
|
23
|
+
"chat_id": chat_id,
|
|
24
|
+
"text": html.escape(full_message),
|
|
25
|
+
"parse_mode": "HTML",
|
|
26
|
+
}
|
|
27
|
+
response = requests.post(self.api_url, data=payload, timeout=5)
|
|
28
|
+
if not response.ok:
|
|
29
|
+
print(f"ERROR: No se pudo enviar log a Telegram chat_id {chat_id}. Response: {response.text}")
|
|
30
|
+
except Exception as e:
|
|
31
|
+
print(f"CRITICAL: Excepción al enviar log a Telegram: {str(e)}")
|
|
32
|
+
|
|
33
|
+
def emit(self, record: logging.LogRecord):
|
|
34
|
+
try:
|
|
35
|
+
text = self.format(record)
|
|
36
|
+
if record.exc_info:
|
|
37
|
+
exc_type, exc_value, exc_traceback = record.exc_info
|
|
38
|
+
stack_trace = ''.join(traceback.format_exception(exc_type, exc_value, exc_traceback))
|
|
39
|
+
text += f"\n\n<b>Stack Trace:</b>\n<pre>{html.escape(stack_trace)}</pre>"
|
|
40
|
+
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
41
|
+
text = f"<b>[{record.levelname}]</b> {timestamp}\n{text}"
|
|
42
|
+
self.send_message(text)
|
|
43
|
+
except Exception:
|
|
44
|
+
self.handleError(record)
|
|
45
|
+
|
|
46
|
+
def init_logger() -> None:
|
|
47
|
+
app_logger = logging.getLogger()
|
|
48
|
+
app_logger.setLevel(logging.DEBUG)
|
|
49
|
+
|
|
50
|
+
if app_logger.hasHandlers():
|
|
51
|
+
app_logger.handlers.clear()
|
|
52
|
+
|
|
53
|
+
standard_formatter = logging.Formatter(
|
|
54
|
+
"%(asctime)s [%(levelname)s] %(name)s: %(message)s"
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
basic_handler = logging.StreamHandler()
|
|
58
|
+
basic_handler.setLevel(logging.INFO)
|
|
59
|
+
basic_handler.setFormatter(standard_formatter)
|
|
60
|
+
app_logger.addHandler(basic_handler)
|
|
61
|
+
|
|
62
|
+
if settings.TELEGRAM_TOKEN and settings.ADMINISTRATOR_IDS:
|
|
63
|
+
telegram_handler = TelegramHandler()
|
|
64
|
+
telegram_handler.setLevel(logging.ERROR)
|
|
65
|
+
telegram_handler.setFormatter(standard_formatter)
|
|
66
|
+
app_logger.addHandler(telegram_handler)
|
|
67
|
+
app_logger.info("Handler de Telegram configurado correctamente.")
|
|
68
|
+
|
|
69
|
+
app_logger.propagate = False
|
|
70
|
+
app_logger.info("Logger inicializado correctamente.")
|
|
71
|
+
|
|
72
|
+
setup_security_logger()
|
|
73
|
+
return app_logger
|
|
74
|
+
|
|
75
|
+
def setup_security_logger():
|
|
76
|
+
security_logger = logging.getLogger("security")
|
|
77
|
+
security_logger.setLevel(logging.INFO)
|
|
78
|
+
security_handler = logging.FileHandler("security.log")
|
|
79
|
+
security_handler.setLevel(logging.INFO)
|
|
80
|
+
security_formatter = logging.Formatter(
|
|
81
|
+
'%(asctime)s - SECURITY - %(levelname)s - %(message)s'
|
|
82
|
+
)
|
|
83
|
+
security_handler.setFormatter(security_formatter)
|
|
84
|
+
security_logger.addHandler(security_handler)
|
|
85
|
+
security_logger.propagate = False
|
|
86
|
+
return security_logger
|
|
87
|
+
|
|
88
|
+
# Funciones de utilidad para logging de seguridad
|
|
89
|
+
def log_security_event(event_type: str, details: str, ip: str = "unknown"):
|
|
90
|
+
security_logger = logging.getLogger("security")
|
|
91
|
+
security_logger.info(f"{event_type} - {details} - IP: {ip}")
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from fastapi import Security, HTTPException, status
|
|
2
|
+
from fastapi.security.api_key import APIKeyHeader
|
|
3
|
+
from app.core.settings import settings
|
|
4
|
+
|
|
5
|
+
# Definir el nombre del header donde se espera el API Key
|
|
6
|
+
API_KEY_NAME = "X-API-Key"
|
|
7
|
+
api_key_header = APIKeyHeader(name=API_KEY_NAME, auto_error=False)
|
|
8
|
+
|
|
9
|
+
async def get_api_key(api_key: str = Security(api_key_header)):
|
|
10
|
+
"""
|
|
11
|
+
Dependencia para validar el API Key en los headers de la solicitud.
|
|
12
|
+
"""
|
|
13
|
+
# Se espera una variable de entorno con el nombre del proyecto en mayúsculas + _API_KEY
|
|
14
|
+
expected_api_key = getattr(settings, "{{ project_name | upper | replace('-', '_') }}_API_KEY", None)
|
|
15
|
+
|
|
16
|
+
if api_key and api_key == expected_api_key:
|
|
17
|
+
return api_key
|
|
18
|
+
|
|
19
|
+
raise HTTPException(
|
|
20
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
21
|
+
detail="API Key inválida o ausente",
|
|
22
|
+
headers={"WWW-Authenticate": "ApiKey"},
|
|
23
|
+
)
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import enum
|
|
2
|
+
import os
|
|
3
|
+
from typing import Union, Any, List
|
|
4
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
5
|
+
from pydantic import field_validator
|
|
6
|
+
import json
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
|
|
9
|
+
class Enviroments(enum.IntEnum):
|
|
10
|
+
"""Enumeración de los entornos de ejecución disponibles."""
|
|
11
|
+
DEV = 1
|
|
12
|
+
TESTING = 2
|
|
13
|
+
PROD = 3
|
|
14
|
+
|
|
15
|
+
@classmethod
|
|
16
|
+
def from_str(cls, value: Union[str, int]) -> 'Enviroments':
|
|
17
|
+
if isinstance(value, int) or (isinstance(value, str) and value.isdigit()):
|
|
18
|
+
return cls(int(value))
|
|
19
|
+
value_map = {
|
|
20
|
+
'DEV': cls.DEV,
|
|
21
|
+
'TESTING': cls.TESTING,
|
|
22
|
+
'PROD': cls.PROD
|
|
23
|
+
}
|
|
24
|
+
return value_map[value.upper()]
|
|
25
|
+
|
|
26
|
+
class Settings(BaseSettings):
|
|
27
|
+
"""Configuración base de la aplicación."""
|
|
28
|
+
# Configuración del entorno
|
|
29
|
+
ENV: Enviroments = Enviroments.DEV
|
|
30
|
+
|
|
31
|
+
# Configuración de Telegram para logging (Opcional)
|
|
32
|
+
TELEGRAM_TOKEN: str = ""
|
|
33
|
+
ADMINISTRATOR_IDS: List[str] = []
|
|
34
|
+
PROJECT_FLAG: str = ""
|
|
35
|
+
ADMIN_KEY: str = ""
|
|
36
|
+
|
|
37
|
+
# Seguridad: API Key
|
|
38
|
+
{{ project_name | upper | replace('-', '_') }}_API_KEY: str = ""
|
|
39
|
+
|
|
40
|
+
# Configuración del servidor API
|
|
41
|
+
API_HOST: str = ""
|
|
42
|
+
API_PORT: int = 8000
|
|
43
|
+
API_DEBUG: bool = True
|
|
44
|
+
API_WORKERS: int = 1
|
|
45
|
+
|
|
46
|
+
# Configuraciones de seguridad de la API
|
|
47
|
+
ALGORITHM: str = "HS256"
|
|
48
|
+
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
|
49
|
+
SECRET_KEY: str = ""
|
|
50
|
+
|
|
51
|
+
# Configuraciones de seguridad adicionales
|
|
52
|
+
ENABLE_RATE_LIMITING: bool = True
|
|
53
|
+
MAX_LOGIN_ATTEMPTS: int = 5
|
|
54
|
+
RATE_LIMIT_WINDOW: int = 60
|
|
55
|
+
|
|
56
|
+
# Configuraciones de logging
|
|
57
|
+
LOG_LEVEL: str = "INFO"
|
|
58
|
+
ENABLE_SECURITY_LOGGING: bool = True
|
|
59
|
+
|
|
60
|
+
# Configuración de la base de datos PostgreSQL
|
|
61
|
+
POSTGRES_USER: str = "postgres"
|
|
62
|
+
POSTGRES_PASSWORD: str = "postgres"
|
|
63
|
+
POSTGRES_DB: str = "{{ project_name | replace('-', '_') }}_db"
|
|
64
|
+
POSTGRES_HOST: str = "db"
|
|
65
|
+
POSTGRES_PORT: int = 5432
|
|
66
|
+
|
|
67
|
+
# CQRS: Hosts específicos para lectura y escritura
|
|
68
|
+
POSTGRES_WRITER_HOST: str = ""
|
|
69
|
+
POSTGRES_WRITER_PORT: int = 0
|
|
70
|
+
POSTGRES_READER_HOST: str = ""
|
|
71
|
+
POSTGRES_READER_PORT: int = 0
|
|
72
|
+
|
|
73
|
+
DATABASE_URL: str = ""
|
|
74
|
+
|
|
75
|
+
model_config = SettingsConfigDict(
|
|
76
|
+
env_file=".env",
|
|
77
|
+
env_file_encoding="utf-8",
|
|
78
|
+
extra="allow",
|
|
79
|
+
env_nested_delimiter="__",
|
|
80
|
+
case_sensitive=False
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
def __init__(self, **values):
|
|
84
|
+
super().__init__(**values)
|
|
85
|
+
|
|
86
|
+
if not self.POSTGRES_WRITER_HOST:
|
|
87
|
+
self.POSTGRES_WRITER_HOST = self.POSTGRES_HOST
|
|
88
|
+
if not self.POSTGRES_WRITER_PORT:
|
|
89
|
+
self.POSTGRES_WRITER_PORT = self.POSTGRES_PORT
|
|
90
|
+
|
|
91
|
+
if not self.POSTGRES_READER_HOST:
|
|
92
|
+
self.POSTGRES_READER_HOST = self.POSTGRES_HOST
|
|
93
|
+
if not self.POSTGRES_READER_PORT:
|
|
94
|
+
self.POSTGRES_READER_PORT = self.POSTGRES_PORT
|
|
95
|
+
|
|
96
|
+
self.DATABASE_URL = self.get_writer_database_url()
|
|
97
|
+
|
|
98
|
+
@field_validator('ENV', mode='before')
|
|
99
|
+
@classmethod
|
|
100
|
+
def validate_env(cls, value):
|
|
101
|
+
if value is None:
|
|
102
|
+
return Enviroments.DEV
|
|
103
|
+
try:
|
|
104
|
+
return Enviroments.from_str(value)
|
|
105
|
+
except (KeyError, ValueError):
|
|
106
|
+
return Enviroments.DEV
|
|
107
|
+
|
|
108
|
+
@field_validator('ADMINISTRATOR_IDS', mode='before')
|
|
109
|
+
@classmethod
|
|
110
|
+
def validate_admin_ids(cls, value):
|
|
111
|
+
if isinstance(value, str):
|
|
112
|
+
try:
|
|
113
|
+
return json.loads(value)
|
|
114
|
+
except json.JSONDecodeError:
|
|
115
|
+
return [value]
|
|
116
|
+
return value
|
|
117
|
+
|
|
118
|
+
def get_database_url(self) -> str:
|
|
119
|
+
return self.DATABASE_URL
|
|
120
|
+
|
|
121
|
+
def get_writer_database_url(self) -> str:
|
|
122
|
+
return (
|
|
123
|
+
"postgresql+asyncpg://"
|
|
124
|
+
f"{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}@"
|
|
125
|
+
f"{self.POSTGRES_WRITER_HOST}:{self.POSTGRES_WRITER_PORT}/{self.POSTGRES_DB}"
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
def get_reader_database_url(self) -> str:
|
|
129
|
+
return (
|
|
130
|
+
"postgresql+asyncpg://"
|
|
131
|
+
f"{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}@"
|
|
132
|
+
f"{self.POSTGRES_READER_HOST}:{self.POSTGRES_READER_PORT}/{self.POSTGRES_DB}"
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
def get_sync_database_url(self) -> str:
|
|
136
|
+
return (
|
|
137
|
+
"postgresql://"
|
|
138
|
+
f"{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}@"
|
|
139
|
+
f"{self.POSTGRES_HOST}:{self.POSTGRES_PORT}/{self.POSTGRES_DB}"
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
settings = Settings()
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
|
|
2
|
+
from sqlalchemy.orm import declarative_base
|
|
3
|
+
from app.core.settings import settings
|
|
4
|
+
|
|
5
|
+
Base = declarative_base()
|
|
6
|
+
|
|
7
|
+
# -----------------------------------------------------------------------------
|
|
8
|
+
# Configuración de Motores (Master/Replica)
|
|
9
|
+
# -----------------------------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
# Motor de Escritura (Master)
|
|
12
|
+
writer_engine = create_async_engine(
|
|
13
|
+
settings.get_writer_database_url(),
|
|
14
|
+
echo=settings.DB_ECHO,
|
|
15
|
+
pool_size=20,
|
|
16
|
+
max_overflow=10,
|
|
17
|
+
pool_timeout=30,
|
|
18
|
+
pool_recycle=1800,
|
|
19
|
+
pool_pre_ping=True
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
# Motor de Lectura (Replica)
|
|
23
|
+
# Si no hay replica configurada, settings devolverá la misma URL del master
|
|
24
|
+
reader_engine = create_async_engine(
|
|
25
|
+
settings.get_reader_database_url(),
|
|
26
|
+
echo=settings.DB_ECHO,
|
|
27
|
+
pool_size=20,
|
|
28
|
+
max_overflow=10,
|
|
29
|
+
pool_timeout=30,
|
|
30
|
+
pool_recycle=1800,
|
|
31
|
+
pool_pre_ping=True
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
# -----------------------------------------------------------------------------
|
|
35
|
+
# Sesiones Asíncronas
|
|
36
|
+
# -----------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
AsyncWriterSession = async_sessionmaker(writer_engine, expire_on_commit=False)
|
|
39
|
+
AsyncReaderSession = async_sessionmaker(reader_engine, expire_on_commit=False)
|
|
40
|
+
|
|
41
|
+
# Alias para compatibilidad
|
|
42
|
+
AsyncSessionLocal = AsyncWriterSession
|
|
43
|
+
engine = writer_engine
|
|
44
|
+
|
|
45
|
+
# -----------------------------------------------------------------------------
|
|
46
|
+
# Dependencias (Dependency Injection)
|
|
47
|
+
# -----------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
async def get_db():
|
|
50
|
+
"""Dependencia por defecto (Escritura/Master)."""
|
|
51
|
+
async with AsyncWriterSession() as session:
|
|
52
|
+
try:
|
|
53
|
+
yield session
|
|
54
|
+
finally:
|
|
55
|
+
await session.close()
|
|
56
|
+
|
|
57
|
+
async def get_write_db():
|
|
58
|
+
"""Dependencia explícita para escritura."""
|
|
59
|
+
async with AsyncWriterSession() as session:
|
|
60
|
+
try:
|
|
61
|
+
yield session
|
|
62
|
+
finally:
|
|
63
|
+
await session.close()
|
|
64
|
+
|
|
65
|
+
async def get_read_db():
|
|
66
|
+
"""Dependencia explícita para lectura."""
|
|
67
|
+
async with AsyncReaderSession() as session:
|
|
68
|
+
try:
|
|
69
|
+
yield session
|
|
70
|
+
finally:
|
|
71
|
+
await session.close()
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from sqlalchemy import Column, Integer, String, DateTime, Boolean
|
|
2
|
+
from sqlalchemy.sql import func
|
|
3
|
+
from app.db.database import Base
|
|
4
|
+
|
|
5
|
+
class BaseModel(Base):
|
|
6
|
+
__abstract__ = True
|
|
7
|
+
id = Column(Integer, primary_key=True, index=True)
|
|
8
|
+
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
|
9
|
+
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
|
10
|
+
is_active = Column(Boolean, default=True)
|
|
11
|
+
|
|
12
|
+
class Item(BaseModel):
|
|
13
|
+
__tablename__ = "items"
|
|
14
|
+
|
|
15
|
+
name = Column(String, index=True)
|
|
16
|
+
description = Column(String, nullable=True)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from contextlib import asynccontextmanager
|
|
2
|
+
from fastapi import Depends, FastAPI
|
|
3
|
+
from fastapi.responses import ORJSONResponse
|
|
4
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
from app.core.settings import settings
|
|
8
|
+
from app.core.logging import init_logger
|
|
9
|
+
from app.api.routers import router as api_router
|
|
10
|
+
{% if with_db %}
|
|
11
|
+
from app.db.database import engine, Base
|
|
12
|
+
{% endif %}
|
|
13
|
+
|
|
14
|
+
# Inicializar logger profesional
|
|
15
|
+
logger = init_logger()
|
|
16
|
+
|
|
17
|
+
@asynccontextmanager
|
|
18
|
+
async def lifespan(app: FastAPI):
|
|
19
|
+
# Startup
|
|
20
|
+
logger.info(f"Iniciando microservicio {settings.PROJECT_FLAG}...")
|
|
21
|
+
|
|
22
|
+
{% if with_db %}
|
|
23
|
+
# Crear tablas (Solo para desarrollo/testing - en prod usar Alembic)
|
|
24
|
+
async with engine.begin() as conn:
|
|
25
|
+
await conn.run_sync(Base.metadata.create_all)
|
|
26
|
+
{% endif %}
|
|
27
|
+
|
|
28
|
+
yield
|
|
29
|
+
|
|
30
|
+
# Shutdown
|
|
31
|
+
logger.info(f"Apagando microservicio {settings.PROJECT_FLAG}...")
|
|
32
|
+
|
|
33
|
+
app = FastAPI(
|
|
34
|
+
title="{{ project_name }} API",
|
|
35
|
+
description="API generada por chuopycore-cli",
|
|
36
|
+
version="1.0.0",
|
|
37
|
+
lifespan=lifespan,
|
|
38
|
+
debug=settings.API_DEBUG,
|
|
39
|
+
default_response_class=ORJSONResponse
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
# CORS config
|
|
43
|
+
app.add_middleware(
|
|
44
|
+
CORSMiddleware,
|
|
45
|
+
allow_origins=["*"],
|
|
46
|
+
allow_credentials=True,
|
|
47
|
+
allow_methods=["*"],
|
|
48
|
+
allow_headers=["*"],
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
app.include_router(
|
|
52
|
+
api_router,
|
|
53
|
+
prefix="/api/v1"
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
@app.get("/health")
|
|
57
|
+
async def health_check():
|
|
58
|
+
return {"status": "ok", "service": settings.PROJECT_FLAG}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from pydantic import BaseModel, ConfigDict
|
|
2
|
+
from typing import Optional
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
|
|
5
|
+
class ItemBase(BaseModel):
|
|
6
|
+
name: str
|
|
7
|
+
description: Optional[str] = None
|
|
8
|
+
|
|
9
|
+
class ItemCreate(ItemBase):
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
class ItemUpdate(ItemBase):
|
|
13
|
+
name: Optional[str] = None
|
|
14
|
+
|
|
15
|
+
class ItemResponse(ItemBase):
|
|
16
|
+
id: int
|
|
17
|
+
created_at: datetime
|
|
18
|
+
updated_at: Optional[datetime] = None
|
|
19
|
+
is_active: bool
|
|
20
|
+
|
|
21
|
+
model_config = ConfigDict(from_attributes=True)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
2
|
+
from sqlalchemy.future import select
|
|
3
|
+
from typing import List, Optional
|
|
4
|
+
|
|
5
|
+
from app.db.models.models import Item
|
|
6
|
+
from app.schemas.schema import ItemCreate, ItemUpdate
|
|
7
|
+
|
|
8
|
+
class ItemService:
|
|
9
|
+
@staticmethod
|
|
10
|
+
async def get_all(db: AsyncSession, skip: int = 0, limit: int = 100) -> List[Item]:
|
|
11
|
+
result = await db.execute(select(Item).offset(skip).limit(limit))
|
|
12
|
+
return result.scalars().all()
|
|
13
|
+
|
|
14
|
+
@staticmethod
|
|
15
|
+
async def get_by_id(db: AsyncSession, item_id: int) -> Optional[Item]:
|
|
16
|
+
result = await db.execute(select(Item).filter(Item.id == item_id))
|
|
17
|
+
return result.scalars().first()
|
|
18
|
+
|
|
19
|
+
@staticmethod
|
|
20
|
+
async def create(db: AsyncSession, item_data: ItemCreate) -> Item:
|
|
21
|
+
db_item = Item(**item_data.model_dump())
|
|
22
|
+
db.add(db_item)
|
|
23
|
+
await db.commit()
|
|
24
|
+
await db.refresh(db_item)
|
|
25
|
+
return db_item
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
services:
|
|
2
|
+
{{ project_name }}-api:
|
|
3
|
+
build: .
|
|
4
|
+
container_name: {{ project_name }}-api
|
|
5
|
+
command: /code/startup.sh
|
|
6
|
+
volumes:
|
|
7
|
+
- ./app:/code/app
|
|
8
|
+
- ./alembic:/code/alembic
|
|
9
|
+
- ./alembic.ini:/code/alembic.ini
|
|
10
|
+
ports:
|
|
11
|
+
- "8000:8000"
|
|
12
|
+
env_file:
|
|
13
|
+
- .env
|
|
14
|
+
environment:
|
|
15
|
+
- POSTGRES_WRITER_HOST=pgbouncer-writer
|
|
16
|
+
- POSTGRES_WRITER_PORT=6432
|
|
17
|
+
- POSTGRES_READER_HOST=pgbouncer-reader
|
|
18
|
+
- POSTGRES_READER_PORT=6433
|
|
19
|
+
- POSTGRES_HOST={{ project_name }}-db-master
|
|
20
|
+
- POSTGRES_PORT=5432
|
|
21
|
+
depends_on:
|
|
22
|
+
pgbouncer-writer:
|
|
23
|
+
condition: service_started
|
|
24
|
+
pgbouncer-reader:
|
|
25
|
+
condition: service_started
|
|
26
|
+
{{ project_name }}-db-master:
|
|
27
|
+
condition: service_healthy
|
|
28
|
+
restart: on-failure
|
|
29
|
+
networks:
|
|
30
|
+
- {{ project_name }}_network
|
|
31
|
+
|
|
32
|
+
pgbouncer-writer:
|
|
33
|
+
image: edoburu/pgbouncer:latest
|
|
34
|
+
container_name: {{ project_name }}-pgbouncer-writer
|
|
35
|
+
environment:
|
|
36
|
+
- DATABASE_URL=postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@{{ project_name }}-db-master:5432/${POSTGRES_DB}
|
|
37
|
+
- POOL_MODE=transaction
|
|
38
|
+
- MAX_CLIENT_CONN=100
|
|
39
|
+
- DEFAULT_POOL_SIZE=20
|
|
40
|
+
- LISTEN_PORT=6432
|
|
41
|
+
- AUTH_TYPE=scram-sha-256
|
|
42
|
+
depends_on:
|
|
43
|
+
{{ project_name }}-db-master:
|
|
44
|
+
condition: service_healthy
|
|
45
|
+
networks:
|
|
46
|
+
- {{ project_name }}_network
|
|
47
|
+
|
|
48
|
+
pgbouncer-reader:
|
|
49
|
+
image: edoburu/pgbouncer:latest
|
|
50
|
+
container_name: {{ project_name }}-pgbouncer-reader
|
|
51
|
+
environment:
|
|
52
|
+
- DATABASE_URL=postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@{{ project_name }}-db-replica:5432/${POSTGRES_DB}
|
|
53
|
+
- POOL_MODE=transaction
|
|
54
|
+
- MAX_CLIENT_CONN=200
|
|
55
|
+
- DEFAULT_POOL_SIZE=40
|
|
56
|
+
- LISTEN_PORT=6433
|
|
57
|
+
- AUTH_TYPE=scram-sha-256
|
|
58
|
+
depends_on:
|
|
59
|
+
{{ project_name }}-db-replica:
|
|
60
|
+
condition: service_started
|
|
61
|
+
networks:
|
|
62
|
+
- {{ project_name }}_network
|
|
63
|
+
|
|
64
|
+
{{ project_name }}-db-master:
|
|
65
|
+
image: bitnami/postgresql:latest
|
|
66
|
+
container_name: {{ project_name }}-db-master
|
|
67
|
+
volumes:
|
|
68
|
+
- {{ project_name }}_postgres_master_data:/bitnami/postgresql
|
|
69
|
+
environment:
|
|
70
|
+
- POSTGRESQL_USERNAME=${POSTGRES_USER}
|
|
71
|
+
- POSTGRESQL_PASSWORD=${POSTGRES_PASSWORD}
|
|
72
|
+
- POSTGRESQL_DATABASE=${POSTGRES_DB}
|
|
73
|
+
- POSTGRESQL_REPLICATION_MODE=master
|
|
74
|
+
- POSTGRESQL_REPLICATION_USER=repl_user
|
|
75
|
+
- POSTGRESQL_REPLICATION_PASSWORD=repl_password
|
|
76
|
+
healthcheck:
|
|
77
|
+
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
|
78
|
+
interval: 5s
|
|
79
|
+
timeout: 5s
|
|
80
|
+
retries: 5
|
|
81
|
+
networks:
|
|
82
|
+
- {{ project_name }}_network
|
|
83
|
+
|
|
84
|
+
{{ project_name }}-db-replica:
|
|
85
|
+
image: bitnami/postgresql:latest
|
|
86
|
+
container_name: {{ project_name }}-db-replica
|
|
87
|
+
volumes:
|
|
88
|
+
- {{ project_name }}_postgres_replica_data:/bitnami/postgresql
|
|
89
|
+
environment:
|
|
90
|
+
- POSTGRESQL_MASTER_HOST={{ project_name }}-db-master
|
|
91
|
+
- POSTGRESQL_MASTER_PORT_NUMBER=5432
|
|
92
|
+
- POSTGRESQL_REPLICATION_MODE=slave
|
|
93
|
+
- POSTGRESQL_REPLICATION_USER=repl_user
|
|
94
|
+
- POSTGRESQL_REPLICATION_PASSWORD=repl_password
|
|
95
|
+
- POSTGRESQL_PASSWORD=${POSTGRES_PASSWORD}
|
|
96
|
+
depends_on:
|
|
97
|
+
{{ project_name }}-db-master:
|
|
98
|
+
condition: service_healthy
|
|
99
|
+
networks:
|
|
100
|
+
- {{ project_name }}_network
|
|
101
|
+
|
|
102
|
+
networks:
|
|
103
|
+
{{ project_name }}_network:
|
|
104
|
+
driver: bridge
|
|
105
|
+
|
|
106
|
+
volumes:
|
|
107
|
+
{{ project_name }}_postgres_master_data:
|
|
108
|
+
{{ project_name }}_postgres_replica_data:
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# --- FastAPI Core ---
|
|
2
|
+
fastapi==0.111.0
|
|
3
|
+
uvicorn[standard]==0.30.1
|
|
4
|
+
gunicorn==22.0.0
|
|
5
|
+
orjson==3.10.5
|
|
6
|
+
|
|
7
|
+
# --- Database ---
|
|
8
|
+
sqlalchemy==2.0.31
|
|
9
|
+
alembic==1.13.1
|
|
10
|
+
asyncpg==0.29.0
|
|
11
|
+
psycopg[binary]>=3.1.18
|
|
12
|
+
psycopg2-binary==2.9.9
|
|
13
|
+
backoff==2.2.1
|
|
14
|
+
|
|
15
|
+
# --- Settings and .env ---
|
|
16
|
+
pydantic-settings==2.3.4
|
|
17
|
+
python-dotenv==1.0.1
|
|
18
|
+
|
|
19
|
+
# --- Security ---
|
|
20
|
+
passlib[bcrypt]==1.7.4
|
|
21
|
+
bcrypt==3.2.0
|
|
22
|
+
python-jose[cryptography]==3.3.0
|
|
23
|
+
slowapi==0.1.9
|
|
24
|
+
requests==2.31.0
|
|
25
|
+
|
|
26
|
+
# --- Testing ---
|
|
27
|
+
pytest==8.2.2
|
|
28
|
+
httpx==0.27.0
|
|
29
|
+
pytest-asyncio==0.23.6
|
|
30
|
+
aiosqlite==0.19.0
|
|
31
|
+
tenacity==8.2.3
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
|
|
3
|
+
# Esperar a que la base de datos esté lista
|
|
4
|
+
postgres_ready() {
|
|
5
|
+
pg_isready -h "$POSTGRES_HOST" -p "$POSTGRES_PORT" -U "$POSTGRES_USER"
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
until postgres_ready; do
|
|
9
|
+
>&2 echo "PostgreSQL no está listo - esperando..."
|
|
10
|
+
sleep 1
|
|
11
|
+
done
|
|
12
|
+
|
|
13
|
+
>&2 echo "PostgreSQL está listo."
|
|
14
|
+
|
|
15
|
+
# Ejecutar migraciones si existe el directorio alembic
|
|
16
|
+
if [ -d "/code/alembic" ]; then
|
|
17
|
+
echo "Ejecutando migraciones de base de datos..."
|
|
18
|
+
alembic upgrade head
|
|
19
|
+
fi
|
|
20
|
+
|
|
21
|
+
# Iniciar la aplicación
|
|
22
|
+
echo "Iniciando {{ project_name }}..."
|
|
23
|
+
exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
|
File without changes
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from setuptools import setup, find_packages
|
|
2
|
+
|
|
3
|
+
setup(
|
|
4
|
+
name="chuopycore-cli",
|
|
5
|
+
version="1.0.0",
|
|
6
|
+
packages=find_packages(),
|
|
7
|
+
include_package_data=True,
|
|
8
|
+
install_requires=[
|
|
9
|
+
"typer[all]",
|
|
10
|
+
"jinja2",
|
|
11
|
+
"rich"
|
|
12
|
+
],
|
|
13
|
+
entry_points={
|
|
14
|
+
'console_scripts': [
|
|
15
|
+
'chuopycore=pycore.cli:app',
|
|
16
|
+
],
|
|
17
|
+
},
|
|
18
|
+
)
|