diff --git a/docs/en/reference/translation-status.md b/docs/en/reference/translation-status.md index 4c8c1be..b5fa610 100644 --- a/docs/en/reference/translation-status.md +++ b/docs/en/reference/translation-status.md @@ -37,11 +37,11 @@ next section explains). | 🇰🇷 Korean (`ko`) | ✅ Complete | 26 / 26 | All locale pages are present. Phase 1: top-level + core user-guide; Phase 2: remaining user-guide + all tutorials; Phase 3: contributing + reference. `docs/ko/changelog.md` intentionally reuses the canonical English `CHANGELOG.md`. | | 🇯🇵 Japanese (`ja`) | ✅ Complete | 26 / 26 | All locale pages are present. Phase 1: top-level + core user-guide; Phase 2: remaining user-guide + all tutorials; Phase 3: contributing + reference. `docs/ja/changelog.md` intentionally reuses the canonical English `CHANGELOG.md`. | | 🇨🇳 Chinese (`zh`) | 🔴 Skeleton | 0 / 26 | Build target only. Every page falls back to English. | -| 🇪🇸 Spanish (`es`) | 🔴 Skeleton | 0 / 26 | Build target only. Every page falls back to English. | +| 🇪🇸 Spanish (`es`) | ✅ Complete | 26 / 26 | All locale pages are present. Phase 1: top-level + core user-guide; Phase 2: remaining user-guide + all tutorials; Phase 3: contributing + reference. `docs/es/changelog.md` intentionally reuses the canonical English `CHANGELOG.md`. | | 🇫🇷 French (`fr`) | 🔴 Skeleton | 0 / 26 | Build target only. Every page falls back to English. | | 🇩🇪 German (`de`) | 🔴 Skeleton | 0 / 26 | Build target only. Every page falls back to English. | -*Snapshot verified 2026-05-10; ja row recounted for the current branch after Phase 3 (contributing + reference) landed. Japanese now has all locale pages present, while `docs/ja/changelog.md` intentionally points to the canonical English changelog.* These counts are maintained by hand; +*Snapshot verified 2026-05-11; es row recounted for the current branch after Phase 3 (contributing + reference) landed. Spanish now has all locale pages present, while `docs/es/changelog.md` intentionally points to the canonical English changelog.* These counts are maintained by hand; to recount the current state from the repo root, run: ```console diff --git a/docs/es/changelog.md b/docs/es/changelog.md new file mode 100644 index 0000000..f4d16fd --- /dev/null +++ b/docs/es/changelog.md @@ -0,0 +1 @@ +{!CHANGELOG.md!} diff --git a/docs/es/contributing/code-guidelines.md b/docs/es/contributing/code-guidelines.md new file mode 100644 index 0000000..4b962ed --- /dev/null +++ b/docs/es/contributing/code-guidelines.md @@ -0,0 +1,748 @@ +# Guía de código + +Guía completa de estándares y buenas prácticas de codificación para contribuir a FastAPI-fastkit. + +## Visión general + +Esta guía garantiza la calidad, consistencia y mantenibilidad del código en todo el proyecto FastAPI-fastkit. Seguir estos estándares ayuda a construir un código base fácil de leer, mantener y ampliar. + +## Estilo de código Python + +### Conformidad con PEP 8 + +Sigue [PEP 8](https://www.python.org/dev/peps/pep-0008/) con estas configuraciones específicas: + +- **Longitud de línea**: 88 caracteres (por defecto en Black) +- **Indentación**: 4 espacios (sin tabs) +- **Comas finales**: requeridas en estructuras multilínea +- **Comillas de strings**: se prefieren comillas dobles + +### Formato de código + +Usamos **Black** para formateo automático: + +```python +# Bien ✅ +def create_project( + name: str, + template: str, + options: Dict[str, Any], +) -> ProjectResult: + """Create a new FastAPI project.""" + return ProjectResult(name=name, template=template) + +# Mal ❌ +def create_project(name: str, template: str, options: Dict[str,Any])->ProjectResult: + """Create a new FastAPI project.""" + return ProjectResult(name=name,template=template) +``` + +### Organización de imports + +Usa **isort** para organizar imports: + +```python +# Imports de la librería estándar +import os +import sys +from pathlib import Path +from typing import Dict, List, Optional, Union + +# Imports de terceros +import click +import pydantic +from fastapi import FastAPI + +# Imports locales +from fastapi_fastkit.commands import BaseCommand +from fastapi_fastkit.utils import validation +from fastapi_fastkit.templates.manager import TemplateManager +``` + +## Type hints + +### Type hints obligatorios + +Todas las funciones y métodos públicos deben incluir type hints: + +```python +# Bien ✅ +def validate_project_name(name: str) -> bool: + """Validate project name format.""" + return name.isidentifier() and not name.startswith('_') + +def create_files( + files: List[Path], + template_data: Dict[str, Any] +) -> List[Path]: + """Create files from template data.""" + created_files = [] + for file_path in files: + # Implementación... + created_files.append(file_path) + return created_files + +# Mal ❌ +def validate_project_name(name): + return name.isidentifier() and not name.startswith('_') +``` + +### Anotaciones de tipos complejos + +Usa anotaciones de tipo adecuadas para estructuras complejas: + +```python +from typing import Dict, List, Optional, Union, Tuple, Any +from pathlib import Path + +# Alias de tipos complejos +ProjectConfig = Dict[str, Union[str, bool, List[str]]] +FileMapping = Dict[Path, str] +ValidationResult = Tuple[bool, Optional[str]] + +def process_template( + template_path: Path, + config: ProjectConfig, + output_dir: Optional[Path] = None, +) -> ValidationResult: + """Process template with configuration.""" + # Implementación... + return True, None +``` + +## Convenciones de nombres + +### Variables y funciones + +- **snake_case** para variables y funciones +- **Nombres descriptivos** que expliquen su propósito +- **Evita abreviaturas** salvo las muy reconocidas + +```python +# Bien ✅ +project_name = "my-api" +template_directory = Path("templates") +user_input_data = get_user_input() + +def validate_email_address(email: str) -> bool: + """Validate email address format.""" + return "@" in email and "." in email + +# Mal ❌ +proj_nm = "my-api" +temp_dir = Path("templates") +usr_data = get_input() + +def validate_email(e): + return "@" in e and "." in e +``` + +### Clases + +- **PascalCase** para nombres de clase +- **Descriptivos y específicos** + +```python +# Bien ✅ +class SomeClass: + """Represents example class of FastAPI-fastkit.""" + pass + +class SomeClassValidationError(Exception): + """Raised when example class validation fails.""" + pass + +class UserInputHandler: + """Handles user input validation and processing.""" + pass + +# Mal ❌ +class Class: + pass + +class Error(Exception): + pass + +class Handler: + pass +``` + +### Constantes + +- **MAYÚSCULAS** con guiones bajos +- Solo **a nivel de módulo** + +```python +# Bien ✅ +DEFAULT_TEMPLATE_NAME = "fastapi-default" +MAX_PROJECT_NAME_LENGTH = 50 +SUPPORTED_PYTHON_VERSIONS = ["3.8", "3.9", "3.10", "3.11", "3.12"] + +# Mal ❌ +default_template = "fastapi-default" +maxLength = 50 +versions = ["3.8", "3.9", "3.10", "3.11", "3.12"] +``` + +## Estándares de documentación + +### Docstrings + +Usa **docstrings estilo Google** para todas las APIs públicas: + +```python +def create_project_structure( + project_name: str, + template_path: Path, + output_directory: Optional[Path] = None, + overwrite: bool = False, +) -> List[Path]: + """Create project structure from template. + + Creates a new FastAPI project structure by copying and processing + template files. Supports variable substitution and file customization. + + Args: + project_name: Name of the project to create. Must be a valid + Python identifier. + template_path: Path to the template directory containing + source files and configuration. + output_directory: Directory where project will be created. + Defaults to current working directory. + overwrite: Whether to overwrite existing files. If False, + raises error when files exist. + + Returns: + List of created file paths in order of creation. + + Raises: + ValueError: If project_name is invalid or empty. + FileExistsError: If output directory exists and overwrite is False. + TemplateNotFoundError: If template_path doesn't exist. + PermissionError: If insufficient permissions to create files. + + Example: + ```python + template_path = Path("templates/fastapi-default") + created_files = create_project_structure( + project_name="my-api", + template_path=template_path, + output_directory=Path("./projects"), + overwrite=False + ) + print(f"Created {len(created_files)} files") + ``` + """ + # Implementación aquí... + pass +``` + +### Comentarios + +- **Explica el PORQUÉ, no el QUÉ** +- **Úsalos con moderación** — el código debería autoexplicarse +- **Actualiza los comentarios** cuando cambia el código + +```python +# Bien ✅ +def validate_dependencies(requirements: List[str]) -> bool: + """Validate project dependencies.""" + # Saltar la validación en modo desarrollo para permitir paquetes experimentales + if os.getenv("FASTKIT_DEV_MODE"): + return True + + # Comprobar cada requirement contra vulnerabilidades de seguridad conocidas + for requirement in requirements: + if is_vulnerable_package(requirement): + return False + + return True + +# Mal ❌ +def validate_dependencies(requirements: List[str]) -> bool: + """Validate project dependencies.""" + # Comprobar si es modo dev + if os.getenv("FASTKIT_DEV_MODE"): + return True + + # Bucle por los requirements + for requirement in requirements: + # Comprobar si es vulnerable + if is_vulnerable_package(requirement): + return False + + # Devolver true + return True +``` + +## Manejo de errores + +### Manejo de excepciones + +- **Captura excepciones concretas** siempre que sea posible +- **Proporciona mensajes de error útiles** +- **Loguea los errores correctamente** + +```python +# Bien ✅ +def load_template_config(template_path: Path) -> Dict[str, Any]: + """Load template configuration from file.""" + config_file = template_path / "template.yaml" + + try: + with open(config_file, 'r') as f: + return yaml.safe_load(f) + except FileNotFoundError: + raise TemplateNotFoundError( + f"Template configuration not found: {config_file}" + ) + except yaml.YAMLError as e: + raise TemplateConfigError( + f"Invalid YAML syntax in {config_file}: {e}" + ) + except PermissionError: + raise TemplateAccessError( + f"Permission denied reading {config_file}" + ) + +# Mal ❌ +def load_template_config(template_path: Path) -> Dict[str, Any]: + """Load template configuration from file.""" + config_file = template_path / "template.yaml" + + try: + with open(config_file, 'r') as f: + return yaml.safe_load(f) + except Exception as e: + raise Exception(f"Error loading config: {e}") +``` + +### Excepciones personalizadas + +Define excepciones específicas para distintas condiciones de error: + +```python +class FastKitError(Exception): + """Base exception for FastAPI-fastkit errors.""" + pass + +class ProjectCreationError(FastKitError): + """Raised when project creation fails.""" + pass + +class TemplateNotFoundError(FastKitError): + """Raised when template is not found.""" + pass + +class ValidationError(FastKitError): + """Raised when input validation fails.""" + + def __init__(self, message: str, field: str = None): + super().__init__(message) + self.field = field +``` + +## Estándares de pruebas + +### Estructura de las pruebas + +Organiza las pruebas con una estructura y nombres claros: + +```python +class TestProjectCreation: + """Test project creation functionality.""" + + def test_create_project_with_valid_name(self, tmp_path): + """Test project creation with valid project name.""" + project_name = "test-project" + result = create_project(project_name, template="minimal", output=tmp_path) + + assert result.success is True + assert (tmp_path / project_name).exists() + assert (tmp_path / project_name / "src" / "main.py").exists() + + def test_create_project_with_invalid_name(self): + """Test project creation fails with invalid name.""" + with pytest.raises(ValueError, match="Invalid project name"): + create_project("invalid-project-name!", template="minimal") + + def test_create_project_overwrites_existing(self, tmp_path): + """Test project creation overwrites existing directory when forced.""" + project_name = "existing-project" + project_dir = tmp_path / project_name + project_dir.mkdir() + + result = create_project( + project_name, + template="minimal", + output=tmp_path, + overwrite=True + ) + + assert result.success is True + assert project_dir.exists() +``` + +### Cobertura de pruebas + +- **Apunta al 90% o más** de cobertura en código nuevo +- **Cubre casos límite** y condiciones de error +- **Mockea las dependencias externas** + +```python +def test_template_download_with_network_error(mock_requests): + """Test template download handles network errors gracefully.""" + mock_requests.get.side_effect = requests.ConnectionError("Network unreachable") + + with pytest.raises(TemplateDownloadError, match="Network error"): + download_template("https://example.com/template.zip") + +def test_file_creation_with_permission_error(mock_open): + """Test file creation handles permission errors.""" + mock_open.side_effect = PermissionError("Permission denied") + + with pytest.raises(FileCreationError, match="Permission denied"): + create_file(Path("/restricted/file.py"), content="test") +``` + +## Guía de imports + +### Organización de imports + +!!! note + + El formateador `isort` organiza los imports automáticamente, así que puedes ordenarlos fácilmente ejecutando `bash scripts/format.sh`. + +1. Primero la **librería estándar** +2. Después los imports de **terceros** +3. Por último los imports de la **aplicación local** +4. **Línea en blanco** entre cada grupo + +```python +# Librería estándar +import os +import sys +from pathlib import Path +from typing import Dict, List, Optional + +# Terceros +import click +import pydantic +import yaml +from fastapi import FastAPI + +# Aplicación local +from fastapi_fastkit.commands.base import BaseCommand +from fastapi_fastkit.utils.validation import validate_project_name +from fastapi_fastkit.templates import TemplateManager +``` + +### Buenas prácticas de imports + +- **Evita imports comodín** (`from module import *`) +- **Usa imports absolutos** para mayor claridad +- **Importa módulos, no elementos concretos** cuando importes muchos elementos + +```python +# Bien ✅ +from fastapi_fastkit.utils import validation, files, formatting + +# Bien ✅ (cuando importas pocos elementos) +from fastapi_fastkit.utils.validation import validate_email, validate_project_name + +# Mal ❌ +from fastapi_fastkit.utils.validation import * + +# Mal ❌ (cuando importas muchos elementos) +from fastapi_fastkit.utils.validation import ( + validate_email, validate_project_name, validate_template_name, + validate_dependencies, validate_python_version, validate_directory +) +``` + +## Guía de seguridad + +### Validación de entrada + +Valida y sanitiza siempre la entrada del usuario: + +```python +def validate_project_name(name: str) -> str: + """Validate and sanitize project name.""" + if not name: + raise ValueError("Project name cannot be empty") + + if not name.isidentifier(): + raise ValueError("Project name must be a valid Python identifier") + + if name.startswith('_'): + raise ValueError("Project name cannot start with underscore") + + if len(name) > 50: + raise ValueError("Project name too long (max 50 characters)") + + # Sanitizar eliminando caracteres peligrosos + sanitized = re.sub(r'[^a-zA-Z0-9_-]', '', name) + + return sanitized +``` + +### Operaciones con archivos + +Ten cuidado con rutas y operaciones de archivos: + +```python +def create_file_safely(file_path: Path, content: str, base_dir: Path) -> None: + """Create file safely within base directory.""" + # Resolver para evitar ataques de directory traversal + resolved_path = file_path.resolve() + resolved_base = base_dir.resolve() + + # Asegurar que el archivo está dentro del directorio base + try: + resolved_path.relative_to(resolved_base) + except ValueError: + raise SecurityError(f"File path outside base directory: {file_path}") + + # Crear los directorios padres de forma segura + resolved_path.parent.mkdir(parents=True, exist_ok=True) + + # Escribir el archivo con permisos adecuados + resolved_path.write_text(content, encoding='utf-8') + resolved_path.chmod(0o644) # Lectura/escritura para el dueño, lectura para el resto +``` + +## Guía de rendimiento + +### Prácticas de código eficiente + +- **Usa generadores** para datasets grandes +- **Evita optimizaciones prematuras** +- **Perfila antes de optimizar** + +```python +# Bien ✅ - generador para eficiencia de memoria +def process_large_template(template_files: List[Path]) -> Iterator[ProcessedFile]: + """Process template files efficiently.""" + for file_path in template_files: + content = file_path.read_text() + processed_content = process_template_content(content) + yield ProcessedFile(path=file_path, content=processed_content) + +# Mal ❌ - carga todo en memoria +def process_large_template(template_files: List[Path]) -> List[ProcessedFile]: + """Process template files.""" + results = [] + for file_path in template_files: + content = file_path.read_text() + processed_content = process_template_content(content) + results.append(ProcessedFile(path=file_path, content=processed_content)) + return results +``` + +### Caché + +Usa caché para operaciones costosas: + +```python +from functools import lru_cache + +@lru_cache(maxsize=128) +def get_template_metadata(template_path: Path) -> TemplateMetadata: + """Get template metadata with caching.""" + config_file = template_path / "template.yaml" + + if not config_file.exists(): + return TemplateMetadata(name=template_path.name) + + config = yaml.safe_load(config_file.read_text()) + return TemplateMetadata.from_config(config) +``` + +## Guía de commits de Git + +### Formato del mensaje de commit + +Usa el formato Conventional Commits: + +``` +type(scope): description + +[optional body] + +[optional footer] +``` + +### Tipos de commit + +- **feat**: nueva funcionalidad +- **fix**: corrección de bug +- **docs**: cambios de documentación +- **style**: cambios de estilo de código (formateo, etc.) +- **refactor**: refactorización +- **test**: añadir o actualizar pruebas +- **chore**: tareas de mantenimiento + +### Ejemplos + +```bash +# Bien ✅ +feat(cli): add template validation command + +Add new command to validate template structure and configuration. +The command checks for required files, validates YAML syntax, +and ensures template follows conventions. + +Closes #123 + +# Bien ✅ +fix(templates): handle missing dependency files gracefully + +When a template references a requirements file that doesn't exist, +show a clear error message instead of crashing. + +# Mal ❌ +update stuff + +# Mal ❌ +Fixed bug +``` + +## Guía de revisión de código + +### Para autores + +Antes de enviar código para revisión: + +1. **Ejecuta todas las pruebas** y comprueba que pasan +2. **Comprueba que mantienes la cobertura** +3. **Actualiza la documentación** si hace falta +4. **Sigue las convenciones de mensaje de commit** +5. **Mantén los pull requests** enfocados y pequeños + +### Para revisores + +Al revisar código: + +1. **Comprueba la funcionalidad**: ¿hace lo que debe? +2. **Revisa las pruebas**: ¿cubren los casos límite? +3. **Verifica la documentación**: ¿es clara y está al día? +4. **Comprueba el estilo de código**: ¿sigue las convenciones? +5. **Considera la seguridad**: ¿hay vulnerabilidades potenciales? + +### Checklist de revisión + +- [ ] El código sigue la guía de estilo +- [ ] Las pruebas son completas y pasan +- [ ] La documentación está actualizada +- [ ] No hay vulnerabilidades de seguridad +- [ ] Se han considerado los aspectos de rendimiento +- [ ] El manejo de errores es adecuado +- [ ] Los mensajes de commit siguen las convenciones + +## Herramientas y automatización + +### Hooks de pre-commit + +Usamos pre-commit hooks para aplicar los estándares: + +```yaml +# .pre-commit-config.yaml +repos: +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-toml + +- repo: local + hooks: + - id: format + name: format + entry: black --config pyproject.toml --check . + language: python + types: [python] + additional_dependencies: ['black>=24.10.0'] + pass_filenames: false + + - id: isort-check + name: isort check + entry: isort --sp pyproject.toml --check-only --diff . + language: python + types: [python] + additional_dependencies: ['isort>=5.13.2'] + pass_filenames: false + + - id: isort-fix + name: isort fix + entry: isort --sp pyproject.toml . + language: python + types: [python] + additional_dependencies: ['isort>=5.13.2'] + pass_filenames: false + + - id: black-fix + name: black fix + entry: black --config pyproject.toml . + language: python + types: [python] + additional_dependencies: ['black>=24.10.0'] + pass_filenames: false + + - id: mypy + name: mypy + entry: mypy --config-file pyproject.toml src + language: python + types: [python] + additional_dependencies: + - mypy>=1.12.0 + - rich>=13.9.2 + - click>=8.1.7 + - pyyaml>=6.0.0 + - types-PyYAML>=6.0.12 + pass_filenames: false + +ci: + autofix_commit_msg: 🎨 [pre-commit.ci] Auto format from pre-commit.com hooks + autoupdate_commit_msg: ⬆ [pre-commit.ci] pre-commit autoupdate +``` + +!!! note + + Los hooks de pre-commit usan entornos Python aislados (`language: python`). + +### Configuración del IDE + +Configuración recomendada para VS Code: + +```json +{ + "python.linting.enabled": true, + "python.linting.mypyEnabled": true, + "python.formatting.provider": "black", + "python.sortImports.path": "isort", + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.organizeImports": true + } +} +``` + +## Próximos pasos + +Tras revisar esta guía: + +1. **Configura el entorno de desarrollo** siguiendo [Configuración de desarrollo](development-setup.md) +2. **Practica con contribuciones pequeñas** para familiarizarte +3. **Pregunta** en GitHub Discussions si algo no está claro +4. **Revisa el código existente** para ver estas convenciones en acción + +!!! tip "Referencia rápida" + - Usa `make check-all` para comprobar que tu código cumple todas las convenciones + - Configura los pre-commit hooks para detectar problemas pronto + - Si tienes dudas, mira el código existente como ejemplo + - No dudes en pedir ayuda en las revisiones de código + +¡Seguir esta guía ayuda a mantener la alta calidad del código de FastAPI-fastkit y facilita la colaboración para todos! 🚀 diff --git a/docs/es/contributing/development-setup.md b/docs/es/contributing/development-setup.md new file mode 100644 index 0000000..ec8e8c6 --- /dev/null +++ b/docs/es/contributing/development-setup.md @@ -0,0 +1,816 @@ +# Configuración del entorno de desarrollo + +Guía completa para configurar un entorno de desarrollo y contribuir a FastAPI-fastkit. + +## Requisitos previos + +Antes de empezar, asegúrate de tener: + +- **Python 3.12 o superior** instalado +- **Git** instalado y configurado +- **Conocimientos básicos** de Python y FastAPI +- **Editor de texto o IDE** (VS Code, PyCharm, etc.) + +## Configuración rápida con el Makefile + +FastAPI-fastkit incluye un Makefile para una configuración de desarrollo fácil: + +
+ +```console +$ git clone https://github.com/bnbong/FastAPI-fastkit.git +$ cd FastAPI-fastkit +$ make install-dev +Setting up development environment... +Creating virtual environment... +Installing dependencies... +Installing pre-commit hooks... +✅ Development environment ready! +``` + +
+ +Este único comando: + +- Instala el paquete en modo editable con las dependencias de desarrollo +- Configura los pre-commit hooks +- Ajusta las herramientas de desarrollo + +!!! note + + Deberías crear y activar un entorno virtual antes de ejecutar este comando. + +## Configuración manual + +Si prefieres configurarlo manualmente o el Makefile no funciona en tu sistema: + +### 1. Clonar el repositorio + +
+ +```console +$ git clone https://github.com/bnbong/FastAPI-fastkit.git +$ cd FastAPI-fastkit +``` + +
+ +### 2. Crear un entorno virtual + +
+ +```console +$ python -m venv .venv +$ source .venv/bin/activate # En Windows: .venv\Scripts\activate +``` + +
+ +### 3. Instalar las dependencias + +
+ +```console +# Instalar el paquete en modo editable con dependencias de desarrollo +$ pip install -e ".[dev]" + +# O instalar desde los archivos de requirements +$ pip install -r requirements.txt +$ pip install -r requirements-dev.txt +``` + +
+ +### 4. Configurar los pre-commit hooks + +
+ +```console +$ pre-commit install +pre-commit installed at .git/hooks/pre-commit +``` + +
+ +### 5. Verificar la instalación + +
+ +```console +$ fastkit --version +fastapi-fastkit, version 1.2.1 + +$ python -m pytest tests/ +======================== test session starts ======================== +collected 45 items +tests/test_cli.py::test_init_command PASSED +tests/test_templates.py::test_template_listing PASSED +... +======================== 45 passed in 2.34s ======================== +``` + +
+ +## Herramientas de desarrollo + +El entorno de desarrollo incluye varias herramientas para mantener la calidad del código: + +### Comandos directos + +Con el Makefile: + +```console +$ make format lint +Running isort... +Running black... +Running mypy... +✅ All checks passed! +``` + +Con los scripts incluidos: + +```console +$ ./scripts/format.sh +$ ./scripts/lint.sh +``` + +### Formato del código + +**Black** — formateador de código: + +
+ +```console +$ black src/ tests/ +reformatted src/main.py +reformatted tests/test_cli.py +All done! ✨ 🍰 ✨ +``` + +
+ +**isort** — ordenador de imports: + +
+ +```console +$ isort src/ tests/ +Fixing import order in src/main.py +``` + +
+ +### Linting de código + +**mypy** — chequeo de tipos: + +
+ +```console +$ mypy src/ +Success: no issues found in 12 source files +``` + +
+ +## Comandos Make disponibles + +El Makefile del proyecto incluye comandos útiles para las tareas de desarrollo más comunes: + +### Comandos de configuración + +| Comando | Descripción | +|---|---| +| `make install` | Instala el paquete en modo producción | +| `make install-dev` | Instala el paquete con dependencias de desarrollo | +| `make install-test` | Instala el paquete para pruebas (desinstala + reinstala) | +| `make uninstall` | Desinstala el paquete | +| `make clean` | Limpia artefactos de build y archivos de caché | + +### Comandos de calidad de código + +| Comando | Descripción | +|---|---| +| `make format` | Formatea el código con black e isort | +| `make format-check` | Comprueba el formato sin hacer cambios | +| `make lint` | Ejecuta todas las comprobaciones de linting (isort, black, mypy) | + +### Comandos de pruebas + +| Comando | Descripción | +|---|---| +| `make test` | Ejecuta todas las pruebas | +| `make test-verbose` | Ejecuta las pruebas con salida detallada | +| `make test-coverage` | Ejecuta las pruebas con informe de cobertura | +| `make coverage-report` | Genera un informe de cobertura detallado (FORMAT=html/xml/json/all) | + +### Comandos de inspección de plantillas + +| Comando | Descripción | +|---|---| +| `make inspect-templates` | Inspecciona todas las plantillas | +| `make inspect-templates-verbose` | Inspecciona con salida detallada | +| `make inspect-template` | Inspecciona plantillas concretas (parámetro TEMPLATES) | + +### Comandos de documentación + +| Comando | Descripción | +|---|---| +| `make serve-docs` | Sirve la documentación localmente | +| `make build-docs` | Construye la documentación | + +### Comandos de traducción + +| Comando | Descripción | +|---|---| +| `make translate` | Traduce la documentación (parámetros LANG, PROVIDER, MODEL) | + +### Ejemplos + +
+ +```console +# Formatear el código y ejecutar todas las comprobaciones +$ make format lint +Running isort... +Running black... +Running mypy... +✅ All checks passed! + +# Ejecutar pruebas con cobertura +$ make test-coverage +======================== test session starts ======================== +collected 45 items +tests/test_cli.py::test_init_command PASSED +... +======================== 45 passed in 2.34s ======================== + +---------- coverage: platform darwin, python 3.12.1-final-0 ---------- +Name Stmts Miss Cover +-------------------------------------------- +src/main.py 45 2 96% +src/cli.py 89 5 94% +src/templates.py 67 3 96% +-------------------------------------------- +TOTAL 201 10 95% + +# Generar informe HTML de cobertura +$ make coverage-report FORMAT=html +🌐 Opening HTML coverage report in browser... + +# Traducir la documentación al coreano +$ make translate LANG=ko PROVIDER=github MODEL=gpt-4o-mini +Starting translation... +Running: python scripts/translate.py --target-lang ko --api-provider github --model gpt-4o-mini +``` + +
+ +## Estructura del proyecto + +Entender la estructura del proyecto es clave para el desarrollo: + +```bash +FastAPI-fastkit/ +├── src/ +│ ├── fastapi_fastkit/ +│ │ ├── __main__.py # Punto de entrada de la aplicación +│ │ ├── backend/ +│ │ │ ├── inspector.py # Inspector de plantillas +│ │ │ ├── interactive/ +│ │ │ │ ├── config_builder.py # Constructor de configuración para el modo interactivo +│ │ │ │ ├── prompts.py # Prompts del modo interactivo +│ │ │ │ ├── selectors.py # Lógica de selectores del modo interactivo +│ │ │ │ └── validators.py # Validadores de entrada del modo interactivo +│ │ │ ├── main.py # Punto de entrada de la lógica del backend +│ │ │ ├── package_managers/ +│ │ │ │ ├── base.py # Clase base de los gestores de paquetes +│ │ │ │ ├── factory.py # Factory de gestores de paquetes +│ │ │ │ ├── pdm_manager.py # Gestor PDM +│ │ │ │ ├── pip_manager.py # Gestor pip +│ │ │ │ ├── poetry_manager.py # Gestor Poetry +│ │ │ │ └── uv_manager.py # Gestor uv +│ │ │ ├── project_builder/ +│ │ │ │ ├── config_generator.py # Generador de configuración para el project builder +│ │ │ │ └── dependency_collector.py # Colector de dependencias para el project builder +│ │ │ └── transducer.py # Transducer del project builder +│ │ ├── cli.py # Punto de entrada principal de la CLI +│ │ ├── core/ +│ │ │ ├── exceptions.py # Manejo de excepciones +│ │ │ └── settings.py # Configuración +│ │ ├── fastapi_project_template/ +│ │ │ ├── PROJECT_README_TEMPLATE.md # README base de las plantillas fastkit +│ │ │ ├── README.md # README de las plantillas fastkit +│ │ │ ├── fastapi-async-crud/ +│ │ │ ├── fastapi-custom-response/ +│ │ │ ├── fastapi-default/ +│ │ │ ├── fastapi-dockerized/ +│ │ │ ├── fastapi-empty/ +│ │ │ ├── fastapi-mcp/ +│ │ │ ├── fastapi-psql-orm/ +│ │ │ ├── fastapi-single-module/ +│ │ │ └── modules/ +│ │ │ ├── api/ +│ │ │ │ └── routes/ +│ │ │ ├── crud/ +│ │ │ └── schemas/ +│ │ ├── py.typed +│ │ └── utils/ +│ │ ├── logging.py # Configuración de logging +│ │ └── main.py # Punto de entrada principal de FastAPI-fastkit +│ └── logs +├── tests +│ ├── conftest.py # Configuración de pytest +│ ├── test_backends/ +│ ├── test_cli_operations/ +│ ├── test_core.py +│ ├── test_rich/ +│ ├── test_templates/ +│ └── test_utils.py +├── uv.lock +├── docs/ # Documentación +├── scripts/ # Scripts de desarrollo +├── mkdocs.yml +├── overrides/ # overrides de mkdocs +├── pdm.lock +├── pyproject.toml +├── requirements-docs.txt # requirements para la documentación +├── requirements.txt # requirements para desarrollo +├── CHANGELOG.md +├── CITATION.cff +├── CODE_OF_CONDUCT.md +├── CONTRIBUTING.md +├── LICENSE +├── MANIFEST.in +├── Makefile +├── README.md +├── SECURITY.md +└── env.example # ejemplo de variables de entorno (configura las del modelo IA de traducción) +``` + +### Directorios clave + +- **`src/fastapi_fastkit/`** — código fuente del paquete principal + - **`cli.py`** — punto de entrada principal de la CLI + - **`backend/`** — lógica central del backend + - **`inspector.py`** — inspector de plantillas + - **`interactive/`** — componentes del modo interactivo (prompts, selectores, validadores) + - **`package_managers/`** — implementaciones de los gestores de paquetes (pip, uv, pdm, poetry) + - **`project_builder/`** — utilidades de construcción de proyectos + - **`transducer.py`** — transducer de plantillas + - **`core/`** — configuración central y excepciones + - **`fastapi_project_template/`** — plantillas de proyecto (fastapi-default, fastapi-async-crud, etc.) + - **`utils/`** — funciones de utilidad compartidas +- **`tests/`** — suite de pruebas + - **`test_backends/`** — pruebas específicas del backend + - **`test_cli_operations/`** — pruebas de operaciones de la CLI + - **`test_templates/`** — pruebas del sistema de plantillas +- **`docs/`** — documentación (MkDocs) + - Guías de usuario, tutoriales y referencia de la API + +## Flujo de desarrollo + +### 1. Crear una rama de feature + +
+ +```console +$ git checkout -b feature/add-new-template +Switched to a new branch 'feature/add-new-template' +``` + +
+ +### 2. Hacer los cambios + +Edita el código, añade funcionalidades, arregla bugs... + +### 3. Ejecutar pruebas y comprobaciones + +
+ +```console +$ make dev-check +Running all quality checks... +Running all tests... +✅ All tests passed! +``` + +
+ +### 4. Hacer commit de los cambios + +Los pre-commit hooks se ejecutan automáticamente: + +
+ +```console +$ git add . +$ git commit -m "Add new FastAPI template with authentication" +format...................................................................Passed +isort-check..............................................................Passed +black-fix................................................................Passed +mypy.....................................................................Passed +[feature/add-new-template abc1234] Add new FastAPI template with authentication +``` + +
+ +### 5. Push y crear el Pull Request + +
+ +```console +$ git push origin feature/add-new-template +$ gh pr create --title "Add new FastAPI template with authentication" +``` + +
+ +## Pruebas + +### Ejecutar las pruebas + +**Todas las pruebas:** + +
+ +```console +$ make test +# o +$ python -m pytest +``` + +
+ +**Un archivo de pruebas concreto:** + +
+ +```console +$ python -m pytest tests/test_cli.py -v +``` + +
+ +**Con cobertura:** + +
+ +```console +$ make test-coverage +# o +$ python -m pytest --cov=src --cov-report=html +``` + +
+ +### Escribir pruebas + +Cuando añadas nuevas funcionalidades, incluye siempre pruebas: + +```python +# tests/test_commands/test_new_feature.py +import pytest +from fastapi_fastkit.commands.new_feature import NewFeatureCommand + +class TestNewFeatureCommand: + def test_command_success(self): + """Test successful command execution""" + command = NewFeatureCommand() + result = command.execute(valid_args) + assert result.success is True + assert result.message == "Feature executed successfully" + + def test_command_validation_error(self): + """Test command with invalid arguments""" + command = NewFeatureCommand() + with pytest.raises(ValueError, match="Invalid argument"): + command.execute(invalid_args) + + def test_command_edge_case(self): + """Test edge case handling""" + command = NewFeatureCommand() + result = command.execute(edge_case_args) + assert result.success is True + assert "warning" in result.message.lower() +``` + +### Categorías de pruebas + +**Pruebas unitarias** — prueban funciones y clases individuales: + +```python +def test_validate_project_name(): + assert validate_project_name("valid-name") is True + assert validate_project_name("invalid name!") is False +``` + +**Pruebas de integración** — prueban interacciones entre comandos: + +```python +def test_init_command_creates_project(tmp_path): + result = runner.invoke(cli, ['init'], input='test-project\n...') + assert result.exit_code == 0 + assert (tmp_path / "test-project").exists() +``` + +**Pruebas de extremo a extremo** — cubren flujos completos: + +```python +def test_full_project_creation_workflow(tmp_path): + # Crear el proyecto + result = runner.invoke(cli, ['init'], input='...') + assert result.exit_code == 0 + + # Añadir una ruta + result = runner.invoke(cli, ['addroute', 'test-project', 'users']) + assert result.exit_code == 0 + + # Verificar que existen los archivos + assert (tmp_path / "test-project" / "src" / "api" / "routes" / "users.py").exists() +``` + +## Documentación + +### Servir la documentación localmente + +
+ +```console +$ make serve-docs +INFO - Building documentation... +INFO - Cleaning site directory +INFO - Documentation built in 0.43 seconds +INFO - [14:30:00] Serving on http://127.0.0.1:8000/ +``` + +
+ +### Construir la documentación + +
+ +```console +$ make build-docs +INFO - Building documentation... +INFO - Documentation built in 0.43 seconds +``` + +
+ +### Escribir documentación + +La documentación se escribe en Markdown y se construye con MkDocs. Aquí tienes un ejemplo de estructura: + +**Plantilla de guía de funcionalidad:** + +````markdown +# New Feature Guide + +This guide explains how to use the new feature. + +## Prerequisites + +- FastAPI-fastkit installed +- Basic Python knowledge + +## Usage + +
+ +```console +$ fastkit new-feature --option value +✅ Feature executed successfully! +``` + +
+ +!!! tip "Pro Tip" + Use `--help` to see all available options. +```` + +Para una referencia detallada sobre cómo usar `mkdocs-material`, consulta la [documentación de mkdocs-material](https://squidfunk.github.io/mkdocs-material/reference/admonitions/). + +## Guía de estilo de código + +### Estilo de código Python + +Sigue [PEP 8](https://www.python.org/dev/peps/pep-0008/) con estas reglas específicas: + +- **Longitud de línea**: 88 caracteres (por defecto en Black) +- **Imports**: organizados con isort +- **Type hints**: requeridos en todas las funciones públicas +- **Docstrings**: estilo Google para todas las APIs públicas + +### Ejemplo + +```python +from typing import List, Optional +from pathlib import Path + +def create_project_structure( + project_name: str, + template_path: Path, + output_dir: Optional[Path] = None, +) -> List[Path]: + """Create project structure from template. + + Args: + project_name: Name of the project to create + template_path: Path to the template directory + output_dir: Output directory, defaults to current directory + + Returns: + List of created file paths + + Raises: + ValueError: If project_name is invalid + FileNotFoundError: If template_path doesn't exist + """ + if not project_name.isidentifier(): + raise ValueError(f"Invalid project name: {project_name}") + + if not template_path.exists(): + raise FileNotFoundError(f"Template not found: {template_path}") + + # Implementación aquí... + return created_files +``` + +## Variables de entorno + +Para desarrollo, puedes definir estas variables de entorno: + +| Variable | Descripción | Por defecto | +|---|---|---| +| `FASTKIT_DEBUG` | Activa el logging de depuración | `False` | +| `FASTKIT_DEV_MODE` | Activa funcionalidades de desarrollo | `False` | +| `FASTKIT_TEMPLATE_DIR` | Directorio de plantillas personalizadas | Plantillas integradas | +| `FASTKIT_CONFIG_DIR` | Directorio de configuración | `~/.fastkit` | +| `TRANSLATION_API_KEY` | Clave de API de traducción (usa un PAT de GitHub al usar el [proveedor de modelos IA de GitHub](https://github.com/marketplace/models/azure-openai)) | `None` | + +
+ +```console +$ export FASTKIT_DEBUG=true +$ export FASTKIT_DEV_MODE=true +$ fastkit init +DEBUG: Loading configuration from /home/user/.fastkit/ +DEBUG: Available templates: ['fastapi-default', ...] +``` + +
+ +Para otras variables de entorno, consulta el módulo [@settings.py](https://github.com/bnbong/FastAPI-fastkit/blob/main/src/fastapi_fastkit/core/settings.py). + +## Solución de problemas + +### Problemas habituales + +**1. Los pre-commit hooks fallan:** + +
+ +```console +$ git commit -m "Fix bug" +black....................................................................Failed +hookid: black + +Files were modified by this hook. Additional output: + +would reformat src/cli.py +``` + +
+ +**Solución:** ejecuta los formateadores y vuelve a hacer commit: + +
+ +```console +$ make format +$ git add . +$ git commit -m "Fix bug" +``` + +
+ +**2. Las pruebas fallan en versiones distintas de Python:** + +**Solución:** usa tox para probar con varias versiones: + +
+ +```console +$ pip install tox +$ tox +py38: commands succeeded +py39: commands succeeded +py310: commands succeeded +py311: commands succeeded +py312: commands succeeded +``` + +
+ +**3. Errores de import en desarrollo:** + +**Solución:** instala el paquete en modo editable: +
+ +```console +$ pip install -e . +``` + +
+ +### Cómo conseguir ayuda + +- **[GitHub Issues](https://github.com/bnbong/FastAPI-fastkit/issues)**: reportar bugs y solicitar funcionalidades +- **[GitHub Discussions](https://github.com/bnbong/FastAPI-fastkit/discussions)**: preguntas e ideas +- **Documentación**: revisa la [Guía de usuario](../user-guide/installation.md) + +## Pautas para contribuir + +### Antes de enviar un PR + +1. **Ejecuta todas las comprobaciones:** `make dev-check` +2. **Actualiza la documentación** si hace falta +3. **Añade pruebas** para las funcionalidades nuevas +4. **Sigue las convenciones de mensaje de commit** + +### Formato del mensaje de commit + +``` +type(scope): brief description + +Longer description if needed + +Fixes #123 +``` + +**Tipos:** + +- `feat`: nueva funcionalidad +- `fix`: corrección de bug +- `docs`: cambios de documentación +- `style`: cambios de estilo de código +- `refactor`: refactorización +- `test`: añadir o cambiar pruebas +- `chore`: tareas de mantenimiento + +**Ejemplos:** + +``` +feat(cli): add new template command + +Add support for creating projects from custom templates. +The command accepts a template path and creates a new +project with the specified configuration. + +Fixes #45 + +fix(templates): handle missing template files gracefully + +When a template file is missing, show a clear error message +instead of crashing with a stack trace. + +Fixes #67 +``` + +## Proceso de release + +Para mantenedores, el proceso de release es: + +1. **Actualizar la versión** en `setup.py` y `__init__.py` +2. **Actualizar CHANGELOG.md** +3. **Crear un PR de release** +4. **Etiquetar la release** tras el merge +5. **GitHub Actions** se encarga de la build y publicación + +
+ +```console +$ git tag v1.2.0 +$ git push origin v1.2.0 +``` + +
+ +## Próximos pasos + +Ahora que tu entorno de desarrollo está listo: + +1. **[Explora el código](https://github.com/bnbong/FastAPI-fastkit/tree/main/src/fastapi_fastkit)** para entender la arquitectura +2. **Ejecuta la suite de pruebas** para asegurarte de que todo funciona +3. **Elige un [issue](https://github.com/bnbong/FastAPI-fastkit/issues)** de GitHub para trabajar +4. **Únete a las [discusiones](https://github.com/bnbong/FastAPI-fastkit/discussions)** para conectar con otros colaboradores + +¡Feliz programación! 🚀 + +!!! tip "Consejos de desarrollo" + - Usa `make dev-check` antes de hacer commit + - Escribe primero las pruebas (enfoque TDD) + - Mantén los commits pequeños y enfocados + - Actualiza la documentación junto con las nuevas funcionalidades diff --git a/docs/es/contributing/template-creation-guide.md b/docs/es/contributing/template-creation-guide.md new file mode 100644 index 0000000..57cce7c --- /dev/null +++ b/docs/es/contributing/template-creation-guide.md @@ -0,0 +1,576 @@ +# Guía para crear plantillas de FastAPI + +Guía completa para añadir nuevas plantillas de proyecto FastAPI a FastAPI-fastkit. + +## 🎯 Visión general + +Añadir una plantilla nueva sigue un proceso de 5 pasos: + +1. **📋 Planificación y diseño** — definir el propósito y la estructura de la plantilla +2. **🏗️ Implementación de la plantilla** — crear la estructura y los archivos requeridos +3. **🔍 Validación local** — validar la plantilla con el inspector +4. **📚 Documentación** — escribir el README y la guía de uso +5. **🚀 Envío y revisión** — crear el PR y obtener la revisión de la comunidad + +## 📋 Paso 1: Planificación y diseño + +### Definir el propósito de la plantilla + +Antes de crear una plantilla nueva, responde a estas preguntas: + +- **¿Cuál es el valor único de esta plantilla?** +- **¿En qué se diferencia de las plantillas existentes?** +- **¿Qué grupo de usuarios es el público objetivo?** +- **¿Qué stack tecnológico va a incluir?** + +### Convención de nombres de plantillas + +``` +fastapi-{purpose}-{stack} +``` + +Ejemplos: + +- `fastapi-microservice` (plantilla de microservicio) +- `fastapi-graphql` (plantilla de integración GraphQL) +- `fastapi-auth-jwt` (plantilla de autenticación JWT) + +### Planificación del stack tecnológico + +Define de antemano las tecnologías principales que vas a incluir: + +```yaml +# Ejemplo: plantilla fastapi-microservice +core_dependencies: + - fastapi + - uvicorn + - pydantic + - pydantic-settings + +additional_features: + - sqlalchemy (ORM) + - alembic (migraciones) + - redis (caché) + - celery (tareas en segundo plano) + - pytest (pruebas) + +development_tools: + - black (formateo de código) + - isort (ordenación de imports) + - mypy (chequeo de tipos) + - pre-commit (hooks de Git) +``` + +## 🏗️ Paso 2: Implementación de la plantilla + +### Estructura de directorios requerida + +``` +fastapi-{template-name}/ +├── src/ # Código fuente de la aplicación +│ ├── main.py-tpl # ✅ Punto de entrada de la app FastAPI (requerido) +│ ├── __init__.py-tpl +│ ├── api/ # Routers de la API +│ │ ├── __init__.py-tpl +│ │ ├── api.py-tpl # Router principal de la API +│ │ └── routes/ # Rutas individuales +│ │ ├── __init__.py-tpl +│ │ └── items.py-tpl # Ruta de ejemplo +│ ├── core/ # Configuración central +│ │ ├── __init__.py-tpl +│ │ └── config.py-tpl # Gestión de ajustes +│ ├── crud/ # Lógica CRUD +│ │ ├── __init__.py-tpl +│ │ └── items.py-tpl +│ ├── schemas/ # Modelos Pydantic +│ │ ├── __init__.py-tpl +│ │ └── items.py-tpl +│ └── utils/ # Funciones utilitarias +│ ├── __init__.py-tpl +│ └── helpers.py-tpl +├── tests/ # ✅ Pruebas (requeridas) +│ ├── __init__.py-tpl +│ ├── conftest.py-tpl # Configuración de pytest +│ └── test_items.py-tpl # Pruebas de ejemplo +├── scripts/ # Scripts +│ ├── format.sh-tpl # Formateo de código +│ ├── lint.sh-tpl # Linting +│ ├── run-server.sh-tpl # Ejecución del servidor +│ └── test.sh-tpl # Ejecución de pruebas +├── pyproject.toml-tpl # ✅ Metadatos primarios (PEP 621, preferido) +├── setup.py-tpl # 🟡 Metadatos legacy (aceptados por compatibilidad) +├── requirements.txt-tpl # 🟡 Opcional si pyproject declara dependencias +├── setup.cfg-tpl # Configuración de herramientas de desarrollo +├── README.md-tpl # ✅ Documentación del proyecto (requerida) +├── .env-tpl # Plantilla de variables de entorno +└── .gitignore-tpl # Archivo Git ignore +``` + +**Archivos mínimos requeridos.** Una plantilla debe proporcionar: + +- Un directorio `tests/` +- `README.md-tpl` +- Al menos un archivo de metadatos: `pyproject.toml-tpl` (preferido, PEP 621) o `setup.py-tpl` (legacy, todavía aceptado) +- Una declaración de `fastapi` como dependencia en al menos uno de: `pyproject.toml-tpl` `[project].dependencies`, `requirements.txt-tpl`, o `setup.py-tpl` `install_requires` + +`requirements.txt-tpl` ya no es estrictamente necesario cuando `pyproject.toml-tpl` declara `[project].dependencies`. Las plantillas modernas DEBERÍAN adoptar `pyproject.toml-tpl` como su archivo de metadatos principal. + +### Guía de escritura de archivos + +#### 1. Escribir main.py-tpl + +```python +""" +Punto de entrada de la aplicación FastAPI + +Este archivo es la aplicación principal del proyecto +creado con FastAPI-fastkit. +""" +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from api.api import api_router +from core.config import settings + +# Crear la app FastAPI (requerido por la validación del inspector) +app = FastAPI( + title="", + description="Project created with FastAPI-fastkit", + version="1.0.0", +) + +# Configuración del middleware CORS +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Registrar el router de la API +app.include_router(api_router, prefix="/api/v1") + +@app.get("/") +async def root(): + """Endpoint raíz""" + return {"message": "Hello from !"} + +@app.get("/health") +async def health_check(): + """Endpoint de health check""" + return {"status": "healthy"} + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=8000) +``` + +#### 2. Escribir pyproject.toml-tpl (preferido) + +Las plantillas modernas deberían declarar metadatos y dependencias con un `pyproject.toml-tpl` PEP 621. Como mínimo el archivo debe exponer una sección `[project]` con `name`, `version`, `description` y una lista `dependencies` que incluya `fastapi`. Las plantillas también deben llevar dos marcadores de identidad de FastAPI-fastkit para que `is_fastkit_project()` pueda distinguir los proyectos generados de otros proyectos FastAPI no relacionados dentro del espacio de trabajo del usuario: + +- Prefijo `[FastAPI-fastkit templated]` en `description` +- Una tabla específica `[tool.fastapi-fastkit]` con `managed = true` + +La detección acepta cualquiera de los dos marcadores (la comparación no distingue entre mayúsculas y minúsculas). La inyección de metadatos añadirá ambos al generar el proyecto si la plantilla los omite, pero los autores deberían incluirlos explícitamente. + +```toml +[project] +name = "" +version = "0.1.0" +description = "[FastAPI-fastkit templated] " +authors = [ + {name = "", email = ""}, +] +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "fastapi>=0.115.0", + "uvicorn[standard]>=0.34.0", + "pydantic>=2.10.0", + "pydantic-settings>=2.7.0", + "python-dotenv>=1.0.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0.0", + "httpx>=0.28.0", +] + +[tool.fastapi-fastkit] +managed = true + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" +``` + +#### 3. Escribir requirements.txt-tpl (opcional) + +Opcional cuando `pyproject.toml-tpl` declara `[project].dependencies`. Sigue siendo útil para plantillas que prefieran flujos exclusivamente con `pip`. + +```txt +# Dependencias FastAPI core (requeridas) +fastapi==0.104.1 +uvicorn[standard]==0.24.0 + +# Validación de datos +pydantic==2.5.0 +pydantic-settings==2.1.0 + +# Gestión de variables de entorno +python-dotenv==1.0.0 + +# Base de datos (si hace falta) +sqlalchemy==2.0.23 +alembic==1.13.0 + +# Herramientas de desarrollo +pytest==7.4.3 +pytest-asyncio==0.21.1 +httpx==0.25.2 + +# Calidad de código +black==23.11.0 +isort==5.12.0 +mypy==1.7.1 +``` + +#### 4. Escribir setup.py-tpl (legacy — opcional si existe pyproject) + +Se conserva para plantillas legacy. Las plantillas nuevas no necesitan este archivo si traen `pyproject.toml-tpl`. + +```python +""" +Setup del paquete + +Proyecto creado con FastAPI-fastkit. +""" +from setuptools import find_packages, setup + +# Lista de dependencias (anotación de tipo requerida) +install_requires: list[str] = [ + "fastapi>=0.104.0", + "uvicorn[standard]>=0.24.0", + "pydantic>=2.5.0", + "pydantic-settings>=2.1.0", + "python-dotenv>=1.0.0", +] + +setup( + name="", + version="1.0.0", + description="[FastAPI-fastkit templated] ", # Marcador de identidad usado por is_fastkit_project() + long_description=open("README.md").read(), + long_description_content_type="text/markdown", + author="", + author_email="", + packages=find_packages(), + install_requires=install_requires, + python_requires=">=3.8", + classifiers=[ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + ], +) +``` + +#### 5. Escribir los archivos de pruebas + +```python +# tests/test_items.py-tpl +""" +Módulo de pruebas de la API de items +""" +import pytest +from fastapi.testclient import TestClient +from main import app + +client = TestClient(app) + +def test_read_root(): + """Test del endpoint raíz""" + response = client.get("/") + assert response.status_code == 200 + assert "message" in response.json() + +def test_health_check(): + """Test del health check""" + response = client.get("/health") + assert response.status_code == 200 + assert response.json() == {"status": "healthy"} + +def test_create_item(): + """Test de creación de item""" + item_data = { + "name": "Test Item", + "description": "Test Description" + } + response = client.post("/api/v1/items/", json=item_data) + assert response.status_code == 200 + data = response.json() + assert data["name"] == item_data["name"] + assert data["description"] == item_data["description"] + +def test_read_items(): + """Test de listado de items""" + response = client.get("/api/v1/items/") + assert response.status_code == 200 + assert isinstance(response.json(), list) +``` + +## 🔍 Paso 3: Validación local + +### Ejecutar los scripts de validación automatizada + +Cuando tu plantilla nueva esté lista, valídala con estos comandos: + +```bash +# Validar todas las plantillas +make inspect-templates + +# Validar solo una plantilla concreta +make inspect-template TEMPLATES="fastapi-your-template" + +# Validar con salida detallada +python scripts/inspect-templates.py --templates "fastapi-your-template" --verbose +``` + +!!! note + + Cuando envíes un PR, el flujo de trabajo **Template PR Inspection** se ejecutará automáticamente y validará tus cambios en la plantilla. Recibirás comentarios directamente en tu PR. + +### Checklist de validación + +El inspector valida automáticamente lo siguiente: + +#### ✅ Validación de la estructura de archivos + +- [ ] Existe el directorio `tests/` +- [ ] Existe el archivo `README.md-tpl` +- [ ] Existe al menos uno de `pyproject.toml-tpl` (preferido) o `setup.py-tpl` (legacy) + +#### ✅ Validación de extensiones + +- [ ] Todos los archivos Python usan la extensión `.py-tpl` +- [ ] No hay archivos con extensión `.py` + +#### ✅ Validación de dependencias + +- [ ] `fastapi` está declarado en al menos uno de: + - [ ] `pyproject.toml-tpl` bajo `[project].dependencies` (preferido) + - [ ] `requirements.txt-tpl` + - [ ] `setup.py-tpl` bajo `install_requires` + +#### ✅ Validación de la implementación de FastAPI + +- [ ] Existe el import de `FastAPI` en `main.py-tpl` +- [ ] Existe la creación de la app del estilo `app = FastAPI()` en `main.py-tpl` + +#### ✅ Validación de ejecución de pruebas + +- [ ] La creación del entorno virtual es correcta +- [ ] La instalación de dependencias es correcta +- [ ] Todas las pruebas de pytest pasan + +#### ✅ Pruebas automatizadas de plantillas + +FastAPI-fastkit incluye **pruebas automatizadas de plantillas** que ejecutan pruebas integrales sobre todas las plantillas: + +**Cobertura de pruebas:** + +- ✅ Proceso de creación de la plantilla +- ✅ Inyección de metadatos del proyecto +- ✅ Configuración del entorno virtual +- ✅ Instalación de dependencias (todos los gestores) +- ✅ Validación básica de la estructura del proyecto +- ✅ Identificación del proyecto como FastAPI + +**Ejecución de las pruebas:** +```console +# Probar todas las plantillas automáticamente +$ pytest tests/test_templates/test_all_templates.py -v + +# Probar una plantilla concreta +$ pytest tests/test_templates/test_all_templates.py::TestAllTemplates::test_template_creation[your-template-name] -v +``` + +**Descubrimiento automático de pruebas:** +Las plantillas nuevas se **descubren automáticamente** y se prueban sin configuración manual: + +1. ✅ **Cero configuración**: añade plantilla → pruebas automáticas +2. ✅ **Pruebas consistentes**: mismos estándares de calidad para todas las plantillas +3. ✅ **Varios gestores de paquetes**: se prueba con UV, PDM, Poetry y PIP +4. ✅ **Validación completa**: estructura, metadatos y funcionalidad + +**Qué significa esto para ti:** + +- 🚀 **No hace falta añadir archivos de prueba en el código principal de `FastAPI-fastkit`**: tu plantilla se prueba automáticamente +- ⚡ **Desarrollo más rápido**: te concentras en el contenido de la plantilla, no en la configuración de las pruebas +- 🛡️ **Aseguramiento de calidad**: pruebas consistentes para todas las plantillas +- 🔄 **Integración CI/CD**: pruebas automáticas en los pull requests + +**Pruebas manuales que aún son necesarias:** + +- 🧪 **Funcionalidad específica de la plantilla**: lógica de negocio y features personalizadas +- 🔧 **Pruebas de integración**: servicios externos y flujos complejos +- 📱 **Escenarios de extremo a extremo**: flujos de usuario completos + +**Buenas prácticas de prueba:** +```console +# 1. Probar tu plantilla localmente +$ fastkit startdemo your-template-name + +# 2. Ejecutar las pruebas automatizadas +$ pytest tests/test_templates/test_all_templates.py::TestAllTemplates::test_template_creation[your-template-name] -v + +# 3. Probar con distintos gestores de paquetes +$ fastkit startdemo your-template-name --package-manager poetry +$ fastkit startdemo your-template-name --package-manager pdm +$ fastkit startdemo your-template-name --package-manager uv +``` + +### Checklist de validación manual + +Además de la validación automatizada, comprueba a mano lo siguiente: + +#### 🔧 Calidad del código + +- [ ] El código sigue la guía de estilo PEP 8 +- [ ] Uso adecuado de type hints +- [ ] Nombres significativos para variables y funciones +- [ ] Comentarios y docstrings adecuados + +#### 🏗️ Arquitectura + +- [ ] Separación de responsabilidades (API, lógica de negocio, acceso a datos) +- [ ] Diseño de componentes reutilizable +- [ ] Estructura escalable +- [ ] Buenas prácticas de seguridad aplicadas + +#### 📚 Documentación + +- [ ] `README.md-tpl` sigue el formato de PROJECT_README_TEMPLATE.md +- [ ] Métodos de instalación y ejecución indicados +- [ ] Documentación de la API (OpenAPI/Swagger) +- [ ] Explicación de las variables de entorno + +## 📚 Paso 4: Documentación + +### Escribir el README.md-tpl + +Escribe basándote en la guía de [PROJECT_README_TEMPLATE.md](https://github.com/bnbong/FastAPI-fastkit/blob/main/src/fastapi_fastkit/fastapi_project_template/PROJECT_README_TEMPLATE.md). + +### Escribir la documentación de descripción de la plantilla + +Añade una descripción de tu plantilla nueva en `src/fastapi_fastkit/fastapi_project_template/README.md`: + +```markdown +## fastapi-your-template + +Escribe aquí una descripción breve y los casos de uso de tu plantilla nueva. + +### Funcionalidades: +- Funcionalidad 1 +- Funcionalidad 2 +- Funcionalidad 3 + +### Casos de uso: +- Caso de uso 1 +- Caso de uso 2 +``` + +## 🚀 Paso 5: Envío y revisión + +### Checklist previo a la creación del PR + +- [ ] Toda la validación automatizada pasada (`make inspect-templates`) +- [ ] Formato de código completado (`make format`) +- [ ] Comprobaciones de linting pasadas (`make lint`) +- [ ] Todas las pruebas pasadas (`make test`) +- [ ] Documentación completada +- [ ] Guía CONTRIBUTING.md seguida + +### Título y descripción del PR + +``` +[TEMPLATE] Add fastapi-{template-name} template + +## Overview +Adds a new {purpose} template. + +## Key Features +- Feature 1 +- Feature 2 +- Feature 3 + +## Validation Results +- [ ] Inspector validation passed +- [ ] All tests passed +- [ ] Documentation completed + +## Usage Example +\```bash +fastkit startdemo +# Select template: fastapi-{template-name} +\``` + +## Related Issues +Closes #issue-number +``` + +### Proceso de revisión + +1. **Validación automatizada**: GitHub Actions valida la plantilla automáticamente + - **Template PR Inspection**: ejecuta `inspect-changed-templates.py` en los PRs que modifican plantillas + - **Inspección semanal**: validación completa de plantillas cada miércoles +2. **Revisión de código**: mantenedores y comunidad revisan el código +3. **Pruebas**: la plantilla se prueba en distintos entornos +4. **Revisión de la documentación**: se revisa la precisión y el grado de completitud de la documentación +5. **Aprobación y merge**: se fusiona en `main` cuando se cumplen todos los requisitos + +!!! note + + Recibirás comentarios automáticos en tu PR con los resultados de la validación. ¡Revísalos antes de pedir revisión! + +## 🎯 Buenas prácticas + +### Consideraciones de seguridad + +- Gestiona la información sensible con variables de entorno +- Configuración CORS adecuada +- Validación de los datos de entrada +- Prevención de inyección SQL + +### Optimización del rendimiento + +- Aprovechar el procesamiento asíncrono +- Optimizar las consultas a la base de datos +- Estrategias de caché adecuadas +- Configuración de compresión de respuestas + +### Mantenibilidad + +- Estructura clara del código +- Cobertura de pruebas amplia +- Documentación detallada +- Configuración de logging y monitorización + +## 🆘 ¿Necesitas ayuda? + +- 📖 [Guía de configuración de desarrollo](development-setup.md) +- 📋 [Guía de código](code-guidelines.md) +- 💬 [GitHub Discussions](https://github.com/bnbong/FastAPI-fastkit/discussions) +- 📧 [Contactar al mantenedor](mailto:bbbong9@gmail.com) + +Añadir una plantilla nueva es una gran contribución a la comunidad de FastAPI-fastkit. +¡Tus ideas y tu esfuerzo serán de gran ayuda para otros desarrolladores! 🚀 diff --git a/docs/es/contributing/translation-guide.md b/docs/es/contributing/translation-guide.md new file mode 100644 index 0000000..4bbd751 --- /dev/null +++ b/docs/es/contributing/translation-guide.md @@ -0,0 +1,367 @@ +# Guía de traducción + +Esta guía explica cómo contribuir traducciones a la documentación de FastAPI-fastkit. + +## Fuente de verdad y política de traducción + +> **El inglés (`en`) es la referencia canónica** de la documentación de FastAPI-fastkit. Los demás idiomas son destinos de traducción y pueden quedarse atrás respecto a una release o a páginas concretas. +> +> Si una página traducida no coincide con la página inglesa, **confía en la página inglesa** hasta que la traducción se ponga al día. Las traducciones se publican con el nivel de avance al que han llegado los colaboradores — la cobertura parcial es normal y esperada. + +La página complementaria de cara al usuario es [Estado de las traducciones](../reference/translation-status.md), que muestra el grado real de traducción de cada idioma y cómo presenta MkDocs las páginas que aún no se han traducido (en resumen: se muestra la versión en inglés). + +El `CHANGELOG.md` de la raíz del repositorio también se mantiene en inglés como historial de releases canónico. Si un idioma expone una página `changelog.md`, esa página debería enlazar o incluir el changelog canónico en inglés en lugar de mantener un changelog traducido aparte, a menos que la política del proyecto cambie más adelante. + +Cuando contribuyas una traducción, actualiza también la tabla de la página de estado para que los usuarios puedan saber qué hay disponible sin adivinarlo a partir del selector de idiomas. + +## Visión general + +FastAPI-fastkit usa un sistema de traducción automatizado impulsado por IA para traducir la documentación a varios idiomas. El sistema: + +- Lee la documentación fuente en inglés +- Traduce el contenido con APIs de IA (OpenAI o Anthropic) +- Guarda las traducciones en directorios por idioma +- Crea Pull Requests de GitHub para revisión + +La automatización ofrece un punto de partida; sigue siendo necesaria una revisión humana antes de fusionar los cambios. Las traducciones generadas por IA deberían marcarse como "draft" en sus PRs y revisarse por una persona con dominio del idioma antes de integrarse. + +## Idiomas soportados + +Estos son los idiomas que el sitio de documentación **compila** actualmente. La configuración del destino de compilación por sí sola **no** significa que las páginas de ese idioma estén traducidas. Consulta [Estado de las traducciones](../reference/translation-status.md) para ver el grado real de traducción por idioma. + +- 🇰🇷 Coreano (ko) +- 🇯🇵 Japonés (ja) +- 🇨🇳 Chino (zh) +- 🇪🇸 Español (es) +- 🇫🇷 Francés (fr) +- 🇩🇪 Alemán (de) + +## Requisitos previos + +### 1. Instalar las dependencias de traducción + +```bash +# Instalar con pip +pip install openai anthropic + +# O con pdm +pdm install -G translation +``` + +### 2. Configurar las claves de API + +Necesitas una clave de API de OpenAI o de Anthropic: + +```bash +# Para OpenAI +export TRANSLATION_API_KEY="sk-..." + +# O para Anthropic +export TRANSLATION_API_KEY="sk-ant-..." +``` + +### 3. Instalar GitHub CLI (opcional) + +Para crear PRs automáticamente: + +```bash +# macOS +brew install gh + +# Linux +curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg +echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null +sudo apt update +sudo apt install gh + +# Autenticarse +gh auth login +``` + +## Uso + +### Con comandos Make (recomendado) + +La forma más fácil de ejecutar las traducciones: + +```bash +# Traducir toda la documentación a todos los idiomas +make translate + +# Traducir a un idioma concreto +make translate LANG=ko + +# Indicar el proveedor de API y el modelo +make translate LANG=ko PROVIDER=openai MODEL=gpt-4 +make translate LANG=ko PROVIDER=github MODEL=gpt-4o-mini +``` + +### Usando el script directamente + +#### Traducir toda la documentación + +Traducir toda la documentación a todos los idiomas soportados: + +```bash +python scripts/translate.py --api-provider openai +``` + +### Traducir a un idioma concreto + +Traducir solo al coreano: + +```bash +python scripts/translate.py --target-lang ko --api-provider openai +``` + +### Traducir archivos específicos + +Traducir solo ciertos archivos de documentación: + +```bash +python scripts/translate.py \ + --target-lang ko \ + --files user-guide/installation.md user-guide/quick-start.md \ + --api-provider openai +``` + +### Saltarse la creación del PR + +Traducir sin crear un PR de GitHub: + +```bash +python scripts/translate.py --target-lang ko --no-pr --api-provider openai +``` + +### Usar Anthropic Claude + +Usa Claude de Anthropic en lugar de OpenAI: + +```bash +python scripts/translate.py \ + --target-lang ko \ + --api-provider anthropic \ + --api-key "sk-ant-..." +``` + +## Estructura de directorios + +Después de traducir, la estructura de la documentación queda así: + +``` +docs/ +├── en/ # Inglés (original) +│ ├── index.md +│ ├── user-guide/ +│ │ ├── installation.md +│ │ ├── quick-start.md +│ │ └── ... +│ ├── tutorial/ +│ └── ... +├── ko/ # Coreano +│ ├── index.md +│ ├── user-guide/ +│ └── ... +├── ja/ # Japonés +├── zh/ # Chino +├── es/ # Español +├── fr/ # Francés +├── de/ # Alemán +├── css/ # Recursos compartidos +├── js/ # Recursos compartidos +└── img/ # Recursos compartidos +``` + +## Flujo de trabajo de traducción + +### 1. Escribir la documentación en inglés + +Toda la documentación se escribe primero en inglés, en el directorio `docs/`: + +```bash +# Crear nueva documentación +vim docs/user-guide/new-feature.md +``` + +### 2. Ejecutar la traducción + +Cuando la documentación en inglés esté lista, ejecuta el script de traducción: + +```bash +python scripts/translate.py --target-lang ko +``` + +### 3. Revisar el Pull Request + +El script crea un Pull Request con las traducciones. Al revisarlo: + +1. Comprueba que se mantiene el formato Markdown +2. Verifica que los términos técnicos se han tratado correctamente +3. Asegúrate de que los ejemplos de código no han cambiado +4. Busca problemas específicos del idioma + +### Política de changelog + +- Mantén el `CHANGELOG.md` de la raíz del repositorio en inglés. +- No abras PRs de traducción cuyo objetivo sea reescribir el historial de releases en otro idioma dentro del changelog de la raíz. +- Si un idioma necesita una página de changelog, trata `docs//changelog.md` como una página puente o de acceso al changelog canónico en inglés. + +### 4. Aprobar y mergear (para mantenedores) + +Cuando la traducción esté verificada: + +```bash +gh pr review --approve +gh pr merge +``` + +### 5. Desplegar la documentación + +El sitio de documentación se reconstruye automáticamente con las nuevas traducciones. + +## Configuración de la traducción + +Edita `scripts/translation_config.json` para personalizar: + +```json +{ + "source_language": "en", + "target_languages": [ + { + "code": "ko", + "name": "Korean", + "native_name": "한국어", + "enabled": true + } + ], + "translation_settings": { + "default_api_provider": "openai", + "batch_size": 5, + "preserve_formatting": true + }, + "github_settings": { + "create_pr_by_default": true, + "branch_prefix": "translation" + } +} +``` + +## Buenas prácticas + +### Para la documentación fuente + +1. **Lenguaje claro**: escribe inglés claro y sencillo que se traduzca bien +2. **Terminología consistente**: usa términos técnicos consistentes +3. **Bloques de código correctos**: especifica siempre el lenguaje en los bloques de código +4. **Verificación de enlaces**: asegúrate de que todos los enlaces internos usan rutas relativas + +### Para revisar traducciones + +1. **Términos técnicos**: verifica que los términos técnicos son adecuados al idioma destino +2. **Contexto cultural**: comprueba si los ejemplos necesitan localización +3. **Formato**: asegúrate de que se conserva todo el formato Markdown +4. **Integridad del código**: verifica que los bloques de código no se han modificado + +## Solución de problemas + +### Límites de la API + +Si llegas a los límites de la API, traduce en lotes más pequeños: + +```bash +# Traducir solo la user guide +python scripts/translate.py \ + --target-lang ko \ + --files user-guide/*.md +``` + +### Problemas de calidad de la traducción + +Si las traducciones son de baja calidad: + +1. Verifica que tu clave de API es válida +2. Prueba con otro proveedor de IA +3. Divide documentos complejos en secciones más pequeñas +4. Revisa y edita la traducción manualmente + +### Falla la creación del PR en GitHub + +Si la creación del PR falla: + +```bash +# Traducir sin PR +python scripts/translate.py --target-lang ko --no-pr + +# Crear el PR manualmente +git checkout -b translation/ko +git add docs/ko/ +git commit -m "Add Korean translations" +git push -u origin translation/ko +gh pr create --title "Add Korean translations" +``` + +## Traducción manual + +También puedes traducir manualmente: + +1. Copia el archivo inglés al directorio del idioma destino: +```bash +mkdir -p docs/ko/user-guide +cp docs/en/user-guide/installation.md docs/ko/user-guide/installation.md +``` + +2. Edita el archivo en tu editor preferido +3. Haz commit y crea un PR + +## Cambio de idioma + +El sitio de documentación incluye un selector de idioma en la navegación superior. Los usuarios pueden: + +1. Pulsar el selector de idioma +2. Elegir su idioma preferido +3. Navegar por la documentación traducida + +## Contribuir nuevos idiomas + +Para añadir un idioma nuevo: + +1. Edita `scripts/translation_config.json`: +```json +{ + "code": "pt", + "name": "Portuguese", + "native_name": "Português", + "enabled": true +} +``` + +2. Actualiza `mkdocs.yml`: +```yaml +- locale: pt + name: Português + build: true +``` + +3. Ejecuta la traducción: +```bash +python scripts/translate.py --target-lang pt +``` + +## ¿Necesitas ayuda? + +- **Issues**: reporta problemas de traducción en [GitHub Issues](https://github.com/bnbong/FastAPI-fastkit/issues) +- **Discussions**: pregunta en [GitHub Discussions](https://github.com/bnbong/FastAPI-fastkit/discussions) +- **Cómo contribuir**: consulta [CONTRIBUTING.md](https://github.com/bnbong/FastAPI-fastkit/blob/main/CONTRIBUTING.md) + +## Estándares de calidad de la traducción + +Todas las traducciones deben cumplir estos estándares: + +- ✅ Conservar todo el formato Markdown +- ✅ No modificar los bloques de código +- ✅ Mantener la terminología técnica adecuada +- ✅ Usar gramática y ortografía correctas +- ✅ Seguir las convenciones específicas del idioma +- ✅ Comprobar que todos los enlaces funcionan + +¡Gracias por contribuir a las traducciones de FastAPI-fastkit! 🌍 diff --git a/docs/es/index.md b/docs/es/index.md new file mode 100644 index 0000000..544b735 --- /dev/null +++ b/docs/es/index.md @@ -0,0 +1,577 @@ +

+ FastAPI-fastkit +

+

+FastAPI-fastkit: kit de inicio rápido y fácil de usar para nuevos usuarios de Python y FastAPI +

+

+ + PyPI - Version + + + GitHub Release + + + PyPI Downloads + +

+ +--- + +Este proyecto se creó para acelerar la configuración del entorno de desarrollo que necesitan las personas que se inician con Python y [FastAPI](https://github.com/fastapi/fastapi) para desarrollar aplicaciones web basadas en Python. + +Está inspirado en el `SpringBoot initializer` y en el CLI `django-admin` de Python Django. + +!!! info "Estado de las traducciones" + El inglés es la referencia principal de esta documentación. Los demás idiomas del selector pueden estar traducidos solo en parte o mostrar la versión en inglés página por página. Consulta el [Estado de las traducciones](reference/translation-status.md) para ver el grado real de traducción de cada idioma. + +## Características principales + +- **⚡ Creación inmediata de proyectos FastAPI**: crea espacios de trabajo y proyectos FastAPI en segundos desde la CLI, inspirándose en la experiencia de `django-admin` de [Python Django](https://github.com/django/django) +- **✨ Asistente interactivo de proyectos**: elige paso a paso bases de datos, autenticación, caché, monitorización y más, con generación automática de código +- **🎨 Salida CLI más bonita**: experiencia CLI cuidada gracias a la [librería rich](https://github.com/Textualize/rich) +- **📋 Plantillas de proyecto FastAPI basadas en estándares**: todas las plantillas de FastAPI-fastkit se basan en estándares Python y patrones de uso habituales de FastAPI +- **🔍 Aseguramiento de calidad automatizado de las plantillas**: pruebas automáticas semanales garantizan que todas las plantillas siguen funcionando y al día +- **🚀 Múltiples plantillas de proyecto**: elige entre varias plantillas preconfiguradas para distintos casos de uso (async CRUD, Docker, PostgreSQL, etc.) +- **📦 Soporte de varios gestores de paquetes**: elige tu gestor de paquetes Python preferido (pip, uv, pdm, poetry) para gestionar dependencias + +## Instalación + +Instala `FastAPI-fastkit` en tu entorno Python. + +
+ +```console +$ pip install FastAPI-fastkit +---> 100% +``` + +
+ + +## Uso + +### Crear al instante el espacio de trabajo de un nuevo proyecto FastAPI + +¡Ahora puedes empezar un nuevo proyecto FastAPI muy rápidamente con FastAPI-fastkit! + +Crea al instante un nuevo espacio de trabajo para tu proyecto FastAPI con: + +
+ +```console +$ fastkit init +Enter the project name: my-awesome-project +Enter the author name: John Doe +Enter the author email: john@example.com +Enter the project description: My awesome FastAPI project + + Project Information +┌──────────────┬────────────────────────────┐ +│ Project Name │ my-awesome-project │ +│ Author │ John Doe │ +│ Author Email │ john@example.com │ +│ Description │ My awesome FastAPI project │ +└──────────────┴────────────────────────────┘ + +Available Stacks and Dependencies: + MINIMAL Stack +┌──────────────┬───────────────────┐ +│ Dependency 1 │ fastapi │ +│ Dependency 2 │ uvicorn │ +│ Dependency 3 │ pydantic │ +│ Dependency 4 │ pydantic-settings │ +└──────────────┴───────────────────┘ + + STANDARD Stack +┌──────────────┬───────────────────┐ +│ Dependency 1 │ fastapi │ +│ Dependency 2 │ uvicorn │ +│ Dependency 3 │ sqlalchemy │ +│ Dependency 4 │ alembic │ +│ Dependency 5 │ pytest │ +│ Dependency 6 │ pydantic │ +│ Dependency 7 │ pydantic-settings │ +└──────────────┴───────────────────┘ + + FULL Stack +┌──────────────┬───────────────────┐ +│ Dependency 1 │ fastapi │ +│ Dependency 2 │ uvicorn │ +│ Dependency 3 │ sqlalchemy │ +│ Dependency 4 │ alembic │ +│ Dependency 5 │ pytest │ +│ Dependency 6 │ redis │ +│ Dependency 7 │ celery │ +│ Dependency 8 │ pydantic │ +│ Dependency 9 │ pydantic-settings │ +└──────────────┴───────────────────┘ + +Select stack (minimal, standard, full): minimal + +Available Package Managers: + Package Managers +┌────────┬────────────────────────────────────────────┐ +│ PIP │ Standard Python package manager │ +│ UV │ Fast Python package manager │ +│ PDM │ Modern Python dependency management │ +│ POETRY │ Python dependency management and packaging │ +└────────┴────────────────────────────────────────────┘ + +Select package manager (pip, uv, pdm, poetry) [uv]: uv +Do you want to proceed with project creation? [y/N]: y +FastAPI project will deploy at '~your-project-path~' + +╭──────────────────────── Info ────────────────────────╮ +│ ℹ Injected metadata into setup.py │ +╰──────────────────────────────────────────────────────╯ +╭──────────────────────── Info ────────────────────────╮ +│ ℹ Injected metadata into config file │ +╰──────────────────────────────────────────────────────╯ + + Creating Project: + my-awesome-project +┌───────────────────┬───────────┐ +│ Component │ Collected │ +│ fastapi │ ✓ │ +│ uvicorn │ ✓ │ +│ pydantic │ ✓ │ +│ pydantic-settings │ ✓ │ +└───────────────────┴───────────┘ + +Creating virtual environment... + +╭──────────────────────── Info ────────────────────────╮ +│ ℹ venv created at │ +│ ~your-project-path~/my-awesome-project/.venv │ +│ To activate the virtual environment, run: │ +│ │ +│ source │ +│ ~your-project-path~/my-awesome-project/.venv/bin/act │ +│ ivate │ +╰──────────────────────────────────────────────────────╯ + +Installing dependencies... +⠙ Setting up project environment...Collecting + +---> 100% + +╭─────────────────────── Success ───────────────────────╮ +│ ✨ Dependencies installed successfully │ +╰───────────────────────────────────────────────────────╯ +╭─────────────────────── Success ───────────────────────╮ +│ ✨ FastAPI project 'my-awesome-project' has been │ +│ created successfully and saved to │ +│ ~your-project-path~! │ +╰───────────────────────────────────────────────────────╯ +╭──────────────────────── Info ────────────────────────╮ +│ ℹ To start your project, run 'fastkit runserver' at │ +│ newly created FastAPI project directory │ +╰──────────────────────────────────────────────────────╯ +``` + +
+ +Este comando crea un nuevo espacio de trabajo para tu proyecto FastAPI e incluye también un entorno virtual de Python. + +### Crear un proyecto en modo interactivo ✨ ¡NUEVO! + +Para proyectos más complejos, usa el **modo interactivo** para construir tu aplicación FastAPI paso a paso con selección inteligente de funcionalidades: + +
+ +```console +$ fastkit init --interactive + +⚡ FastAPI-fastkit Interactive Project Setup ⚡ + +📋 Basic Project Information +Enter the project name: my-fullstack-project +Enter the author name: John Doe +Enter the author email: john@example.com +Enter the project description: Full-stack FastAPI project with PostgreSQL and JWT + +🧱 Architecture Preset +Pick a project layout. Press Enter to accept the recommended default. + 1. minimal - Smallest viable FastAPI app + 2. single-module - Everything in one module (prototypes / scripts) + 3. classic-layered - api/routes + crud + schemas + core (à la fastapi-default) + 4. domain-starter - Domain-oriented src/app/domains// (recommended) + +Select architecture preset: [4] + +🗄️ Database Selection +Select database (PostgreSQL, MySQL, MongoDB, Redis, SQLite, None): + 1. PostgreSQL - PostgreSQL database with SQLAlchemy + 2. MySQL - MySQL database with SQLAlchemy + 3. MongoDB - MongoDB with motor async driver + 4. Redis - Redis for caching and session storage + 5. SQLite - SQLite database for development + 6. None - No database + +Select database: 1 + +🔐 Authentication Selection +Select authentication (JWT, OAuth2, FastAPI-Users, Session-based, None): + 1. JWT - JSON Web Token authentication + 2. OAuth2 - OAuth2 with password flow + 3. FastAPI-Users - Full featured user management + 4. Session-based - Cookie-based sessions + 5. None - No authentication + +Select authentication: 1 + +⚙️ Background Tasks Selection +Select background tasks (Celery, Dramatiq, None): + 1. Celery - Distributed task queue + 2. Dramatiq - Fast and reliable task processing + 3. None - No background tasks + +Select background tasks: 1 + +💾 Caching Selection +Select caching (Redis, fastapi-cache2, None): + 1. Redis - Redis caching + 2. fastapi-cache2 - Simple caching for FastAPI + 3. None - No caching + +Select caching: 1 + +📊 Monitoring Selection +Select monitoring (Loguru, OpenTelemetry, Prometheus, None): + 1. Loguru - Simple and powerful logging + 2. OpenTelemetry - Observability framework + 3. Prometheus - Metrics and monitoring + 4. None - No monitoring + +Select monitoring: 3 + +🧪 Testing Framework Selection +Select testing framework (Basic, Coverage, Advanced, None): + 1. Basic - pytest + httpx for API testing + 2. Coverage - Basic + code coverage + 3. Advanced - Coverage + faker + factory-boy for fixtures + 4. None - No testing framework + +Select testing framework: 2 + +🛠️ Additional Utilities +Select utilities (comma-separated numbers, e.g., 1,3,4): + 1. CORS - Cross-Origin Resource Sharing + 2. Rate-Limiting - Request rate limiting + 3. Pagination - Pagination support + 4. WebSocket - WebSocket support + +Select utilities: 1 + +🚀 Deployment Configuration +Select deployment option: + 1. Docker - Generate Dockerfile + 2. docker-compose - Generate docker-compose.yml (includes Docker) + 3. None - No deployment configuration + +Select deployment option: 2 + +📦 Package Manager Selection +Select package manager (pip, uv, pdm, poetry): uv + +📝 Custom Packages (optional) +Enter custom package names (comma-separated, press Enter to skip): + +📋 Project Configuration Summary +┌─────────────────────┬───────────────────────────────────────────────────────────────────────────┐ +│ Setting │ Value │ +├─────────────────────┼───────────────────────────────────────────────────────────────────────────┤ +│ Project Name │ my-fullstack-project │ +│ Author │ John Doe │ +│ Email │ john@example.com │ +│ Description │ Full-stack FastAPI project with PostgreSQL and JWT │ +│ Architecture Preset │ domain-starter — Domain-oriented: src/app/domains// (recommended)│ +│ Database │ PostgreSQL │ +│ Authentication │ JWT │ +│ Async Tasks │ Celery │ +│ Caching │ Redis │ +│ Monitoring │ Prometheus │ +│ Testing │ Coverage │ +│ Utilities │ CORS │ +│ Package Manager │ uv │ +└─────────────────────┴───────────────────────────────────────────────────────────────────────────┘ + +Total dependencies to install: 18 + +Proceed with project creation? [Y/n]: y + +╭──────────────────────── Info ────────────────────────╮ +│ ℹ Injected metadata into pyproject.toml │ +╰──────────────────────────────────────────────────────╯ +╭─────────────────────── Success ───────────────────────╮ +│ ✨ Generated dependency file with 18 packages │ +╰───────────────────────────────────────────────────────╯ +╭──────────────────────── Info ────────────────────────╮ +│ ℹ Preserving template-shipped main.py for preset │ +│ 'domain-starter'. │ +╰──────────────────────────────────────────────────────╯ +╭─────────────────────── Success ───────────────────────╮ +│ ✨ Generated Docker deployment files │ +╰───────────────────────────────────────────────────────╯ +╭────────────────────── Warning ────────────────────────╮ +│ ⚠ Preset compatibility │ +│ fastapi-domain-starter's shipped src/app/main.py is │ +│ preserved. The selections below need manual wiring │ +│ there (CORS is already wired — set │ +│ BACKEND_CORS_ORIGINS in .env to activate it). │ +│ Affected selections (packages installed, but no │ +│ dynamic main.py edits applied for the │ +│ 'domain-starter' preset): Prometheus │ +╰───────────────────────────────────────────────────────╯ +╭─────────────────────── Success ───────────────────────╮ +│ ✨ Generated configuration files for selected stack │ +╰───────────────────────────────────────────────────────╯ + +Creating virtual environment... +Installing dependencies... + +----> 100% + +╭─────────────────────── Success ───────────────────────╮ +│ ✨ FastAPI project 'my-fullstack-project' from │ +│ 'fastapi-domain-starter' has been created! │ +╰───────────────────────────────────────────────────────╯ +``` + +
+ +El modo interactivo ofrece: + +- **Selección del preset de arquitectura** (`minimal` / `single-module` / `classic-layered` / `domain-starter`) que escoge la plantilla base y el layout adecuados +- **Selección guiada** de bases de datos, autenticación, tareas en segundo plano, caché, monitorización y más +- **Código autogenerado** para las funcionalidades seleccionadas — varía según el preset (`main.py` regenerado para `minimal` / `single-module`; `main.py` que viene en la plantilla preservado y módulos de configuración superpuestos para `classic-layered` / `domain-starter`) +- **Generación de Docker consciente del preset** — el `CMD` del `Dockerfile` generado apunta al punto de entrada real del preset (`src.main:app` o `src.app.main:app`) +- **Gestión inteligente de dependencias** con compatibilidad automática con pip +- **Validación de funcionalidades** con avisos de cableado manual para las selecciones que el preset no puede conectar automáticamente +- **Marcadores de identidad** en el `pyproject.toml` generado (marcador en `description` + tabla `[tool.fastapi-fastkit]`) para que `is_fastkit_project()` reconozca después los proyectos generados + +### Añadir una ruta nueva al proyecto FastAPI + +`FastAPI-fastkit` facilita extender tu proyecto FastAPI. + +Añade un nuevo endpoint de ruta a tu proyecto FastAPI con: + +
+ +```console +$ fastkit addroute user my-awesome-project + Adding New Route +┌──────────────────┬──────────────────────────────────────────┐ +│ Project │ my-awesome-project │ +│ Route Name │ user │ +│ Target Directory │ ~your-project-path~ │ +└──────────────────┴──────────────────────────────────────────┘ + +Do you want to add route 'user' to project 'my-awesome-project'? [Y/n]: y + +╭──────────────────────── Info ────────────────────────╮ +│ ℹ Updated main.py to include the API router │ +╰──────────────────────────────────────────────────────╯ +╭─────────────────────── Success ───────────────────────╮ +│ ✨ Successfully added new route 'user' to project │ +│ `my-awesome-project` │ +╰───────────────────────────────────────────────────────╯ +``` + +
+ +### Desplegar de inmediato un proyecto de demo FastAPI estructurado + +También puedes empezar con un proyecto de demo FastAPI estructurado. + +Los proyectos demo se componen de varias pilas tecnológicas con endpoints CRUD simples sobre items. + +Despliega de inmediato un proyecto de demo FastAPI estructurado con: + +
+ +```console +$ fastkit startdemo +Enter the project name: my-awesome-demo +Enter the author name: John Doe +Enter the author email: john@example.com +Enter the project description: My awesome FastAPI demo +Deploying FastAPI project using 'fastapi-default' template +Template path: +/~fastapi_fastkit-package-path~/fastapi_project_template/fastapi-default + + Project Information +┌──────────────┬─────────────────────────┐ +│ Project Name │ my-awesome-demo │ +│ Author │ John Doe │ +│ Author Email │ john@example.com │ +│ Description │ My awesome FastAPI demo │ +└──────────────┴─────────────────────────┘ + + Template Dependencies +┌──────────────┬───────────────────┐ +│ Dependency 1 │ fastapi │ +│ Dependency 2 │ uvicorn │ +│ Dependency 3 │ pydantic │ +│ Dependency 4 │ pydantic-settings │ +│ Dependency 5 │ python-dotenv │ +└──────────────┴───────────────────┘ + +Available Package Managers: + Package Managers +┌────────┬────────────────────────────────────────────┐ +│ PIP │ Standard Python package manager │ +│ UV │ Fast Python package manager │ +│ PDM │ Modern Python dependency management │ +│ POETRY │ Python dependency management and packaging │ +└────────┴────────────────────────────────────────────┘ + +Select package manager (pip, uv, pdm, poetry) [uv]: uv +Do you want to proceed with project creation? [y/N]: y +FastAPI template project will deploy at '~your-project-path~' + +---> 100% + +╭─────────────────────── Success ───────────────────────╮ +│ ✨ Dependencies installed successfully │ +╰───────────────────────────────────────────────────────╯ +╭─────────────────────── Success ───────────────────────╮ +│ ✨ FastAPI project 'my-awesome-demo' from │ +│ 'fastapi-default' has been created and saved to │ +│ ~your-project-path~! │ +╰───────────────────────────────────────────────────────╯ +``` + +
+ +Para ver la lista de demos FastAPI disponibles, ejecuta: + +
+ +```console +$ fastkit list-templates + Available Templates +┌────────────────────────┬───────────────────────────────────────────────────────┐ +│ fastapi-custom-response│ Async Item Management API with Custom Response System │ +│ fastapi-mcp │ FastAPI MCP Project │ +│ fastapi-domain-starter │ FastAPI Domain Starter │ +│ fastapi-dockerized │ Dockerized FastAPI Item Management API │ +│ fastapi-empty │ Minimal FastAPI Template │ +│ fastapi-async-crud │ Async Item Management API Server │ +│ fastapi-psql-orm │ Dockerized FastAPI Item Management API with │ +│ │ PostgreSQL │ +│ fastapi-default │ Simple FastAPI Project │ +│ fastapi-single-module │ FastAPI Single Module Template │ +└────────────────────────┴───────────────────────────────────────────────────────┘ +``` + +
+ +## Documentación + +Para guías completas e instrucciones detalladas, explora nuestra documentación: + +- 📚 **[Guía de usuario](user-guide/quick-start.md)** - Guías detalladas de instalación y uso +- 🎯 **[Tutorial](tutorial/getting-started.md)** - Tutoriales paso a paso para principiantes +- 📖 **[Referencia de la CLI](user-guide/cli-reference.md)** - Referencia completa de comandos +- 🔍 **[Aseguramiento de calidad de plantillas](reference/template-quality-assurance.md)** - Pruebas automatizadas y estándares de calidad + +## 🚀 Tutoriales basados en plantillas + +Aprende FastAPI con casos prácticos usando nuestras plantillas ya preparadas: + +### 📖 Tutoriales centrales + +- **[Construir un servidor API básico](tutorial/basic-api-server.md)** - Crea tu primer servidor FastAPI con la plantilla `fastapi-default` +- **[Construir una API CRUD asíncrona](tutorial/async-crud-api.md)** - Desarrolla una API async de alto rendimiento con la plantilla `fastapi-async-crud` +- **[Proyecto orientado a dominios (Domain Starter)](tutorial/domain-starter.md)** - Construye una API mediana con la plantilla `fastapi-domain-starter`, la opción moderna recomendada + +### 🗄️ Base de datos e infraestructura + +- **[Integrar con una base de datos](tutorial/database-integration.md)** - Usa PostgreSQL + SQLAlchemy con la plantilla `fastapi-psql-orm` +- **[Dockerizar y desplegar](tutorial/docker-deployment.md)** - Configura un entorno de despliegue de producción con la plantilla `fastapi-dockerized` + +### ⚡ Funcionalidades avanzadas + +- **[Manejo personalizado de respuestas y diseño avanzado de API](tutorial/custom-response-handling.md)** - Construye APIs de nivel empresarial con la plantilla `fastapi-custom-response` +- **[Integrar con MCP](tutorial/mcp-integration.md)** - Crea un servidor API integrado con modelos de IA con la plantilla `fastapi-mcp` + +Cada tutorial ofrece: + +- ✅ **Ejemplos prácticos** - Código que puedes usar directamente en proyectos reales +- ✅ **Guías paso a paso** - Explicaciones detalladas, fáciles de seguir para principiantes +- ✅ **Buenas prácticas** - Patrones estándar de la industria y consideraciones de seguridad +- ✅ **Métodos de extensión** - Orientación para llevar tu proyecto al siguiente nivel + +## Cómo colaborar + +¡Las contribuciones de la comunidad son bienvenidas! FastAPI-fastkit se diseñó para ayudar a quienes empiezan con Python y FastAPI, y tu contribución puede tener un impacto importante. + +### Qué puedes aportar + +- 🚀 **Nuevas plantillas FastAPI** - Añade plantillas para distintos casos de uso +- 🐛 **Correcciones de bugs** - Ayúdanos a mejorar la estabilidad y la fiabilidad +- 📚 **Documentación** - Mejora guías, ejemplos y traducciones +- 🧪 **Tests** - Aumenta la cobertura de pruebas y añade tests de integración +- 💡 **Funcionalidades** - Propón e implementa nuevas funcionalidades de la CLI + +### Empezar a colaborar + +Para empezar a contribuir a FastAPI-fastkit, consulta nuestras guías completas: + +- **[Configuración de desarrollo](contributing/development-setup.md)** - Guía completa para configurar tu entorno de desarrollo +- **[Guía de código](contributing/code-guidelines.md)** - Estándares y buenas prácticas de codificación +- **[CONTRIBUTING.md](https://github.com/bnbong/FastAPI-fastkit/blob/main/CONTRIBUTING.md)** - Guía de contribución completa +- **[CODE_OF_CONDUCT.md](https://github.com/bnbong/FastAPI-fastkit/blob/main/CODE_OF_CONDUCT.md)** - Principios del proyecto y estándares de la comunidad +- **[SECURITY.md](https://github.com/bnbong/FastAPI-fastkit/blob/main/SECURITY.md)** - Pautas de seguridad y reportes + +## Por qué FastAPI-fastkit + +FastAPI-fastkit pretende ofrecer un kit de inicio rápido y fácil de usar a las personas que empiezan con Python y FastAPI. + +Esta idea surgió con el objetivo de ayudar a los recién llegados a FastAPI a aprender desde el principio, en línea con el sentido productivo del paquete FastAPI-cli añadido en la [actualización a la versión 0.111.0 de FastAPI](https://github.com/fastapi/fastapi/releases/tag/0.111.0). + +Como alguien que lleva mucho tiempo usando y disfrutando FastAPI, quería desarrollar un proyecto que pudiera contribuir a la [maravillosa motivación](https://github.com/fastapi/fastapi/pull/11522#issuecomment-2264639417) que ha expresado [tiangolo](https://github.com/tiangolo), creador de FastAPI. + +FastAPI-fastkit cubre el hueco entre empezar y construir aplicaciones listas para producción aportando: + +- **Productividad inmediata** para los recién llegados a quienes la complejidad inicial puede abrumarles +- **Buenas prácticas** integradas en cada plantilla, ayudando a aprender los patrones correctos de FastAPI +- **Bases escalables** que crecen con el usuario, de principiante a experto +- **Plantillas impulsadas por la comunidad** que reflejan patrones de uso reales de FastAPI + +## Próximos pasos + +¿Listo para empezar con FastAPI-fastkit? Sigue estos pasos: + +### 🚀 Inicio rápido + +1. **[Instalación](user-guide/installation.md)**: Instala FastAPI-fastkit +2. **[Inicio rápido](user-guide/quick-start.md)**: Crea tu primer proyecto en 5 minutos +3. **[Tutorial inicial](tutorial/getting-started.md)**: Tutorial detallado paso a paso + +### 📚 Aprendizaje avanzado + +- **[Crear proyectos](user-guide/creating-projects.md)**: Crea proyectos con distintos stacks +- **[Añadir rutas](user-guide/adding-routes.md)**: Añade endpoints de API a tu proyecto +- **[Usar plantillas](user-guide/using-templates.md)**: Usa plantillas de proyecto ya preparadas + +### 🛠️ Contribuir + +¿Quieres contribuir a FastAPI-fastkit? + +- **[Configuración de desarrollo](contributing/development-setup.md)**: Configura tu entorno de desarrollo +- **[Guía de código](contributing/code-guidelines.md)**: Sigue nuestros estándares y buenas prácticas +- **[Guía de contribución](https://github.com/bnbong/FastAPI-fastkit/blob/main/CONTRIBUTING.md)**: Guía completa de contribución + +### 🔍 Referencia + +- **[Referencia de la CLI](user-guide/cli-reference.md)**: Referencia completa de comandos +- **[Aseguramiento de calidad de plantillas](reference/template-quality-assurance.md)**: Pruebas automatizadas y estándares de calidad +- **[FAQ](reference/faq.md)**: Preguntas frecuentes +- **[Repositorio GitHub](https://github.com/bnbong/FastAPI-fastkit)**: Código fuente y seguimiento de issues + +## Licencia + +Este proyecto se distribuye bajo la licencia MIT — consulta el archivo [LICENSE](https://github.com/bnbong/FastAPI-fastkit/blob/main/LICENSE) para más detalles. diff --git a/docs/es/reference/faq.md b/docs/es/reference/faq.md new file mode 100644 index 0000000..d0ae340 --- /dev/null +++ b/docs/es/reference/faq.md @@ -0,0 +1,784 @@ +# Preguntas frecuentes + +Preguntas y respuestas habituales sobre FastAPI-fastkit. + +## Instalación y configuración + +### P: ¿Qué versiones de Python están soportadas? + +**R:** FastAPI-fastkit requiere **Python 3.12 o superior**. Recomendamos usar la última versión estable de Python para tener la mejor experiencia. + +
+ +```console +$ python --version +Python 3.12.1 + +$ pip install fastapi-fastkit +``` + +
+ +### P: ¿Cómo instalo FastAPI-fastkit? + +**R:** Puedes instalar FastAPI-fastkit con pip: + +
+ +```console +# Última versión estable +$ pip install fastapi-fastkit + +# Versión de desarrollo desde GitHub +$ pip install git+https://github.com/bnbong/FastAPI-fastkit.git + +# Una versión concreta +$ pip install fastapi-fastkit==1.0.0 +``` + +
+ +### P: La instalación falla con errores de permisos + +**R:** Prueba a instalar en un entorno virtual o con permisos de usuario: + +
+ +```console +# Crear el entorno virtual +$ python -m venv fastapi-env +$ source fastapi-env/bin/activate # En Windows: fastapi-env\Scripts\activate + +# Instalar dentro del entorno virtual +$ pip install fastapi-fastkit + +# O instalar solo para el usuario actual +$ pip install --user fastapi-fastkit +``` + +
+ +### P: El comando `fastkit` no aparece tras la instalación + +**R:** Suele significar que el directorio de instalación no está en tu PATH: + +
+ +```console +# Comprobar que está instalado +$ pip show fastapi-fastkit + +# Buscar la ruta de instalación +$ python -c "import fastapi_fastkit; print(fastapi_fastkit.__file__)" + +# Probar a ejecutarlo directamente +$ python -m fastapi_fastkit --version + +# O añadirlo al PATH (Linux/macOS) +$ export PATH="$HOME/.local/bin:$PATH" +``` + +
+ +## Creación de proyectos + +### P: ¿Qué stacks de dependencias están disponibles? + +**R:** FastAPI-fastkit ofrece tres stacks: + +- **MINIMAL**: FastAPI, Uvicorn, Pydantic, Pydantic-Settings (API web básica) +- **STANDARD**: añade SQLAlchemy, Alembic, pytest (soporte de base de datos) +- **FULL**: añade Redis, Celery (tareas en segundo plano) + +!!! tip "Gestor de paquetes por defecto" + El gestor de paquetes por defecto es `uv` para instalar dependencias más rápido. También puedes elegir `pip`, `pdm` o `poetry`. + +
+ +```console +$ fastkit init +# Elige tu stack preferido durante la creación del proyecto +``` + +
+ +### P: ¿Puedo personalizar la plantilla del proyecto? + +**R:** ¡Sí! Tienes varias opciones: + +1. **Usar las plantillas existentes** con `fastkit startdemo` +2. **Crear plantillas personalizadas** copiando y modificando las existentes +3. **Añadir rutas de forma incremental** con `fastkit addroute` + +
+ +```console +# Usar plantillas ya preparadas +$ fastkit list-templates +$ fastkit startdemo + +# Añadir rutas a un proyecto existente +$ fastkit addroute users . # Añade la ruta 'users' al directorio actual +$ fastkit addroute users my-project # Añade la ruta 'users' a 'my-project' +``` + +
+ +### P: ¿Cómo creo un proyecto con un formato concreto de nombre? + +**R:** El nombre del proyecto debe ser un identificador Python válido: + +- ✅ `my-api`, `blog_system`, `UserService` +- ❌ `my api`, `123project`, `project-name!` + +
+ +```console +$ fastkit init +Enter the project name: my_awesome_api # Válido +Enter the project name: my-awesome-api # Válido (los guiones se convierten en guiones bajos) +``` + +
+ +### P: La creación del proyecto falla con "directory already exists" + +**R:** El directorio del proyecto ya existe. Tienes varias opciones: + +1. **Elige otro nombre** +2. **Elimina el directorio existente** (si es seguro hacerlo) +3. **Crea el proyecto en otra ubicación** + +
+ +```console +# Comprobar si el directorio existe +$ ls my-project + +# Eliminar si es seguro (¡PRECAUCIÓN!) +$ rm -rf my-project + +# O crear en otra ubicación +$ mkdir projects +$ cd projects +$ fastkit init +``` + +
+ +### P: ¿Cómo uso el modo interactivo para configurar el proyecto? + +**R:** Usa `fastkit init --interactive` para una configuración paso a paso guiada con selección inteligente de funcionalidades: + +
+ +```console +$ fastkit init --interactive +``` + +
+ +El modo interactivo te lleva por estos pasos en orden: + +1. **Información del proyecto** — nombre, autor, email, descripción. +2. **Preset de arquitectura** — elige la estructura del proyecto. La opción recomendada es `domain-starter`; pulsa Enter para aceptarla. Consulta la [matriz de presets / funcionalidades](preset-feature-matrix.md) para ver la estructura exacta que produce cada preset y qué combinaciones requieren cableado manual. +3. **Selección de funcionalidades** — base de datos, autenticación, tareas en segundo plano, caché, monitorización, pruebas, utilidades, despliegue. +4. **Gestor de paquetes y paquetes personalizados** — pip / uv / pdm / poetry, además de cualquier extra que quieras fijar. +5. **Confirmación** — una tabla resumen muestra todas las elecciones (incluido el preset de arquitectura) antes de crear el proyecto. + +El modo interactivo te permite seleccionar de un catálogo de funcionalidades completo: + +| Categoría | Opciones disponibles | +|---|---| +| **Arquitectura** | minimal, single-module, classic-layered, **domain-starter** (opción recomendada por defecto) | +| **Base de datos** | PostgreSQL, MySQL, MongoDB, Redis, SQLite | +| **Autenticación** | JWT, OAuth2, FastAPI-Users, Basada en sesiones | +| **Tareas en segundo plano** | Celery, Dramatiq | +| **Pruebas** | Basic (pytest), Coverage, Advanced (con faker, factory-boy) | +| **Caché** | Redis con fastapi-cache2 | +| **Monitorización** | Loguru, OpenTelemetry, Prometheus | +| **Utilidades** | CORS, Rate-Limiting, Paginación, WebSocket | +| **Despliegue** | Docker, docker-compose con configs autogeneradas | + +El modo interactivo genera automáticamente: + +- `main.py` con las funcionalidades seleccionadas integradas +- Archivos de configuración de base de datos y autenticación cuando las opciones seleccionadas soportan generación de código (p. ej. PostgreSQL/MySQL/SQLite/MongoDB para base de datos, JWT/FastAPI-Users para autenticación); otras opciones solo instalan los paquetes necesarios +- Archivos de despliegue acordes con la opción de despliegue elegida (`Dockerfile` si seleccionas `Docker`, `docker-compose.yml` si seleccionas `docker-compose`) +- Configuración de pruebas según la opción de testing elegida (la configuración de coverage solo se incluye cuando seleccionas `Coverage` o `Advanced`) + +### P: ¿Cómo veo las funcionalidades disponibles del modo interactivo? + +**R:** Usa el comando `list-features` para mostrar todas las funcionalidades disponibles y sus paquetes: + +
+ +```console +$ fastkit list-features +# Muestra todas las funcionalidades disponibles organizadas por categoría +# con sus paquetes asociados +``` + +
+ +Esto te ayuda a entender qué paquetes se instalarán para cada selección. + +## Desarrollo de rutas + +### P: ¿Cómo añado autenticación a mis rutas? + +**R:** Crea una dependencia para la autenticación: + +```python +# src/api/deps.py +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPBearer + +security = HTTPBearer() + +def get_current_user(token: str = Depends(security)): + # Verificar el token y devolver el usuario + if not verify_token(token): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid authentication credentials" + ) + return get_user_from_token(token) + +# src/api/routes/users.py +@router.get("/me") +def get_current_user_profile(user = Depends(get_current_user)): + return user +``` + +### P: ¿Cómo añado modelos de base de datos a mi proyecto? + +**R:** Para stacks STANDARD o FULL, crea modelos SQLAlchemy: + +```python +# src/models/users.py +from sqlalchemy import Column, Integer, String, Boolean +from sqlalchemy.ext.declarative import declarative_base + +Base = declarative_base() + +class User(Base): + __tablename__ = "users" + + id = Column(Integer, primary_key=True, index=True) + email = Column(String, unique=True, index=True) + username = Column(String, unique=True, index=True) + hashed_password = Column(String) + is_active = Column(Boolean, default=True) +``` + +### P: ¿Cómo añado validación a los datos de petición? + +**R:** Usa modelos Pydantic en tus esquemas: + +```python +# src/schemas/users.py +from pydantic import BaseModel, EmailStr, Field + +class UserCreate(BaseModel): + email: EmailStr + username: str = Field(..., min_length=3, max_length=50) + password: str = Field(..., min_length=8) + + @validator('username') + def validate_username(cls, v): + if not v.isalnum(): + raise ValueError('Username must be alphanumeric') + return v +``` + +### P: ¿Cómo manejo la subida de archivos? + +**R:** Usa `UploadFile` de FastAPI: + +```python +from fastapi import UploadFile, File + +@router.post("/upload") +async def upload_file(file: UploadFile = File(...)): + contents = await file.read() + + # Guardar el archivo + with open(f"uploads/{file.filename}", "wb") as f: + f.write(contents) + + return {"filename": file.filename, "size": len(contents)} +``` + +## Plantillas + +### P: ¿Qué plantillas hay disponibles? + +**R:** FastAPI-fastkit incluye varias plantillas ya preparadas: + +
+ +```console +$ fastkit list-templates + Available Templates +┌─────────────────────────┬───────────────────────────────────┐ +│ fastapi-default │ Simple FastAPI Project │ +│ fastapi-async-crud │ Async Item Management API Server │ +│ fastapi-custom-response │ Custom Response System │ +│ fastapi-dockerized │ Dockerized FastAPI API │ +│ fastapi-empty │ Minimal FastAPI Project │ +│ fastapi-mcp │ MCP (Model Context Protocol) API │ +│ fastapi-psql-orm │ PostgreSQL FastAPI API │ +│ fastapi-single-module │ Single-file FastAPI Project │ +└─────────────────────────┴───────────────────────────────────┘ +``` + +
+ +### P: ¿Cómo uso una plantilla concreta? + +**R:** Usa el comando `startdemo`: + +
+ +```console +$ fastkit startdemo +Enter the project name: my-blog +Select template: fastapi-psql-orm +``` + +
+ +### P: ¿Puedo crear mis propias plantillas? + +**R:** ¡Sí! Crea una estructura de directorios y usa variables de plantilla: + +``` +my-template/ +├── src/ +│ └── main.py-tpl +├── requirements.txt-tpl +└── template.yaml +``` + +```python +# main.py-tpl +from fastapi import FastAPI + +app = FastAPI(title="{{PROJECT_NAME}}") + +@app.get("/") +def read_root(): + return {"message": "Hello from {{PROJECT_NAME}}!"} +``` + +### P: ¿Cómo modifico una plantilla existente? + +**R:** Las plantillas están en el directorio `fastapi_project_template`. Puedes: + +1. **Hacer fork del repositorio** y modificar las plantillas +2. **Crear una plantilla personalizada** basada en las existentes +3. **Sobrescribir archivos concretos** después de crear el proyecto + +## Servidor de desarrollo + +### P: ¿Cómo arranco el servidor de desarrollo? + +**R:** Usa el comando `runserver` desde el directorio del proyecto: + +
+ +```console +$ cd my-project +$ source .venv/bin/activate # Activar el entorno virtual +$ fastkit runserver +INFO: Uvicorn running on http://127.0.0.1:8000 +``` + +
+ +### P: El servidor no arranca — "Address already in use" + +**R:** El puerto 8000 está ocupado. Usa otro puerto o mata el proceso existente: + +
+ +```console +# Usar otro puerto +$ fastkit runserver --port 8080 + +# O buscar y matar el proceso existente +$ lsof -ti:8000 | xargs kill -9 + +# En Windows +$ netstat -ano | findstr :8000 +$ taskkill /PID /F +``` + +
+ +### P: El auto-reload no funciona + +**R:** Asegúrate de estar en el directorio del proyecto y de tener el entorno virtual activado: + +
+ +```console +# Comprobar el directorio actual +$ pwd +/path/to/my-project + +# Comprobar el entorno virtual +$ which python +/path/to/my-project/.venv/bin/python + +# Arrancar con reload explícito +$ fastkit runserver --reload +``` + +
+ +### P: ¿Cómo configuro el servidor para producción? + +**R:** No uses el servidor de desarrollo en producción. En su lugar: + +```python +# Usar gunicorn u otro servidor WSGI +$ pip install gunicorn +$ gunicorn src.main:app -w 4 -k uvicorn.workers.UvicornWorker + +# O usar Docker con la plantilla fastapi-dockerized +$ fastkit startdemo # Selecciona fastapi-dockerized +$ docker build -t my-app . +$ docker run -p 8000:8000 my-app +``` + +## Rendimiento y optimización + +### P: ¿Cómo mejoro el rendimiento de la API? + +**R:** Varias estrategias de optimización: + +1. **Usa async/await** para operaciones de I/O +2. **Añade caché** para operaciones costosas +3. **Optimiza las consultas a la base de datos** +4. **Usa tareas en segundo plano** para procesamientos pesados + +```python +# Endpoint asíncrono +@router.get("/users/{user_id}") +async def get_user(user_id: int): + user = await users_service.get_user_async(user_id) + return user + +# Tarea en segundo plano +from fastapi import BackgroundTasks + +@router.post("/send-email") +def send_email(background_tasks: BackgroundTasks, email: str): + background_tasks.add_task(send_notification_email, email) + return {"message": "Email will be sent in background"} +``` + +### P: ¿Cómo añado caché? + +**R:** Usa Redis para la caché: + +```python +import redis +from functools import wraps + +redis_client = redis.Redis(host='localhost', port=6379, db=0) + +def cache_result(expiration: int = 300): + def decorator(func): + @wraps(func) + async def wrapper(*args, **kwargs): + cache_key = f"{func.__name__}:{hash(str(args) + str(kwargs))}" + + # Intentar obtener desde la caché + cached = redis_client.get(cache_key) + if cached: + return json.loads(cached) + + # Ejecutar la función y cachear el resultado + result = await func(*args, **kwargs) + redis_client.setex(cache_key, expiration, json.dumps(result)) + return result + return wrapper + return decorator + +@cache_result(expiration=600) +async def get_expensive_data(): + # Operación costosa + return complex_calculation() +``` + +### P: ¿Cómo manejo muchas peticiones concurrentes? + +**R:** Usa una configuración de servidor adecuada: + +
+ +```console +# Desarrollo +$ fastkit runserver --workers 1 # Un solo worker para desarrollo + +# Producción +$ gunicorn src.main:app -w 4 -k uvicorn.workers.UvicornWorker +$ uvicorn src.main:app --workers 4 --host 0.0.0.0 --port 8000 +``` + +
+ +## Pruebas + +### P: ¿Cómo ejecuto las pruebas? + +**R:** Usa pytest desde el directorio del proyecto: + +
+ +```console +$ cd my-project +$ source .venv/bin/activate +$ python -m pytest + +# Con cobertura +$ python -m pytest --cov=src + +# Un archivo de pruebas concreto +$ python -m pytest tests/test_users.py + +# Con salida detallada +$ python -m pytest -v +``` + +
+ +### P: ¿Cómo escribo pruebas de API? + +**R:** Usa el cliente de pruebas de FastAPI: + +```python +from fastapi.testclient import TestClient +from src.main import app + +client = TestClient(app) + +def test_create_user(): + response = client.post( + "/api/v1/users/", + json={"email": "test@example.com", "username": "testuser"} + ) + assert response.status_code == 201 + assert response.json()["email"] == "test@example.com" + +def test_get_user(): + response = client.get("/api/v1/users/1") + assert response.status_code == 200 +``` + +### P: ¿Cómo hago mock de dependencias externas? + +**R:** Usa fixtures de pytest y mocks: + +```python +import pytest +from unittest.mock import Mock, patch + +@pytest.fixture +def mock_database(): + with patch('src.database.get_db') as mock_db: + mock_db.return_value = Mock() + yield mock_db + +def test_user_creation_with_mock_db(mock_database): + # Probar con la base de datos mockeada + response = client.post("/api/v1/users/", json=user_data) + assert response.status_code == 201 +``` + +## Cómo contribuir + +### P: ¿Cómo contribuyo a FastAPI-fastkit? + +**R:** Sigue estos pasos: + +1. **Haz fork del repositorio** en GitHub +2. **Configura el entorno de desarrollo** +3. **Crea una rama de funcionalidad** +4. **Haz tus cambios** con pruebas +5. **Envía un pull request** + +
+ +```console +$ git clone https://github.com/yourusername/FastAPI-fastkit.git +$ cd FastAPI-fastkit +$ make dev-setup # Configurar el entorno de desarrollo +$ git checkout -b feature/my-feature +# Hacer cambios... +$ make dev-check # Formatear, lintear y probar +$ git commit -m "feat: add new feature" +$ git push origin feature/my-feature +``` + +
+ +### P: ¿Qué debe incluir un pull request? + +**R:** Todo pull request debe incluir: + +- [ ] **Descripción clara** de los cambios +- [ ] **Pruebas** para las nuevas funcionalidades +- [ ] **Actualizaciones de documentación** si hace falta +- [ ] **Cumplir la guía de código** +- [ ] **Todas las verificaciones en verde** + +### P: ¿Cómo reporto un bug? + +**R:** Crea un issue en GitHub con: + +1. **Descripción del bug** y comportamiento esperado +2. **Pasos para reproducirlo** +3. **Información del entorno** (SO, versión de Python, etc.) +4. **Mensajes de error** o logs +5. **Ejemplo mínimo** si es posible + +### P: ¿Cómo solicito una nueva funcionalidad? + +**R:** Abre un issue de petición de funcionalidad con: + +1. **Descripción clara** de la funcionalidad +2. **Caso de uso** y motivación +3. **Implementación propuesta** (opcional) +4. **Ejemplos** de funcionalidades similares + +## Solución de problemas + +### P: Estoy recibiendo errores de import + +**R:** Revisa tu Python path y tu entorno virtual: + +
+ +```console +# Comprobar que el entorno virtual está activo +$ which python +/path/to/project/.venv/bin/python + +# Comprobar el Python path +$ python -c "import sys; print(sys.path)" + +# Reinstalar en modo editable (para desarrollo) +$ pip install -e . +``` + +
+ +### P: Problemas de conexión a la base de datos + +**R:** En las plantillas con base de datos, asegúrate de que la base de datos está corriendo: + +
+ +```console +# Plantilla PostgreSQL +$ docker-compose up -d postgres # Arrancar la base de datos +$ alembic upgrade head # Ejecutar las migraciones + +# Comprobar la conexión +$ docker-compose logs postgres +``` + +
+ +### P: No se encuentran los archivos de plantilla + +**R:** Suele indicar un problema con la ruta de las plantillas: + +
+ +```console +# Comprobar las plantillas disponibles +$ fastkit list-templates + +# Comprobar el directorio de las plantillas +$ python -c "import fastapi_fastkit; print(fastapi_fastkit.__path__)" + +# Reinstalar si faltan plantillas +$ pip uninstall fastapi-fastkit +$ pip install fastapi-fastkit +``` + +
+ +### P: Los hooks de pre-commit fallan + +**R:** Instala y ejecuta los hooks: + +
+ +```console +$ pip install pre-commit +$ pre-commit install +$ pre-commit run --all-files + +# Arreglar problemas de formato +$ black src/ tests/ +$ isort src/ tests/ +``` + +
+ +### P: Las pruebas fallan en CI pero pasan en local + +**R:** Causas y soluciones habituales: + +1. **Diferencias de entorno**: comprueba que las versiones de Python coinciden +2. **Dependencias faltantes**: asegúrate de instalar las dependencias de testing +3. **Problemas de rutas**: usa imports absolutos +4. **Problemas de timing**: añade esperas adecuadas en pruebas asíncronas + +
+ +```console +# Probar con la misma versión de Python que CI +$ python3.12 -m pytest + +# Comprobar si faltan dependencias +$ pip install -r requirements-dev.txt + +# Ejecutar las pruebas en un entorno aislado +$ tox +``` + +
+ +## Pedir ayuda + +### P: ¿Dónde puedo conseguir ayuda? + +**R:** Varias opciones para conseguir ayuda: + +- **GitHub Issues**: para bugs y peticiones de funcionalidad +- **GitHub Discussions**: para preguntas y soporte de la comunidad +- **Documentación**: guías y tutoriales +- **Ejemplos de código**: revisa las plantillas y las pruebas existentes + +### P: ¿Cómo me mantengo al día? + +**R:** Sigue las novedades del proyecto: + +- Haz **Watch del repositorio** en GitHub +- Revisa los **releases** para ver las nuevas funcionalidades +- Lee el **changelog** para ver los cambios incompatibles +- Sigue las **buenas prácticas** de la documentación + +!!! tip "Consejos pro" + - Usa siempre entornos virtuales para tus proyectos Python + - Mantén tu instalación de FastAPI-fastkit actualizada + - Usa `fastkit --help` para ver los comandos disponibles + - Consulta la documentación cuando te bloquees + - No dudes en preguntar en GitHub Discussions diff --git a/docs/es/reference/preset-feature-matrix.md b/docs/es/reference/preset-feature-matrix.md new file mode 100644 index 0000000..5769d9f --- /dev/null +++ b/docs/es/reference/preset-feature-matrix.md @@ -0,0 +1,60 @@ +# Matriz de presets de arquitectura / funcionalidades + +`fastkit init --interactive` pregunta por un **preset de arquitectura** ([issue #44](https://github.com/bnbong/FastAPI-fastkit/issues/44)) antes de recoger las selecciones de funcionalidades. El preset define la estructura del proyecto generado: cada preset parte de una plantilla base distinta y coloca los archivos de configuración en rutas diferentes, para que encajen con la estructura existente en lugar de crear un árbol paralelo `src/config/`. + +Esta página es la referencia principal para saber qué hace cada preset, dónde se generan los archivos y qué combinaciones de funcionalidades requieren cableado manual. + +## Preset → plantilla base + +| Preset | Plantilla base | Descripción | +|---|---|---| +| `minimal` | `fastapi-empty` | La app FastAPI viable más pequeña — el `main.py` placeholder se regenera a partir de tus selecciones de features. | +| `single-module` | `fastapi-single-module` | App FastAPI en un solo archivo — `main.py` se regenera. | +| `classic-layered` | `fastapi-default` | Partición en capas (`api/routes`, `crud`, `schemas`, `core`). El `main.py` que trae la plantilla se conserva. | +| `domain-starter` | `fastapi-domain-starter` | Orientado a dominios (`src/app/domains//`). El `main.py` que trae la plantilla se conserva. **Opción recomendada.** | + +## Ubicación de los archivos generados + +| Preset | Overlay de `main.py` | Destino de la config de base de datos | Destino de la config de autenticación | +|---|---|---|---| +| `minimal` | regenerado en `src/main.py` | `src/config/database.py` | `src/config/auth.py` | +| `single-module` | regenerado en `src/main.py` | `src/config/database.py` | `src/config/auth.py` | +| `classic-layered` | conservado (el que viene en la plantilla) | `src/core/database.py` | `src/core/auth.py` | +| `domain-starter` | conservado (el que viene en la plantilla) | `src/app/core/database.py` | `src/app/core/auth.py` | + +## Soporte de base de datos / autenticación por preset + +Estas funcionalidades están soportadas en **todos** los presets: la instalación de paquetes siempre se completa con éxito; la diferencia está en si la regeneración dinámica de `main.py` también las conecta automáticamente. + +| Funcionalidad | `minimal` / `single-module` | `classic-layered` / `domain-starter` | +|---|---|---| +| **Base de datos** (PostgreSQL, MySQL, SQLite, MongoDB) | Genera el módulo de configuración **y** añade llamadas stub `await init_db()` en el `main.py` regenerado. | Genera el módulo de configuración en la ruta del preset. El `main.py` que viene en la plantilla se **conserva**, así que tienes que cablear `get_db()` en los routers manualmente. | +| **Autenticación** (JWT, FastAPI-Users, OAuth2, basada en sesiones) | Genera el módulo de configuración de auth. JWT también importa `HTTPBearer` en el `main.py` regenerado. | Genera el módulo de configuración de auth en la ruta del preset. No se añaden imports al `main.py` — cablea las dependencias manualmente. | +| **Tareas en segundo plano** (Celery, Dramatiq) | Los paquetes se instalan; por ahora no hay regeneración automática de `main.py`. | Igual. | +| **Caché** (Redis) | Los paquetes se instalan; por ahora no hay regeneración automática de `main.py`. | Igual. | +| **CORS** (utilidad) | Se añade `CORSMiddleware` al `main.py` regenerado con `allow_origins=['*']`. | **Ya cableado** en el `main.py` que trae la plantilla (condicional a `settings.all_cors_origins`). Actívalo definiendo `BACKEND_CORS_ORIGINS` en `.env` — no requiere cambios de código. | +| **Pruebas** (Basic / Coverage / Advanced) | Se genera `pytest.ini` en la raíz del proyecto. | Igual. | +| **Despliegue** (Docker, docker-compose) | Se escribe `Dockerfile` y/o `docker-compose.yml` en la raíz del proyecto. | Igual. | + +## Cuándo verás un aviso de "Preset compatibility" + +Para los presets que **conservan el `main.py` que trae la plantilla** (`classic-layered`, `domain-starter`), algunas selecciones de funcionalidad no se cablean automáticamente en la app. La CLI muestra al final de la generación un único aviso que lista qué selecciones requieren cableado manual: + +| Funcionalidad seleccionada | ¿Dispara aviso bajo `classic-layered` / `domain-starter`? | +|---|---| +| `CORS` (utilidad) | ❌ — ya cableado en el `main.py` de la plantilla. Solo rellena `BACKEND_CORS_ORIGINS` en `.env`. | +| `Rate-Limiting` (utilidad) | ✅ — la configuración del limitador `slowapi` no se añade | +| `Prometheus` (monitorización) | ✅ — no se llama a `Instrumentator().instrument(app)` | +| Cualquier selección de base de datos / auth | ⚠️ — los archivos de configuración se generan, pero tienes que añadirlos con `Depends()` en tus routers | + +Para los presets `minimal` y `single-module`, la regeneración dinámica de `main.py` se encarga automáticamente de CORS, rate limiting e instrumentación con Prometheus; no aparece ningún aviso. + +## Combinaciones no soportadas (juego seguro) + +El estratega deliberadamente **no** intenta inyectar el código generado en un `main.py` que trae la plantilla. Hacerlo correría el riesgo de producir imports rotos o duplicar routers. El contrato es: + +- Los paquetes seleccionados siempre se instalan (así `pip freeze` refleja la intención del usuario). +- Los módulos de configuración generados siempre se guardan en la ruta apropiada para el preset. +- Para los presets que preservan `main.py`, se le indica al usuario qué selecciones todavía requieren cableado manual en lugar de entregar código silenciosamente roto. + +Si necesitas que todas las funcionalidades se cableen automáticamente, elige `minimal` o `single-module` — esos presets regeneran `main.py` a partir de los feature flags. diff --git a/docs/es/reference/template-quality-assurance.md b/docs/es/reference/template-quality-assurance.md new file mode 100644 index 0000000..ee5d493 --- /dev/null +++ b/docs/es/reference/template-quality-assurance.md @@ -0,0 +1,218 @@ +# Aseguramiento de calidad de las plantillas + +FastAPI-fastkit ofrece una validación automatizada e integral de las plantillas para garantizar que todas mantengan una calidad alta y sigan funcionando en distintos entornos y gestores de paquetes. + +## Aseguramiento de calidad multicapa + +FastAPI-fastkit emplea **dos sistemas de aseguramiento de calidad complementarios**: + +### 1. Inspección estática de plantillas +**Validación automatizada semanal de la estructura y la sintaxis de las plantillas** + +### 2. Pruebas dinámicas de plantillas +**Pruebas completas de extremo a extremo creando proyectos reales** + +## Inspección automatizada semanal + +Cada miércoles a medianoche (UTC), nuestro flujo de trabajo de GitHub Actions inspecciona automáticamente todas las plantillas FastAPI para asegurarse de que cumplen los estándares de calidad: + +- ✅ **Validación de la estructura de archivos** — comprueba que existen todos los archivos y directorios requeridos +- ✅ **Verificación de extensiones** — valida que los archivos de plantilla usan las extensiones `.py-tpl` correctas +- ✅ **Comprobación de dependencias** — confirma que FastAPI y las dependencias necesarias están bien declaradas +- ✅ **Implementación de FastAPI** — verifica que las plantillas contienen una inicialización adecuada de la app FastAPI +- ✅ **Ejecución de pruebas** — ejecuta las pruebas de la plantilla para confirmar que funcionan + +## Sistema automatizado de pruebas de plantillas + +FastAPI-fastkit incluye un **sistema automatizado de pruebas revolucionario** que valida cada plantilla de forma integral: + +### Descubrimiento dinámico de plantillas + +El sistema de pruebas **descubre automáticamente todas las plantillas** sin configuración manual: + +```console +# Probar todas las plantillas automáticamente +$ pytest tests/test_templates/test_all_templates.py -v + +# Los resultados muestran todas las plantillas descubiertas +PASSED tests/test_templates/test_all_templates.py::TestAllTemplates::test_template_creation[fastapi-default] +PASSED tests/test_templates/test_all_templates.py::TestAllTemplates::test_template_creation[fastapi-async-crud] +PASSED tests/test_templates/test_all_templates.py::TestAllTemplates::test_template_creation[fastapi-dockerized] +PASSED tests/test_templates/test_all_templates.py::TestAllTemplates::test_template_creation[fastapi-psql-orm] +``` + +### Cobertura de pruebas integral + +Cada plantilla pasa por **pruebas completas de extremo a extremo**: + +#### ✅ Proceso de creación del proyecto +- Copiado de la plantilla y transformación de archivos +- Inyección de metadatos del proyecto (nombre, autor, descripción) +- Validación de la estructura de archivos + +#### ✅ Compatibilidad con gestores de paquetes +- **UV** (por defecto): gestor rápido basado en Rust +- **PDM**: gestión moderna de dependencias Python +- **Poetry**: gestión de dependencias consolidada +- **PIP**: gestor de paquetes tradicional + +#### ✅ Gestión de entornos virtuales +- Creación del entorno para cada gestor de paquetes +- Verificación de la instalación de dependencias +- Flujos específicos de cada gestor + +#### ✅ Resolución de dependencias +- Generación de `pyproject.toml` (UV, PDM, Poetry) +- Generación de `requirements.txt` (PIP) +- Cumplimiento de metadatos (PEP 621) +- Configuración del build system + +#### ✅ Validación de la estructura del proyecto +- Identificación del proyecto como FastAPI +- Existencia de los archivos requeridos +- Verificación de la estructura de directorios + +### Ejemplos de ejecución de pruebas + +**Ejecutar todas las pruebas de plantillas:** +```console +$ pytest tests/test_templates/test_all_templates.py -v +``` + +**Probar una plantilla concreta:** +```console +$ pytest tests/test_templates/test_all_templates.py::TestAllTemplates::test_template_creation[fastapi-default] -v +``` + +**Probar con un entorno PDM:** +```console +$ pdm run pytest tests/test_templates/test_all_templates.py -v +``` + +### Integración continua + +El sistema de pruebas automatizado corre en **pipelines CI/CD**: + +- ✅ **Validación en pull requests**: cada PR prueba las plantillas afectadas +- ✅ **Pruebas nocturnas**: validación completa de la suite de plantillas +- ✅ **Pruebas multi-gestor**: validación cruzada con todos los gestores +- ✅ **Pruebas multientorno**: varias versiones de Python y plataformas + +### Beneficios para colaboradores + +**Pruebas sin configuración:** + +- 🚀 Añade una plantilla nueva → pruebas automáticas +- ⚡ No hace falta crear archivos de prueba manualmente +- 🛡️ Estándares de calidad consistentes + +**Cobertura completa:** + +- 🔍 Pruebas de extremo a extremo para la creación de proyectos +- 📦 Validación con varios gestores de paquetes +- 🏗️ Pruebas completas de resolución de dependencias +- ✅ Simulación de uso real + +**Experiencia de desarrollo:** + +- 🎯 **Foco en el contenido de la plantilla**: las pruebas son automáticas +- 🔄 **Feedback inmediato**: ejecución rápida de las pruebas +- 📊 **Resultados claros**: informe detallado +- 🚫 **Sin boilerplate**: cero configuración de pruebas + +## Inspección manual de plantillas + +Para desarrollo y depuración, puedes inspeccionar las plantillas manualmente con nuestro script local o con los comandos del Makefile: + +### Usar el script de inspección directamente + +```console +# Inspeccionar todas las plantillas +$ python scripts/inspect-templates.py + +# Inspeccionar plantillas concretas +$ python scripts/inspect-templates.py --templates fastapi-default,fastapi-async-crud + +# Salida detallada +$ python scripts/inspect-templates.py --verbose + +# Guardar los resultados en un archivo +$ python scripts/inspect-templates.py --output my_results.json +``` + +### Usar los comandos del Makefile + +```console +# Inspeccionar todas las plantillas +$ make inspect-templates + +# Inspeccionar con salida detallada +$ make inspect-templates-verbose + +# Inspeccionar plantillas concretas +$ make inspect-template TEMPLATES="fastapi-default,fastapi-async-crud" +``` + +## Resultados de la inspección + +- Las **inspecciones exitosas** se registran en las salidas y artefactos del flujo de trabajo +- Las **inspecciones fallidas** crean automáticamente issues de GitHub con informes detallados +- El **historial de inspecciones** se conserva 30 días en los artefactos de GitHub Actions + +## Entender la salida de la inspección + +Al ejecutar la inspección verás algo así: + +```console +📋 Found 6 templates to inspect: fastapi-async-crud, fastapi-custom-response, fastapi-default, fastapi-dockerized, fastapi-empty, fastapi-psql-orm +============================================================ +🔍 Inspecting template: fastapi-async-crud + Path: /path/to/src/fastapi_fastkit/fastapi_project_template/fastapi-async-crud +✅ fastapi-async-crud: PASSED +---------------------------------------- +🔍 Inspecting template: fastapi-custom-response + Path: /path/to/src/fastapi_fastkit/fastapi_project_template/fastapi-custom-response +✅ fastapi-custom-response: PASSED +---------------------------------------- +... +============================================================ +📊 INSPECTION SUMMARY + Total templates: 6 + ✅ Passed: 6 + ❌ Failed: 0 +🎉 All templates passed inspection! +📄 Results saved to: template_inspection_results.json +``` + +## Requisitos de la plantilla + +Para que una plantilla pase la inspección, debe cumplir estos requisitos: + +### Estructura de archivos +- Debe contener un directorio `src/` con archivos fuente Python +- Los archivos Python deben usar la extensión `.py-tpl` +- Debe incluir un directorio `tests/` y un archivo `README.md-tpl` +- Debe incluir **al menos uno** de los archivos de metadatos: + - `pyproject.toml-tpl` (preferido, PEP 621), o + - `setup.py-tpl` (legacy, todavía aceptado) +- `requirements.txt-tpl` es opcional cuando `pyproject.toml-tpl` declara `[project].dependencies` + +### Requisitos de FastAPI +- Debe contener la inicialización de la app FastAPI +- Debe declarar `fastapi` como dependencia en al menos uno de: `pyproject.toml-tpl` `[project].dependencies`, `requirements.txt-tpl`, o `setup.py-tpl` `install_requires` +- Todos los archivos de la plantilla deben tener sintaxis Python válida + +### Marcadores de identidad +Las plantillas deberían llevar marcadores de identidad de FastAPI-fastkit para que los proyectos generados puedan distinguirse de otros proyectos FastAPI no relacionados dentro del espacio de trabajo del usuario: + +- `pyproject.toml-tpl` — tanto un prefijo `[FastAPI-fastkit templated]` en `description` como una tabla `[tool.fastapi-fastkit]` con `managed = true`. +- `setup.py-tpl` — prefijo `[FastAPI-fastkit templated]` en el argumento `description` de `setup()`. + +`is_fastkit_project()` acepta cualquiera de ellos (`pyproject` tiene prioridad y `setup.py` queda como compatibilidad heredada; la comparación no distingue entre mayúsculas y minúsculas). La inyección de metadatos asegura que los marcadores lleguen a los proyectos generados aunque la plantilla los olvide. + +### Estándares de calidad +- Todos los archivos de la plantilla deben ser sintácticamente correctos +- Las dependencias deben estar especificadas correctamente +- La estructura de la plantilla debe seguir las convenciones de FastAPI-fastkit + +Este aseguramiento de calidad automatizado garantiza que todas las plantillas sigan siendo fiables y estén listas para usarse en producción. diff --git a/docs/es/reference/translation-status.md b/docs/es/reference/translation-status.md new file mode 100644 index 0000000..e2494ab --- /dev/null +++ b/docs/es/reference/translation-status.md @@ -0,0 +1,82 @@ +# Estado de las traducciones + +La documentación de FastAPI-fastkit se publica en varios idiomas, pero esas traducciones **no siempre tienen el mismo grado de avance**. Esta página es la referencia principal para saber qué está realmente traducido en cada idioma, qué se muestra cuando una página aún no se ha traducido y cómo colaborar. + +## Fuente de verdad + +> **El inglés (`en`) es la referencia principal.** Todo lo que la documentación explica sobre el producto, la CLI y la API se escribe primero en los archivos en inglés. Los demás idiomas son traducciones de esa base inglesa y pueden quedarse atrás respecto a una release o a páginas concretas. +> +> Si una página traducida no coincide con la página inglesa, **confía en la página inglesa** hasta que la traducción se actualice. + +Los archivos en inglés viven en [`docs/en/`](https://github.com/bnbong/FastAPI-fastkit/tree/main/docs/en). Cualquier otro idioma (`docs/ko/`, `docs/ja/`, `docs/es/`, ...) es un destino de traducción. + +El `CHANGELOG.md` de la raíz del repositorio también forma parte de esa referencia inglesa. Pueden existir páginas `changelog.md` específicas de cada idioma como páginas de acceso o páginas puente, pero reutilizan intencionadamente el historial de releases canónico en inglés en lugar de mantener copias traducidas. + +## Completitud por idioma + +Los números siguientes cuentan páginas Markdown presentes en el árbol de cada idioma respecto a la fuente inglesa. Reflejan lo que realmente está en el repositorio, no lo que aparece en el selector de idiomas (la siguiente sección explica la diferencia). + +| Idioma | Estado | Páginas Markdown | Notas | +|---|---|---:|---| +| 🇬🇧 Inglés (`en`) | ✅ Fuente de verdad | 26 / 26 | Autoritativa. | +| 🇰🇷 Coreano (`ko`) | ✅ Completo | 26 / 26 | Todas las páginas del idioma están presentes. Phase 1: nivel superior + user-guide principal; Phase 2: user-guide restante + todos los tutorials; Phase 3: contributing + reference. `docs/ko/changelog.md` reutiliza intencionadamente el `CHANGELOG.md` canónico en inglés. | +| 🇯🇵 Japonés (`ja`) | ✅ Completo | 26 / 26 | Todas las páginas del idioma están presentes. Phase 1: nivel superior + user-guide principal; Phase 2: user-guide restante + todos los tutorials; Phase 3: contributing + reference. `docs/ja/changelog.md` reutiliza intencionadamente el `CHANGELOG.md` canónico en inglés. | +| 🇪🇸 Español (`es`) | ✅ Completo | 26 / 26 | Todas las páginas del idioma están presentes. Phase 1: nivel superior + user-guide principal; Phase 2: user-guide restante + todos los tutorials; Phase 3: contributing + reference. `docs/es/changelog.md` reutiliza intencionadamente el `CHANGELOG.md` canónico en inglés. | +| 🇨🇳 Chino (`zh`) | 🔴 Esqueleto | 0 / 26 | Solo está configurado como destino de compilación. Cada página muestra la versión en inglés. | +| 🇫🇷 Francés (`fr`) | 🔴 Esqueleto | 0 / 26 | Solo está configurado como destino de compilación. Cada página muestra la versión en inglés. | +| 🇩🇪 Alemán (`de`) | 🔴 Esqueleto | 0 / 26 | Solo está configurado como destino de compilación. Cada página muestra la versión en inglés. | + +*Verificado el 2026-05-11; la fila de `es` se volvió a contar para la rama actual tras completar Phase 3 (contributing + reference). El español ya tiene todas las páginas del idioma presentes, mientras que `docs/es/changelog.md` apunta al changelog canónico en inglés.* Esta cuenta se mantiene a mano; para volver a contar el estado actual desde la raíz del repositorio, ejecuta: + +```console +$ for loc in en ko ja zh es fr de; do + echo "$loc: $(find docs/$loc -name '*.md' 2>/dev/null | wc -l | tr -d ' ')" + done +``` + +Si el recuento difiere de la tabla, la tabla está obsoleta — actualízala (o abre un PR / issue señalando el desfase). + +Leyenda: + +- ✅ **Fuente de verdad** — el idioma contra el que se escribe primero. +- 🟡 **Parcial** — hay algunas páginas traducidas; las que faltan muestran la versión en inglés. +- 🔴 **Esqueleto** — la entrada del selector de idiomas existe, pero todavía no hay páginas traducidas en el repositorio. El sitio renderiza contenido en inglés bajo las etiquetas de navegación traducidas. + +## Cómo funciona el uso de la versión en inglés + +El sitio de documentación usa [`mkdocs-static-i18n`](https://github.com/ultrabug/mkdocs-static-i18n) con `fallback_to_default: true`. Esto significa: + +- Para cada idioma traducido, MkDocs solo escribe las páginas que existen en el directorio de ese idioma. +- Para cada página que **no** exista en un idioma, la compilación recurre a la versión inglesa de esa página. +- El selector global de idiomas siempre muestra todos los idiomas configurados, independientemente de cuántas páginas tenga cada uno, porque la compilación genera una URL accesible para cada caso (la página correspondiente o, si hace falta, su versión inglesa). + +Por lo tanto, una entrada 🔴 Esqueleto en el selector **no** significa que la documentación ya esté traducida; solo indica que ese idioma está configurado como destino de compilación. Este comportamiento es intencionado (los colaboradores externos pueden traducir una página cada vez sin romper la estructura de enlaces), pero también hace que el selector de idiomas parezca más completo de lo que realmente está el contenido. + +## Cómo leer el sitio + +- **Por defecto, usa el inglés** si quieres la información más precisa y actualizada. +- **Usa un idioma traducido** solo después de comprobar su estado en esta página. Si aparece como 🟡 o 🔴 y llegas a un tema que aún no se ha traducido, en realidad estarás leyendo la página en inglés bajo una etiqueta de navegación traducida. + +## Cómo ayudar + +La estrategia actual es **un issue de seguimiento por idioma**, con el trabajo dividido en **fases**. Por ejemplo, `ko` se completó en Phase 1 (nivel superior + user-guide principal), Phase 2 (user-guide restante + todos los tutorials) y Phase 3 (contributing + reference). Cada fase se envía en su propio PR para que los revisores puedan aprobar una parte coherente sin tener que esperar a que todo el idioma esté terminado. + +Si quieres colaborar: + +1. Lee la [Guía de traducción](../contributing/translation-guide.md) para conocer el flujo, las herramientas y las convenciones de estilo. +2. **Comprueba o abre primero el issue de seguimiento del idioma.** Si un idioma ya tiene un issue de seguimiento abierto, reserva una fase (o una página concreta dentro de una fase) ahí para que el trabajo no se duplique. Si no hay issue de seguimiento para el idioma con el que quieres trabajar, abre uno que liste qué páginas pertenecen a cada fase y empieza por Phase 1. +3. **Lo ideal es un PR por fase.** Los PR pequeños del tipo "arregla esta única página" siguen siendo bienvenidos — sobre todo para corregir una traducción desincronizada — pero, al arrancar un idioma desde cero, agrupar por fase ayuda a mantener consistentes las decisiones de glosario y los textos de los enlaces cruzados dentro de ese bloque. +4. Abre el PR añadiendo archivos bajo `docs//`. Mantén los nombres de archivo idénticos a la fuente inglesa para que MkDocs los recoja automáticamente. +5. Trata las páginas changelog localizadas como páginas puente hacia el `CHANGELOG.md` canónico en inglés, salvo que la política del proyecto cambie de forma explícita. +6. Actualiza la tabla de esta página para reflejar el nuevo grado de traducción (usa el snippet de recuento de arriba) y actualiza la fecha de "Verificado" para que los revisores vean cuándo se revisó por última vez. Indica en la columna "Notas" qué fase se ha completado si el idioma sigue estando parcial. + +Los reportes de errores sobre páginas traducidas que se hayan desincronizado respecto a la fuente inglesa son bienvenidos; enlaza la página inglesa y la traducida para que podamos revisarlas con rapidez. + +## Por qué publicamos idiomas en 🔴 Esqueleto + +Dos razones: + +1. **Un espacio de URLs predecible.** Cada idioma tiene ya su subárbol `//` accesible, así que en cuanto se añade una página traducida el enlace es estable desde el primer día, incluidos los enlaces publicados en esta guía. +2. **Menos fricción para quienes contribuyen.** Quien traduce una sola página no tiene que configurar además un nuevo idioma en la configuración de MkDocs; basta con añadir el archivo. + +Si un idioma se queda en 🔴 Esqueleto sin actividad durante mucho tiempo, podemos replantearnos si dejar habilitado su destino de build. Esa decisión se rastrea por separado y **no** es algo que esta página de estado cambie silenciosamente. diff --git a/docs/es/tutorial/async-crud-api.md b/docs/es/tutorial/async-crud-api.md new file mode 100644 index 0000000..47aa3a5 --- /dev/null +++ b/docs/es/tutorial/async-crud-api.md @@ -0,0 +1,665 @@ +# Construir APIs CRUD asíncronas + +Aprende a construir APIs CRUD de alto rendimiento aprovechando las capacidades asíncronas de FastAPI. En este tutorial implementaremos E/S de archivos asíncrona y procesamiento eficiente de datos con la plantilla `fastapi-async-crud`. + +## Lo que aprenderás en este tutorial + +- Entender las aplicaciones FastAPI asíncronas +- Operaciones CRUD asíncronas con la sintaxis `async/await` +- Procesamiento asíncrono de archivos con aiofiles +- Escribir y ejecutar pruebas asíncronas +- Técnicas de optimización de rendimiento + +## Requisitos previos + +- Haber completado el [tutorial del servidor API básico](basic-api-server.md) +- Conceptos básicos de `async/await` en Python +- FastAPI-fastkit instalado + +## Por qué hace falta procesamiento asíncrono + +Entendamos la diferencia entre procesamiento síncrono y asíncrono: + +### Procesamiento síncrono + +```python +def process_items(): + item1 = read_file("item1.json") # Espera 2 segundos + item2 = read_file("item2.json") # Espera 2 segundos + item3 = read_file("item3.json") # Espera 2 segundos + return [item1, item2, item3] # Total: 6 segundos +``` + +### Procesamiento asíncrono + +```python +async def process_items(): + item1_task = read_file_async("item1.json") # Arranca en paralelo + item2_task = read_file_async("item2.json") # Arranca en paralelo + item3_task = read_file_async("item3.json") # Arranca en paralelo + + items = await asyncio.gather(item1_task, item2_task, item3_task) + return items # Total: 2 segundos +``` + +## Paso 1: Crear un proyecto CRUD asíncrono + +Crea un proyecto con la plantilla `fastapi-async-crud`: + +
+ +```console +$ fastkit startdemo fastapi-async-crud +Enter the project name: async-todo-api +Enter the author name: Developer Kim +Enter the author email: developer@example.com +Enter the project description: Asynchronous todo management API +Deploying FastAPI project using 'fastapi-async-crud' template + + Project Information +┌──────────────┬─────────────────────────────────────────┐ +│ Project Name │ async-todo-api │ +│ Author │ Developer Kim │ +│ Author Email │ developer@example.com │ +│ Description │ Asynchronous todo management API │ +└──────────────┴─────────────────────────────────────────┘ + + Template Dependencies +┌──────────────┬───────────────────┐ +│ Dependency 1 │ fastapi │ +│ Dependency 2 │ uvicorn │ +│ Dependency 3 │ pydantic │ +│ Dependency 4 │ pydantic-settings │ +│ Dependency 5 │ aiofiles │ +│ Dependency 6 │ pytest-asyncio │ +└──────────────┴───────────────────┘ + +Select package manager (pip, uv, pdm, poetry) [uv]: uv +Do you want to proceed with project creation? [y/N]: y + +✨ FastAPI project 'async-todo-api' from 'fastapi-async-crud' has been created successfully! +``` + +
+ +## Paso 2: Analizar la estructura del proyecto + +Examinemos las diferencias clave del proyecto generado: + +``` +async-todo-api/ +├── src/ +│ ├── main.py # Aplicación FastAPI asíncrona +│ ├── api/ +│ │ └── routes/ +│ │ └── items.py # Endpoints CRUD asíncronos +│ ├── crud/ +│ │ └── items.py # Lógica asíncrona de procesamiento de datos +│ ├── schemas/ +│ │ └── items.py # Modelos de datos (los mismos) +│ ├── mocks/ +│ │ └── mock_items.json # Base de datos en archivo JSON +│ └── core/ +│ └── config.py # Archivo de configuración +└── tests/ + ├── conftest.py # Configuración de pruebas asíncronas + └── test_items.py # Casos de prueba asíncronos +``` + +### Diferencias clave + +1. **aiofiles**: procesamiento asíncrono de E/S de archivos +2. **pytest-asyncio**: soporte de pruebas asíncronas +3. **Patrón async/await**: todas las operaciones CRUD están implementadas de forma asíncrona + +## Paso 3: Entender la lógica CRUD asíncrona + +### Procesamiento asíncrono de datos (`src/crud/items.py`) + +```python +import json +import asyncio +from typing import List, Optional +from aiofiles import open as aio_open +from pathlib import Path + +from src.schemas.items import Item, ItemCreate, ItemUpdate + +class AsyncItemCRUD: + def __init__(self, data_file: str = "src/mocks/mock_items.json"): + self.data_file = Path(data_file) + + async def _read_data(self) -> List[dict]: + """Lee datos del archivo JSON de forma asíncrona""" + try: + async with aio_open(self.data_file, 'r', encoding='utf-8') as f: + content = await f.read() + return json.loads(content) + except FileNotFoundError: + return [] + + async def _write_data(self, data: List[dict]) -> None: + """Escribe datos al archivo JSON de forma asíncrona""" + async with aio_open(self.data_file, 'w', encoding='utf-8') as f: + await f.write(json.dumps(data, indent=2, ensure_ascii=False)) + + async def get_items(self) -> List[Item]: + """Recupera todos los items (asíncrono)""" + data = await self._read_data() + return [Item(**item) for item in data] + + async def get_item(self, item_id: int) -> Optional[Item]: + """Recupera un item concreto (asíncrono)""" + data = await self._read_data() + item_data = next((item for item in data if item["id"] == item_id), None) + return Item(**item_data) if item_data else None + + async def create_item(self, item: ItemCreate) -> Item: + """Crea un item nuevo (asíncrono)""" + data = await self._read_data() + new_id = max([item["id"] for item in data], default=0) + 1 + + new_item = Item(id=new_id, **item.dict()) + data.append(new_item.dict()) + + await self._write_data(data) + return new_item + + async def update_item(self, item_id: int, item_update: ItemUpdate) -> Optional[Item]: + """Actualiza un item (asíncrono)""" + data = await self._read_data() + + for i, item in enumerate(data): + if item["id"] == item_id: + update_data = item_update.dict(exclude_unset=True) + data[i].update(update_data) + await self._write_data(data) + return Item(**data[i]) + + return None + + async def delete_item(self, item_id: int) -> bool: + """Elimina un item (asíncrono)""" + data = await self._read_data() + original_length = len(data) + + data = [item for item in data if item["id"] != item_id] + + if len(data) < original_length: + await self._write_data(data) + return True + + return False +``` + +### Endpoints asíncronos de la API (`src/api/routes/items.py`) + +```python +from typing import List +from fastapi import APIRouter, HTTPException, status + +from src.schemas.items import Item, ItemCreate, ItemUpdate +from src.crud.items import AsyncItemCRUD + +router = APIRouter() +crud = AsyncItemCRUD() + +@router.get("/", response_model=List[Item]) +async def read_items(): + """Recupera todos los items (asíncrono)""" + return await crud.get_items() + +@router.get("/{item_id}", response_model=Item) +async def read_item(item_id: int): + """Recupera un item concreto (asíncrono)""" + item = await crud.get_item(item_id) + if item is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Item with id {item_id} not found" + ) + return item + +@router.post("/", response_model=Item, status_code=status.HTTP_201_CREATED) +async def create_item(item: ItemCreate): + """Crea un item nuevo (asíncrono)""" + return await crud.create_item(item) + +@router.put("/{item_id}", response_model=Item) +async def update_item(item_id: int, item_update: ItemUpdate): + """Actualiza un item (asíncrono)""" + updated_item = await crud.update_item(item_id, item_update) + if updated_item is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Item with id {item_id} not found" + ) + return updated_item + +@router.delete("/{item_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_item(item_id: int): + """Elimina un item (asíncrono)""" + deleted = await crud.delete_item(item_id) + if not deleted: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Item with id {item_id} not found" + ) +``` + +## Paso 4: Ejecutar el servidor y probarlo + +Entra en el directorio del proyecto y arranca el servidor: + +
+ +```console +$ cd async-todo-api +$ fastkit runserver +Starting FastAPI server at 127.0.0.1:8000... + +INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit) +INFO: Started reloader process [12345] using WatchFiles +INFO: Started server process [12346] +INFO: Waiting for application startup. +INFO: Application startup complete. +``` + +
+ +### Pruebas de rendimiento + +Verifiquemos el rendimiento del procesamiento asíncrono enviando varias peticiones a la vez: + +**Test de peticiones concurrentes (script Python)** + +```python +import asyncio +import aiohttp +import time + +async def create_item(session, item_data): + async with session.post("http://127.0.0.1:8000/items/", json=item_data) as response: + return await response.json() + +async def test_concurrent_requests(): + start_time = time.time() + + items_to_create = [ + {"name": f"Item {i}", "description": f"Description {i}", "price": i * 10, "tax": i} + for i in range(1, 11) # Crear 10 items en paralelo + ] + + async with aiohttp.ClientSession() as session: + tasks = [create_item(session, item) for item in items_to_create] + results = await asyncio.gather(*tasks) + + end_time = time.time() + print(f"Created 10 items in: {end_time - start_time:.2f} seconds") + print(f"Number of items created: {len(results)}") + +# Ejecutar test +# asyncio.run(test_concurrent_requests()) +``` + +## Paso 5: Escribir pruebas asíncronas + +### Configuración de pruebas (`tests/conftest.py`) + +```python +import pytest +import asyncio +from httpx import AsyncClient +from src.main import app + +@pytest.fixture(scope="session") +def event_loop(): + """Configuración del event loop""" + loop = asyncio.get_event_loop_policy().new_event_loop() + yield loop + loop.close() + +@pytest.fixture +async def async_client(): + """Cliente de pruebas asíncrono""" + async with AsyncClient(app=app, base_url="http://test") as client: + yield client +``` + +### Casos de prueba asíncronos (`tests/test_items.py`) + +```python +import pytest +from httpx import AsyncClient + +@pytest.mark.asyncio +async def test_create_item_async(async_client: AsyncClient): + """Test asíncrono de creación de item""" + item_data = { + "name": "Test Item", + "description": "Item for asynchronous testing", + "price": 100.0, + "tax": 10.0 + } + + response = await async_client.post("/items/", json=item_data) + + assert response.status_code == 201 + data = response.json() + assert data["name"] == item_data["name"] + assert data["price"] == item_data["price"] + assert "id" in data + +@pytest.mark.asyncio +async def test_read_items_async(async_client: AsyncClient): + """Test asíncrono de listado de items""" + response = await async_client.get("/items/") + + assert response.status_code == 200 + items = response.json() + assert isinstance(items, list) + +@pytest.mark.asyncio +async def test_concurrent_operations(async_client: AsyncClient): + """Test de operaciones concurrentes""" + import asyncio + + # Crear varios items en paralelo + tasks = [] + for i in range(5): + item_data = { + "name": f"ConcurrentItem{i}", + "description": f"Description{i}", + "price": i * 10, + "tax": i + } + task = async_client.post("/items/", json=item_data) + tasks.append(task) + + responses = await asyncio.gather(*tasks) + + # Verificar que todas las peticiones tuvieron éxito + for response in responses: + assert response.status_code == 201 + + # Verificar los items creados + response = await async_client.get("/items/") + items = response.json() + assert len(items) >= 5 +``` + +### Ejecutar las pruebas + +
+ +```console +$ pytest tests/ -v --asyncio-mode=auto +======================== test session starts ======================== +collected 8 items + +tests/test_items.py::test_create_item_async PASSED [ 12%] +tests/test_items.py::test_read_items_async PASSED [ 25%] +tests/test_items.py::test_read_item_async PASSED [ 37%] +tests/test_items.py::test_update_item_async PASSED [ 50%] +tests/test_items.py::test_delete_item_async PASSED [ 62%] +tests/test_items.py::test_concurrent_operations PASSED [ 75%] +tests/test_items.py::test_item_not_found_async PASSED [ 87%] +tests/test_items.py::test_invalid_item_data_async PASSED [100%] + +======================== 8 passed in 0.24s ======================== +``` + +
+ +## Paso 6: Monitorización y optimización del rendimiento + +### Añadir middleware de medición de tiempo de respuesta + +Vamos a añadir monitorización del rendimiento en `src/main.py`: + +```python +import time +from fastapi import FastAPI, Request +from src.api.api import api_router +from src.core.config import settings + +app = FastAPI( + title=settings.PROJECT_NAME, + version=settings.VERSION, + description=settings.DESCRIPTION, +) + +@app.middleware("http") +async def add_process_time_header(request: Request, call_next): + """Añadir el tiempo de procesamiento de la petición a las cabeceras""" + start_time = time.time() + response = await call_next(request) + process_time = time.time() - start_time + response.headers["X-Process-Time"] = str(process_time) + return response + +app.include_router(api_router) + +@app.get("/") +async def read_root(): + return {"message": "Welcome to the Asynchronous Todo API!"} +``` + +### Implementar procesamiento por lotes asíncrono + +Vamos a añadir endpoints en lote para procesar varios items a la vez: + +```python +# Añadir a src/api/routes/items.py + +@router.post("/batch", response_model=List[Item]) +async def create_items_batch(items: List[ItemCreate]): + """Crear varios items en paralelo (procesamiento por lotes)""" + import asyncio + + # Ejecutar todas las tareas de creación en paralelo + tasks = [crud.create_item(item) for item in items] + created_items = await asyncio.gather(*tasks) + + return created_items + +@router.get("/batch/{item_ids}") +async def read_items_batch(item_ids: str): + """Recuperar varios items en paralelo (procesamiento por lotes)""" + import asyncio + + # Parsear los IDs separados por comas + ids = [int(id.strip()) for id in item_ids.split(",")] + + # Ejecutar todas las tareas de recuperación en paralelo + tasks = [crud.get_item(item_id) for item_id in ids] + items = await asyncio.gather(*tasks) + + # Devolver solo los items no None + return [item for item in items if item is not None] +``` + +### Probar el procesamiento por lotes + +
+ +```console +# Test de creación por lotes +$ curl -X POST "http://127.0.0.1:8000/items/batch" \ + -H "Content-Type: application/json" \ + -d '[ + {"name": "Item1", "description": "Description1", "price": 10.0, "tax": 1.0}, + {"name": "Item2", "description": "Description2", "price": 20.0, "tax": 2.0}, + {"name": "Item3", "description": "Description3", "price": 30.0, "tax": 3.0} + ]' + +# Test de recuperación por lotes +$ curl -X GET "http://127.0.0.1:8000/items/batch/1,2,3" +``` + +
+ +## Paso 7: Patrones asíncronos avanzados + +### Implementar rate limiting + +```python +import asyncio +from collections import defaultdict +from fastapi import HTTPException, Request +from datetime import datetime, timedelta + +class AsyncRateLimiter: + def __init__(self, max_requests: int = 100, window_seconds: int = 60): + self.max_requests = max_requests + self.window_seconds = window_seconds + self.requests = defaultdict(list) + + async def is_allowed(self, client_ip: str) -> bool: + now = datetime.now() + cutoff = now - timedelta(seconds=self.window_seconds) + + # eliminar registros antiguos + self.requests[client_ip] = [ + req_time for req_time in self.requests[client_ip] + if req_time > cutoff + ] + + # comprobar el número actual de peticiones + if len(self.requests[client_ip]) >= self.max_requests: + return False + + # añadir el registro actual + self.requests[client_ip].append(now) + return True + +# instancia global del rate limiter +rate_limiter = AsyncRateLimiter() + +@app.middleware("http") +async def rate_limit_middleware(request: Request, call_next): + client_ip = request.client.host + + if not await rate_limiter.is_allowed(client_ip): + raise HTTPException( + status_code=429, + detail="Too many requests" + ) + + response = await call_next(request) + return response +``` + +### Implementar caché asíncrona + +```python +import asyncio +from typing import Optional, Any +from datetime import datetime, timedelta + +class AsyncCache: + def __init__(self): + self._cache = {} + self._expiry = {} + + async def get(self, key: str) -> Optional[Any]: + # eliminar elementos caducados + if key in self._expiry and datetime.now() > self._expiry[key]: + del self._cache[key] + del self._expiry[key] + return None + + return self._cache.get(key) + + async def set(self, key: str, value: Any, ttl_seconds: int = 300): + self._cache[key] = value + self._expiry[key] = datetime.now() + timedelta(seconds=ttl_seconds) + + async def delete(self, key: str): + self._cache.pop(key, None) + self._expiry.pop(key, None) + +# instancia global de la caché +cache = AsyncCache() + +# modificar los métodos CRUD para usar la caché +async def get_items_cached(self) -> List[Item]: + """Recuperar items usando la caché""" + cache_key = "all_items" + cached_items = await cache.get(cache_key) + + if cached_items: + return cached_items + + # si no hay caché, leer desde el archivo + items = await self.get_items() + await cache.set(cache_key, items, ttl_seconds=60) # caché de 1 minuto + + return items +``` + +## Paso 8: Consideraciones de producción + +### Gestionar los pools de conexiones + +```python +# añadir a src/core/config.py +class Settings(BaseSettings): + # ... configuración existente ... + + # configuración relacionada con procesamiento asíncrono + MAX_CONCURRENT_REQUESTS: int = 100 + REQUEST_TIMEOUT: int = 30 + CONNECTION_POOL_SIZE: int = 20 + +settings = Settings() +``` + +### Mejorar el manejo de errores + +```python +import logging +from fastapi import HTTPException +from typing import Union + +logger = logging.getLogger(__name__) + +async def safe_async_operation(operation, *args, **kwargs) -> Union[Any, None]: + """Ejecutar una operación asíncrona segura""" + try: + return await operation(*args, **kwargs) + except asyncio.TimeoutError: + logger.error(f"Timeout in {operation.__name__}") + raise HTTPException(status_code=504, detail="Request timeout") + except Exception as e: + logger.error(f"Error in {operation.__name__}: {str(e)}") + raise HTTPException(status_code=500, detail="Internal server error") + +# ejemplo de uso +@router.get("/safe/{item_id}") +async def read_item_safe(item_id: int): + return await safe_async_operation(crud.get_item, item_id) +``` + +## Próximos pasos + +¡Has terminado de construir una API CRUD asíncrona! Próximos pasos: + +1. **[Integración con base de datos](database-integration.md)** - Usar PostgreSQL con SQLAlchemy asíncrono +2. **[Contenedorización con Docker](docker-deployment.md)** - Contenedorizar aplicaciones asíncronas +3. **[Manejo personalizado de respuestas](custom-response-handling.md)** - Formatos de respuesta avanzados y manejo de errores + + + +## Resumen + +En este tutorial hemos usado FastAPI asíncrono para: + +- ✅ Implementar operaciones CRUD asíncronas +- ✅ Optimizar E/S de archivos con aiofiles +- ✅ Manejar peticiones concurrentes y probar el rendimiento +- ✅ Escribir y ejecutar pruebas asíncronas +- ✅ Implementar procesamiento por lotes y patrones asíncronos avanzados +- ✅ Atender consideraciones de producción (caché, manejo de errores, gestión de conexiones) + +¡Dominar el procesamiento asíncrono te permite construir servidores API de alto rendimiento! diff --git a/docs/es/tutorial/basic-api-server.md b/docs/es/tutorial/basic-api-server.md new file mode 100644 index 0000000..1094fa5 --- /dev/null +++ b/docs/es/tutorial/basic-api-server.md @@ -0,0 +1,398 @@ +# Construir un servidor API básico + +Aprende a crear rápidamente una API REST sencilla con FastAPI-fastkit. Este tutorial está pensado para quienes empiezan con FastAPI y cubre la creación de APIs CRUD básicas. + +## Lo que aprenderás en este tutorial + +- Crear un servidor API básico con el comando `fastkit startdemo` +- Entender la estructura de un proyecto FastAPI +- Usar endpoints CRUD básicos +- Probar y documentar la API +- Métodos para ampliar el proyecto + +## Requisitos previos + +- Python 3.12 o superior instalado +- FastAPI-fastkit instalado (`pip install fastapi-fastkit`) +- Conocimientos básicos de Python + +## Paso 1: Crear un proyecto API básico + +Vamos a crear una API básica usando la plantilla `fastapi-default`. + +
+ +```console +$ fastkit startdemo fastapi-default +Enter the project name: my-first-api +Enter the author name: Developer Kim +Enter the author email: developer@example.com +Enter the project description: My first FastAPI server +Deploying FastAPI project using 'fastapi-default' template + + Project Information +┌──────────────┬────────────────────────────┐ +│ Project Name │ my-first-api │ +│ Author │ Developer Kim │ +│ Author Email │ developer@example.com │ +│ Description │ My first FastAPI server │ +└──────────────┴────────────────────────────┘ + + Template Dependencies +┌──────────────┬───────────────────┐ +│ Dependency 1 │ fastapi │ +│ Dependency 2 │ uvicorn │ +│ Dependency 3 │ pydantic │ +│ Dependency 4 │ pydantic-settings │ +│ Dependency 5 │ python-dotenv │ +└──────────────┴───────────────────┘ + +Select package manager (pip, uv, pdm, poetry) [uv]: uv +Do you want to proceed with project creation? [y/N]: y + +✨ FastAPI project 'my-first-api' from 'fastapi-default' has been created successfully! +``` + +
+ +## Paso 2: Entender la estructura del proyecto generado + +Examinemos la estructura del proyecto generado: + +``` +my-first-api/ +├── README.md # Documentación del proyecto +├── requirements.txt # Lista de paquetes de dependencias +├── setup.py # Configuración del paquete +├── scripts/ +│ └── run-server.sh # Script de ejecución del servidor +├── src/ # Código fuente principal +│ ├── main.py # Punto de entrada de la app FastAPI +│ ├── core/ +│ │ └── config.py # Gestión de configuración +│ ├── api/ +│ │ ├── api.py # Conjunto de routers de la API +│ │ └── routes/ +│ │ └── items.py # Endpoints relacionados con items +│ ├── schemas/ +│ │ └── items.py # Definiciones de modelos de datos +│ ├── crud/ +│ │ └── items.py # Lógica de procesamiento de datos +│ └── mocks/ +│ └── mock_items.json # Datos de prueba +└── tests/ # Código de pruebas + ├── __init__.py + ├── conftest.py + └── test_items.py +``` + +### Descripción de los archivos clave + +- **`src/main.py`**: Punto de entrada de la app FastAPI +- **`src/api/routes/items.py`**: Definiciones de endpoints relacionados con items +- **`src/schemas/items.py`**: Definiciones de estructuras de datos de petición / respuesta +- **`src/crud/items.py`**: Lógica de operaciones de base de datos +- **`src/mocks/mock_items.json`**: Datos de ejemplo para desarrollo + +## Paso 3: Ejecutar el servidor + +Entra en el directorio del proyecto generado y ejecuta el servidor. + +
+ +```console +$ cd my-first-api +$ fastkit runserver +Starting FastAPI server at 127.0.0.1:8000... + +INFO: Will watch for changes in these directories: ['/path/to/my-first-api'] +INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit) +INFO: Started reloader process [12345] using WatchFiles +INFO: Started server process [12346] +INFO: Waiting for application startup. +INFO: Application startup complete. +``` + +
+ +Una vez que el servidor esté en marcha, puedes acceder a estas URLs en tu navegador: + +- **Servidor API**: http://127.0.0.1:8000 +- **Documentación Swagger UI**: http://127.0.0.1:8000/docs +- **Documentación ReDoc**: http://127.0.0.1:8000/redoc + +## Paso 4: Explorar los endpoints de la API + +La API generada ofrece por defecto estos endpoints: + +| Método | Endpoint | Descripción | +|---|---|---| +| GET | `/items/` | Obtener todos los items | +| GET | `/items/{item_id}` | Obtener un item concreto | +| POST | `/items/` | Crear un item nuevo | +| PUT | `/items/{item_id}` | Actualizar un item | +| DELETE | `/items/{item_id}` | Eliminar un item | + +### Probar la API + +**1. Obtener todos los items** + +
+ +```console +$ curl -X GET "http://127.0.0.1:8000/items/" +[ + { + "id": 1, + "name": "Laptop", + "description": "High-performance laptop", + "price": 999.99, + "tax": 99.99 + }, + { + "id": 2, + "name": "Mouse", + "description": "Wireless mouse", + "price": 29.99, + "tax": 2.99 + } +] +``` + +
+ +**2. Crear un item nuevo** + +
+ +```console +$ curl -X POST "http://127.0.0.1:8000/items/" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "Keyboard", + "description": "Mechanical keyboard", + "price": 150.00, + "tax": 15.00 + }' + +{ + "id": 3, + "name": "Keyboard", + "description": "Mechanical keyboard", + "price": 150.0, + "tax": 15.0 +} +``` + +
+ +**3. Obtener un item concreto** + +
+ +```console +$ curl -X GET "http://127.0.0.1:8000/items/1" +{ + "id": 1, + "name": "Laptop", + "description": "High-performance laptop", + "price": 999.99, + "tax": 99.99 +} +``` + +
+ +## Paso 5: Probar la API con Swagger UI + +Entra en http://127.0.0.1:8000/docs en tu navegador para ver la documentación de la API generada automáticamente. + +Lo que puedes hacer con Swagger UI: + +1. **Ver los endpoints de la API**: visualmente, todos los endpoints disponibles +2. **Consultar los esquemas de petición / respuesta**: formatos de entrada / salida de cada endpoint +3. **Probar APIs directamente**: hacer llamadas reales con el botón "Try it out" +4. **Ver datos de ejemplo**: ejemplos de petición / respuesta de cada endpoint + +### Cómo usar Swagger UI + +1. Pulsa en el endpoint GET `/items/` +2. Pulsa el botón "Try it out" +3. Pulsa el botón "Execute" +4. Mira la respuesta del servidor + +## Paso 6: Entender la estructura del código + +### Aplicación principal (`src/main.py`) + +```python +from fastapi import FastAPI +from src.api.api import api_router +from src.core.config import settings + +app = FastAPI( + title=settings.PROJECT_NAME, + version=settings.VERSION, + description=settings.DESCRIPTION, +) + +app.include_router(api_router) + +@app.get("/") +def read_root(): + return {"message": "Hello World"} +``` + +### Esquema de Item (`src/schemas/items.py`) + +```python +from pydantic import BaseModel +from typing import Optional + +class ItemBase(BaseModel): + name: str + description: Optional[str] = None + price: float + tax: Optional[float] = None + +class ItemCreate(ItemBase): + pass + +class ItemUpdate(ItemBase): + name: Optional[str] = None + price: Optional[float] = None + +class Item(ItemBase): + id: int + + class Config: + from_attributes = True +``` + +### Lógica CRUD (`src/crud/items.py`) + +```python +from typing import List, Optional +from src.schemas.items import Item, ItemCreate, ItemUpdate + +class ItemCRUD: + def __init__(self): + self.items: List[Item] = [] + self.next_id = 1 + + def create_item(self, item: ItemCreate) -> Item: + new_item = Item(id=self.next_id, **item.dict()) + self.items.append(new_item) + self.next_id += 1 + return new_item + + def get_items(self) -> List[Item]: + return self.items + + def get_item(self, item_id: int) -> Optional[Item]: + return next((item for item in self.items if item.id == item_id), None) +``` + +## Paso 7: Ampliar el proyecto + +### Añadir rutas nuevas + +Puedes añadir nuevos endpoints con el comando `fastkit addroute`: + +
+ +```console +$ fastkit addroute user + Adding New Route +┌──────────────────┬──────────────────────────────────────────┐ +│ Project │ my-first-api │ +│ Route Name │ user │ +│ Target Directory │ /path/to/my-first-api │ +└──────────────────┴──────────────────────────────────────────┘ + +Do you want to add route 'user' to the current project? [Y/n]: y + +✨ Successfully added new route 'user' to the current project! +``` + +
+ +Este comando crea los archivos: + +- `src/api/routes/user.py` - endpoints relacionados con user +- `src/schemas/user.py` - modelos de datos para user +- `src/crud/user.py` - lógica de procesamiento de datos para user + +### Personalizar la configuración del entorno + +Puedes modificar `src/core/config.py` para cambiar los ajustes del proyecto: + +```python +from pydantic_settings import BaseSettings + +class Settings(BaseSettings): + PROJECT_NAME: str = "My First API" + VERSION: str = "1.0.0" + DESCRIPTION: str = "My first FastAPI server" + API_V1_STR: str = "/api/v1" + + class Config: + env_file = ".env" + +settings = Settings() +``` + +## Paso 8: Ejecutar las pruebas + +El proyecto incluye pruebas básicas: + +
+ +```console +$ pytest tests/ -v +======================== test session starts ======================== +collected 4 items + +tests/test_items.py::test_create_item PASSED [ 25%] +tests/test_items.py::test_read_items PASSED [ 50%] +tests/test_items.py::test_read_item PASSED [ 75%] +tests/test_items.py::test_update_item PASSED [100%] + +======================== 4 passed in 0.15s ======================== +``` + +
+ +## Próximos pasos + +¡Has terminado de construir un servidor API básico! Próximos pasos: + +1. **[Construir APIs CRUD asíncronas](async-crud-api.md)** - Aprende procesamiento asíncrono más complejo +2. **[Integración con base de datos](database-integration.md)** - Usar PostgreSQL y SQLAlchemy +3. **[Contenedorización con Docker](docker-deployment.md)** - Preparar el despliegue a producción +4. **[Manejo personalizado de respuestas](custom-response-handling.md)** - Configurar formatos de respuesta avanzados + +## Solución de problemas + +### Problemas comunes + +**P: El servidor no arranca** +R: Comprueba que el entorno virtual está activado y que las dependencias se han instalado correctamente. + +**P: No se puede acceder a los endpoints de la API** +R: Verifica que el servidor está corriendo correctamente y que el número de puerto (por defecto: 8000) es el correcto. + +**P: Las APIs no aparecen en Swagger UI** +R: Comprueba que el router está correctamente incluido en `src/main.py`. + +## Resumen + +En este tutorial hemos usado FastAPI-fastkit para: + +- ✅ Crear un proyecto FastAPI básico +- ✅ Entender la estructura del proyecto +- ✅ Usar endpoints CRUD +- ✅ Documentar y probar la API +- ✅ Aprender métodos para ampliar el proyecto + +Ahora que conoces lo básico de FastAPI, ¡prueba proyectos más complejos! diff --git a/docs/es/tutorial/custom-response-handling.md b/docs/es/tutorial/custom-response-handling.md new file mode 100644 index 0000000..a3feb45 --- /dev/null +++ b/docs/es/tutorial/custom-response-handling.md @@ -0,0 +1,1393 @@ +# Manejo personalizado de respuestas y diseño avanzado de APIs + +Aprende a implementar formatos de respuesta consistentes, manejo de errores, paginación y documentación OpenAPI personalizada con las funcionalidades avanzadas de FastAPI. Implementaremos patrones de diseño de API a nivel empresa usando la plantilla `fastapi-custom-response`. + +## Lo que aprenderás en este tutorial + +- Diseñar formatos de respuesta estandarizados +- Manejo global de excepciones y respuestas de error personalizadas +- Implementar sistemas de paginación +- Filtrado y ordenación +- Personalizar la documentación OpenAPI +- Versionado de la API +- Caché y optimización de respuestas + +## Requisitos previos + +- Haber completado el [tutorial de contenedorización con Docker](docker-deployment.md) +- Conocer los principios de diseño REST +- Conocer los códigos de estado HTTP +- Conceptos básicos de OpenAPI / Swagger + +## Importancia de respuestas de API estandarizadas + +### Respuestas inconsistentes vs estandarizadas + +**Formato de respuesta problemático:** +```json +// Éxito +{"id": 1, "name": "item"} + +// Error +{"detail": "Not found"} + +// Listado +[{"id": 1}, {"id": 2}] +``` + +**Formato de respuesta estandarizado:** +```json +// Éxito +{ + "success": true, + "data": {"id": 1, "name": "item"}, + "message": "Item retrieved successfully", + "timestamp": "2024-01-01T12:00:00Z" +} + +// Error +{ + "success": false, + "error": { + "code": "ITEM_NOT_FOUND", + "message": "Item not found", + "details": {"item_id": 123} + }, + "timestamp": "2024-01-01T12:00:00Z" +} +``` + +## Paso 1: Crear un proyecto de respuestas personalizadas + +Crea un proyecto con la plantilla `fastapi-custom-response`: + +
+ +```console +$ fastkit startdemo fastapi-custom-response +Enter the project name: advanced-api-server +Enter the author name: Developer Kim +Enter the author email: developer@example.com +Enter the project description: API server with advanced response handling +Deploying FastAPI project using 'fastapi-custom-response' template + + Project Information +┌──────────────┬─────────────────────────────────────────────┐ +│ Project Name │ advanced-api-server │ +│ Author │ Developer Kim │ +│ Author Email │ developer@example.com │ +│ Description │ API server with advanced response handling │ +└──────────────┴─────────────────────────────────────────────┘ + + Template Dependencies +┌──────────────┬───────────────────┐ +│ Dependency 1 │ fastapi │ +│ Dependency 2 │ uvicorn │ +│ Dependency 3 │ pydantic │ +│ Dependency 4 │ pydantic-settings │ +│ Dependency 5 │ aiofiles │ +│ Dependency 6 │ python-multipart │ +└──────────────┴───────────────────┘ + +Select package manager (pip, uv, pdm, poetry) [uv]: uv +Do you want to proceed with project creation? [y/N]: y + +✨ FastAPI project 'advanced-api-server' from 'fastapi-custom-response' has been created successfully! +``` + +
+ +## Paso 2: Analizar la estructura del proyecto + +Examinemos las funcionalidades avanzadas del proyecto generado: + +``` +advanced-api-server/ +├── src/ +│ ├── main.py # Aplicación FastAPI +│ ├── schemas/ +│ │ ├── base.py # Esquemas base de respuesta +│ │ ├── items.py # Esquemas de items +│ │ └── responses.py # Definiciones de formato de respuesta +│ ├── helper/ +│ │ ├── exceptions.py # Clases de excepciones personalizadas +│ │ └── pagination.py # Helpers de paginación +│ ├── utils/ +│ │ ├── responses.py # Utilidades de respuesta +│ │ └── documents.py # Personalización de OpenAPI +│ ├── api/ +│ │ └── routes/ +│ │ └── items.py # Endpoints avanzados +│ ├── crud/ +│ │ └── items.py # Lógica CRUD +│ └── core/ +│ └── config.py # Configuración +└── tests/ + └── test_responses.py # Pruebas de formato de respuesta +``` + +## Paso 3: Implementar esquemas de respuesta estandarizados + +### Esquema base de respuesta (`src/schemas/base.py`) + +```python +from typing import Generic, TypeVar, Optional, Any, Dict, List +from pydantic import BaseModel, Field +from datetime import datetime +from enum import Enum + +T = TypeVar('T') + +class ResponseStatus(str, Enum): + """Estado de la respuesta""" + SUCCESS = "success" + ERROR = "error" + WARNING = "warning" + +class ErrorDetail(BaseModel): + """Información detallada del error""" + code: str = Field(..., description="Error code") + message: str = Field(..., description="Error message") + field: Optional[str] = Field(None, description="Field where error occurred") + details: Optional[Dict[str, Any]] = Field(None, description="Additional error information") + +class BaseResponse(BaseModel, Generic[T]): + """Formato base de respuesta""" + success: bool = Field(..., description="Request success status") + status: ResponseStatus = Field(..., description="Response status") + data: Optional[T] = Field(None, description="Response data") + message: Optional[str] = Field(None, description="Response message") + timestamp: datetime = Field(default_factory=datetime.utcnow, description="Response timestamp") + request_id: Optional[str] = Field(None, description="Request tracking ID") + +class ErrorResponse(BaseModel): + """Formato de respuesta de error""" + success: bool = Field(False, description="Request success status") + status: ResponseStatus = Field(ResponseStatus.ERROR, description="Response status") + error: ErrorDetail = Field(..., description="Error information") + timestamp: datetime = Field(default_factory=datetime.utcnow, description="Response timestamp") + request_id: Optional[str] = Field(None, description="Request tracking ID") + +class PaginationMeta(BaseModel): + """Metadatos de paginación""" + page: int = Field(..., ge=1, description="Current page") + size: int = Field(..., ge=1, le=100, description="Page size") + total: int = Field(..., ge=0, description="Total number of items") + pages: int = Field(..., ge=0, description="Total number of pages") + has_next: bool = Field(..., description="Whether next page exists") + has_prev: bool = Field(..., description="Whether previous page exists") + +class PaginatedResponse(BaseModel, Generic[T]): + """Respuesta paginada""" + success: bool = Field(True, description="Request success status") + status: ResponseStatus = Field(ResponseStatus.SUCCESS, description="Response status") + data: List[T] = Field(..., description="Data list") + meta: PaginationMeta = Field(..., description="Pagination information") + message: Optional[str] = Field(None, description="Response message") + timestamp: datetime = Field(default_factory=datetime.utcnow, description="Response time") + request_id: Optional[str] = Field(None, description="Request tracking ID") + +class ValidationErrorDetail(BaseModel): + """Detalle de error de validación""" + field: str = Field(..., description="Validation failed field") + message: str = Field(..., description="Error message") + invalid_value: Any = Field(..., description="Invalid value") + +class ValidationErrorResponse(BaseModel): + """Respuesta de error de validación""" + success: bool = Field(False, description="Request success status") + status: ResponseStatus = Field(ResponseStatus.ERROR, description="Response status") + error: ErrorDetail = Field(..., description="Error information") + validation_errors: List[ValidationErrorDetail] = Field(..., description="Validation error list") + timestamp: datetime = Field(default_factory=datetime.utcnow, description="Response time") + request_id: Optional[str] = Field(None, description="Request tracking ID") +``` + +### Funciones utilitarias de respuesta (`src/utils/responses.py`) + +```python +from typing import Any, Optional, List, TypeVar +from fastapi import Request +from fastapi.responses import JSONResponse +import uuid + +from src.schemas.base import ( + BaseResponse, ErrorResponse, PaginatedResponse, + ResponseStatus, ErrorDetail, PaginationMeta +) + +T = TypeVar('T') + +def generate_request_id() -> str: + """Generar un ID de seguimiento de la petición""" + return str(uuid.uuid4()) + +def success_response( + data: Any = None, + message: Optional[str] = None, + request_id: Optional[str] = None, + status_code: int = 200 +) -> JSONResponse: + """Generar respuesta de éxito""" + response_data = BaseResponse[Any]( + success=True, + status=ResponseStatus.SUCCESS, + data=data, + message=message or "Request processed successfully", + request_id=request_id or generate_request_id() + ) + + return JSONResponse( + status_code=status_code, + content=response_data.dict(exclude_none=True) + ) + +def error_response( + error_code: str, + error_message: str, + details: Optional[dict] = None, + status_code: int = 400, + request_id: Optional[str] = None +) -> JSONResponse: + """Generar respuesta de error""" + error_detail = ErrorDetail( + code=error_code, + message=error_message, + details=details + ) + + response_data = ErrorResponse( + error=error_detail, + request_id=request_id or generate_request_id() + ) + + return JSONResponse( + status_code=status_code, + content=response_data.dict(exclude_none=True) + ) + +def paginated_response( + data: List[T], + page: int, + size: int, + total: int, + message: Optional[str] = None, + request_id: Optional[str] = None +) -> JSONResponse: + """Generar respuesta paginada""" + pages = (total + size - 1) // size # redondeo hacia arriba + has_next = page < pages + has_prev = page > 1 + + meta = PaginationMeta( + page=page, + size=size, + total=total, + pages=pages, + has_next=has_next, + has_prev=has_prev + ) + + response_data = PaginatedResponse[T]( + data=data, + meta=meta, + message=message or f"Page {page}/{pages} data retrieved", + request_id=request_id or generate_request_id() + ) + + return JSONResponse( + status_code=200, + content=response_data.dict(exclude_none=True) + ) + +class ResponseHelper: + """Helper de respuestas""" + + @staticmethod + def created(data: Any, message: str = "Resource created successfully") -> JSONResponse: + return success_response(data=data, message=message, status_code=201) + + @staticmethod + def updated(data: Any, message: str = "Resource updated successfully") -> JSONResponse: + return success_response(data=data, message=message, status_code=200) + + @staticmethod + def deleted(message: str = "Resource deleted successfully") -> JSONResponse: + return success_response(data=None, message=message, status_code=204) + + @staticmethod + def not_found(resource: str = "Resource") -> JSONResponse: + return error_response( + error_code="RESOURCE_NOT_FOUND", + error_message=f"{resource} not found", + status_code=404 + ) + + @staticmethod + def bad_request(message: str = "Bad request") -> JSONResponse: + return error_response( + error_code="BAD_REQUEST", + error_message=message, + status_code=400 + ) + + @staticmethod + def unauthorized(message: str = "Authentication required") -> JSONResponse: + return error_response( + error_code="UNAUTHORIZED", + error_message=message, + status_code=401 + ) + + @staticmethod + def forbidden(message: str = "Permission denied") -> JSONResponse: + return error_response( + error_code="FORBIDDEN", + error_message=message, + status_code=403 + ) + + @staticmethod + def server_error(message: str = "Server internal error occurred") -> JSONResponse: + return error_response( + error_code="INTERNAL_SERVER_ERROR", + error_message=message, + status_code=500 + ) +``` + +## Paso 4: Sistema personalizado de manejo de excepciones + +### Clase de excepción personalizada (`src/helper/exceptions.py`) + +```python +from typing import Optional, Dict, Any +from fastapi import HTTPException + +class BaseAPIException(HTTPException): + """Clase base de excepción de la API""" + + def __init__( + self, + error_code: str, + message: str, + status_code: int = 400, + details: Optional[Dict[str, Any]] = None + ): + self.error_code = error_code + self.message = message + self.details = details or {} + super().__init__(status_code=status_code, detail=message) + +class ValidationException(BaseAPIException): + """Excepción de validación""" + + def __init__(self, message: str, field: Optional[str] = None, details: Optional[Dict] = None): + super().__init__( + error_code="VALIDATION_ERROR", + message=message, + status_code=422, + details=details or {"field": field} if field else None + ) + +class ResourceNotFoundException(BaseAPIException): + """Excepción de recurso no encontrado""" + + def __init__(self, resource: str, resource_id: Any): + super().__init__( + error_code="RESOURCE_NOT_FOUND", + message=f"{resource}(ID: {resource_id}) not found", + status_code=404, + details={"resource": resource, "id": resource_id} + ) + +class DuplicateResourceException(BaseAPIException): + """Excepción de recurso duplicado""" + + def __init__(self, resource: str, field: str, value: Any): + super().__init__( + error_code="DUPLICATE_RESOURCE", + message=f"{resource} {field} '{value}' already exists", + status_code=409, + details={"resource": resource, "field": field, "value": value} + ) + +class BusinessLogicException(BaseAPIException): + """Excepción de lógica de negocio""" + + def __init__(self, message: str, error_code: str = "BUSINESS_LOGIC_ERROR"): + super().__init__( + error_code=error_code, + message=message, + status_code=422 + ) + +class RateLimitException(BaseAPIException): + """Excepción de límite de peticiones""" + + def __init__(self, retry_after: int = 60): + super().__init__( + error_code="RATE_LIMIT_EXCEEDED", + message="Request limit exceeded. Please try again later", + status_code=429, + details={"retry_after": retry_after} + ) + +class AuthenticationException(BaseAPIException): + """Excepción de autenticación""" + + def __init__(self, message: str = "Authentication required"): + super().__init__( + error_code="AUTHENTICATION_REQUIRED", + message=message, + status_code=401 + ) + +class AuthorizationException(BaseAPIException): + """Excepción de autorización""" + + def __init__(self, message: str = "Permission denied"): + super().__init__( + error_code="INSUFFICIENT_PERMISSIONS", + message=message, + status_code=403 + ) +``` + +### Manejador global de excepciones (`src/main.py`) + +```python +from fastapi import FastAPI, Request, status +from fastapi.exceptions import RequestValidationError, HTTPException +from fastapi.responses import JSONResponse +from pydantic import ValidationError +import logging +import traceback + +from src.helper.exceptions import BaseAPIException +from src.utils.responses import error_response, generate_request_id +from src.schemas.base import ValidationErrorDetail, ValidationErrorResponse + +logger = logging.getLogger(__name__) + +app = FastAPI( + title="Advanced API Server", + description="API server with advanced response handling", + version="1.0.0" +) + +@app.exception_handler(BaseAPIException) +async def custom_api_exception_handler(request: Request, exc: BaseAPIException): + """Manejador personalizado de excepciones de la API""" + request_id = generate_request_id() + + logger.error( + f"API Exception: {exc.error_code} - {exc.message}", + extra={ + "request_id": request_id, + "path": request.url.path, + "method": request.method, + "details": exc.details + } + ) + + return error_response( + error_code=exc.error_code, + error_message=exc.message, + details=exc.details, + status_code=exc.status_code, + request_id=request_id + ) + +@app.exception_handler(RequestValidationError) +async def validation_exception_handler(request: Request, exc: RequestValidationError): + """Manejador de excepciones de validación de Pydantic""" + request_id = generate_request_id() + + validation_errors = [] + for error in exc.errors(): + field = ".".join(str(loc) for loc in error["loc"]) + validation_errors.append( + ValidationErrorDetail( + field=field, + message=error["msg"], + invalid_value=error.get("input", "") + ) + ) + + error_response_data = ValidationErrorResponse( + error={ + "code": "VALIDATION_ERROR", + "message": "Input data validation failed", + "details": {"error_count": len(validation_errors)} + }, + validation_errors=validation_errors, + request_id=request_id + ) + + logger.warning( + f"Validation Error: {len(validation_errors)} validation errors", + extra={ + "request_id": request_id, + "path": request.url.path, + "method": request.method, + "errors": [err.dict() for err in validation_errors] + } + ) + + return JSONResponse( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + content=error_response_data.dict(exclude_none=True) + ) + +@app.exception_handler(HTTPException) +async def http_exception_handler(request: Request, exc: HTTPException): + """Manejador de excepciones HTTP""" + request_id = generate_request_id() + + error_code_map = { + 400: "BAD_REQUEST", + 401: "UNAUTHORIZED", + 403: "FORBIDDEN", + 404: "NOT_FOUND", + 405: "METHOD_NOT_ALLOWED", + 500: "INTERNAL_SERVER_ERROR" + } + + error_code = error_code_map.get(exc.status_code, "HTTP_ERROR") + + return error_response( + error_code=error_code, + error_message=exc.detail, + status_code=exc.status_code, + request_id=request_id + ) + +@app.exception_handler(Exception) +async def general_exception_handler(request: Request, exc: Exception): + """Manejador general de excepciones""" + request_id = generate_request_id() + + logger.error( + f"Unhandled Exception: {type(exc).__name__} - {str(exc)}", + extra={ + "request_id": request_id, + "path": request.url.path, + "method": request.method, + "traceback": traceback.format_exc() + } + ) + + return error_response( + error_code="INTERNAL_SERVER_ERROR", + error_message="Unexpected error occurred", + status_code=500, + request_id=request_id + ) +``` + +## Paso 5: Sistema avanzado de paginación + +### Helper de paginación (`src/helper/pagination.py`) + +```python +from typing import List, Optional, Any, Dict, Callable +from pydantic import BaseModel, Field +from fastapi import Query +from enum import Enum + +class SortOrder(str, Enum): + """Orden de la ordenación""" + ASC = "asc" + DESC = "desc" + +class PaginationParams(BaseModel): + """Parámetros de paginación""" + page: int = Field(1, ge=1, description="Page number") + size: int = Field(20, ge=1, le=100, description="Page size") + sort_by: Optional[str] = Field(None, description="Sort field") + sort_order: SortOrder = Field(SortOrder.ASC, description="Sort order") + +class FilterParams(BaseModel): + """Parámetros de filtrado""" + search: Optional[str] = Field(None, description="Search term") + category: Optional[str] = Field(None, description="Category") + status: Optional[str] = Field(None, description="Status") + date_from: Optional[str] = Field(None, description="Start date (YYYY-MM-DD)") + date_to: Optional[str] = Field(None, description="End date (YYYY-MM-DD)") + +def pagination_params( + page: int = Query(1, ge=1, description="Page number"), + size: int = Query(20, ge=1, le=100, description="Page size"), + sort_by: Optional[str] = Query(None, description="Sort field"), + sort_order: SortOrder = Query(SortOrder.ASC, description="Sort order") +) -> PaginationParams: + """Dependencia de parámetros de paginación""" + return PaginationParams( + page=page, + size=size, + sort_by=sort_by, + sort_order=sort_order + ) + +def filter_params( + search: Optional[str] = Query(None, description="Search term"), + category: Optional[str] = Query(None, description="Category"), + status: Optional[str] = Query(None, description="Status"), + date_from: Optional[str] = Query(None, description="Start date (YYYY-MM-DD)"), + date_to: Optional[str] = Query(None, description="End date (YYYY-MM-DD)") +) -> FilterParams: + """Dependencia de parámetros de filtrado""" + return FilterParams( + search=search, + category=category, + status=status, + date_from=date_from, + date_to=date_to + ) + +class AdvancedPaginator: + """Clase de paginación avanzada""" + + def __init__(self, data: List[Any], pagination: PaginationParams, filters: FilterParams): + self.data = data + self.pagination = pagination + self.filters = filters + self.filtered_data = self._apply_filters() + self.sorted_data = self._apply_sorting() + + def _apply_filters(self) -> List[Any]: + """Aplicar filtros""" + filtered = self.data + + if self.filters.search: + # Filtrar por término de búsqueda (ejemplo: campos name o description) + search_term = self.filters.search.lower() + filtered = [ + item for item in filtered + if (hasattr(item, 'name') and search_term in item.name.lower()) or + (hasattr(item, 'description') and item.description and search_term in item.description.lower()) + ] + + if self.filters.category: + filtered = [item for item in filtered if hasattr(item, 'category') and item.category == self.filters.category] + + if self.filters.status: + filtered = [item for item in filtered if hasattr(item, 'status') and item.status == self.filters.status] + + # Filtro por fecha (si existe el campo de fecha) + if self.filters.date_from or self.filters.date_to: + from datetime import datetime + filtered = self._apply_date_filter(filtered) + + return filtered + + def _apply_date_filter(self, data: List[Any]) -> List[Any]: + """Aplicar filtro por fecha""" + from datetime import datetime + + if not self.filters.date_from and not self.filters.date_to: + return data + + filtered = [] + for item in data: + if not hasattr(item, 'created_at'): + continue + + item_date = item.created_at.date() if hasattr(item.created_at, 'date') else item.created_at + + if self.filters.date_from: + start_date = datetime.strptime(self.filters.date_from, "%Y-%m-%d").date() + if item_date < start_date: + continue + + if self.filters.date_to: + end_date = datetime.strptime(self.filters.date_to, "%Y-%m-%d").date() + if item_date > end_date: + continue + + filtered.append(item) + + return filtered + + def _apply_sorting(self) -> List[Any]: + """Aplicar ordenación""" + if not self.pagination.sort_by: + return self.filtered_data + + reverse = self.pagination.sort_order == SortOrder.DESC + + try: + return sorted( + self.filtered_data, + key=lambda x: getattr(x, self.pagination.sort_by, 0), + reverse=reverse + ) + except (AttributeError, TypeError): + # Devolver los datos originales si el campo no existe o no se puede ordenar + return self.filtered_data + + def get_page(self) -> tuple[List[Any], int]: + """Devolver los datos de la página actual y el total""" + total = len(self.sorted_data) + start = (self.pagination.page - 1) * self.pagination.size + end = start + self.pagination.size + + page_data = self.sorted_data[start:end] + return page_data, total + + def get_metadata(self) -> Dict[str, Any]: + """Devolver los metadatos de paginación""" + total = len(self.sorted_data) + pages = (total + self.pagination.size - 1) // self.pagination.size + + return { + "page": self.pagination.page, + "size": self.pagination.size, + "total": total, + "pages": pages, + "has_next": self.pagination.page < pages, + "has_prev": self.pagination.page > 1, + "filters_applied": { + "search": self.filters.search, + "category": self.filters.category, + "status": self.filters.status, + "date_range": f"{self.filters.date_from} ~ {self.filters.date_to}" if self.filters.date_from or self.filters.date_to else None + }, + "sorting": { + "field": self.pagination.sort_by, + "order": self.pagination.sort_order + } if self.pagination.sort_by else None + } +``` + +## Paso 6: Implementar endpoints avanzados + +### Router de items (`src/api/routes/items.py`) + +```python +from typing import List, Optional +from fastapi import APIRouter, Depends, HTTPException, Query, Path, BackgroundTasks +from fastapi.responses import JSONResponse + +from src.schemas.items import Item, ItemCreate, ItemUpdate, ItemResponse +from src.helper.pagination import pagination_params, filter_params, PaginationParams, FilterParams, AdvancedPaginator +from src.helper.exceptions import ResourceNotFoundException, DuplicateResourceException, ValidationException +from src.utils.responses import success_response, paginated_response, ResponseHelper +from src.crud.items import ItemCRUD + +router = APIRouter(prefix="/items", tags=["items"]) +crud = ItemCRUD() + +@router.post("/", response_model=dict, status_code=201) +async def create_item( + item_create: ItemCreate, + background_tasks: BackgroundTasks +) -> JSONResponse: + """ + Crear un item nuevo + + - **name**: Nombre del item (obligatorio) + - **description**: Descripción del item (opcional) + - **price**: Precio (obligatorio, 0 o mayor) + - **category**: Categoría (opcional) + """ + # Comprobar duplicados + existing_item = await crud.get_by_name(item_create.name) + if existing_item: + raise DuplicateResourceException("Item", "name", item_create.name) + + # Validación de lógica de negocio + if item_create.price < 0: + raise ValidationException("Price must be 0 or greater", "price") + + # Crear item + created_item = await crud.create(item_create) + + # Tarea en segundo plano (p. ej. enviar notificación, log, etc.) + background_tasks.add_task(send_creation_notification, created_item.id) + + return ResponseHelper.created( + data=created_item.dict(), + message=f"Item '{created_item.name}' created successfully" + ) + +@router.get("/", response_model=dict) +async def list_items( + pagination: PaginationParams = Depends(pagination_params), + filters: FilterParams = Depends(filter_params) +) -> JSONResponse: + """ + Obtener la lista de items (con paginación, filtrado y ordenación) + + **Paginación:** + - page: número de página (por defecto: 1) + - size: tamaño de página (por defecto: 20, máximo: 100) + + **Ordenación:** + - sort_by: campo de ordenación (name, price, created_at, etc.) + - sort_order: orden (asc, desc) + + **Filtrado:** + - search: término de búsqueda (busca en name o description) + - category: filtro de categoría + - status: filtro de estado + - date_from: fecha inicial (YYYY-MM-DD) + - date_to: fecha final (YYYY-MM-DD) + """ + # Obtener todos los items + all_items = await crud.get_all() + + # Aplicar la paginación avanzada + paginator = AdvancedPaginator(all_items, pagination, filters) + page_data, total = paginator.get_page() + + # Añadir metadatos extra a la respuesta + metadata = paginator.get_metadata() + + # Construir mensaje personalizado + message = f"Total {total} items, {len(page_data)} items retrieved" + if filters.search: + message += f" (Search term: '{filters.search}')" + + return paginated_response( + data=[item.dict() for item in page_data], + page=pagination.page, + size=pagination.size, + total=total, + message=message + ) + +@router.get("/search/advanced", response_model=dict) +async def advanced_search( + q: str = Query(..., min_length=1, description="Search term"), + fields: List[str] = Query(["name", "description"], description="Search fields"), + exact_match: bool = Query(False, description="Exact match"), + case_sensitive: bool = Query(False, description="Case sensitive"), + pagination: PaginationParams = Depends(pagination_params) +) -> JSONResponse: + """ + Funcionalidad de búsqueda avanzada + + - **q**: término de búsqueda (obligatorio) + - **fields**: lista de campos donde buscar + - **exact_match**: coincidencia exacta + - **case_sensitive**: sensible a mayúsculas + """ + results = await crud.advanced_search( + query=q, + fields=fields, + exact_match=exact_match, + case_sensitive=case_sensitive + ) + + # Aplicar paginación + total = len(results) + start = (pagination.page - 1) * pagination.size + end = start + pagination.size + page_data = results[start:end] + + return paginated_response( + data=[item.dict() for item in page_data], + page=pagination.page, + size=pagination.size, + total=total, + message=f"'{q}' search results: {total} items" + ) + +@router.get("/{item_id}", response_model=dict) +async def get_item( + item_id: int = Path(..., gt=0, description="Item ID") +) -> JSONResponse: + """Obtener un item concreto""" + item = await crud.get_by_id(item_id) + if not item: + raise ResourceNotFoundException("Item", item_id) + + return success_response( + data=item.dict(), + message=f"Item '{item.name}' retrieved successfully" + ) + +@router.put("/{item_id}", response_model=dict) +async def update_item( + item_id: int = Path(..., gt=0, description="Item ID"), + item_update: ItemUpdate +) -> JSONResponse: + """Actualizar un item""" + existing_item = await crud.get_by_id(item_id) + if not existing_item: + raise ResourceNotFoundException("Item", item_id) + + # Comprobar duplicados de nombre (frente a otros items) + if item_update.name and item_update.name != existing_item.name: + duplicate = await crud.get_by_name(item_update.name) + if duplicate: + raise DuplicateResourceException("Item", "name", item_update.name) + + updated_item = await crud.update(item_id, item_update) + + return ResponseHelper.updated( + data=updated_item.dict(), + message=f"Item '{updated_item.name}' updated successfully" + ) + +@router.delete("/{item_id}", response_model=dict, status_code=204) +async def delete_item( + item_id: int = Path(..., gt=0, description="Item ID"), + force: bool = Query(False, description="Force delete") +) -> JSONResponse: + """Eliminar un item""" + item = await crud.get_by_id(item_id) + if not item: + raise ResourceNotFoundException("Item", item_id) + + # Validación previa al borrado (p. ej. existencia de pedidos relacionados) + if not force and await crud.has_related_orders(item_id): + raise ValidationException( + "Related orders exist, cannot be deleted. Use force=true to force delete" + ) + + await crud.delete(item_id) + + return ResponseHelper.deleted( + message=f"Item '{item.name}' deleted successfully" + ) + +@router.post("/bulk", response_model=dict) +async def bulk_create_items( + items: List[ItemCreate], + skip_duplicates: bool = Query(False, description="Skip duplicates") +) -> JSONResponse: + """Crear varios items en lote""" + if len(items) > 100: + raise ValidationException("Maximum 100 items can be created at once") + + created_items = [] + skipped_items = [] + errors = [] + + for i, item_create in enumerate(items): + try: + # Comprobar duplicados + existing = await crud.get_by_name(item_create.name) + if existing: + if skip_duplicates: + skipped_items.append({"index": i, "name": item_create.name, "reason": "Duplicate name"}) + continue + else: + errors.append({"index": i, "name": item_create.name, "error": "Duplicate name"}) + continue + + created_item = await crud.create(item_create) + created_items.append(created_item) + + except Exception as e: + errors.append({"index": i, "name": item_create.name, "error": str(e)}) + + result = { + "created_count": len(created_items), + "skipped_count": len(skipped_items), + "error_count": len(errors), + "created_items": [item.dict() for item in created_items], + "skipped_items": skipped_items, + "errors": errors + } + + message = f"{len(created_items)} items created" + if skipped_items: + message += f", {len(skipped_items)} skipped" + if errors: + message += f", {len(errors)} errors" + + return success_response(data=result, message=message) + +async def send_creation_notification(item_id: int): + """Notificación de creación de item (tarea en segundo plano)""" + # En la implementación real, envía notificación por email, Slack, etc. + import asyncio + await asyncio.sleep(1) # Simulación + print(f"Item {item_id} creation notification sent") +``` + +## Paso 7: Personalización de la documentación OpenAPI + +### Personalización de OpenAPI (`src/utils/documents.py`) + +```python +from fastapi import FastAPI +from fastapi.openapi.utils import get_openapi +from typing import Dict, Any + +def custom_openapi(app: FastAPI) -> Dict[str, Any]: + """Crear el esquema OpenAPI personalizado""" + if app.openapi_schema: + return app.openapi_schema + + openapi_schema = get_openapi( + title=app.title, + version=app.version, + description=app.description, + routes=app.routes, + ) + + # Añadir información personalizada + openapi_schema["info"].update({ + "contact": { + "name": "API Support", + "url": "https://example.com/support", + "email": "support@example.com" + }, + "license": { + "name": "MIT", + "url": "https://opensource.org/licenses/MIT" + }, + "termsOfService": "https://example.com/terms" + }) + + # Añadir información de servidores + openapi_schema["servers"] = [ + { + "url": "https://api.example.com", + "description": "Production server" + }, + { + "url": "https://staging-api.example.com", + "description": "Staging server" + }, + { + "url": "http://localhost:8000", + "description": "Development server" + } + ] + + # Añadir esquemas comunes de respuesta + openapi_schema["components"]["schemas"].update({ + "SuccessResponse": { + "type": "object", + "properties": { + "success": {"type": "boolean", "example": True}, + "status": {"type": "string", "example": "success"}, + "data": {"type": "object"}, + "message": {"type": "string", "example": "Request processed successfully"}, + "timestamp": {"type": "string", "format": "date-time"}, + "request_id": {"type": "string", "example": "123e4567-e89b-12d3-a456-426614174000"} + } + }, + "ErrorResponse": { + "type": "object", + "properties": { + "success": {"type": "boolean", "example": False}, + "status": {"type": "string", "example": "error"}, + "error": { + "type": "object", + "properties": { + "code": {"type": "string", "example": "RESOURCE_NOT_FOUND"}, + "message": {"type": "string", "example": "Resource not found"}, + "details": {"type": "object"} + } + }, + "timestamp": {"type": "string", "format": "date-time"}, + "request_id": {"type": "string", "example": "123e4567-e89b-12d3-a456-426614174000"} + } + } + }) + + # Añadir agrupación y descripción de tags + openapi_schema["tags"] = [ + { + "name": "items", + "description": "Item management API", + "externalDocs": { + "description": "More information", + "url": "https://example.com/docs/items" + } + }, + { + "name": "health", + "description": "System status check API" + } + ] + + # Añadir esquema de seguridad + openapi_schema["components"]["securitySchemes"] = { + "BearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT" + }, + "ApiKeyAuth": { + "type": "apiKey", + "in": "header", + "name": "X-API-Key" + } + } + + app.openapi_schema = openapi_schema + return app.openapi_schema + +def setup_docs(app: FastAPI): + """Configurar la documentación""" + app.openapi = lambda: custom_openapi(app) + + # Configuración de Swagger UI + app.docs_url = "/docs" + app.redoc_url = "/redoc" + + # Endpoint adicional de documentación + @app.get("/openapi.json", include_in_schema=False) + async def get_openapi_endpoint(): + return custom_openapi(app) +``` + +### Aplicar a la app principal (`src/main.py` adicional) + +```python +from src.utils.documents import setup_docs +from src.api.routes import items + +# Incluir router +app.include_router(items.router, prefix="/api/v1") + +# Aplicar configuración de la documentación +setup_docs(app) + +# Añadir middleware de request ID +@app.middleware("http") +async def add_request_id(request: Request, call_next): + request_id = generate_request_id() + request.state.request_id = request_id + + response = await call_next(request) + response.headers["X-Request-ID"] = request_id + + return response +``` + +## Paso 8: Implementar sistema de caché + +### Caché de respuestas (`src/utils/cache.py`) + +```python +from typing import Optional, Any, Dict +from functools import wraps +import asyncio +import json +import hashlib +from datetime import datetime, timedelta + +class MemoryCache: + """Caché en memoria""" + + def __init__(self): + self._cache: Dict[str, Dict[str, Any]] = {} + + async def get(self, key: str) -> Optional[Any]: + """Obtener un valor de la caché""" + if key not in self._cache: + return None + + item = self._cache[key] + if datetime.utcnow() > item["expires_at"]: + del self._cache[key] + return None + + return item["value"] + + async def set(self, key: str, value: Any, ttl_seconds: int = 300): + """Guardar un valor en la caché""" + self._cache[key] = { + "value": value, + "expires_at": datetime.utcnow() + timedelta(seconds=ttl_seconds), + "created_at": datetime.utcnow() + } + + async def delete(self, key: str): + """Eliminar un valor de la caché""" + self._cache.pop(key, None) + + async def clear(self): + """Eliminar toda la caché""" + self._cache.clear() + + def get_stats(self) -> Dict[str, Any]: + """Estadísticas de la caché""" + now = datetime.utcnow() + valid_items = [ + item for item in self._cache.values() + if now <= item["expires_at"] + ] + + return { + "total_items": len(self._cache), + "valid_items": len(valid_items), + "expired_items": len(self._cache) - len(valid_items), + "memory_usage_mb": len(str(self._cache)) / 1024 / 1024 + } + +# instancia global de la caché +cache = MemoryCache() + +def cache_response(ttl_seconds: int = 300, key_prefix: str = ""): + """Decorador de caché de respuestas""" + def decorator(func): + @wraps(func) + async def wrapper(*args, **kwargs): + # Generar la clave de caché + cache_key = generate_cache_key(func.__name__, args, kwargs, key_prefix) + + # Obtener desde caché + cached_response = await cache.get(cache_key) + if cached_response: + return cached_response + + # Ejecutar la función + response = await func(*args, **kwargs) + + # Cachear la respuesta + await cache.set(cache_key, response, ttl_seconds) + + return response + return wrapper + return decorator + +def generate_cache_key(func_name: str, args: tuple, kwargs: dict, prefix: str = "") -> str: + """Generar la clave de caché""" + # Generar clave única basada en el nombre de la función y los argumentos + key_data = { + "function": func_name, + "args": str(args), + "kwargs": sorted(kwargs.items()) + } + + key_string = json.dumps(key_data, sort_keys=True) + key_hash = hashlib.md5(key_string.encode()).hexdigest() + + return f"{prefix}:{func_name}:{key_hash}" if prefix else f"{func_name}:{key_hash}" + +# Endpoint de gestión de caché +@app.get("/admin/cache/stats") +async def get_cache_stats(): + """Estadísticas de la caché""" + stats = cache.get_stats() + return success_response(data=stats, message="Cache statistics retrieved") + +@app.delete("/admin/cache/clear") +async def clear_cache(): + """Limpiar toda la caché""" + await cache.clear() + return success_response(message="Cache deleted successfully") +``` + +### Ejemplo de uso de caché + +```python +# Aplicar caché en src/api/routes/items.py + +from src.utils.cache import cache_response + +@router.get("/", response_model=dict) +@cache_response(ttl_seconds=60, key_prefix="items_list") # caché de 1 minuto +async def list_items( + pagination: PaginationParams = Depends(pagination_params), + filters: FilterParams = Depends(filter_params) +) -> JSONResponse: + # ... código existente ... + +@router.get("/{item_id}", response_model=dict) +@cache_response(ttl_seconds=300, key_prefix="item_detail") # caché de 5 minutos +async def get_item(item_id: int = Path(..., gt=0)) -> JSONResponse: + # ... código existente ... +``` + +## Paso 9: Probar la API + +### Ejecutar el servidor y prueba básica + +
+ +```console +$ cd advanced-api-server +$ fastkit runserver +Starting FastAPI server at 127.0.0.1:8000... + +# Probar el formato de respuesta personalizado +$ curl -X POST "http://localhost:8000/api/v1/items/" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "Advanced notebook", + "description": "Notebook with latest technology", + "price": 2500000, + "category": "electronics" + }' + +{ + "success": true, + "status": "success", + "data": { + "id": 1, + "name": "Advanced notebook", + "description": "Notebook with latest technology", + "price": 2500000, + "category": "electronics", + "created_at": "2024-01-01T12:00:00Z" + }, + "message": "Item 'Advanced notebook' created successfully", + "timestamp": "2024-01-01T12:00:00.123456Z", + "request_id": "123e4567-e89b-12d3-a456-426614174000" +} + +# Probar paginación y filtrado +$ curl "http://localhost:8000/api/v1/items/?page=1&size=10&search=notebook&sort_by=price&sort_order=desc" + +# Probar la búsqueda avanzada +$ curl "http://localhost:8000/api/v1/items/search/advanced?q=notebook&fields=name&fields=description&exact_match=false" + +# Probar la respuesta de error +$ curl "http://localhost:8000/api/v1/items/999" + +{ + "success": false, + "status": "error", + "error": { + "code": "RESOURCE_NOT_FOUND", + "message": "Item (ID: 999) not found", + "details": { + "resource": "Item", + "id": 999 + } + }, + "timestamp": "2024-01-01T12:00:00.123456Z", + "request_id": "123e4567-e89b-12d3-a456-426614174000" +} +``` + +
+ +### Revisar la documentación OpenAPI + +Entra en http://localhost:8000/docs en tu navegador para ver la documentación personalizada de la API. + +## Próximos pasos + +¡Has terminado el sistema de manejo personalizado de respuestas! Próximos pasos: + +1. **[Integración con MCP](mcp-integration.md)** - Implementar el Model Context Protocol + + + + +## Resumen + +En este tutorial hemos implementado un sistema avanzado de manejo de respuestas: + +- ✅ Diseñar formatos de respuesta estandarizados +- ✅ Manejo global de excepciones y respuestas de error personalizadas +- ✅ Sistemas avanzados de paginación y filtrado +- ✅ Personalización de la documentación OpenAPI +- ✅ Caché de respuestas y optimización del rendimiento +- ✅ Sistema de seguimiento de peticiones (request ID) +- ✅ Procesamiento en segundo plano +- ✅ APIs de operaciones en lote + +¡Ahora puedes implementar todas las funcionalidades clave de un servidor API de nivel empresa! diff --git a/docs/es/tutorial/database-integration.md b/docs/es/tutorial/database-integration.md new file mode 100644 index 0000000..40616a9 --- /dev/null +++ b/docs/es/tutorial/database-integration.md @@ -0,0 +1,1027 @@ +# Integración con base de datos (PostgreSQL + SQLAlchemy) + +Construye una aplicación FastAPI con base de datos PostgreSQL y SQLAlchemy ORM lista para usarse en producción. En este tutorial implementaremos un sistema completo de integración con base de datos usando la plantilla `fastapi-psql-orm`. + +## Lo que aprenderás en este tutorial + +- Configurar e integrar PostgreSQL +- Modelado de datos con SQLAlchemy ORM +- Migraciones de base de datos con Alembic +- Configurar el entorno de desarrollo con Docker Compose +- Gestión del pool de conexiones a la base de datos +- Procesamiento de transacciones e integridad de datos + +## Requisitos previos + +- Haber completado el [tutorial de APIs CRUD asíncronas](async-crud-api.md) +- Docker y Docker Compose instalados +- Conocimientos básicos de PostgreSQL +- Comprensión de los conceptos básicos de SQLAlchemy ORM + +## Por qué PostgreSQL y SQLAlchemy + +### Comparación: archivos JSON vs PostgreSQL + +| Categoría | Archivos JSON | PostgreSQL | +|---|---|---| +| **Rendimiento** | Limitado | Indexación de alto rendimiento | +| **Concurrencia** | Problemas de bloqueo de archivos | Soporte de transacciones | +| **Escalabilidad** | Limitada por la memoria | Procesamiento de datos a gran escala | +| **Integridad** | No garantizada | Propiedades ACID garantizadas | +| **Consultas** | Hay que cargar todos los datos | Soporte de consultas complejas | +| **Copia de seguridad** | Copia de archivos | Backup / recuperación completos | + +## Paso 1: Crear un proyecto PostgreSQL + ORM + +Crea un proyecto usando la plantilla `fastapi-psql-orm`: + +
+ +```console +$ fastkit startdemo fastapi-psql-orm +Enter the project name: todo-postgres-api +Enter the author name: Developer Kim +Enter the author email: developer@example.com +Enter the project description: Todo management API using PostgreSQL +Deploying FastAPI project using 'fastapi-psql-orm' template + + Project Information +┌──────────────┬─────────────────────────────────────────┐ +│ Project Name │ todo-postgres-api │ +│ Author │ Developer Kim │ +│ Author Email │ developer@example.com │ +│ Description │ Todo management API using PostgreSQL │ +└──────────────┴─────────────────────────────────────────┘ + + Template Dependencies +┌──────────────┬────────────────┐ +│ Dependency 1 │ fastapi │ +│ Dependency 2 │ uvicorn │ +│ Dependency 3 │ sqlalchemy │ +│ Dependency 4 │ alembic │ +│ Dependency 5 │ psycopg2 │ +│ Dependency 6 │ asyncpg │ +│ Dependency 7 │ sqlmodel │ +└──────────────┴────────────────┘ + +Select package manager (pip, uv, pdm, poetry) [uv]: uv +Do you want to proceed with project creation? [y/N]: y + +✨ FastAPI project 'todo-postgres-api' from 'fastapi-psql-orm' has been created successfully! +``` + +
+ +## Paso 2: Analizar la estructura del proyecto + +El proyecto generado ofrece un entorno completo de integración con base de datos: + +``` +todo-postgres-api/ +├── docker-compose.yml # Configuración del contenedor PostgreSQL +├── Dockerfile # Contenedor de la aplicación +├── alembic.ini # Configuración de Alembic +├── template-config.yml # Configuración de la plantilla +├── scripts/ +│ ├── pre-start.sh # Inicialización previa al arranque +│ └── test.sh # Script de ejecución de pruebas +├── src/ +│ ├── main.py # Aplicación FastAPI +│ ├── core/ +│ │ ├── config.py # Configuración de entorno +│ │ └── db.py # Configuración de la conexión a la BD +│ ├── api/ +│ │ ├── deps.py # Inyección de dependencias +│ │ └── routes/ +│ │ └── items.py # Endpoints de la API +│ ├── crud/ +│ │ └── items.py # Operaciones de base de datos +│ ├── schemas/ +│ │ └── items.py # Modelos Pydantic +│ ├── utils/ +│ │ ├── backend_pre_start.py # Inicialización del backend +│ │ ├── init_data.py # Carga de datos iniciales +│ │ └── tests_pre_start.py # Preparación de pruebas +│ └── alembic/ +│ ├── env.py # Configuración del entorno Alembic +│ └── versions/ # Archivos de migración +└── tests/ + ├── conftest.py # Configuración de pruebas + └── test_items.py # Pruebas de la API +``` + +### Componentes principales + +1. **SQLModel**: integración SQLAlchemy + Pydantic +2. **Alembic**: migraciones de esquema de base de datos +3. **asyncpg**: driver PostgreSQL asíncrono +4. **Docker Compose**: contenedorización del entorno de desarrollo + +## Paso 3: Entender la configuración de la base de datos + +### Configuración de la conexión (`src/core/db.py`) + +```python +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine +from sqlalchemy.orm import sessionmaker +from sqlmodel import SQLModel + +from src.core.config import settings + +# Crear engine PostgreSQL asíncrono +engine = create_async_engine( + settings.DATABASE_URL, + echo=settings.DEBUG, # Mostrar logs de SQL + pool_size=20, # Tamaño del pool de conexiones + max_overflow=0, # Número de conexiones adicionales permitidas + pool_pre_ping=True, # Comprobar el estado de la conexión +) + +# Factory de sesiones asíncronas +AsyncSessionLocal = sessionmaker( + autocommit=False, + autoflush=False, + bind=engine, + class_=AsyncSession, + expire_on_commit=False, +) + +async def create_tables(): + """Crear las tablas de la base de datos""" + async with engine.begin() as conn: + await conn.run_sync(SQLModel.metadata.create_all) + +async def get_session() -> AsyncSession: + """Proporcionar una sesión de base de datos (para inyección de dependencias)""" + async with AsyncSessionLocal() as session: + try: + yield session + finally: + await session.close() +``` + +### Configuración de entorno (`src/core/config.py`) + +```python +from pydantic_settings import BaseSettings +from typing import Optional + +class Settings(BaseSettings): + PROJECT_NAME: str = "Todo PostgreSQL API" + VERSION: str = "1.0.0" + DESCRIPTION: str = "Todo management API using PostgreSQL" + + # Configuración de base de datos + POSTGRES_SERVER: str = "localhost" + POSTGRES_USER: str = "postgres" + POSTGRES_PASSWORD: str = "password" + POSTGRES_DB: str = "todoapp" + POSTGRES_PORT: int = 5432 + + # Base de datos de pruebas + TEST_DATABASE_URL: Optional[str] = None + + # Modo debug + DEBUG: bool = False + + @property + def DATABASE_URL(self) -> str: + """Generar la URL de conexión a PostgreSQL""" + return ( + f"postgresql+asyncpg://{self.POSTGRES_USER}:" + f"{self.POSTGRES_PASSWORD}@{self.POSTGRES_SERVER}:" + f"{self.POSTGRES_PORT}/{self.POSTGRES_DB}" + ) + + class Config: + env_file = ".env" + +settings = Settings() +``` + +## Paso 4: Definir el modelo de datos + +### Modelo de datos con SQLModel (`src/schemas/items.py`) + +```python +from sqlmodel import SQLModel, Field +from typing import Optional +from datetime import datetime + +# Definir campos comunes +class ItemBase(SQLModel): + name: str = Field(index=True, max_length=100) + description: Optional[str] = Field(default=None, max_length=500) + price: float = Field(gt=0, description="Price must be greater than 0") + tax: Optional[float] = Field(default=None, ge=0) + is_active: bool = Field(default=True) + +# Modelo de tabla +class Item(ItemBase, table=True): + __tablename__ = "items" + + id: Optional[int] = Field(default=None, primary_key=True) + created_at: datetime = Field(default_factory=datetime.utcnow) + updated_at: Optional[datetime] = Field(default=None) + + # Configuración del índice + class Config: + schema_extra = { + "example": { + "name": "notebook", + "description": "High-performance gaming notebook", + "price": 1500000.0, + "tax": 150000.0, + "is_active": True + } + } + +# Modelos de petición / respuesta de la API +class ItemCreate(ItemBase): + pass + +class ItemUpdate(SQLModel): + name: Optional[str] = Field(default=None, max_length=100) + description: Optional[str] = Field(default=None, max_length=500) + price: Optional[float] = Field(default=None, gt=0) + tax: Optional[float] = Field(default=None, ge=0) + is_active: Optional[bool] = Field(default=None) + +class ItemResponse(ItemBase): + id: int + created_at: datetime + updated_at: Optional[datetime] +``` + +## Paso 5: Implementar operaciones CRUD + +### Lógica CRUD de base de datos (`src/crud/items.py`) + +```python +from typing import List, Optional +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, update, delete +from sqlalchemy.orm import selectinload +from datetime import datetime + +from src.schemas.items import Item, ItemCreate, ItemUpdate + +class ItemCRUD: + def __init__(self, db: AsyncSession): + self.db = db + + async def create(self, item_create: ItemCreate) -> Item: + """Crear un item nuevo""" + db_item = Item(**item_create.dict()) + + self.db.add(db_item) + await self.db.commit() + await self.db.refresh(db_item) + + return db_item + + async def get_by_id(self, item_id: int) -> Optional[Item]: + """Obtener item por ID""" + statement = select(Item).where(Item.id == item_id) + result = await self.db.execute(statement) + return result.scalar_one_or_none() + + async def get_many( + self, + skip: int = 0, + limit: int = 100, + active_only: bool = True + ) -> List[Item]: + """Obtener varios items (con paginación)""" + statement = select(Item) + + if active_only: + statement = statement.where(Item.is_active == True) + + statement = statement.offset(skip).limit(limit) + result = await self.db.execute(statement) + return result.scalars().all() + + async def update(self, item_id: int, item_update: ItemUpdate) -> Optional[Item]: + """Actualizar un item""" + # Preparar los datos de actualización + update_data = item_update.dict(exclude_unset=True) + if update_data: + update_data["updated_at"] = datetime.utcnow() + + # Ejecutar la actualización + statement = ( + update(Item) + .where(Item.id == item_id) + .values(**update_data) + .returning(Item) + ) + + result = await self.db.execute(statement) + await self.db.commit() + + return result.scalar_one_or_none() + + async def delete(self, item_id: int) -> bool: + """Eliminar item (soft delete)""" + statement = ( + update(Item) + .where(Item.id == item_id) + .values(is_active=False, updated_at=datetime.utcnow()) + ) + + result = await self.db.execute(statement) + await self.db.commit() + + return result.rowcount > 0 + + async def hard_delete(self, item_id: int) -> bool: + """Eliminar un item por completo""" + statement = delete(Item).where(Item.id == item_id) + result = await self.db.execute(statement) + await self.db.commit() + + return result.rowcount > 0 + + async def search(self, query: str) -> List[Item]: + """Buscar item (name, description)""" + statement = select(Item).where( + (Item.name.ilike(f"%{query}%")) | + (Item.description.ilike(f"%{query}%")) + ).where(Item.is_active == True) + + result = await self.db.execute(statement) + return result.scalars().all() + + async def get_total_count(self, active_only: bool = True) -> int: + """Obtener el total de items""" + from sqlalchemy import func + + statement = select(func.count(Item.id)) + if active_only: + statement = statement.where(Item.is_active == True) + + result = await self.db.execute(statement) + return result.scalar() +``` + +## Paso 6: Implementar los endpoints de la API + +### Configuración de inyección de dependencias (`src/api/deps.py`) + +```python +from typing import AsyncGenerator +from fastapi import Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from src.core.db import get_session +from src.crud.items import ItemCRUD + +async def get_db() -> AsyncGenerator[AsyncSession, None]: + """Dependencia de sesión de base de datos""" + async for session in get_session(): + yield session + +def get_item_crud(db: AsyncSession = Depends(get_db)) -> ItemCRUD: + """Dependencia del CRUD de items""" + return ItemCRUD(db) +``` + +### Implementación del router de la API (`src/api/routes/items.py`) + +```python +from typing import List +from fastapi import APIRouter, Depends, HTTPException, Query, status + +from src.api.deps import get_item_crud +from src.crud.items import ItemCRUD +from src.schemas.items import Item, ItemCreate, ItemUpdate, ItemResponse + +router = APIRouter() + +@router.post("/", response_model=ItemResponse, status_code=status.HTTP_201_CREATED) +async def create_item( + item_create: ItemCreate, + crud: ItemCRUD = Depends(get_item_crud) +): + """Crear un item nuevo""" + return await crud.create(item_create) + +@router.get("/", response_model=List[ItemResponse]) +async def read_items( + skip: int = Query(0, ge=0, description="Skip items"), + limit: int = Query(100, ge=1, le=1000, description="Maximum items to retrieve"), + active_only: bool = Query(True, description="Only active items"), + crud: ItemCRUD = Depends(get_item_crud) +): + """Obtener la lista de items (con paginación)""" + return await crud.get_many(skip=skip, limit=limit, active_only=active_only) + +@router.get("/search", response_model=List[ItemResponse]) +async def search_items( + q: str = Query(..., min_length=1, description="Search term"), + crud: ItemCRUD = Depends(get_item_crud) +): + """Buscar item""" + return await crud.search(q) + +@router.get("/count") +async def get_items_count( + active_only: bool = Query(True, description="Only active items"), + crud: ItemCRUD = Depends(get_item_crud) +): + """Obtener el total de items""" + count = await crud.get_total_count(active_only) + return {"total": count} + +@router.get("/{item_id}", response_model=ItemResponse) +async def read_item( + item_id: int, + crud: ItemCRUD = Depends(get_item_crud) +): + """Obtener un item concreto""" + item = await crud.get_by_id(item_id) + if not item: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Item ID {item_id} not found" + ) + return item + +@router.put("/{item_id}", response_model=ItemResponse) +async def update_item( + item_id: int, + item_update: ItemUpdate, + crud: ItemCRUD = Depends(get_item_crud) +): + """Actualizar un item""" + updated_item = await crud.update(item_id, item_update) + if not updated_item: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Item ID {item_id} not found" + ) + return updated_item + +@router.delete("/{item_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_item( + item_id: int, + hard_delete: bool = Query(False, description="Complete delete"), + crud: ItemCRUD = Depends(get_item_crud) +): + """Eliminar item""" + if hard_delete: + deleted = await crud.hard_delete(item_id) + else: + deleted = await crud.delete(item_id) + + if not deleted: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Item ID {item_id} not found" + ) +``` + +## Paso 7: Ejecutar los contenedores de Docker + +### Revisar la configuración de Docker Compose (`docker-compose.yml`) + +```yaml +version: '3.8' + +services: + db: + image: postgres:15 + restart: always + environment: + POSTGRES_DB: todoapp + POSTGRES_USER: postgres + POSTGRES_PASSWORD: password + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + + app: + build: . + restart: always + ports: + - "8000:8000" + environment: + POSTGRES_SERVER: db + POSTGRES_USER: postgres + POSTGRES_PASSWORD: password + POSTGRES_DB: todoapp + depends_on: + - db + volumes: + - ./src:/app/src + +volumes: + postgres_data: +``` + +### Ejecutar los contenedores + +
+ +```console +$ cd todo-postgres-api + +# Arrancar el servicio en segundo plano +$ docker-compose up -d +Creating network "todo-postgres-api_default" with the default driver +Creating volume "todo-postgres-api_postgres_data" with default driver +Pulling db (postgres:15)... +Creating todo-postgres-api_db_1 ... done +Building app +Creating todo-postgres-api_app_1 ... done + +# Comprobar el estado del servicio +$ docker-compose ps + Name Command State Ports +------------------------------------------------------------------------------------- +todo-postgres-api_app_1 uvicorn src.main:app --host=0.0.0.0 --port=8000 Up 0.0.0.0:8000->8000/tcp +todo-postgres-api_db_1 docker-entrypoint.sh postgres Up 0.0.0.0:5432->5432/tcp + +# Comprobar los logs +$ docker-compose logs app +``` + +
+ +## Paso 8: Migraciones de base de datos + +### Crear la migración inicial con Alembic + +
+ +```console +# Ejecutar la migración dentro del contenedor +$ docker-compose exec app alembic revision --autogenerate -m "Create items table" +INFO [alembic.runtime.migration] Context impl PostgresqlImpl. +INFO [alembic.runtime.migration] Will assume transactional DDL. +INFO [alembic.autogenerate.compare] Detected added table 'items' +Generating migration script /app/src/alembic/versions/001_create_items_table.py ... done + +# Aplicar la migración +$ docker-compose exec app alembic upgrade head +INFO [alembic.runtime.migration] Context impl PostgresqlImpl. +INFO [alembic.runtime.migration] Will assume transactional DDL. +INFO [alembic.runtime.migration] Running upgrade -> 001, Create items table +``` + +
+ +### Revisar el archivo de migración + +Comprueba el archivo de migración generado: + +```python +# src/alembic/versions/001_create_items_table.py +"""Create items table + +Revision ID: 001 +Revises: +Create Date: 2024-01-01 12:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel + +# revision identifiers +revision = '001' +down_revision = None +branch_labels = None +depends_on = None + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('items', + sa.Column('name', sqlmodel.sql.sqltypes.AutoString(length=100), nullable=False), + sa.Column('description', sqlmodel.sql.sqltypes.AutoString(length=500), nullable=True), + sa.Column('price', sa.Float(), nullable=False), + sa.Column('tax', sa.Float(), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_items_name'), 'items', ['name'], unique=False) + # ### end Alembic commands ### + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_items_name'), table_name='items') + op.drop_table('items') + # ### end Alembic commands ### +``` + +## Paso 9: Probar la API + +### Probar CRUD básico + +
+ +```console +# Crear item nuevo +$ curl -X POST "http://localhost:8000/items/" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "MacBook Pro", + "description": "M2 chipset-equipped high-performance notebook", + "price": 2500000, + "tax": 250000 + }' + +{ + "id": 1, + "name": "MacBook Pro", + "description": "M2 chipset-equipped high-performance notebook", + "price": 2500000.0, + "tax": 250000.0, + "is_active": true, + "created_at": "2024-01-01T12:00:00.123456", + "updated_at": null +} + +# Obtener lista de items +$ curl "http://localhost:8000/items/" + +# Obtener lista con paginación +$ curl "http://localhost:8000/items/?skip=0&limit=10" + +# Buscar item +$ curl "http://localhost:8000/items/search?q=MacBook" + +# Obtener el total de items +$ curl "http://localhost:8000/items/count" +{"total": 1} +``` + +
+ +### Probar funcionalidades de consulta avanzadas + +
+ +```console +# Listar incluyendo items inactivos +$ curl "http://localhost:8000/items/?active_only=false" + +# Actualizar item +$ curl -X PUT "http://localhost:8000/items/1" \ + -H "Content-Type: application/json" \ + -d '{ + "price": 2300000, + "tax": 230000 + }' + +# Soft delete del item +$ curl -X DELETE "http://localhost:8000/items/1" + +# Hard delete del item +$ curl -X DELETE "http://localhost:8000/items/1?hard_delete=true" +``` + +
+ +## Paso 10: Funcionalidades avanzadas de base de datos + +### Procesamiento de transacciones + +```python +# Añadir a src/crud/items.py + +from sqlalchemy.exc import SQLAlchemyError + +async def create_items_batch(self, items_create: List[ItemCreate]) -> List[Item]: + """Crear varios items dentro de una transacción""" + created_items = [] + + try: + for item_create in items_create: + db_item = Item(**item_create.dict()) + self.db.add(db_item) + created_items.append(db_item) + + await self.db.commit() + + # Refrescar todos los items + for item in created_items: + await self.db.refresh(item) + + return created_items + + except SQLAlchemyError: + await self.db.rollback() + raise +``` + +### Modelado de datos relacional + +```python +# Añadir a src/schemas/items.py + +from sqlmodel import Relationship + +class Category(SQLModel, table=True): + __tablename__ = "categories" + + id: Optional[int] = Field(default=None, primary_key=True) + name: str = Field(max_length=50, unique=True) + description: Optional[str] = None + + # Definir la relación + items: List["Item"] = Relationship(back_populates="category") + +class Item(ItemBase, table=True): + __tablename__ = "items" + + id: Optional[int] = Field(default=None, primary_key=True) + created_at: datetime = Field(default_factory=datetime.utcnow) + updated_at: Optional[datetime] = Field(default=None) + + # Añadir clave foránea + category_id: Optional[int] = Field(foreign_key="categories.id") + + # Definir la relación + category: Optional[Category] = Relationship(back_populates="items") +``` + +### Optimización de índices + +```python +# Añadir a src/schemas/items.py + +from sqlalchemy import Index + +class Item(ItemBase, table=True): + __tablename__ = "items" + + # ... campos existentes ... + + # Definir índices compuestos + __table_args__ = ( + Index('ix_items_price_active', 'price', 'is_active'), + Index('ix_items_created_at', 'created_at'), + Index('ix_items_name_description', 'name', 'description'), # Para búsqueda full text + ) +``` + +## Paso 11: Escribir pruebas + +### Configuración de pruebas de base de datos (`tests/conftest.py`) + +```python +import pytest +import asyncio +from httpx import AsyncClient +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine +from sqlalchemy.orm import sessionmaker +from sqlmodel import SQLModel + +from src.main import app +from src.core.db import get_session +from src.core.config import settings + +# Engine de base de datos de pruebas +test_engine = create_async_engine( + settings.TEST_DATABASE_URL or "sqlite+aiosqlite:///./test.db", + echo=False, +) + +TestSessionLocal = sessionmaker( + autocommit=False, + autoflush=False, + bind=test_engine, + class_=AsyncSession, + expire_on_commit=False, +) + +@pytest.fixture(scope="session") +def event_loop(): + loop = asyncio.get_event_loop_policy().new_event_loop() + yield loop + loop.close() + +@pytest.fixture(scope="function") +async def db_session(): + # Crear tabla de pruebas + async with test_engine.begin() as conn: + await conn.run_sync(SQLModel.metadata.create_all) + + # Proporcionar la sesión + async with TestSessionLocal() as session: + yield session + + # Eliminar la tabla tras el test + async with test_engine.begin() as conn: + await conn.run_sync(SQLModel.metadata.drop_all) + +@pytest.fixture +async def client(db_session: AsyncSession): + # Sobreescribir la dependencia + async def override_get_session(): + yield db_session + + app.dependency_overrides[get_session] = override_get_session + + async with AsyncClient(app=app, base_url="http://test") as client: + yield client + + app.dependency_overrides.clear() +``` + +### Pruebas de integración (`tests/test_items.py`) + +```python +import pytest +from httpx import AsyncClient + +@pytest.mark.asyncio +async def test_create_and_read_item(client: AsyncClient): + """Prueba de integración: crear y leer item""" + # Crear item + item_data = { + "name": "Test Item", + "description": "Database test", + "price": 50000, + "tax": 5000 + } + + response = await client.post("/items/", json=item_data) + assert response.status_code == 201 + + created_item = response.json() + assert created_item["name"] == item_data["name"] + assert "id" in created_item + assert "created_at" in created_item + + # Leer el item creado + item_id = created_item["id"] + response = await client.get(f"/items/{item_id}") + assert response.status_code == 200 + + retrieved_item = response.json() + assert retrieved_item["id"] == item_id + assert retrieved_item["name"] == item_data["name"] + +@pytest.mark.asyncio +async def test_item_pagination(client: AsyncClient): + """Probar la paginación""" + # Crear varios items + for i in range(15): + item_data = { + "name": f"Item {i}", + "description": f"Description {i}", + "price": i * 1000, + "tax": i * 100 + } + await client.post("/items/", json=item_data) + + # Obtener primera página + response = await client.get("/items/?skip=0&limit=10") + assert response.status_code == 200 + + items = response.json() + assert len(items) == 10 + + # Obtener segunda página + response = await client.get("/items/?skip=10&limit=10") + assert response.status_code == 200 + + items = response.json() + assert len(items) == 5 + +@pytest.mark.asyncio +async def test_item_search(client: AsyncClient): + """Probar la búsqueda""" + # Crear items de prueba + items = [ + {"name": "iPhone 15", "description": "Latest smartphone", "price": 1200000, "tax": 120000}, + {"name": "Galaxy S24", "description": "Samsung flagship", "price": 1100000, "tax": 110000}, + {"name": "MacBook Air", "description": "Apple notebook", "price": 1500000, "tax": 150000}, + ] + + for item in items: + await client.post("/items/", json=item) + + # Buscar "iPhone" + response = await client.get("/items/search?q=iPhone") + assert response.status_code == 200 + + results = response.json() + assert len(results) == 1 + assert results[0]["name"] == "iPhone 15" + + # Buscar "smartphone" (en descripción) + response = await client.get("/items/search?q=smartphone") + assert response.status_code == 200 + + results = response.json() + assert len(results) == 1 + assert results[0]["description"] == "Latest smartphone" +``` + +### Ejecutar las pruebas + +
+ +```console +# Ejecutar las pruebas dentro del contenedor +$ docker-compose exec app python -m pytest tests/ -v +======================== test session starts ======================== +collected 12 items + +tests/test_items.py::test_create_and_read_item PASSED [ 8%] +tests/test_items.py::test_item_pagination PASSED [16%] +tests/test_items.py::test_item_search PASSED [25%] +tests/test_items.py::test_update_item PASSED [33%] +tests/test_items.py::test_delete_item PASSED [41%] +tests/test_items.py::test_soft_delete PASSED [50%] +tests/test_items.py::test_item_not_found PASSED [58%] +tests/test_items.py::test_invalid_item_data PASSED [66%] +tests/test_items.py::test_database_transaction PASSED [75%] +tests/test_items.py::test_concurrent_operations PASSED [83%] +tests/test_items.py::test_item_count PASSED [91%] +tests/test_items.py::test_batch_operations PASSED [100%] + +======================== 12 passed in 2.34s ======================== +``` + +
+ +## Paso 12: Consideraciones de despliegue a producción + +### Optimizar el pool de conexiones + +```python +# Añadir a src/core/config.py + +class Settings(BaseSettings): + # ... configuración existente ... + + # Configuración del pool de conexiones + DB_POOL_SIZE: int = 20 + DB_MAX_OVERFLOW: int = 0 + DB_POOL_PRE_PING: bool = True + DB_POOL_RECYCLE: int = 300 # 5 minutos + + # Timeout de consulta + DB_QUERY_TIMEOUT: int = 30 + + # Reintentos de conexión + DB_RETRY_ATTEMPTS: int = 3 + DB_RETRY_DELAY: int = 1 +``` + +### Monitorización de la base de datos + +```python +# Añadir a src/core/db.py + +import logging +from sqlalchemy import event +from sqlalchemy.engine import Engine + +logger = logging.getLogger(__name__) + +@event.listens_for(Engine, "before_cursor_execute") +def receive_before_cursor_execute(conn, cursor, statement, parameters, context, executemany): + """Log antes de ejecutar la consulta""" + context._query_start_time = time.time() + +@event.listens_for(Engine, "after_cursor_execute") +def receive_after_cursor_execute(conn, cursor, statement, parameters, context, executemany): + """Log tras ejecutar la consulta""" + total = time.time() - context._query_start_time + if total > 1.0: # Log de consultas lentas (más de 1 segundo) + logger.warning(f"Slow query: {total:.2f}s - {statement[:100]}...") +``` + +## Próximos pasos + +¡Has terminado la integración con PostgreSQL! Próximos pasos: + +1. **[Contenedorización con Docker](docker-deployment.md)** - Construir un entorno de despliegue a producción +2. **[Manejo personalizado de respuestas](custom-response-handling.md)** - Formatos de respuesta avanzados + + + +## Resumen + +En este tutorial hemos usado PostgreSQL y SQLAlchemy para: + +- ✅ Integrar PostgreSQL +- ✅ Implementar ORM con SQLModel +- ✅ Configurar el sistema de migraciones con Alembic +- ✅ Operaciones CRUD avanzadas y optimización de consultas +- ✅ Transacciones e integridad de datos +- ✅ Paginación, búsqueda y ordenación +- ✅ Pruebas de integración y de base de datos +- ✅ Consideraciones de despliegue a producción + +¡Ahora puedes construir APIs robustas basadas en base de datos listas para entornos de producción reales! diff --git a/docs/es/tutorial/docker-deployment.md b/docs/es/tutorial/docker-deployment.md new file mode 100644 index 0000000..5b52252 --- /dev/null +++ b/docs/es/tutorial/docker-deployment.md @@ -0,0 +1,1177 @@ +# Contenedorización y despliegue con Docker + +Aprende a contenedorizar aplicaciones FastAPI con Docker para construir entornos de desarrollo consistentes y preparar el despliegue a producción. Configuraremos un entorno de despliegue completo basado en Docker usando la plantilla `fastapi-dockerized`. + +## Lo que aprenderás en este tutorial + +- Contenedorizar aplicaciones FastAPI con Docker +- Crear imágenes Docker optimizadas con builds multi-stage +- Configurar entornos de desarrollo con Docker Compose +- Configuración Docker para despliegue a producción +- Monitorización de contenedores y gestión de logs +- Construir pipelines CI/CD + +## Requisitos previos + +- Haber completado el [tutorial de integración con base de datos](database-integration.md) +- Docker y Docker Compose instalados +- Conocimiento de comandos Docker básicos +- Conceptos básicos sobre contenedores + +## Ventajas de contenedorizar con Docker + +### Enfoque tradicional vs Docker + +| Categoría | Enfoque tradicional | Enfoque Docker | +|---|---|---| +| **Consistencia de entornos** | Diferencias entre entornos | Mismo entorno en todas partes | +| **Gestión de dependencias** | Instalación manual | Todas las dependencias en la imagen | +| **Velocidad de despliegue** | Lenta | Despliegue rápido | +| **Escalabilidad** | Limitada | Escalado fácil | +| **Rollback** | Complejo | Rollback inmediato a la versión previa | +| **Uso de recursos** | Pesado | Contenedores ligeros | + +## Paso 1: Crear un proyecto basado en Docker + +Crea un proyecto con la plantilla `fastapi-dockerized`: + +
+ +```console +$ fastkit startdemo fastapi-dockerized +Enter the project name: dockerized-todo-api +Enter the author name: Developer Kim +Enter the author email: developer@example.com +Enter the project description: Dockerized todo management API +Deploying FastAPI project using 'fastapi-dockerized' template + + Project Information +┌──────────────┬─────────────────────────────────────────────┐ +│ Project Name │ dockerized-todo-api │ +│ Author │ Developer Kim │ +│ Author Email │ developer@example.com │ +│ Description │ Dockerized todo management API │ +└──────────────┴─────────────────────────────────────────────┘ + + Template Dependencies +┌──────────────┬───────────────────┐ +│ Dependency 1 │ fastapi │ +│ Dependency 2 │ uvicorn │ +│ Dependency 3 │ pydantic │ +│ Dependency 4 │ pydantic-settings │ +│ Dependency 5 │ python-dotenv │ +└──────────────┴───────────────────┘ + +Select package manager (pip, uv, pdm, poetry) [uv]: uv +Do you want to proceed with project creation? [y/N]: y + +✨ FastAPI project 'dockerized-todo-api' from 'fastapi-dockerized' has been created successfully! +``` + +
+ +## Paso 2: Analizar los archivos de configuración de Docker + +Examinemos los archivos relacionados con Docker del proyecto generado: + +``` +dockerized-todo-api/ +├── Dockerfile # Configuración de build de la imagen +├── docker-compose.yml # Setup del contenedor de desarrollo +├── docker-compose.prod.yml # Configuración del entorno de producción +├── .dockerignore # Archivos a excluir del build +├── scripts/ +│ ├── start.sh # Script de arranque del contenedor +│ ├── prestart.sh # Script de inicialización previa +│ └── gunicorn.conf.py # Configuración de Gunicorn +├── src/ +│ ├── main.py # Aplicación FastAPI +│ └── ... # Otro código fuente +└── requirements.txt # Dependencias de Python +``` + +### Análisis del Dockerfile + +```dockerfile +# Dockerfile optimizado con build multi-stage + +# ============================================ +# Stage 1: Build +# ============================================ +FROM python:3.12-slim as builder + +# Instalar herramientas de build +RUN apt-get update && apt-get install -y \ + build-essential \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# Copiar el archivo de dependencias e instalar +COPY requirements.txt . +RUN pip install --user --no-cache-dir -r requirements.txt + +# ============================================ +# Stage 2: Runtime +# ============================================ +FROM python:3.12-slim + +# Actualización del sistema e instalación de paquetes esenciales +RUN apt-get update && apt-get install -y \ + curl \ + && rm -rf /var/lib/apt/lists/* \ + && apt-get clean + +# Crear usuario no root (mejora de seguridad) +RUN groupadd -r appuser && useradd -r -g appuser appuser + +# Crear directorio de la aplicación +WORKDIR /app + +# Copiar paquetes Python desde el stage de build +COPY --from=builder /root/.local /home/appuser/.local + +# Copiar el código de la aplicación +COPY . . + +# Configurar permisos +RUN chown -R appuser:appuser /app +RUN chmod +x scripts/start.sh scripts/prestart.sh + +# Añadir la ruta de paquetes Python al PATH +ENV PATH=/home/appuser/.local/bin:$PATH + +# Cambiar al usuario no root +USER appuser + +# Configurar health check +HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:8000/health || exit 1 + +# Exponer puerto +EXPOSE 8000 + +# Ejecutar el script de arranque +CMD ["./scripts/start.sh"] +``` + +### Entorno de desarrollo con Docker Compose (`docker-compose.yml`) + +```yaml +version: '3.8' + +services: + app: + build: + context: . + dockerfile: Dockerfile + container_name: dockerized-todo-api + restart: unless-stopped + ports: + - "8000:8000" + environment: + - ENVIRONMENT=development + - DEBUG=true + - RELOAD=true + volumes: + # Volumen montado para desarrollo (recarga automática al cambiar el código) + - ./src:/app/src:ro + - ./scripts:/app/scripts:ro + networks: + - app-network + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + + # Redis (para caché y almacenamiento de sesión) + redis: + image: redis:7-alpine + container_name: dockerized-todo-redis + restart: unless-stopped + ports: + - "6379:6379" + volumes: + - redis_data:/data + networks: + - app-network + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 30s + timeout: 10s + retries: 3 + + # Nginx (proxy inverso) + nginx: + image: nginx:alpine + container_name: dockerized-todo-nginx + restart: unless-stopped + ports: + - "80:80" + - "443:443" + volumes: + - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro + - ./nginx/ssl:/etc/nginx/ssl:ro + depends_on: + - app + networks: + - app-network + healthcheck: + test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost/health"] + interval: 30s + timeout: 10s + retries: 3 + +volumes: + redis_data: + +networks: + app-network: + driver: bridge +``` + +### Entorno de producción con Docker Compose (`docker-compose.prod.yml`) + +```yaml +version: '3.8' + +services: + app: + build: + context: . + dockerfile: Dockerfile + restart: always + environment: + - ENVIRONMENT=production + - DEBUG=false + - WORKERS=4 + - MAX_WORKERS=8 + volumes: + - app_logs:/app/logs + networks: + - app-network + deploy: + replicas: 2 + resources: + limits: + cpus: '1.0' + memory: 1G + reservations: + cpus: '0.5' + memory: 512M + restart_policy: + condition: on-failure + delay: 5s + max_attempts: 3 + + redis: + image: redis:7-alpine + restart: always + command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD} + volumes: + - redis_data:/data + networks: + - app-network + deploy: + resources: + limits: + cpus: '0.5' + memory: 512M + + nginx: + image: nginx:alpine + restart: always + ports: + - "80:80" + - "443:443" + volumes: + - ./nginx/nginx.prod.conf:/etc/nginx/nginx.conf:ro + - ./nginx/ssl:/etc/nginx/ssl:ro + - nginx_logs:/var/log/nginx + depends_on: + - app + networks: + - app-network + deploy: + resources: + limits: + cpus: '0.5' + memory: 256M + +volumes: + redis_data: + app_logs: + nginx_logs: + +networks: + app-network: + driver: overlay + attachable: true +``` + +## Paso 3: Configurar los scripts de arranque + +### Script principal de arranque (`scripts/start.sh`) + +```bash +#!/bin/bash + +set -e + +# Definir variables de entorno +export PYTHONPATH=/app:$PYTHONPATH + +# Ejecutar script previo al arranque +echo "Running pre-start script..." +./scripts/prestart.sh + +# Determinar el modo según el entorno +if [[ "$ENVIRONMENT" == "production" ]]; then + echo "Starting production server with Gunicorn..." + exec gunicorn src.main:app \ + --config scripts/gunicorn.conf.py \ + --bind 0.0.0.0:8000 \ + --workers ${WORKERS:-4} \ + --worker-class uvicorn.workers.UvicornWorker \ + --max-requests 1000 \ + --max-requests-jitter 100 \ + --preload \ + --access-logfile - \ + --error-logfile - +else + echo "Starting development server with Uvicorn..." + if [[ "$RELOAD" == "true" ]]; then + exec uvicorn src.main:app \ + --host 0.0.0.0 \ + --port 8000 \ + --reload \ + --reload-dir src \ + --log-level debug + else + exec uvicorn src.main:app \ + --host 0.0.0.0 \ + --port 8000 \ + --log-level info + fi +fi +``` + +### Script previo al arranque (`scripts/prestart.sh`) + +```bash +#!/bin/bash + +set -e + +echo "Running pre-start checks..." + +# Comprobar módulos Python y dependencias +echo "Checking Python dependencies..." +python -c "import fastapi, uvicorn, pydantic; print('✓ Core dependencies OK')" + +# Comprobar variables de entorno +if [[ -z "$ENVIRONMENT" ]]; then + export ENVIRONMENT="development" + echo "ℹ ENVIRONMENT not set, defaulting to development" +fi + +# Crear directorio de logs +mkdir -p /app/logs +touch /app/logs/app.log + +# Comprobar si el endpoint de health existe +echo "Checking health endpoint..." +python -c " +from src.main import app +routes = [route.path for route in app.routes] +if '/health' not in routes: + print('⚠ Warning: /health endpoint not found') +else: + print('✓ Health endpoint OK') +" + +echo "Pre-start checks completed successfully!" +``` + +### Configuración de Gunicorn (`scripts/gunicorn.conf.py`) + +```python +import multiprocessing +import os + +# Socket del servidor +bind = "0.0.0.0:8000" +backlog = 2048 + +# Procesos worker +workers = int(os.getenv("WORKERS", multiprocessing.cpu_count() * 2 + 1)) +worker_class = "uvicorn.workers.UvicornWorker" +worker_connections = 1000 +max_requests = 1000 +max_requests_jitter = 100 + +# Configuración de reinicio de workers +preload_app = True +timeout = 120 +keepalive = 2 + +# Logging +accesslog = "-" +errorlog = "-" +loglevel = "info" +access_log_format = '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s" %(D)s' + +# Nombre del proceso +proc_name = "dockerized-todo-api" + +# Seguridad +limit_request_line = 4094 +limit_request_fields = 100 +limit_request_field_size = 8190 + +# Tuning de rendimiento +def when_ready(server): + server.log.info("Server is ready. Spawning workers") + +def worker_int(worker): + worker.log.info("worker received INT or QUIT signal") + +def pre_fork(server, worker): + server.log.info("Worker spawned (pid: %s)", worker.pid) + +def post_fork(server, worker): + server.log.info("Worker spawned (pid: %s)", worker.pid) + +def worker_abort(worker): + worker.log.info("worker received SIGABRT signal") +``` + +## Paso 4: Implementar health check y monitorización + +### Añadir endpoint de health check (`src/main.py`) + +```python +from fastapi import FastAPI, status, Depends +from fastapi.responses import JSONResponse +import psutil +import time +from datetime import datetime + +app = FastAPI( + title="Dockerized Todo API", + description="Dockerized todo management API", + version="1.0.0" +) + +# Hora de arranque de la aplicación +start_time = time.time() + +@app.get("/health", status_code=status.HTTP_200_OK) +async def health_check(): + """ + Endpoint de health check del contenedor + """ + current_time = time.time() + uptime = current_time - start_time + + # Información de recursos del sistema + memory_info = psutil.virtual_memory() + cpu_percent = psutil.cpu_percent(interval=1) + + health_data = { + "status": "healthy", + "timestamp": datetime.utcnow().isoformat(), + "uptime_seconds": round(uptime, 2), + "version": app.version, + "system": { + "memory_usage_percent": memory_info.percent, + "memory_available_mb": round(memory_info.available / 1024 / 1024, 2), + "cpu_usage_percent": cpu_percent, + }, + "checks": { + "database": await check_database_connection(), + "redis": await check_redis_connection(), + "disk_space": check_disk_space(), + } + } + + # Comprobar si todos los checks han pasado + all_checks_passed = all(health_data["checks"].values()) + + if not all_checks_passed: + return JSONResponse( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + content=health_data + ) + + return health_data + +async def check_database_connection() -> bool: + """Comprobar el estado de la conexión a la base de datos""" + try: + # En la implementación real, probar la conexión a la BD + return True + except Exception: + return False + +async def check_redis_connection() -> bool: + """Comprobar el estado de la conexión a Redis""" + try: + # En la implementación real, probar la conexión a Redis + return True + except Exception: + return False + +def check_disk_space() -> bool: + """Comprobar el espacio en disco""" + disk_usage = psutil.disk_usage('/') + free_percentage = (disk_usage.free / disk_usage.total) * 100 + return free_percentage > 10 # Hace falta al menos un 10% libre + +@app.get("/health/ready", status_code=status.HTTP_200_OK) +async def readiness_check(): + """ + Endpoint para readiness probe de Kubernetes + """ + # Comprobar si la app está lista para recibir tráfico + return {"status": "ready", "timestamp": datetime.utcnow().isoformat()} + +@app.get("/health/live", status_code=status.HTTP_200_OK) +async def liveness_check(): + """ + Endpoint para liveness probe de Kubernetes + """ + return {"status": "alive", "timestamp": datetime.utcnow().isoformat()} +``` + +## Paso 5: Configurar Nginx como proxy inverso + +### Configuración de Nginx para desarrollo (`nginx/nginx.conf`) + +```nginx +events { + worker_connections 1024; +} + +http { + upstream fastapi_backend { + # Especificar backend por nombre de contenedor + server app:8000; + } + + # Definir formato de log + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for" ' + 'rt=$request_time uct="$upstream_connect_time" ' + 'uht="$upstream_header_time" urt="$upstream_response_time"'; + + access_log /var/log/nginx/access.log main; + error_log /var/log/nginx/error.log warn; + + # Configuración por defecto + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + types_hash_max_size 2048; + client_max_body_size 100M; + + # Compresión gzip + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_types text/plain text/css text/xml text/javascript + application/json application/javascript application/xml+rss + application/atom+xml image/svg+xml; + + server { + listen 80; + server_name localhost; + + # Cabeceras de seguridad + add_header X-Content-Type-Options nosniff; + add_header X-Frame-Options DENY; + add_header X-XSS-Protection "1; mode=block"; + + # Endpoint de health check + location /health { + proxy_pass http://fastapi_backend; + 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; + + # El health check debe responder rápido + proxy_connect_timeout 5s; + proxy_send_timeout 5s; + proxy_read_timeout 5s; + } + + # Endpoint de la API + location / { + proxy_pass http://fastapi_backend; + 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; + + # Timeouts + proxy_connect_timeout 30s; + proxy_send_timeout 30s; + proxy_read_timeout 30s; + + # Buffering + proxy_buffering on; + proxy_buffer_size 4k; + proxy_buffers 8 4k; + } + + # Caché de archivos estáticos (uso futuro) + location /static { + expires 1y; + add_header Cache-Control public; + add_header ETag ""; + } + } +} +``` + +### Configuración de Nginx para producción (`nginx/nginx.prod.conf`) + +```nginx +events { + worker_connections 2048; +} + +http { + upstream fastapi_backend { + # Balanceo de carga entre varias instancias de la app + server app:8000 max_fails=3 fail_timeout=30s; + # server app2:8000 max_fails=3 fail_timeout=30s; # Para escalado + + # Keep-alive + keepalive 32; + } + + # Configuración de seguridad + server_tokens off; + + # Rate limiting + limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s; + limit_req_zone $binary_remote_addr zone=health:10m rate=100r/s; + + # Configuración SSL + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers ECDHE-RSA-AES256-GCM-SHA512:DHE-RSA-AES256-GCM-SHA512:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES256-GCM-SHA384; + ssl_prefer_server_ciphers off; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 10m; + + server { + listen 80; + server_name your-domain.com; + return 301 https://$server_name$request_uri; + } + + server { + listen 443 ssl http2; + server_name your-domain.com; + + ssl_certificate /etc/nginx/ssl/cert.pem; + ssl_certificate_key /etc/nginx/ssl/key.pem; + + # Cabeceras de seguridad + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options nosniff always; + add_header X-Frame-Options DENY always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + # Health check (con rate limit) + location /health { + limit_req zone=health burst=20 nodelay; + proxy_pass http://fastapi_backend; + include /etc/nginx/proxy_params; + } + + # Endpoint de la API (con rate limit) + location / { + limit_req zone=api burst=20 nodelay; + proxy_pass http://fastapi_backend; + include /etc/nginx/proxy_params; + } + } +} +``` + +## Paso 6: Construir y ejecutar los contenedores + +### Ejecutar en entorno de desarrollo + +
+ +```console +$ cd dockerized-todo-api + +# Construir la imagen Docker +$ docker-compose build +Building app +Step 1/15 : FROM python:3.12-slim as builder + ---> abc123def456 +Step 2/15 : RUN apt-get update && apt-get install -y build-essential curl + ---> Running in xyz789abc123 +... +Successfully built def456ghi789 +Successfully tagged dockerized-todo-api_app:latest + +# Ejecutar los contenedores (en segundo plano) +$ docker-compose up -d +Creating network "dockerized-todo-api_app-network" with driver "bridge" +Creating volume "dockerized-todo-api_redis_data" with default driver +Creating dockerized-todo-redis ... done +Creating dockerized-todo-api ... done +Creating dockerized-todo-nginx ... done + +# Comprobar el estado de los contenedores +$ docker-compose ps + Name Command State Ports +------------------------------------------------------------------------------------------------ +dockerized-todo-api ./scripts/start.sh Up (healthy) 8000/tcp +dockerized-todo-nginx /docker-entrypoint.sh ngin ... Up 0.0.0.0:80->80/tcp, :::80->80/tcp +dockerized-todo-redis docker-entrypoint.sh redis ... Up (healthy) 0.0.0.0:6379->6379/tcp, :::6379->6379/tcp +``` + +
+ +### Revisar los logs + +
+ +```console +# Ver los logs de todos los servicios +$ docker-compose logs + +# Logs de un servicio concreto +$ docker-compose logs app +$ docker-compose logs nginx +$ docker-compose logs redis + +# Logs en tiempo real +$ docker-compose logs -f app +``` + +
+ +### Probar el health check + +
+ +```console +# Health check básico +$ curl http://localhost/health +{ + "status": "healthy", + "timestamp": "2024-01-01T12:00:00.123456", + "uptime_seconds": 45.67, + "version": "1.0.0", + "system": { + "memory_usage_percent": 25.3, + "memory_available_mb": 3072.45, + "cpu_usage_percent": 5.2 + }, + "checks": { + "database": true, + "redis": true, + "disk_space": true + } +} + +# Probar las probes de Kubernetes +$ curl http://localhost/health/ready +$ curl http://localhost/health/live +``` + +
+ +## Paso 7: Despliegue a producción + +### Definir variables de entorno (`.env.prod`) + +```bash +# Configuración de la aplicación +ENVIRONMENT=production +DEBUG=false +SECRET_KEY=your-super-secret-key-here +WORKERS=4 + +# Configuración de base de datos +DATABASE_URL=postgresql://user:password@db:5432/todoapp +REDIS_URL=redis://:password@redis:6379/0 +REDIS_PASSWORD=your-redis-password + +# Configuración de logs +LOG_LEVEL=info +LOG_FILE=/app/logs/app.log + +# Configuración de seguridad +ALLOWED_HOSTS=["your-domain.com"] +CORS_ORIGINS=["https://your-frontend.com"] + +# Monitorización +SENTRY_DSN=https://your-sentry-dsn@sentry.io/project-id +``` + +### Comando de despliegue a producción + +
+ +```console +# Desplegar en producción +$ docker-compose -f docker-compose.prod.yml --env-file .env.prod up -d + +# Escalado (escalar las instancias de app) +$ docker-compose -f docker-compose.prod.yml up -d --scale app=3 + +# Rolling update +$ docker-compose -f docker-compose.prod.yml build app +$ docker-compose -f docker-compose.prod.yml up -d --no-deps app + +# Apagar de forma segura antes de un backup +$ docker-compose -f docker-compose.prod.yml down --timeout 30 +``` + +
+ +## Paso 8: Monitorización y logs + +### Monitorización de recursos de los contenedores Docker + +
+ +```console +# Ver el uso de recursos en tiempo real +$ docker stats + +CONTAINER ID NAME CPU % MEM USAGE / LIMIT MEM % NET I/O BLOCK I/O PIDS +abc123def456 dockerized-todo-api 2.34% 128.5MiB / 1GiB 12.55% 1.23MB / 456kB 12.3MB / 4.56MB 15 +def456ghi789 dockerized-todo-nginx 0.12% 12.5MiB / 256MiB 4.88% 456kB / 1.23MB 1.23MB / 456kB 3 +ghi789jkl012 dockerized-todo-redis 1.45% 32.1MiB / 512MiB 6.27% 789kB / 2.34MB 4.56MB / 1.23MB 4 + +# Ver los detalles de un contenedor concreto +$ docker inspect dockerized-todo-api + +# Ver los procesos internos del contenedor +$ docker-compose exec app ps aux +``` + +
+ +### Agregación y análisis de logs + +```yaml +# docker-compose.logging.yml +version: '3.8' + +services: + # ELK Stack para agregación de logs + elasticsearch: + image: docker.elastic.co/elasticsearch/elasticsearch:8.6.0 + environment: + - discovery.type=single-node + - xpack.security.enabled=false + volumes: + - elasticsearch_data:/usr/share/elasticsearch/data + networks: + - logging + + logstash: + image: docker.elastic.co/logstash/logstash:8.6.0 + volumes: + - ./logstash/pipeline:/usr/share/logstash/pipeline:ro + - ./logstash/config:/usr/share/logstash/config:ro + networks: + - logging + depends_on: + - elasticsearch + + kibana: + image: docker.elastic.co/kibana/kibana:8.6.0 + ports: + - "5601:5601" + environment: + - ELASTICSEARCH_HOSTS=http://elasticsearch:9200 + networks: + - logging + depends_on: + - elasticsearch + + # Fluentd para recopilar logs + fluentd: + image: fluent/fluentd:v1.16-debian-1 + volumes: + - ./fluentd/conf:/fluentd/etc:ro + - /var/log:/var/log:ro + networks: + - logging + depends_on: + - elasticsearch + +volumes: + elasticsearch_data: + +networks: + logging: + driver: bridge +``` + +### Recolección de métricas con Prometheus + +```python +# src/monitoring.py +from prometheus_client import Counter, Histogram, Gauge, generate_latest +from fastapi import Request, Response +import time + +# Definir métricas +REQUEST_COUNT = Counter( + 'http_requests_total', + 'Total HTTP requests', + ['method', 'endpoint', 'status_code'] +) + +REQUEST_DURATION = Histogram( + 'http_request_duration_seconds', + 'HTTP request duration in seconds', + ['method', 'endpoint'] +) + +ACTIVE_CONNECTIONS = Gauge( + 'active_connections', + 'Number of active connections' +) + +async def metrics_middleware(request: Request, call_next): + """Middleware de recolección de métricas Prometheus""" + start_time = time.time() + method = request.method + endpoint = request.url.path + + ACTIVE_CONNECTIONS.inc() + + try: + response = await call_next(request) + status_code = response.status_code + except Exception as e: + status_code = 500 + raise + finally: + duration = time.time() - start_time + REQUEST_DURATION.labels(method=method, endpoint=endpoint).observe(duration) + REQUEST_COUNT.labels(method=method, endpoint=endpoint, status_code=status_code).inc() + ACTIVE_CONNECTIONS.dec() + + return response + +@app.get("/metrics") +async def get_metrics(): + """Endpoint de métricas Prometheus""" + return Response(generate_latest(), media_type="text/plain") +``` + +## Paso 9: Construir el pipeline CI/CD + +### Flujo de trabajo con GitHub Actions (`.github/workflows/deploy.yml`) + +```yaml +name: Deploy to Production + +on: + push: + branches: [main] + pull_request: + branches: [main] + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.12' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install pytest pytest-asyncio httpx + + - name: Run tests + run: | + pytest tests/ -v --cov=src --cov-report=xml + + - name: Upload coverage reports + uses: codecov/codecov-action@v3 + with: + file: ./coverage.xml + + build: + needs: test + runs-on: ubuntu-latest + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + + steps: + - uses: actions/checkout@v4 + + - name: Log in to Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=ref,event=branch + type=ref,event=pr + type=sha + type=raw,value=latest + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + file: ./Dockerfile + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + deploy: + needs: build + runs-on: ubuntu-latest + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + + steps: + - uses: actions/checkout@v4 + + - name: Deploy to production + uses: appleboy/ssh-action@v1.0.0 + with: + host: ${{ secrets.PROD_HOST }} + username: ${{ secrets.PROD_USERNAME }} + key: ${{ secrets.PROD_SSH_KEY }} + script: | + cd /opt/dockerized-todo-api + + # Bajar la nueva imagen + docker-compose -f docker-compose.prod.yml pull + + # Rolling update + docker-compose -f docker-compose.prod.yml up -d --no-deps app + + # Health check + sleep 30 + curl -f http://localhost/health || exit 1 + + # Limpiar imagen anterior + docker image prune -f +``` + +## Paso 10: Mejorar la seguridad + +### Configuración de seguridad de los contenedores + +```dockerfile +# Añadir refuerzos de seguridad al Dockerfile + +# Ejecutar como usuario no root +USER appuser + +# Sistema de archivos raíz de solo lectura +# docker run --read-only --tmpfs /tmp dockerized-todo-api + +# Limitar permisos +# docker run --cap-drop=ALL dockerized-todo-api + +# Aislamiento de red +# docker run --network=none dockerized-todo-api +``` + +### Configuración de seguridad de Docker Compose + +```yaml +# Añadir configuración de seguridad a docker-compose.yml +services: + app: + # ... configuración existente ... + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + cap_add: + - NET_BIND_SERVICE + read_only: true + tmpfs: + - /tmp + - /app/logs + user: "1000:1000" +``` + +### Gestión de secretos + +```yaml +# Añadir configuración de secretos a docker-compose.yml +version: '3.8' + +services: + app: + secrets: + - db_password + - api_key + environment: + - DB_PASSWORD_FILE=/run/secrets/db_password + - API_KEY_FILE=/run/secrets/api_key + +secrets: + db_password: + file: ./secrets/db_password.txt + api_key: + external: true +``` + +## Próximos pasos + +¡Has terminado la contenedorización con Docker! Próximos pasos: + +1. **[Manejo personalizado de respuestas](custom-response-handling.md)** - Implementar formatos de respuesta avanzados + + + + +## Resumen + +En este tutorial hemos usado Docker para: + +- ✅ Crear imágenes de contenedor optimizadas con builds multi-stage +- ✅ Configurar entornos de desarrollo / producción con Docker Compose +- ✅ Configurar Nginx como proxy inverso con balanceo de carga +- ✅ Construir sistemas de health check y monitorización +- ✅ Implementar despliegue automatizado vía pipelines CI/CD +- ✅ Configurar seguridad de nivel producción +- ✅ Implementar sistemas de logs y recolección de métricas + +¡Ahora puedes desplegar aplicaciones FastAPI a entornos de producción de forma segura y eficiente! diff --git a/docs/es/tutorial/domain-starter.md b/docs/es/tutorial/domain-starter.md new file mode 100644 index 0000000..c7911bf --- /dev/null +++ b/docs/es/tutorial/domain-starter.md @@ -0,0 +1,392 @@ +# FastAPI orientado a dominios con `fastapi-domain-starter` + +Construye un servicio FastAPI de tamaño medio con la estructura moderna recomendada: **una carpeta por cada concepto de negocio** dentro de `src/app/domains/`. Este tutorial recorre la plantilla `fastapi-domain-starter` de principio a fin: cómo generarla, para qué sirve cada paquete principal, cómo se integra el ejemplo `items` incluido y cómo añadir tu siguiente dominio. + +## Qué vas a aprender + +- Generar un proyecto con `fastkit startdemo fastapi-domain-starter` +- El papel de `core`, `db`, `domains` y `tests` dentro de la estructura +- Cómo se divide un dominio en router → service → repository → schemas → models +- El contrato para añadir un dominio nuevo (copiar la carpeta items, registrar el router) +- Cómo se integran en la app el endpoint `/health` y el CRUD `/api/v1/items` + +## Requisitos previos + +- Python 3.12+ +- FastAPI-fastkit instalado (`pip install fastapi-fastkit`) +- Familiaridad con los conceptos básicos de FastAPI (operaciones de ruta, esquemas pydantic, dependencias) + +Si este es tu primer proyecto FastAPI, empieza mejor por [Construir un servidor API básico](basic-api-server.md) — ese tutorial usa la plantilla más simple `fastapi-default`. + +## Paso 1: Generar el proyecto + +```console +$ fastkit startdemo fastapi-domain-starter +Enter the project name: orders-api +Enter the author name: Developer Kim +Enter the author email: developer@example.com +Enter the project description: Domain-oriented orders service +Select package manager (pip, uv, pdm, poetry) [uv]: uv +Do you want to proceed with project creation? [y/N]: y +``` + +`fastkit` genera el proyecto a partir de la plantilla, rellena los marcadores, crea un entorno virtual e instala las dependencias. Cuando termine, entra en el directorio: + +```console +$ cd orders-api +$ bash scripts/run-server.sh # o: uvicorn src.app.main:app --reload +``` + +La documentación de la API se sirve entonces en . + +## Paso 2: El árbol generado + +``` +orders-api/ +├── README.md +├── pyproject.toml # Metadatos PEP 621 + [tool.fastapi-fastkit] +├── requirements.txt # Dependencias fijadas (la plantilla incluye ambos archivos; si añades paquetes, tendrás que mantenerlos tú) +├── .env # SECRET_KEY, ENVIRONMENT +├── .gitignore +├── scripts/ +│ ├── format.sh # black + isort +│ ├── lint.sh # black --check + isort --check + mypy +│ ├── run-server.sh # uvicorn src.app.main:app --reload +│ └── test.sh # pytest +├── src/ +│ ├── __init__.py +│ └── app/ # el paquete de la aplicación +│ ├── __init__.py +│ ├── main.py # FastAPI() + middleware + include de api_router +│ ├── core/ # configuración transversal +│ │ ├── __init__.py +│ │ └── config.py # pydantic-settings (PROJECT_NAME, CORS, ...) +│ ├── db/ # abstracciones de persistencia +│ │ ├── __init__.py +│ │ └── memory.py # InMemoryStore[T] almacén key-value genérico +│ ├── api/ # enrutamiento a nivel HTTP +│ │ ├── __init__.py +│ │ ├── health.py # GET /health +│ │ └── router.py # agrega health + el router de cada dominio +│ └── domains/ # conceptos de negocio (una carpeta cada uno) +│ ├── __init__.py +│ └── items/ # el dominio de ejemplo +│ ├── __init__.py +│ ├── models.py # @dataclass Item (entidad) +│ ├── schemas.py # ItemCreate, ItemRead (pydantic) +│ ├── repository.py # ItemRepository sobre InMemoryStore +│ ├── service.py # ItemService + ItemNotFoundError +│ └── router.py # APIRouter(prefix="/items") +└── tests/ + ├── __init__.py + ├── conftest.py # fixture TestClient, reinicio del almacén en memoria + ├── test_health.py + └── test_items.py +``` + +Dos ideas clave: + +1. **`src/app/`** es el **paquete principal de la aplicación**. Todo lo que usa la app en ejecución vive aquí. Los tests también importan desde aquí (`from src.app.main import app`). El `src/` exterior existe para que el proyecto pueda instalarse con `pip install`. +2. **`src/app/domains//`** es el **bloque de cada concepto**. Cada concepto de negocio (items, orders, users, ...) mantiene su propio router / service / repository / schemas / models y no mezcla esa lógica con la de otros dominios. + +## Paso 3: Qué hace cada paquete de nivel superior + +### `src/app/core/` — configuración + +Contiene la configuración transversal de la aplicación. El `config.py` incluido expone una clase `Settings` con pydantic-settings que lee de `.env` / variables de entorno: + +```python +class Settings(BaseSettings): + PROJECT_NAME: str = "" + ENVIRONMENT: Literal["development", "staging", "production"] = "development" + SECRET_KEY: str = secrets.token_urlsafe(32) + API_V1_PREFIX: str = "/api/v1" + BACKEND_CORS_ORIGINS: ... = [] + ... + +settings = Settings() +``` + +`main.py` usa `settings.PROJECT_NAME`, `settings.API_V1_PREFIX` y `settings.all_cors_origins` para configurar la app FastAPI. + +**Cuándo añadir a `core/`:** cualquier cosa no específica de un dominio — ajustes globales, logging estructurado, middleware personalizado, helpers de seguridad, etc. + +### `src/app/db/` — frontera de persistencia + +Contiene la abstracción sobre tu capa de persistencia. El starter trae `memory.py`, que define un `InMemoryStore[T]` genérico, local al proceso y parametrizado por el tipo de entidad. El repository de cada dominio envuelve un `InMemoryStore`, así que sustituirlo más adelante por SQLAlchemy o por drivers asíncronos es un cambio acotado: basta con reescribir los repositories. + +```python +class InMemoryStore(Generic[T]): + def list(self) -> Iterable[T]: ... + def get(self, id_: int) -> Optional[T]: ... + def add(self, item: T) -> int: ... + def replace(self, id_: int, item: T) -> bool: ... + def delete(self, id_: int) -> bool: ... + def clear(self) -> None: ... +``` + +**Cuándo ampliar `db/`:** añade un `session.py` con tu fábrica real de sesiones de base de datos cuando dejes de usar `InMemoryStore`. Mantén la interfaz pública de los métodos (`list` / `get` / `add` / ...) para que los repositories de dominio no tengan que cambiar su contrato interno. + +### `src/app/api/` — enrutamiento HTTP + +Dos piezas: + +- `health.py` — un `APIRouter` pequeño que expone `GET /health` devolviendo `{"status": "ok"}`. Sin efectos secundarios, ideal para probes de liveness. +- `router.py` — el **agregador de nivel superior**. Incluye el router de health y el router de cada dominio, y ese único `api_router` combinado se monta en la app FastAPI bajo `/api/v1`: + +```python +# src/app/api/router.py +api_router = APIRouter() +api_router.include_router(health.router) +api_router.include_router(items_router.router) +``` + +```python +# src/app/main.py +app.include_router(api_router, prefix=settings.API_V1_PREFIX) +``` + +**Por qué se agrega aquí:** cuando añades un dominio nuevo, solo editas `src/app/api/router.py` para registrar su router. `main.py` no cambia. + +### `src/app/domains//` — bloques de negocio + +Aquí es donde vivirá la mayor parte de tu código a medida que el proyecto crezca. Cada dominio mantiene cinco archivos principales: + +| Archivo | Papel | +|---|---| +| `models.py` | Entidad de dominio (un `@dataclass` en el starter; podría ser SQLAlchemy / SQLModel más adelante). La forma interna — no el formato sobre la red. | +| `schemas.py` | Esquemas de E/S de la API (pydantic). Separados de la entidad para que el formato sobre la red pueda evolucionar sin tocar la lógica de dominio. | +| `repository.py` | Acceso a datos. Envuelve el almacén con métodos tipados por entidad. Es el punto donde cambiarás la persistencia si más adelante pasas a una base de datos real. | +| `service.py` | Lógica de negocio. Los routers llaman a `service`, nunca directamente al `repository`. Las excepciones específicas del dominio (p. ej. `ItemNotFoundError`) viven aquí. | +| `router.py` | Transporte HTTP. Traduce entre esquemas pydantic ↔ llamadas a service; convierte excepciones de dominio en `HTTPException`. | + +La **dirección de las dependencias** es `router → service → repository → store`. Cada capa solo depende de la inmediatamente inferior. El router y el service usan los schemas; el repository y el service usan los models. + +### `tests/` + +Refleja la estructura de la aplicación en ejecución: un módulo de tests por cada superficie cuyo comportamiento conviene dejar cubierto. El starter trae: + +- `conftest.py` — un fixture `autouse` que reinicia el almacén de items entre tests, además de un fixture `client` que envuelve `TestClient(app)`. +- `test_health.py` — verifica que `GET /api/v1/health` devuelve 200 y `{"status": "ok"}`. +- `test_items.py` — cobertura completa de CRUD de los endpoints de items, incluyendo 404 para ids desconocidos y 422 para un payload inválido. + +Ejecútalos con: + +```console +$ bash scripts/test.sh # o: pytest +``` + +## Paso 4: Repaso del dominio `items` incluido + +El dominio de ejemplo es un CRUD sobre una entidad pequeña: + +```python +# src/app/domains/items/models.py +@dataclass +class Item: + id: int + name: str + price: float + in_stock: bool = True +``` + +Los esquemas de API separan la forma de entrada de la de salida para poder añadir campos controlados por el servidor (`id`) y validación (price ≥ 0): + +```python +# src/app/domains/items/schemas.py +class ItemCreate(BaseModel): + name: str = Field(min_length=1, max_length=120) + price: float = Field(ge=0) + in_stock: bool = True + +class ItemRead(BaseModel): + id: int + name: str + price: float + in_stock: bool + model_config = ConfigDict(from_attributes=True) +``` + +El repository envuelve el almacén en memoria y asigna ids al insertar: + +```python +# src/app/domains/items/repository.py +class ItemRepository: + def __init__(self, store: Optional[InMemoryStore[Item]] = None) -> None: + self._store = store if store is not None else _store + + def add(self, name: str, price: float, in_stock: bool = True) -> Item: + item = Item(id=0, name=name, price=price, in_stock=in_stock) + new_id = self._store.add(item) + item.id = new_id + return item + # list_all / get / replace / delete / reset omitidos +``` + +La capa de service es donde se concentran las reglas de negocio. Hoy funciona como una capa ligera con una excepción específica, pero aquí es donde vivirá la lógica futura ("no puedes borrar un item que está en un pedido abierto", etc.): + +```python +# src/app/domains/items/service.py +class ItemNotFoundError(Exception): ... + +class ItemService: + def __init__(self, repository: Optional[ItemRepository] = None) -> None: + self._repository = repository if repository is not None else ItemRepository() + + def get_item(self, item_id: int) -> Item: + item = self._repository.get(item_id) + if item is None: + raise ItemNotFoundError(f"Item {item_id} does not exist") + return item + # list_items / create_item / replace_item / delete_item omitidos +``` + +El router es la única pieza que conoce HTTP. Fíjate en que recibe el service mediante un `Depends(...)` de FastAPI para que los tests puedan sustituirlo, y mapea `ItemNotFoundError` → `HTTPException(404)`: + +```python +# src/app/domains/items/router.py +router = APIRouter(prefix="/items", tags=["items"]) + +def get_item_service() -> ItemService: + return ItemService() + +@router.get("/{item_id}", response_model=ItemRead) +def get_item(item_id: int, service: ItemService = Depends(get_item_service)) -> ItemRead: + try: + return ItemRead.model_validate(service.get_item(item_id)) + except ItemNotFoundError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) +``` + +El router completo expone: + +| Método | Ruta | Qué hace | +|---|---|---| +| `GET` | `/api/v1/items` | Listar items | +| `GET` | `/api/v1/items/{item_id}` | Leer uno | +| `POST` | `/api/v1/items` | Crear (devuelve 201) | +| `PUT` | `/api/v1/items/{item_id}` | Reemplazar | +| `DELETE` | `/api/v1/items/{item_id}` | Eliminar (devuelve 204) | +| `GET` | `/api/v1/health` | Probe de liveness | + +Pruébalo: + +```console +$ curl -X POST http://127.0.0.1:8000/api/v1/items \ + -H 'Content-Type: application/json' \ + -d '{"name":"Mug","price":9.5,"in_stock":true}' +{"id":1,"name":"Mug","price":9.5,"in_stock":true} + +$ curl http://127.0.0.1:8000/api/v1/items +[{"id":1,"name":"Mug","price":9.5,"in_stock":true}] + +$ curl http://127.0.0.1:8000/api/v1/items/999 +{"detail":"Item 999 does not exist"} +``` + +## Paso 5: Añadir tu siguiente dominio + +El starter está pensado para que **añadir un dominio sea tan simple como copiar y renombrar**. Imagina que quieres un dominio `users` junto a `items`: + +### 1. Copia la carpeta `items/` + +```console +$ cp -r src/app/domains/items src/app/domains/users +``` + +### 2. Reescribe la entidad, los esquemas y los nombres de clase por archivo + +```python +# src/app/domains/users/models.py +from dataclasses import dataclass + +@dataclass +class User: + id: int + email: str + is_active: bool = True +``` + +```python +# src/app/domains/users/schemas.py +from pydantic import BaseModel, ConfigDict, Field + +class UserCreate(BaseModel): + # Dejar ``str`` mantiene este snippet listo para usar sin dependencias + # extra. Para usar la validación de email integrada de pydantic, instala + # la dependencia opcional (``pip install 'pydantic[email]'`` — incluye + # ``email-validator``) y cambia ``str`` por ``EmailStr``. + email: str = Field(min_length=3, max_length=320) + is_active: bool = True + +class UserRead(BaseModel): + id: int + email: str + is_active: bool + model_config = ConfigDict(from_attributes=True) +``` + +Renombra `Item → User`, `ItemNotFoundError → UserNotFoundError`, `ItemRepository → UserRepository`, `ItemService → UserService` en `models.py`, `schemas.py`, `repository.py`, `service.py` y `router.py`. No olvides cambiar `prefix="/items"` → `prefix="/users"` y `tags=["items"]` → `tags=["users"]` en el router. + +El repository puede mantener el mismo patrón respaldado por `InMemoryStore` — es genérico sobre el tipo de entidad: + +```python +# src/app/domains/users/repository.py +_store: InMemoryStore[User] = InMemoryStore() + +class UserRepository: + def __init__(self, store: Optional[InMemoryStore[User]] = None) -> None: + self._store = store if store is not None else _store + # ... misma forma que ItemRepository ... +``` + +### 3. Actualiza el `__init__.py` del dominio + +El dominio items reexporta sus módulos para que los llamantes puedan escribir `from src.app.domains.items import service`. Refleja eso en users: + +```python +# src/app/domains/users/__init__.py +from src.app.domains.users import ( # noqa: F401 + models, + repository, + router, + schemas, + service, +) +``` + +### 4. Registra el router en el agregador + +Este es el **único archivo fuera de `domains/users/` que necesitas tocar**: + +```python +# src/app/api/router.py +from src.app.api import health +from src.app.domains.items import router as items_router +from src.app.domains.users import router as users_router # ← añadir + +api_router = APIRouter() +api_router.include_router(health.router) +api_router.include_router(items_router.router) +api_router.include_router(users_router.router) # ← añadir +``` + +Tras reiniciar el servidor verás `/api/v1/users` montado en `/docs`. + +### 5. Añade tests + +Usa `tests/test_items.py` como base para crear `tests/test_users.py`: sigue el mismo patrón con `client`, pero llamando a los nuevos endpoints. El fixture `autouse` que reinicia el almacén en `conftest.py` ya mantiene cada test aislado. + +Si añades un segundo dominio que también use `InMemoryStore`, amplía el fixture para resetear también su store, o mantén un fixture por dominio. + +## Paso 6: A dónde ir después + +- La [Matriz de presets de arquitectura](../reference/preset-feature-matrix.md) muestra qué genera `fastkit init --interactive` para cada preset, incluidas las selecciones de funcionalidad que necesitan cableado manual bajo `domain-starter`. +- El [tutorial de `fastapi-default`](basic-api-server.md) cubre la alternativa en capas si quieres comparar estructuras antes de decidirte. +- Para integración con base de datos, el [tutorial de integración con base de datos](database-integration.md) muestra el patrón PostgreSQL + SQLAlchemy + Alembic. Las mismas ideas encajan en `src/app/db/` y en los `repository.py` por dominio. + +## Recapitulación + +- **Generación**: `fastkit startdemo fastapi-domain-starter` → `bash scripts/run-server.sh` → docs en `/docs`. +- **Estructura**: `core/` para la configuración, `db/` para las abstracciones de persistencia, `domains//` para los bloques de negocio, `api/router.py` como único punto de agregación y `tests/` reflejando los módulos de la aplicación. +- **Añadir un dominio**: copia `items/`, renombra entidad / esquemas / clases, actualiza los re-exports de `__init__.py`, registra el router en `src/app/api/router.py`, añade un módulo de tests. Sin tocar `main.py`. diff --git a/docs/es/tutorial/first-project.md b/docs/es/tutorial/first-project.md new file mode 100644 index 0000000..1eff349 --- /dev/null +++ b/docs/es/tutorial/first-project.md @@ -0,0 +1,1252 @@ +# Tu primer proyecto + +Construye una API de blog completa con gestión de usuarios, creación de posts y sistema de comentarios usando FastAPI-fastkit. + +## Visión general del proyecto + +En este tutorial vamos a crear una **API de blog** con las siguientes funcionalidades: + +- **Gestión de usuarios**: registro, autenticación y perfiles +- **Gestión de posts**: crear, leer, actualizar y eliminar entradas de blog +- **Sistema de comentarios**: añadir comentarios a las entradas +- **Validación de datos**: validación robusta de entrada y manejo de errores +- **Documentación de la API**: documentación OpenAPI automática +- **Pruebas**: suite de pruebas completa + +### Qué aprenderás + +Al final de este tutorial entenderás: + +- Estructura avanzada de un proyecto FastAPI-fastkit +- Integración de base de datos con SQLAlchemy +- Autenticación y autorización de usuarios +- Relaciones de datos complejas +- Manejo de errores y validación +- Buenas prácticas de pruebas + +## Requisitos previos + +Antes de empezar, asegúrate de tener: + +- Haber completado el tutorial de [Primeros pasos](getting-started.md) +- Conocimientos básicos de APIs REST +- Python 3.12+ instalado +- Editor de texto o IDE listo + +## Paso 1: Crear el proyecto + +Empecemos creando un proyecto nuevo con el stack **STANDARD** para tener soporte de base de datos: + +
+ +```console +$ fastkit init +Enter the project name: blog-api +Enter the author name: Your Name +Enter the author email: your.email@example.com +Enter the project description: A complete blog API with users, posts, and comments + + Project Information +┌──────────────┬─────────────────────────────────────────┐ +│ Project Name │ blog-api │ +│ Author │ Your Name │ +│ Author Email │ your.email@example.com │ +│ Description │ A complete blog API with users, posts, │ +│ │ and comments │ +└──────────────┴─────────────────────────────────────────┘ + +Available Stacks and Dependencies: + MINIMAL Stack +┌──────────────┬───────────────────┐ +│ Dependency 1 │ fastapi │ +│ Dependency 2 │ uvicorn │ +│ Dependency 3 │ pydantic │ +│ Dependency 4 │ pydantic-settings │ +└──────────────┴───────────────────┘ + + STANDARD Stack +┌──────────────┬───────────────────┐ +│ Dependency 1 │ fastapi │ +│ Dependency 2 │ uvicorn │ +│ Dependency 3 │ sqlalchemy │ +│ Dependency 4 │ alembic │ +│ Dependency 5 │ pytest │ +│ Dependency 6 │ pydantic │ +│ Dependency 7 │ pydantic-settings │ +└──────────────┴───────────────────┘ + +Select stack (minimal, standard, full): standard + +Available Package Managers: + Package Managers +┌────────┬────────────────────────────────────────────┐ +│ PIP │ Standard Python package manager │ +│ UV │ Fast Python package manager │ +│ PDM │ Modern Python dependency management │ +│ POETRY │ Python dependency management and packaging │ +└────────┴────────────────────────────────────────────┘ + +Select package manager (pip, uv, pdm, poetry) [uv]: uv +Do you want to proceed with project creation? [y/N]: y + +✨ FastAPI project 'blog-api' has been created successfully! +``` + +
+ +## Paso 2: Preparar el proyecto + +Entra en el proyecto y activa el entorno virtual: + +
+ +```console +$ cd blog-api +$ source .venv/bin/activate +``` + +
+ +## Paso 3: Añadir las rutas necesarias + +Añadamos los recursos principales de nuestra API de blog: + +
+ +```console +$ fastkit addroute users blog-api +✨ Successfully added new route 'users' to project 'blog-api' + +$ fastkit addroute posts blog-api +✨ Successfully added new route 'posts' to project 'blog-api' + +$ fastkit addroute comments blog-api +✨ Successfully added new route 'comments' to project 'blog-api' +``` + +
+ +## Paso 4: Diseñar los modelos de datos + +Vamos a diseñar nuestros esquemas de datos. Empezaremos actualizando el esquema de usuario para que sea más realista. + +### Actualizar el esquema de usuario + +Edita `src/schemas/users.py`: + +```python +from typing import Optional, List +from datetime import datetime +from pydantic import BaseModel, EmailStr, Field + +class UserBase(BaseModel): + email: EmailStr + username: str = Field(..., min_length=3, max_length=50) + full_name: Optional[str] = None + bio: Optional[str] = Field(None, max_length=500) + is_active: bool = True + +class UserCreate(UserBase): + password: str = Field(..., min_length=8) + +class UserUpdate(BaseModel): + email: Optional[EmailStr] = None + username: Optional[str] = Field(None, min_length=3, max_length=50) + full_name: Optional[str] = None + bio: Optional[str] = Field(None, max_length=500) + is_active: Optional[bool] = None + +class User(UserBase): + id: int + created_at: datetime + posts_count: int = 0 + + class Config: + from_attributes = True + +class UserInDB(User): + hashed_password: str +``` + +### Crear el esquema de post + +Edita `src/schemas/posts.py`: + +```python +from typing import Optional, List +from datetime import datetime +from pydantic import BaseModel, Field + +class PostBase(BaseModel): + title: str = Field(..., min_length=1, max_length=200) + content: str = Field(..., min_length=1) + published: bool = True + +class PostCreate(PostBase): + pass + +class PostUpdate(BaseModel): + title: Optional[str] = Field(None, min_length=1, max_length=200) + content: Optional[str] = Field(None, min_length=1) + published: Optional[bool] = None + +class Post(PostBase): + id: int + author_id: int + created_at: datetime + updated_at: datetime + comments_count: int = 0 + + class Config: + from_attributes = True + +class PostWithAuthor(Post): + author: "User" + +class PostWithComments(Post): + comments: List["Comment"] = [] + +# Import para evitar imports circulares +from src.schemas.users import User +from src.schemas.comments import Comment +PostWithAuthor.model_rebuild() +PostWithComments.model_rebuild() +``` + +### Crear el esquema de comentario + +Edita `src/schemas/comments.py`: + +```python +from typing import Optional +from datetime import datetime +from pydantic import BaseModel, Field + +class CommentBase(BaseModel): + content: str = Field(..., min_length=1, max_length=1000) + +class CommentCreate(CommentBase): + post_id: int + +class CommentUpdate(BaseModel): + content: Optional[str] = Field(None, min_length=1, max_length=1000) + +class Comment(CommentBase): + id: int + post_id: int + author_id: int + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + +class CommentWithAuthor(Comment): + author: "User" + +# Import para evitar imports circulares +from src.schemas.users import User +CommentWithAuthor.model_rebuild() +``` + +## Paso 5: Implementar operaciones CRUD avanzadas + +### CRUD de usuarios mejorado + +Actualiza `src/crud/users.py`: + +```python +from typing import List, Optional +from datetime import datetime +import hashlib +from src.schemas.users import UserCreate, UserUpdate, UserInDB + +class UsersCRUD: + def __init__(self): + self._users: List[UserInDB] = [] + self._next_id = 1 + + def _hash_password(self, password: str) -> str: + """Hash simple de contraseña (usa bcrypt en producción)""" + return hashlib.sha256(password.encode()).hexdigest() + + def _verify_password(self, plain_password: str, hashed_password: str) -> bool: + """Verifica una contraseña contra su hash""" + return self._hash_password(plain_password) == hashed_password + + def get_all(self) -> List[UserInDB]: + """Obtener todos los usuarios""" + return [user for user in self._users if user.is_active] + + def get_by_id(self, user_id: int) -> Optional[UserInDB]: + """Obtener usuario por ID""" + return next((user for user in self._users if user.id == user_id), None) + + def get_by_email(self, email: str) -> Optional[UserInDB]: + """Obtener usuario por email""" + return next((user for user in self._users if user.email == email), None) + + def get_by_username(self, username: str) -> Optional[UserInDB]: + """Obtener usuario por nombre de usuario""" + return next((user for user in self._users if user.username == username), None) + + def create(self, user: UserCreate) -> UserInDB: + """Crear un nuevo usuario con validación""" + # Comprobar duplicados + if self.get_by_email(user.email): + raise ValueError("Email already registered") + if self.get_by_username(user.username): + raise ValueError("Username already taken") + + new_user = UserInDB( + id=self._next_id, + email=user.email, + username=user.username, + full_name=user.full_name, + bio=user.bio, + is_active=user.is_active, + created_at=datetime.now(), + posts_count=0, + hashed_password=self._hash_password(user.password) + ) + self._next_id += 1 + self._users.append(new_user) + return new_user + + def update(self, user_id: int, user_update: UserUpdate) -> Optional[UserInDB]: + """Actualizar un usuario existente""" + user = self.get_by_id(user_id) + if not user: + return None + + # Comprobar duplicados en cambios de email/username + update_data = user_update.dict(exclude_unset=True) + if "email" in update_data and update_data["email"] != user.email: + if self.get_by_email(update_data["email"]): + raise ValueError("Email already registered") + + if "username" in update_data and update_data["username"] != user.username: + if self.get_by_username(update_data["username"]): + raise ValueError("Username already taken") + + for field, value in update_data.items(): + setattr(user, field, value) + + return user + + def delete(self, user_id: int) -> bool: + """Eliminar usuario lógicamente (desactivar)""" + user = self.get_by_id(user_id) + if user: + user.is_active = False + return True + return False + + def authenticate(self, email: str, password: str) -> Optional[UserInDB]: + """Autenticar usuario por email y contraseña""" + user = self.get_by_email(email) + if user and self._verify_password(password, user.hashed_password): + return user + return None + +users_crud = UsersCRUD() +``` + +### CRUD de posts + +Actualiza `src/crud/posts.py`: + +```python +from typing import List, Optional +from datetime import datetime +from src.schemas.posts import PostCreate, PostUpdate, Post + +class PostsCRUD: + def __init__(self): + self._posts: List[Post] = [] + self._next_id = 1 + + def get_all(self, skip: int = 0, limit: int = 100, published_only: bool = True) -> List[Post]: + """Obtener todos los posts con paginación""" + posts = self._posts + if published_only: + posts = [post for post in posts if post.published] + return posts[skip:skip + limit] + + def get_by_id(self, post_id: int) -> Optional[Post]: + """Obtener post por ID""" + return next((post for post in self._posts if post.id == post_id), None) + + def get_by_author(self, author_id: int, skip: int = 0, limit: int = 100) -> List[Post]: + """Obtener posts por autor""" + author_posts = [post for post in self._posts if post.author_id == author_id] + return author_posts[skip:skip + limit] + + def create(self, post: PostCreate, author_id: int) -> Post: + """Crear un post nuevo""" + now = datetime.now() + new_post = Post( + id=self._next_id, + title=post.title, + content=post.content, + published=post.published, + author_id=author_id, + created_at=now, + updated_at=now, + comments_count=0 + ) + self._next_id += 1 + self._posts.append(new_post) + + # Actualizar el contador de posts del autor + from src.crud.users import users_crud + author = users_crud.get_by_id(author_id) + if author: + author.posts_count += 1 + + return new_post + + def update(self, post_id: int, post_update: PostUpdate, author_id: int) -> Optional[Post]: + """Actualizar un post existente""" + post = self.get_by_id(post_id) + if not post or post.author_id != author_id: + return None + + update_data = post_update.dict(exclude_unset=True) + for field, value in update_data.items(): + setattr(post, field, value) + + post.updated_at = datetime.now() + return post + + def delete(self, post_id: int, author_id: int) -> bool: + """Eliminar un post""" + post = self.get_by_id(post_id) + if post and post.author_id == author_id: + self._posts.remove(post) + + # Actualizar el contador de posts del autor + from src.crud.users import users_crud + author = users_crud.get_by_id(author_id) + if author: + author.posts_count = max(0, author.posts_count - 1) + + return True + return False + + def search(self, query: str, skip: int = 0, limit: int = 100) -> List[Post]: + """Buscar posts por título o contenido""" + query_lower = query.lower() + matching_posts = [ + post for post in self._posts + if post.published and ( + query_lower in post.title.lower() or + query_lower in post.content.lower() + ) + ] + return matching_posts[skip:skip + limit] + +posts_crud = PostsCRUD() +``` + +### CRUD de comentarios + +Actualiza `src/crud/comments.py`: + +```python +from typing import List, Optional +from datetime import datetime +from src.schemas.comments import CommentCreate, CommentUpdate, Comment + +class CommentsCRUD: + def __init__(self): + self._comments: List[Comment] = [] + self._next_id = 1 + + def get_all(self) -> List[Comment]: + """Obtener todos los comentarios""" + return self._comments + + def get_by_id(self, comment_id: int) -> Optional[Comment]: + """Obtener comentario por ID""" + return next((comment for comment in self._comments if comment.id == comment_id), None) + + def get_by_post(self, post_id: int, skip: int = 0, limit: int = 100) -> List[Comment]: + """Obtener comentarios de un post concreto""" + post_comments = [comment for comment in self._comments if comment.post_id == post_id] + return post_comments[skip:skip + limit] + + def get_by_author(self, author_id: int, skip: int = 0, limit: int = 100) -> List[Comment]: + """Obtener comentarios por autor""" + author_comments = [comment for comment in self._comments if comment.author_id == author_id] + return author_comments[skip:skip + limit] + + def create(self, comment: CommentCreate, author_id: int) -> Comment: + """Crear un comentario nuevo""" + # Verificar que el post existe + from src.crud.posts import posts_crud + post = posts_crud.get_by_id(comment.post_id) + if not post: + raise ValueError("Post not found") + + now = datetime.now() + new_comment = Comment( + id=self._next_id, + content=comment.content, + post_id=comment.post_id, + author_id=author_id, + created_at=now, + updated_at=now + ) + self._next_id += 1 + self._comments.append(new_comment) + + # Actualizar contador de comentarios del post + post.comments_count += 1 + + return new_comment + + def update(self, comment_id: int, comment_update: CommentUpdate, author_id: int) -> Optional[Comment]: + """Actualizar un comentario existente""" + comment = self.get_by_id(comment_id) + if not comment or comment.author_id != author_id: + return None + + update_data = comment_update.dict(exclude_unset=True) + for field, value in update_data.items(): + setattr(comment, field, value) + + comment.updated_at = datetime.now() + return comment + + def delete(self, comment_id: int, author_id: int) -> bool: + """Eliminar un comentario""" + comment = self.get_by_id(comment_id) + if comment and comment.author_id == author_id: + self._comments.remove(comment) + + # Actualizar contador de comentarios del post + from src.crud.posts import posts_crud + post = posts_crud.get_by_id(comment.post_id) + if post: + post.comments_count = max(0, post.comments_count - 1) + + return True + return False + +comments_crud = CommentsCRUD() +``` + +## Paso 6: Implementar rutas de API avanzadas + +### Rutas de usuario mejoradas + +Actualiza `src/api/routes/users.py`: + +```python +from typing import List +from fastapi import APIRouter, HTTPException, status, Depends, Query +from src.schemas.users import User, UserCreate, UserUpdate +from src.crud.users import users_crud + +router = APIRouter() + +# Helper para obtener el usuario actual (simplificado para el tutorial) +def get_current_user_id() -> int: + # En una app real, esto verificaría el token JWT y devolvería el ID + return 1 # Para el tutorial + +@router.get("/", response_model=List[User]) +def read_users( + skip: int = Query(0, ge=0), + limit: int = Query(100, ge=1, le=100) +): + """Obtener todos los usuarios con paginación""" + users = users_crud.get_all()[skip:skip + limit] + return [User(**user.dict()) for user in users] + +@router.post("/", response_model=User, status_code=status.HTTP_201_CREATED) +def create_user(user: UserCreate): + """Registrar un nuevo usuario""" + try: + new_user = users_crud.create(user) + return User(**new_user.dict()) + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e) + ) + +@router.get("/{user_id}", response_model=User) +def read_user(user_id: int): + """Obtener un usuario concreto""" + user = users_crud.get_by_id(user_id) + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"User with id {user_id} not found" + ) + return User(**user.dict()) + +@router.put("/{user_id}", response_model=User) +def update_user( + user_id: int, + user_update: UserUpdate, + current_user_id: int = Depends(get_current_user_id) +): + """Actualizar el perfil de usuario""" + if user_id != current_user_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You can only update your own profile" + ) + + try: + updated_user = users_crud.update(user_id, user_update) + if not updated_user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found" + ) + return User(**updated_user.dict()) + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e) + ) + +@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_user( + user_id: int, + current_user_id: int = Depends(get_current_user_id) +): + """Desactivar la cuenta de usuario""" + if user_id != current_user_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You can only delete your own account" + ) + + success = users_crud.delete(user_id) + if not success: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found" + ) + +@router.post("/login") +def login(email: str, password: str): + """Autenticar usuario""" + user = users_crud.authenticate(email, password) + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid email or password" + ) + + # En una app real, devolverías un token JWT + return { + "message": "Login successful", + "user_id": user.id, + "username": user.username + } +``` + +### Rutas de posts mejoradas + +Actualiza `src/api/routes/posts.py`: + +```python +from typing import List, Optional +from fastapi import APIRouter, HTTPException, status, Depends, Query +from src.schemas.posts import Post, PostCreate, PostUpdate +from src.crud.posts import posts_crud + +router = APIRouter() + +def get_current_user_id() -> int: + return 1 # Simplificado para el tutorial + +@router.get("/", response_model=List[Post]) +def read_posts( + skip: int = Query(0, ge=0), + limit: int = Query(100, ge=1, le=100), + search: Optional[str] = Query(None) +): + """Obtener todos los posts con búsqueda opcional""" + if search: + posts = posts_crud.search(search, skip, limit) + else: + posts = posts_crud.get_all(skip, limit) + return posts + +@router.post("/", response_model=Post, status_code=status.HTTP_201_CREATED) +def create_post( + post: PostCreate, + current_user_id: int = Depends(get_current_user_id) +): + """Crear una nueva entrada de blog""" + new_post = posts_crud.create(post, current_user_id) + return new_post + +@router.get("/{post_id}", response_model=Post) +def read_post(post_id: int): + """Obtener un post concreto""" + post = posts_crud.get_by_id(post_id) + if not post: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Post not found" + ) + return post + +@router.put("/{post_id}", response_model=Post) +def update_post( + post_id: int, + post_update: PostUpdate, + current_user_id: int = Depends(get_current_user_id) +): + """Actualizar una entrada de blog""" + updated_post = posts_crud.update(post_id, post_update, current_user_id) + if not updated_post: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Post not found or you don't have permission to edit it" + ) + return updated_post + +@router.delete("/{post_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_post( + post_id: int, + current_user_id: int = Depends(get_current_user_id) +): + """Eliminar una entrada de blog""" + success = posts_crud.delete(post_id, current_user_id) + if not success: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Post not found or you don't have permission to delete it" + ) + +@router.get("/author/{author_id}", response_model=List[Post]) +def read_posts_by_author( + author_id: int, + skip: int = Query(0, ge=0), + limit: int = Query(100, ge=1, le=100) +): + """Obtener posts de un autor concreto""" + posts = posts_crud.get_by_author(author_id, skip, limit) + return posts +``` + +### Rutas de comentarios mejoradas + +Actualiza `src/api/routes/comments.py`: + +```python +from typing import List +from fastapi import APIRouter, HTTPException, status, Depends, Query +from src.schemas.comments import Comment, CommentCreate, CommentUpdate +from src.crud.comments import comments_crud + +router = APIRouter() + +def get_current_user_id() -> int: + return 1 # Simplificado para el tutorial + +@router.get("/", response_model=List[Comment]) +def read_comments( + skip: int = Query(0, ge=0), + limit: int = Query(100, ge=1, le=100) +): + """Obtener todos los comentarios""" + comments = comments_crud.get_all()[skip:skip + limit] + return comments + +@router.post("/", response_model=Comment, status_code=status.HTTP_201_CREATED) +def create_comment( + comment: CommentCreate, + current_user_id: int = Depends(get_current_user_id) +): + """Crear un comentario nuevo""" + try: + new_comment = comments_crud.create(comment, current_user_id) + return new_comment + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e) + ) + +@router.get("/{comment_id}", response_model=Comment) +def read_comment(comment_id: int): + """Obtener un comentario concreto""" + comment = comments_crud.get_by_id(comment_id) + if not comment: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Comment not found" + ) + return comment + +@router.put("/{comment_id}", response_model=Comment) +def update_comment( + comment_id: int, + comment_update: CommentUpdate, + current_user_id: int = Depends(get_current_user_id) +): + """Actualizar un comentario""" + updated_comment = comments_crud.update(comment_id, comment_update, current_user_id) + if not updated_comment: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Comment not found or you don't have permission to edit it" + ) + return updated_comment + +@router.delete("/{comment_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_comment( + comment_id: int, + current_user_id: int = Depends(get_current_user_id) +): + """Eliminar un comentario""" + success = comments_crud.delete(comment_id, current_user_id) + if not success: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Comment not found or you don't have permission to delete it" + ) + +@router.get("/post/{post_id}", response_model=List[Comment]) +def read_comments_by_post( + post_id: int, + skip: int = Query(0, ge=0), + limit: int = Query(100, ge=1, le=100) +): + """Obtener comentarios de un post concreto""" + comments = comments_crud.get_by_post(post_id, skip, limit) + return comments + +@router.get("/author/{author_id}", response_model=List[Comment]) +def read_comments_by_author( + author_id: int, + skip: int = Query(0, ge=0), + limit: int = Query(100, ge=1, le=100) +): + """Obtener comentarios de un autor concreto""" + comments = comments_crud.get_by_author(author_id, skip, limit) + return comments +``` + +## Paso 7: Probar tu API de blog + +Vamos a arrancar el servidor y probar nuestra API de blog completa: + +
+ +```console +$ fastkit runserver +INFO: Uvicorn running on http://127.0.0.1:8000 +``` + +
+ +### Probar el registro de usuario + +
+ +```console +$ curl -X POST "http://127.0.0.1:8000/api/v1/users/" \ + -H "Content-Type: application/json" \ + -d '{ + "email": "john@example.com", + "username": "john_doe", + "full_name": "John Doe", + "bio": "Software developer and blogger", + "password": "securepassword123" + }' + +{ + "id": 1, + "email": "john@example.com", + "username": "john_doe", + "full_name": "John Doe", + "bio": "Software developer and blogger", + "is_active": true, + "created_at": "2023-12-07T10:30:00", + "posts_count": 0 +} +``` + +
+ +### Probar el login + +
+ +```console +$ curl -X POST "http://127.0.0.1:8000/api/v1/users/login" \ + -H "Content-Type: application/json" \ + -d '{ + "email": "john@example.com", + "password": "securepassword123" + }' + +{ + "message": "Login successful", + "user_id": 1, + "username": "john_doe" +} +``` + +
+ +### Probar la creación de un post + +
+ +```console +$ curl -X POST "http://127.0.0.1:8000/api/v1/posts/" \ + -H "Content-Type: application/json" \ + -d '{ + "title": "My First Blog Post", + "content": "This is the content of my first blog post. It'\''s about learning FastAPI with FastAPI-fastkit!", + "published": true + }' + +{ + "id": 1, + "title": "My First Blog Post", + "content": "This is the content of my first blog post. It's about learning FastAPI with FastAPI-fastkit!", + "published": true, + "author_id": 1, + "created_at": "2023-12-07T10:35:00", + "updated_at": "2023-12-07T10:35:00", + "comments_count": 0 +} +``` + +
+ +### Probar la creación de un comentario + +
+ +```console +$ curl -X POST "http://127.0.0.1:8000/api/v1/comments/" \ + -H "Content-Type: application/json" \ + -d '{ + "content": "Great post! I learned a lot from this.", + "post_id": 1 + }' + +{ + "id": 1, + "content": "Great post! I learned a lot from this.", + "post_id": 1, + "author_id": 1, + "created_at": "2023-12-07T10:40:00", + "updated_at": "2023-12-07T10:40:00" +} +``` + +
+ +### Probar la búsqueda + +
+ +```console +$ curl "http://127.0.0.1:8000/api/v1/posts/?search=FastAPI" + +[ + { + "id": 1, + "title": "My First Blog Post", + "content": "This is the content of my first blog post. It's about learning FastAPI with FastAPI-fastkit!", + "published": true, + "author_id": 1, + "created_at": "2023-12-07T10:35:00", + "updated_at": "2023-12-07T10:35:00", + "comments_count": 1 + } +] +``` + +
+ +## Paso 8: Documentación de la API + +Entra en [http://127.0.0.1:8000/docs](http://127.0.0.1:8000/docs) para ver la documentación completa de tu API. Deberías ver: + +- **Users**: Registro, login, gestión de perfil +- **Posts**: Operaciones CRUD, búsqueda, filtrado por autor +- **Comments**: Operaciones CRUD, filtrado por post / autor +- **Items**: Endpoints de ejemplo originales + +La documentación muestra: + +- Todos los endpoints disponibles +- Esquemas de petición / respuesta +- Reglas de validación de datos +- Respuestas de error + +## Paso 9: Escribir pruebas + +Vamos a crear pruebas completas para la API de blog. Crea `tests/test_blog_api.py`: + +```python +from fastapi.testclient import TestClient +from src.main import app + +client = TestClient(app) + +class TestUserAPI: + def test_create_user(self): + user_data = { + "email": "test@example.com", + "username": "testuser", + "full_name": "Test User", + "bio": "Test bio", + "password": "testpassword123" + } + response = client.post("/api/v1/users/", json=user_data) + assert response.status_code == 201 + data = response.json() + assert data["email"] == user_data["email"] + assert data["username"] == user_data["username"] + assert "id" in data + assert "hashed_password" not in data # No debe exponer la contraseña + + def test_duplicate_email(self): + # Primer usuario + user_data1 = { + "email": "duplicate@example.com", + "username": "user1", + "password": "password123" + } + response1 = client.post("/api/v1/users/", json=user_data1) + assert response1.status_code == 201 + + # Segundo usuario con el mismo email + user_data2 = { + "email": "duplicate@example.com", + "username": "user2", + "password": "password123" + } + response2 = client.post("/api/v1/users/", json=user_data2) + assert response2.status_code == 400 + assert "Email already registered" in response2.json()["detail"] + + def test_login(self): + # Crear usuario primero + user_data = { + "email": "login@example.com", + "username": "loginuser", + "password": "loginpassword123" + } + client.post("/api/v1/users/", json=user_data) + + # Probar login + login_data = { + "email": "login@example.com", + "password": "loginpassword123" + } + response = client.post("/api/v1/users/login", json=login_data) + assert response.status_code == 200 + data = response.json() + assert "user_id" in data + assert data["username"] == "loginuser" + +class TestPostAPI: + def test_create_post(self): + post_data = { + "title": "Test Post", + "content": "This is a test post content", + "published": True + } + response = client.post("/api/v1/posts/", json=post_data) + assert response.status_code == 201 + data = response.json() + assert data["title"] == post_data["title"] + assert data["content"] == post_data["content"] + assert "id" in data + assert "author_id" in data + + def test_read_posts(self): + response = client.get("/api/v1/posts/") + assert response.status_code == 200 + data = response.json() + assert isinstance(data, list) + + def test_search_posts(self): + # Crear post con contenido concreto + post_data = { + "title": "FastAPI Tutorial", + "content": "Learn how to build APIs with FastAPI", + "published": True + } + client.post("/api/v1/posts/", json=post_data) + + # Buscar el post + response = client.get("/api/v1/posts/?search=FastAPI") + assert response.status_code == 200 + data = response.json() + assert len(data) > 0 + assert any("FastAPI" in post["title"] or "FastAPI" in post["content"] for post in data) + +class TestCommentAPI: + def test_create_comment(self): + # Crear un post primero + post_data = { + "title": "Post for Comments", + "content": "This post will receive comments", + "published": True + } + post_response = client.post("/api/v1/posts/", json=post_data) + post_id = post_response.json()["id"] + + # Crear comentario + comment_data = { + "content": "This is a test comment", + "post_id": post_id + } + response = client.post("/api/v1/comments/", json=comment_data) + assert response.status_code == 201 + data = response.json() + assert data["content"] == comment_data["content"] + assert data["post_id"] == post_id + + def test_get_comments_by_post(self): + # Crear post y comentario primero + post_data = { + "title": "Post with Comments", + "content": "This post has comments", + "published": True + } + post_response = client.post("/api/v1/posts/", json=post_data) + post_id = post_response.json()["id"] + + comment_data = { + "content": "Comment on post", + "post_id": post_id + } + client.post("/api/v1/comments/", json=comment_data) + + # Obtener comentarios del post + response = client.get(f"/api/v1/comments/post/{post_id}") + assert response.status_code == 200 + data = response.json() + assert len(data) > 0 + assert all(comment["post_id"] == post_id for comment in data) + +# Ejecutar las pruebas +if __name__ == "__main__": + import pytest + pytest.main([__file__]) +``` + +### Ejecutar las pruebas + +
+ +```console +$ python -m pytest tests/test_blog_api.py -v +======================== test session starts ======================== +tests/test_blog_api.py::TestUserAPI::test_create_user PASSED +tests/test_blog_api.py::TestUserAPI::test_duplicate_email PASSED +tests/test_blog_api.py::TestUserAPI::test_login PASSED +tests/test_blog_api.py::TestPostAPI::test_create_post PASSED +tests/test_blog_api.py::TestPostAPI::test_read_posts PASSED +tests/test_blog_api.py::TestPostAPI::test_search_posts PASSED +tests/test_blog_api.py::TestCommentAPI::test_create_comment PASSED +tests/test_blog_api.py::TestCommentAPI::test_get_comments_by_post PASSED +======================== 8 passed in 1.23s ======================== +``` + +
+ +## Lo que has construido + +¡Enhorabuena! Has construido con éxito una API de blog completa con: + +### ✅ Funcionalidades implementadas + +- **Gestión de usuarios** + - Registro de usuarios con validación + - Autenticación de usuarios (login) + - Gestión de perfil + - Prevención de duplicados + +- **Posts de blog** + - Crear, leer, actualizar y eliminar posts + - Filtrado por autor + - Funcionalidad de búsqueda + - Estado publicado / borrador + +- **Sistema de comentarios** + - Añadir comentarios a posts + - Ver comentarios por post o autor + - Gestión de comentarios + +- **Validación de datos** + - Validación de email + - Requisitos de contraseña + - Límites de longitud de contenido + - Validación de campos obligatorios + +- **Manejo de errores** + - Códigos de estado HTTP adecuados + - Mensajes de error descriptivos + - Errores de validación de entrada + +- **Documentación de la API** + - Generación automática de OpenAPI + - Interfaz interactiva de pruebas + - Esquemas de petición / respuesta + +- **Pruebas** + - Cobertura amplia + - Pruebas unitarias para todos los endpoints + - Pruebas de casos límite + +## Próximos pasos + +### Posibles mejoras + +1. **Autenticación real** + - Implementar tokens JWT + - Hash de contraseñas con bcrypt + - Permisos basados en roles + +2. **Integración con base de datos** + - Usar PostgreSQL o MySQL + - Implementar modelos de base de datos + - Añadir migraciones + +3. **Funcionalidades avanzadas** + - Subida de archivos para imágenes + - Notificaciones por email + - Categorías / etiquetas + - Sistema de "me gusta" / "no me gusta" + +4. **Listo para producción** + - Añadir logging + - Implementar caché + - Añadir rate limiting + - Configuración por entorno + +### Continúa aprendiendo + +1. **[Usar plantillas](../user-guide/using-templates.md)**: Explora la plantilla `fastapi-psql-orm` para integración con base de datos +2. **[Añadir rutas](../user-guide/adding-routes.md)**: Aprende patrones de enrutamiento más avanzados +3. **[Contribuir](../contributing/development-setup.md)**: Contribuye a FastAPI-fastkit + +!!! tip "Buenas prácticas que has aprendido" + - **Arquitectura modular**: Separación de responsabilidades con schemas, CRUD y rutas + - **Validación de datos**: Uso de Pydantic para validación de entrada robusta + - **Manejo de errores**: Códigos de estado HTTP y mensajes de error adecuados + - **Pruebas**: Cobertura completa para todas las funcionalidades + - **Documentación**: Aprovecha la generación automática de documentación de API + +¡Ahora tienes las habilidades para construir APIs de nivel producción con FastAPI-fastkit! 🚀 diff --git a/docs/es/tutorial/getting-started.md b/docs/es/tutorial/getting-started.md new file mode 100644 index 0000000..999879d --- /dev/null +++ b/docs/es/tutorial/getting-started.md @@ -0,0 +1,564 @@ +# Primeros pasos + +Tutorial completo paso a paso para empezar con FastAPI-fastkit. Esta guía te lleva desde la instalación hasta tener tu primera API en marcha en unos 15 minutos. + +## Requisitos previos + +Antes de empezar, asegúrate de tener: + +- **Python 3.12 o superior** instalado en el sistema +- **Conocimientos básicos de Python** (variables, funciones, clases) +- Acceso a una **terminal / línea de comandos** +- **Editor de texto o IDE** (VS Code, PyCharm, etc.) + +## Paso 1: Instalación + +Primero instalemos FastAPI-fastkit. Recomendamos usar un entorno virtual para aislar tus proyectos. + +### Opción A: Con pip (tradicional) + +
+ +```console +$ pip install fastapi-fastkit +---> 100% +Successfully installed fastapi-fastkit +``` + +
+ +### Opción B: Con UV (recomendado - más rápido) + +UV es un gestor de paquetes Python rápido. Si todavía no tienes UV instalado: + +
+ +```console +# Primero instala UV +$ curl -LsSf https://astral.sh/uv/install.sh | sh + +# Luego instala FastAPI-fastkit +$ uv pip install fastapi-fastkit +---> 100% +Successfully installed fastapi-fastkit +``` + +
+ +### Opción C: Con un entorno virtual + +
+ +```console +$ python -m venv fastapi-env +$ source fastapi-env/bin/activate # En Windows: fastapi-env\Scripts\activate +$ pip install fastapi-fastkit +``` + +
+ +### Verificar la instalación + +Comprueba que FastAPI-fastkit se ha instalado correctamente: + +
+ +```console +$ fastkit --version +FastAPI-fastkit version 1.0.0 +``` + +
+ +## Paso 2: Crear tu primer proyecto + +Ahora vamos a crear tu primer proyecto FastAPI con el comando interactivo `init`: + +
+ +```console +$ fastkit init +Enter the project name: my-first-api +Enter the author name: Your Name +Enter the author email: your.email@example.com +Enter the project description: My first FastAPI project + + Project Information +┌──────────────┬─────────────────────────┐ +│ Project Name │ my-first-api │ +│ Author │ Your Name │ +│ Author Email │ your.email@example.com │ +│ Description │ My first FastAPI project│ +└──────────────┴─────────────────────────┘ + +Available Stacks and Dependencies: + MINIMAL Stack +┌──────────────┬───────────────────┐ +│ Dependency 1 │ fastapi │ +│ Dependency 2 │ uvicorn │ +│ Dependency 3 │ pydantic │ +│ Dependency 4 │ pydantic-settings │ +└──────────────┴───────────────────┘ + + STANDARD Stack +┌──────────────┬───────────────────┐ +│ Dependency 1 │ fastapi │ +│ Dependency 2 │ uvicorn │ +│ Dependency 3 │ sqlalchemy │ +│ Dependency 4 │ alembic │ +│ Dependency 5 │ pytest │ +│ Dependency 6 │ pydantic │ +│ Dependency 7 │ pydantic-settings │ +└──────────────┴───────────────────┘ + +Select stack (minimal, standard, full): minimal + +Available Package Managers: + Package Managers +┌────────┬────────────────────────────────────────────┐ +│ PIP │ Standard Python package manager │ +│ UV │ Fast Python package manager │ +│ PDM │ Modern Python dependency management │ +│ POETRY │ Python dependency management and packaging │ +└────────┴────────────────────────────────────────────┘ + +Select package manager (pip, uv, pdm, poetry) [uv]: uv +Do you want to proceed with project creation? [y/N]: y + +Creating virtual environment... +Installing dependencies... +✨ FastAPI project 'my-first-api' has been created successfully! +``` + +
+ +!!! note "Selección del stack" + Hemos elegido **MINIMAL** para este tutorial por simplicidad. Para proyectos reales, considera **STANDARD** (incluye soporte de base de datos) o **FULL** (incluye tareas en segundo plano). + +## Paso 3: Entrar en tu proyecto + +Entra en el directorio del proyecto recién creado: + +
+ +```console +$ cd my-first-api +$ ls -la +total 32 +drwxr-xr-x 8 user user 256 Dec 7 10:30 . +drwxr-xr-x 3 user user 96 Dec 7 10:30 .. +drwxr-xr-x 5 user user 160 Dec 7 10:30 .venv +-rw-r--r-- 1 user user 156 Dec 7 10:30 README.md +-rw-r--r-- 1 user user 243 Dec 7 10:30 requirements.txt +drwxr-xr-x 3 user user 96 Dec 7 10:30 scripts +-rw-r--r-- 1 user user 1245 Dec 7 10:30 setup.py +drwxr-xr-x 8 user user 256 Dec 7 10:30 src +drwxr-xr-x 3 user user 96 Dec 7 10:30 tests +``` + +
+ +## Paso 4: Activar el entorno virtual + +El proyecto incluye un entorno virtual preconfigurado. Vamos a activarlo: + +
+ +```console +$ source .venv/bin/activate # En Windows: .venv\Scripts\activate +(my-first-api) $ +``` + +
+ +Fíjate en cómo el prompt del terminal ahora muestra `(my-first-api)`, indicando que el entorno virtual está activo. + +## Paso 5: Iniciar el servidor de desarrollo + +Llega la parte emocionante — vamos a arrancar tu servidor FastAPI: + +
+ +```console +$ fastkit runserver +INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit) +INFO: Started reloader process [28720] using StatReload +INFO: Started server process [28722] +INFO: Waiting for application startup. +INFO: Application startup complete. +``` + +
+ +🎉 **¡Enhorabuena!** Tu servidor FastAPI ya está en marcha. + +## Paso 6: Probar tu API + +Vamos a probar tu API de varias formas: + +### Método 1: Navegador + +Abre tu navegador web y visita: + +- **Endpoint principal de la API**: [http://127.0.0.1:8000](http://127.0.0.1:8000) + +Deberías ver: +```json +{"message": "Hello World"} +``` + +### Método 2: Documentación interactiva de la API + +Entra en la documentación generada automáticamente: + +- **Swagger UI**: [http://127.0.0.1:8000/docs](http://127.0.0.1:8000/docs) +- **ReDoc**: [http://127.0.0.1:8000/redoc](http://127.0.0.1:8000/redoc) + +Swagger UI es especialmente útil — te permite: + +- Ver todos los endpoints disponibles +- Probar endpoints directamente desde el navegador +- Consultar esquemas de petición / respuesta +- Descargar la especificación OpenAPI + +### Método 3: Línea de comandos + +Abre una terminal nueva (deja el servidor en marcha) y prueba con curl: + +
+ +```console +$ curl http://127.0.0.1:8000 +{"message":"Hello World"} + +$ curl http://127.0.0.1:8000/api/v1/items/ +[] + +$ curl -X POST "http://127.0.0.1:8000/api/v1/items/" \ + -H "Content-Type: application/json" \ + -d '{"title": "My First Item", "description": "This is a test item"}' +{ + "id": 1, + "title": "My First Item", + "description": "This is a test item" +} +``` + +
+ +## Paso 7: Entender la estructura del proyecto + +Vamos a explorar lo que FastAPI-fastkit ha generado para ti: + +
+ +```console +$ tree src +src/ +├── __init__.py +├── main.py # Punto de entrada de la app FastAPI +├── core/ +│ ├── __init__.py +│ └── config.py # Configuración de la aplicación +├── api/ +│ ├── __init__.py +│ ├── api.py # Router principal de la API +│ └── routes/ +│ ├── __init__.py +│ └── items.py # Endpoints de la API de items +├── crud/ +│ ├── __init__.py +│ └── items.py # Lógica de negocio para items +├── schemas/ +│ ├── __init__.py +│ └── items.py # Esquemas de validación de datos +└── mocks/ + ├── __init__.py + └── mock_items.json # Datos de ejemplo +``` + +
+ +### Archivos clave + +**`src/main.py`** — El corazón de tu aplicación: +```python +from fastapi import FastAPI +from src.api.api import api_router +from src.core.config import settings + +app = FastAPI( + title=settings.PROJECT_NAME, + version=settings.VERSION, + openapi_url=f"{settings.API_V1_STR}/openapi.json" +) + +app.include_router(api_router, prefix=settings.API_V1_STR) + +@app.get("/") +def read_root(): + return {"message": "Hello World"} +``` + +**`src/core/config.py`** — Ajustes de la aplicación: +```python +from pydantic_settings import BaseSettings + +class Settings(BaseSettings): + PROJECT_NAME: str = "my-first-api" + VERSION: str = "1.0.0" + API_V1_STR: str = "/api/v1" + + class Config: + env_file = ".env" + +settings = Settings() +``` + +**`src/api/routes/items.py`** — Endpoints de la API: +```python +from typing import List +from fastapi import APIRouter, HTTPException +from src.schemas.items import Item, ItemCreate, ItemUpdate +from src.crud.items import items_crud + +router = APIRouter() + +@router.get("/", response_model=List[Item]) +def read_items(): + """Get all items""" + return items_crud.get_all() + +@router.post("/", response_model=Item) +def create_item(item: ItemCreate): + """Create a new item""" + return items_crud.create(item) +``` + +## Paso 8: Añadir tu primera ruta personalizada + +Añadamos una nueva ruta de API para practicar lo que has aprendido: + +
+ +```console +$ fastkit addroute users my-first-api + Adding New Route +┌──────────────────┬──────────────────────────────────────────┐ +│ Project │ my-first-api │ +│ Route Name │ users │ +│ Target Directory │ ~/my-first-api │ +└──────────────────┴──────────────────────────────────────────┘ + +Do you want to add route 'users' to project 'my-first-api'? [Y/n]: y + +✨ Successfully added new route 'users' to project 'my-first-api' +``` + +
+ +El servidor se reinicia automáticamente y ahora tienes nuevos endpoints: + +- `GET /api/v1/users/` - Obtener todos los usuarios +- `POST /api/v1/users/` - Crear un usuario nuevo +- `GET /api/v1/users/{user_id}` - Obtener un usuario concreto +- Y más... + +### Probar la nueva ruta + +
+ +```console +$ curl -X POST "http://127.0.0.1:8000/api/v1/users/" \ + -H "Content-Type: application/json" \ + -d '{"title": "John Doe", "description": "Software Developer"}' +{ + "id": 1, + "title": "John Doe", + "description": "Software Developer" +} + +$ curl http://127.0.0.1:8000/api/v1/users/ +[ + { + "id": 1, + "title": "John Doe", + "description": "Software Developer" + } +] +``` + +
+ +## Paso 9: Explorar y modificar el código + +Ahora hagamos una pequeña modificación para entender cómo funciona el código. + +### Cambiar el mensaje de bienvenida + +Abre `src/main.py` en tu editor y cambia el endpoint raíz: + +```python +@app.get("/") +def read_root(): + return {"message": "Welcome to my first FastAPI application!"} +``` + +Guarda el archivo. Gracias a la recarga automática, tu servidor se reinicia solo. + +### Probar el cambio + +
+ +```console +$ curl http://127.0.0.1:8000 +{"message":"Welcome to my first FastAPI application!"} +``` + +
+ +### Añadir un endpoint nuevo + +Añadamos un endpoint simple en `src/main.py`: + +```python +@app.get("/hello/{name}") +def say_hello(name: str): + return {"message": f"Hello, {name}!"} +``` + +### Probar el nuevo endpoint + +
+ +```console +$ curl http://127.0.0.1:8000/hello/World +{"message":"Hello, World!"} + +$ curl http://127.0.0.1:8000/hello/FastAPI +{"message":"Hello, FastAPI!"} +``` + +
+ +## Paso 10: Ejecutar las pruebas + +El proyecto trae pruebas preconfiguradas. Vamos a ejecutarlas: + +
+ +```console +$ python -m pytest +======================== test session starts ======================== +collected 5 items + +tests/test_items.py::test_create_item PASSED +tests/test_items.py::test_read_items PASSED +tests/test_items.py::test_read_item PASSED +tests/test_items.py::test_update_item PASSED +tests/test_items.py::test_delete_item PASSED + +======================== 5 passed in 0.45s ======================== +``` + +
+ +## Conceptos centrales + +### 1. Estructura de la aplicación FastAPI + +FastAPI-fastkit sigue una **arquitectura modular**: + +- **`main.py`**: Punto de entrada de la aplicación y endpoints globales +- **`api/`**: Organización de rutas de la API +- **`core/`**: Configuración y ajustes de la aplicación +- **`crud/`**: Lógica de negocio y operaciones sobre datos +- **`schemas/`**: Validación y serialización de datos +- **`tests/`**: Pruebas automatizadas + +### 2. Gestión de dependencias + +Tu proyecto usa gestión moderna de dependencias Python: + +- **Entorno virtual**: entorno Python aislado +- **requirements.txt**: lista todas las dependencias +- **Instalación automática**: las dependencias se instalan al crear el proyecto + +### 3. Servidor de desarrollo + +FastAPI-fastkit usa **Uvicorn** como servidor ASGI: + +- **Recarga automática**: se reinicia solo cuando el código cambia +- **Arranque rápido**: iteración de desarrollo ágil +- **Listo para producción**: el mismo servidor se usa en producción + +### 4. Documentación de la API + +FastAPI genera automáticamente: + +- **Especificación OpenAPI**: documentación de API estándar de la industria +- **Swagger UI**: interfaz interactiva para pruebas +- **ReDoc**: visualización alternativa de la documentación + +## Próximos pasos + +¡Enhorabuena! Has conseguido: + +✅ Instalar FastAPI-fastkit +✅ Crear tu primer proyecto +✅ Arrancar el servidor de desarrollo +✅ Probar tus endpoints de API +✅ Añadir una ruta nueva +✅ Modificar código existente +✅ Ejecutar pruebas + +### Seguir aprendiendo + +1. **[Tu primer proyecto](first-project.md)**: Construye una API de blog completa con funcionalidades avanzadas +2. **[Añadir rutas](../user-guide/adding-routes.md)**: Aprende a crear endpoints más complejos +3. **[Usar plantillas](../user-guide/using-templates.md)**: Explora plantillas de proyecto ya preparadas + +### Experimenta más + +Prueba estos retos: + +1. **Añadir validación**: Modifica los esquemas para añadir reglas de validación de datos +2. **Respuestas personalizadas**: Cambia los formatos de respuesta en las rutas +3. **Variables de entorno**: Usa archivos `.env` para la configuración +4. **Añadir middleware**: Implementa CORS o autenticación +5. **Integración con base de datos**: Actualiza al stack STANDARD para soporte de base de datos + +### Problemas comunes y soluciones + +**El servidor no arranca:** + +- Comprueba que estás en el directorio del proyecto +- Asegúrate de que el entorno virtual está activado +- Verifica que no hay errores de sintaxis en tu código + +**Errores de import:** + +- Asegúrate de que existen todos los archivos `__init__.py` +- Comprueba que tus rutas de import son correctas +- Verifica que estás usando el entorno virtual + +**Puerto ya en uso:** +```console +$ fastkit runserver --port 8080 +``` + +## Buenas prácticas que has aprendido + +1. **Entornos virtuales**: Usa siempre entornos aislados +2. **Estructura del proyecto**: Sigue una arquitectura modular y organizada +3. **Recarga automática**: Usa el servidor de desarrollo para iterar rápido +4. **Documentación de API**: Aprovecha la generación automática de documentación +5. **Pruebas**: Ejecuta las pruebas regularmente durante el desarrollo + +!!! tip "Consejos de desarrollo" + - Mantén el servidor de desarrollo en marcha mientras programas + - Usa la documentación interactiva (`/docs`) para probar tus APIs + - Revisa el terminal en busca de mensajes de error útiles + - Haz commits frecuentes al control de versiones + +¡Ya estás listo para construir APIs increíbles con FastAPI-fastkit! 🚀 diff --git a/docs/es/tutorial/mcp-integration.md b/docs/es/tutorial/mcp-integration.md new file mode 100644 index 0000000..22bd259 --- /dev/null +++ b/docs/es/tutorial/mcp-integration.md @@ -0,0 +1,1730 @@ +# Integración con MCP (Model Context Protocol) + +Aprende a integrar Model Context Protocol (MCP) con FastAPI para construir un sistema en el que los modelos de IA puedan usar endpoints de API como herramientas. Implementaremos una API integrada con IA completa que incluye autenticación, gestión de permisos e implementación del servidor MCP usando la plantilla `fastapi-mcp`. + +## Lo que aprenderás en este tutorial + +- Conceptos e implementación de Model Context Protocol (MCP) +- Construir sistemas de autenticación basados en JWT +- Implementar control de acceso basado en roles (RBAC) +- Exponer y gestionar herramientas MCP +- Comunicación segura entre la API y los modelos de IA +- Gestión de sesiones y contexto de usuario + +## Requisitos previos + +- Haber completado el [tutorial de manejo personalizado de respuestas](custom-response-handling.md) +- Conceptos básicos de JWT y OAuth2 +- Conceptos de comunicación API con modelos IA/LLM +- Conocimientos básicos del protocolo MCP + +## ¿Qué es Model Context Protocol (MCP)? + +MCP es un protocolo estandarizado que permite a los modelos de IA interactuar con sistemas externos. + +### Enfoque tradicional vs MCP + +**Enfoque tradicional (llamadas directas a la API):** +``` +Modelo IA → Petición HTTP → Servidor API → Respuesta +``` + +**Enfoque MCP:** +``` +Modelo IA → Cliente MCP → Servidor MCP (FastAPI) → Ejecución segura de herramientas → Respuesta +``` + +### Ventajas de MCP + +- **Seguridad**: gestión integrada de autenticación y permisos +- **Estandarización**: provee una interfaz consistente +- **Gestión de contexto**: mantenimiento de estado basado en sesiones +- **Abstracción de herramientas**: expone APIs complejas como herramientas simples + +## Paso 1: Crear un proyecto de integración MCP + +Crea un proyecto usando la plantilla `fastapi-mcp`: + +
+ +```console +$ fastkit startdemo fastapi-mcp +Enter the project name: ai-integrated-api +Enter the author name: Developer Kim +Enter the author email: developer@example.com +Enter the project description: MCP-based API server integrated with AI models +Deploying FastAPI project using 'fastapi-mcp' template + + Project Information +┌──────────────┬─────────────────────────────────────────────┐ +│ Project Name │ ai-integrated-api │ +│ Author │ Developer Kim │ +│ Author Email │ developer@example.com │ +│ Description │ MCP-based API server integrated with AI models │ +└──────────────┴─────────────────────────────────────────────┘ + + Template Dependencies +┌──────────────┬────────────────┐ +│ Dependency 1 │ fastapi │ +│ Dependency 2 │ uvicorn │ +│ Dependency 3 │ pydantic │ +│ Dependency 4 │ python-jose │ +│ Dependency 5 │ passlib │ +│ Dependency 6 │ python-multipart│ +│ Dependency 7 │ mcp │ +└──────────────┴────────────────┘ + +Select package manager (pip, uv, pdm, poetry) [uv]: uv +Do you want to proceed with project creation? [y/N]: y + +✨ FastAPI project 'ai-integrated-api' from 'fastapi-mcp' has been created successfully! +``` + +
+ +## Paso 2: Análisis de la estructura del proyecto + +Veamos la estructura del proyecto generado: + +``` +ai-integrated-api/ +├── src/ +│ ├── main.py # Aplicación FastAPI +│ ├── auth/ +│ │ ├── __init__.py +│ │ ├── models.py # Modelos relacionados con autenticación +│ │ ├── jwt_handler.py # Manejo de tokens JWT +│ │ ├── dependencies.py # Dependencias de autenticación +│ │ └── routes.py # Router de autenticación +│ ├── mcp/ +│ │ ├── __init__.py +│ │ ├── server.py # Implementación del servidor MCP +│ │ ├── tools.py # Definiciones de herramientas MCP +│ │ └── client.py # Cliente MCP (para pruebas) +│ ├── api/ +│ │ ├── __init__.py +│ │ ├── api.py # Conjunto de routers +│ │ └── routes/ +│ │ ├── items.py # API de gestión de items +│ │ ├── users.py # API de gestión de usuarios +│ │ └── admin.py # API de administración +│ ├── schemas/ +│ │ ├── __init__.py +│ │ ├── auth.py # Esquemas de autenticación +│ │ ├── users.py # Esquemas de usuario +│ │ └── items.py # Esquemas de items +│ └── core/ +│ ├── __init__.py +│ ├── config.py # Configuración +│ ├── database.py # Base de datos (en memoria) +│ └── security.py # Configuración de seguridad +└── tests/ + ├── test_auth.py # Pruebas de autenticación + ├── test_mcp.py # Pruebas de MCP + └── test_integration.py # Pruebas de integración +``` + +## Paso 3: Implementación del sistema de autenticación + +### Manejo de tokens JWT (`src/auth/jwt_handler.py`) + +```python +from datetime import datetime, timedelta +from typing import Optional, Dict, Any +from jose import JWTError, jwt +from passlib.context import CryptContext + +from src.core.config import settings + +# Hash de contraseñas +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + +def verify_password(plain_password: str, hashed_password: str) -> bool: + """Verificación de contraseña""" + return pwd_context.verify(plain_password, hashed_password) + +def get_password_hash(password: str) -> str: + """Hash de contraseña""" + return pwd_context.hash(password) + +def create_access_token(data: Dict[str, Any], expires_delta: Optional[timedelta] = None) -> str: + """Generación del access token""" + to_encode = data.copy() + + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + + to_encode.update({"exp": expire, "iat": datetime.utcnow()}) + + encoded_jwt = jwt.encode( + to_encode, + settings.SECRET_KEY, + algorithm=settings.ALGORITHM + ) + + return encoded_jwt + +def create_refresh_token(user_id: str) -> str: + """Generación del refresh token""" + data = {"sub": user_id, "type": "refresh"} + expire = datetime.utcnow() + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS) + + to_encode = data.copy() + to_encode.update({"exp": expire, "iat": datetime.utcnow()}) + + return jwt.encode( + to_encode, + settings.SECRET_KEY, + algorithm=settings.ALGORITHM + ) + +def decode_token(token: str) -> Optional[Dict[str, Any]]: + """Decodificar token""" + try: + payload = jwt.decode( + token, + settings.SECRET_KEY, + algorithms=[settings.ALGORITHM] + ) + return payload + except JWTError: + return None + +def verify_token(token: str, token_type: str = "access") -> Optional[str]: + """Verifica el token y devuelve el ID de usuario""" + payload = decode_token(token) + + if not payload: + return None + + # Verificar el tipo de token + if token_type == "refresh" and payload.get("type") != "refresh": + return None + + user_id = payload.get("sub") + if not user_id: + return None + + return user_id + +class TokenManager: + """Gestor de tokens""" + + def __init__(self): + self.blacklisted_tokens = set() + + def blacklist_token(self, token: str): + """Añade token a la lista negra""" + self.blacklisted_tokens.add(token) + + def is_blacklisted(self, token: str) -> bool: + """Comprueba si un token está en la lista negra""" + return token in self.blacklisted_tokens + + def create_token_pair(self, user_id: str, user_role: str) -> Dict[str, str]: + """Crea un par de access/refresh tokens""" + access_token_data = { + "sub": user_id, + "role": user_role, + "type": "access" + } + + access_token = create_access_token(access_token_data) + refresh_token = create_refresh_token(user_id) + + return { + "access_token": access_token, + "refresh_token": refresh_token, + "token_type": "bearer" + } + +# Gestor global de tokens +token_manager = TokenManager() +``` + +### Modelo de usuario y base de datos (`src/auth/models.py`) + +```python +from typing import List, Optional, Dict, Any +from pydantic import BaseModel, EmailStr +from enum import Enum +from datetime import datetime + +class UserRole(str, Enum): + """Roles de usuario""" + ADMIN = "admin" + USER = "user" + AI_AGENT = "ai_agent" + READONLY = "readonly" + +class Permission(str, Enum): + """Permisos""" + READ_ITEMS = "read:items" + WRITE_ITEMS = "write:items" + DELETE_ITEMS = "delete:items" + MANAGE_USERS = "manage:users" + USE_MCP_TOOLS = "use:mcp_tools" + ADMIN_MCP = "admin:mcp" + +class User(BaseModel): + """Modelo de usuario""" + id: str + email: EmailStr + username: str + full_name: Optional[str] = None + role: UserRole + permissions: List[Permission] + is_active: bool = True + created_at: datetime + last_login: Optional[datetime] = None + api_key: Optional[str] = None # Para el cliente MCP + +class UserInDB(User): + """Modelo de usuario para almacenar en base de datos""" + hashed_password: str + +class UserCreate(BaseModel): + """Esquema de creación de usuario""" + email: EmailStr + username: str + password: str + full_name: Optional[str] = None + role: UserRole = UserRole.USER + +class UserUpdate(BaseModel): + """Esquema de actualización de usuario""" + email: Optional[EmailStr] = None + username: Optional[str] = None + full_name: Optional[str] = None + role: Optional[UserRole] = None + is_active: Optional[bool] = None + +class LoginRequest(BaseModel): + """Esquema de petición de login""" + username: str + password: str + +class TokenResponse(BaseModel): + """Esquema de respuesta del token""" + access_token: str + refresh_token: str + token_type: str = "bearer" + expires_in: int + user: User + +# Permisos por defecto según el rol +ROLE_PERMISSIONS = { + UserRole.ADMIN: [ + Permission.READ_ITEMS, + Permission.WRITE_ITEMS, + Permission.DELETE_ITEMS, + Permission.MANAGE_USERS, + Permission.USE_MCP_TOOLS, + Permission.ADMIN_MCP + ], + UserRole.USER: [ + Permission.READ_ITEMS, + Permission.WRITE_ITEMS, + Permission.USE_MCP_TOOLS + ], + UserRole.AI_AGENT: [ + Permission.READ_ITEMS, + Permission.WRITE_ITEMS, + Permission.USE_MCP_TOOLS + ], + UserRole.READONLY: [ + Permission.READ_ITEMS + ] +} + +class UserDatabase: + """Base de datos de usuarios en memoria""" + + def __init__(self): + self.users: Dict[str, UserInDB] = {} + self._init_default_users() + + def _init_default_users(self): + """Crea usuarios por defecto""" + from src.auth.jwt_handler import get_password_hash + import uuid + + # Cuenta de administrador + admin_id = str(uuid.uuid4()) + self.users[admin_id] = UserInDB( + id=admin_id, + email="admin@example.com", + username="admin", + full_name="System Administrator", + role=UserRole.ADMIN, + permissions=ROLE_PERMISSIONS[UserRole.ADMIN], + hashed_password=get_password_hash("admin123"), + created_at=datetime.utcnow(), + api_key=str(uuid.uuid4()) + ) + + # Cuenta de agente IA + ai_id = str(uuid.uuid4()) + self.users[ai_id] = UserInDB( + id=ai_id, + email="ai@example.com", + username="ai_agent", + full_name="AI Assistant", + role=UserRole.AI_AGENT, + permissions=ROLE_PERMISSIONS[UserRole.AI_AGENT], + hashed_password=get_password_hash("ai123"), + created_at=datetime.utcnow(), + api_key=str(uuid.uuid4()) + ) + + def get_user_by_username(self, username: str) -> Optional[UserInDB]: + """Obtener usuario por nombre de usuario""" + return next( + (user for user in self.users.values() if user.username == username), + None + ) + + def get_user_by_id(self, user_id: str) -> Optional[UserInDB]: + """Obtener usuario por ID""" + return self.users.get(user_id) + + def get_user_by_api_key(self, api_key: str) -> Optional[UserInDB]: + """Obtener usuario por API key""" + return next( + (user for user in self.users.values() if user.api_key == api_key), + None + ) + + def create_user(self, user_create: UserCreate) -> UserInDB: + """Crear usuario""" + import uuid + from src.auth.jwt_handler import get_password_hash + + user_id = str(uuid.uuid4()) + user = UserInDB( + id=user_id, + email=user_create.email, + username=user_create.username, + full_name=user_create.full_name, + role=user_create.role, + permissions=ROLE_PERMISSIONS[user_create.role], + hashed_password=get_password_hash(user_create.password), + created_at=datetime.utcnow(), + api_key=str(uuid.uuid4()) + ) + + self.users[user_id] = user + return user + + def update_user(self, user_id: str, user_update: UserUpdate) -> Optional[UserInDB]: + """Actualizar usuario""" + if user_id not in self.users: + return None + + user = self.users[user_id] + update_data = user_update.dict(exclude_unset=True) + + for field, value in update_data.items(): + setattr(user, field, value) + + # Actualizar permisos si cambia el rol + if "role" in update_data: + user.permissions = ROLE_PERMISSIONS[user.role] + + return user + + def update_last_login(self, user_id: str): + """Actualizar el último login""" + if user_id in self.users: + self.users[user_id].last_login = datetime.utcnow() + +# Instancia global de base de datos +user_db = UserDatabase() +``` + +## Paso 4: Implementación de dependencias de autenticación + +### Dependencias de autenticación (`src/auth/dependencies.py`) + +```python +from typing import Optional, List +from fastapi import Depends, HTTPException, status, Security +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials, APIKeyHeader +from jose import JWTError + +from src.auth.jwt_handler import decode_token, token_manager +from src.auth.models import User, UserInDB, Permission, user_db + +# Esquema de seguridad +security = HTTPBearer() +api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False) + +async def get_current_user( + credentials: HTTPAuthorizationCredentials = Security(security) +) -> User: + """Obtener el usuario autenticado actual""" + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + + try: + token = credentials.credentials + + # Comprobar la lista negra + if token_manager.is_blacklisted(token): + raise credentials_exception + + payload = decode_token(token) + if payload is None: + raise credentials_exception + + user_id: str = payload.get("sub") + if user_id is None: + raise credentials_exception + + except JWTError: + raise credentials_exception + + user = user_db.get_user_by_id(user_id) + if user is None: + raise credentials_exception + + if not user.is_active: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Inactive user" + ) + + return User(**user.dict()) + +async def get_current_user_by_api_key( + api_key: Optional[str] = Security(api_key_header) +) -> Optional[User]: + """Autenticar usuario por API key""" + if not api_key: + return None + + user = user_db.get_user_by_api_key(api_key) + if not user or not user.is_active: + return None + + return User(**user.dict()) + +async def get_current_user_flexible( + token_user: Optional[User] = Depends(get_current_user), + api_key_user: Optional[User] = Depends(get_current_user_by_api_key) +) -> User: + """Autenticar usuario por token o API key (autenticación flexible)""" + user = token_user or api_key_user + + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Authentication required" + ) + + return user + +def require_permissions(*required_permissions: Permission): + """Dependencia que requiere ciertos permisos""" + def permission_checker(current_user: User = Depends(get_current_user_flexible)) -> User: + for permission in required_permissions: + if permission not in current_user.permissions: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Permission '{permission}' required" + ) + return current_user + + return permission_checker + +def require_roles(*required_roles): + """Dependencia que requiere ciertos roles""" + def role_checker(current_user: User = Depends(get_current_user_flexible)) -> User: + if current_user.role not in required_roles: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Role must be one of: {', '.join(required_roles)}" + ) + return current_user + + return role_checker + +# Dependencias de permisos comunes +RequireAdmin = require_roles("admin") +RequireReadItems = require_permissions(Permission.READ_ITEMS) +RequireWriteItems = require_permissions(Permission.WRITE_ITEMS) +RequireDeleteItems = require_permissions(Permission.DELETE_ITEMS) +RequireMCPTools = require_permissions(Permission.USE_MCP_TOOLS) +RequireAdminMCP = require_permissions(Permission.ADMIN_MCP) +``` + +### Router de autenticación (`src/auth/routes.py`) + +```python +from datetime import timedelta +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.security import OAuth2PasswordRequestForm + +from src.auth.models import ( + User, UserCreate, UserUpdate, LoginRequest, TokenResponse, + user_db, UserRole +) +from src.auth.jwt_handler import ( + verify_password, token_manager, verify_token, create_access_token +) +from src.auth.dependencies import get_current_user, RequireAdmin +from src.core.config import settings + +router = APIRouter(prefix="/auth", tags=["authentication"]) + +@router.post("/register", response_model=User) +async def register_user(user_create: UserCreate): + """Registrar usuario""" + # Comprobar duplicado de username + if user_db.get_user_by_username(user_create.username): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Username already registered" + ) + + # El primer usuario se marca automáticamente como admin + if not user_db.users: + user_create.role = UserRole.ADMIN + + user = user_db.create_user(user_create) + return User(**user.dict()) + +@router.post("/login", response_model=TokenResponse) +async def login_user(form_data: OAuth2PasswordRequestForm = Depends()): + """Login de usuario""" + user = user_db.get_user_by_username(form_data.username) + + if not user or not verify_password(form_data.password, user.hashed_password): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect username or password", + headers={"WWW-Authenticate": "Bearer"}, + ) + + if not user.is_active: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Inactive user" + ) + + # Crear tokens + tokens = token_manager.create_token_pair(user.id, user.role) + + # Actualizar último login + user_db.update_last_login(user.id) + + return TokenResponse( + access_token=tokens["access_token"], + refresh_token=tokens["refresh_token"], + token_type=tokens["token_type"], + expires_in=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60, + user=User(**user.dict()) + ) + +@router.post("/refresh", response_model=dict) +async def refresh_token(refresh_token: str): + """Refrescar token""" + user_id = verify_token(refresh_token, "refresh") + + if not user_id: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid refresh token" + ) + + user = user_db.get_user_by_id(user_id) + if not user or not user.is_active: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User not found or inactive" + ) + + # Crear nuevo par de tokens + tokens = token_manager.create_token_pair(user.id, user.role) + + return { + "access_token": tokens["access_token"], + "refresh_token": tokens["refresh_token"], + "token_type": tokens["token_type"], + "expires_in": settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60 + } + +@router.post("/logout") +async def logout_user(current_user: User = Depends(get_current_user)): + """Logout de usuario""" + # En la implementación real, añade el token a la lista negra + return {"message": "Successfully logged out"} + +@router.get("/me", response_model=User) +async def get_current_user_info(current_user: User = Depends(get_current_user)): + """Obtener información del usuario actual""" + return current_user + +@router.put("/me", response_model=User) +async def update_current_user( + user_update: UserUpdate, + current_user: User = Depends(get_current_user) +): + """Actualizar información del usuario actual""" + # Los usuarios normales no pueden cambiar el rol + if user_update.role and current_user.role != UserRole.ADMIN: + user_update.role = None + + updated_user = user_db.update_user(current_user.id, user_update) + if not updated_user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found" + ) + + return User(**updated_user.dict()) + +@router.get("/users", response_model=list[User]) +async def list_users(admin_user: User = Depends(RequireAdmin)): + """Listar usuarios (solo admin)""" + return [User(**user.dict()) for user in user_db.users.values()] + +@router.post("/users/{user_id}/generate-api-key") +async def generate_api_key( + user_id: str, + admin_user: User = Depends(RequireAdmin) +): + """Crear API key de usuario (solo admin)""" + import uuid + + user = user_db.get_user_by_id(user_id) + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found" + ) + + # Crear nueva API key + new_api_key = str(uuid.uuid4()) + user.api_key = new_api_key + + return { + "api_key": new_api_key, + "message": "API key generated successfully" + } +``` + +## Paso 5: Implementación del servidor MCP + +### Definición de herramientas MCP (`src/mcp/tools.py`) + +```python +from typing import Dict, Any, List, Optional +from pydantic import BaseModel, Field +from enum import Enum + +class ToolCategory(str, Enum): + """Categoría de herramienta""" + DATA_MANAGEMENT = "data_management" + SEARCH = "search" + ANALYSIS = "analysis" + ADMIN = "admin" + +class MCPTool(BaseModel): + """Definición de herramienta MCP""" + name: str = Field(..., description="Tool name") + description: str = Field(..., description="Tool description") + category: ToolCategory = Field(..., description="Tool category") + parameters: Dict[str, Any] = Field(default_factory=dict, description="Parameter schema") + required_permissions: List[str] = Field(default_factory=list, description="Required permissions") + examples: List[Dict[str, Any]] = Field(default_factory=list, description="Usage examples") + +class ToolRegistry: + """Registro de herramientas""" + + def __init__(self): + self.tools: Dict[str, MCPTool] = {} + self._register_default_tools() + + def _register_default_tools(self): + """Registra las herramientas por defecto""" + + # Herramienta de creación de item + self.register_tool(MCPTool( + name="create_item", + description="Create a new item", + category=ToolCategory.DATA_MANAGEMENT, + parameters={ + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Item name" + }, + "description": { + "type": "string", + "description": "Item description" + }, + "price": { + "type": "number", + "description": "Item price", + "minimum": 0 + }, + "category": { + "type": "string", + "description": "Item category" + } + }, + "required": ["name", "price"] + }, + required_permissions=["write:items"], + examples=[ + { + "name": "Notebook", + "description": "High-performance gaming notebook", + "price": 1500000, + "category": "electronics" + } + ] + )) + + # Herramienta de búsqueda de items + self.register_tool(MCPTool( + name="search_items", + description="Search for items", + category=ToolCategory.SEARCH, + parameters={ + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Search query" + }, + "category": { + "type": "string", + "description": "Category filter" + }, + "min_price": { + "type": "number", + "description": "Minimum price" + }, + "max_price": { + "type": "number", + "description": "Maximum price" + }, + "limit": { + "type": "integer", + "description": "Result count limit", + "default": 10, + "maximum": 100 + } + }, + "required": ["query"] + }, + required_permissions=["read:items"], + examples=[ + { + "query": "Notebook", + "category": "electronics", + "max_price": 2000000, + "limit": 5 + } + ] + )) + + # Herramienta de análisis de items + self.register_tool(MCPTool( + name="analyze_items", + description="Analyze item data", + category=ToolCategory.ANALYSIS, + parameters={ + "type": "object", + "properties": { + "analysis_type": { + "type": "string", + "enum": ["price_distribution", "category_breakdown", "trend_analysis"], + "description": "Analysis type" + }, + "date_range": { + "type": "object", + "properties": { + "start_date": {"type": "string", "format": "date"}, + "end_date": {"type": "string", "format": "date"} + }, + "description": "Analysis period" + } + }, + "required": ["analysis_type"] + }, + required_permissions=["read:items"], + examples=[ + { + "analysis_type": "price_distribution", + "date_range": { + "start_date": "2024-01-01", + "end_date": "2024-12-31" + } + } + ] + )) + + # Herramienta de gestión de usuarios (solo admin) + self.register_tool(MCPTool( + name="manage_users", + description="Manage users", + category=ToolCategory.ADMIN, + parameters={ + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["list", "create", "update", "deactivate"], + "description": "Action to perform" + }, + "user_data": { + "type": "object", + "description": "User data (create/update)" + }, + "user_id": { + "type": "string", + "description": "User ID (update/deactivate)" + } + }, + "required": ["action"] + }, + required_permissions=["manage:users"], + examples=[ + { + "action": "list" + }, + { + "action": "create", + "user_data": { + "username": "newuser", + "email": "newuser@example.com", + "role": "user" + } + } + ] + )) + + def register_tool(self, tool: MCPTool): + """Registrar una herramienta""" + self.tools[tool.name] = tool + + def get_tool(self, tool_name: str) -> Optional[MCPTool]: + """Obtener una herramienta""" + return self.tools.get(tool_name) + + def list_tools(self, user_permissions: List[str] = None) -> List[MCPTool]: + """Lista de herramientas según los permisos del usuario""" + if user_permissions is None: + return list(self.tools.values()) + + available_tools = [] + for tool in self.tools.values(): + # Comprobar permisos + if all(perm in user_permissions for perm in tool.required_permissions): + available_tools.append(tool) + + return available_tools + + def get_tools_by_category(self, category: ToolCategory, user_permissions: List[str] = None) -> List[MCPTool]: + """Lista de herramientas por categoría""" + tools = self.list_tools(user_permissions) + return [tool for tool in tools if tool.category == category] + +# Registro global de herramientas +tool_registry = ToolRegistry() +``` + +### Implementación del servidor MCP (`src/mcp/server.py`) + +```python +from typing import Dict, Any, List, Optional +from fastapi import HTTPException, status +import asyncio +import json + +from src.mcp.tools import tool_registry, ToolCategory +from src.auth.models import User, Permission +from src.api.routes.items import ItemCRUD +from src.auth.models import user_db + +class MCPServer: + """Servidor de Model Context Protocol""" + + def __init__(self): + self.item_crud = ItemCRUD() + self.active_sessions: Dict[str, Dict[str, Any]] = {} + + async def create_session(self, user: User) -> str: + """Crear sesión MCP""" + import uuid + + session_id = str(uuid.uuid4()) + self.active_sessions[session_id] = { + "user_id": user.id, + "user": user, + "created_at": datetime.utcnow(), + "context": {}, + "tool_usage_count": 0, + "last_activity": datetime.utcnow() + } + + return session_id + + async def get_session(self, session_id: str) -> Optional[Dict[str, Any]]: + """Obtener sesión""" + session = self.active_sessions.get(session_id) + if session: + session["last_activity"] = datetime.utcnow() + return session + + async def close_session(self, session_id: str): + """Cerrar sesión""" + if session_id in self.active_sessions: + del self.active_sessions[session_id] + + async def list_tools(self, user: User) -> List[Dict[str, Any]]: + """Listar herramientas disponibles para el usuario""" + user_permissions = [perm.value for perm in user.permissions] + tools = tool_registry.list_tools(user_permissions) + + return [ + { + "name": tool.name, + "description": tool.description, + "category": tool.category, + "parameters": tool.parameters, + "examples": tool.examples + } + for tool in tools + ] + + async def execute_tool( + self, + tool_name: str, + parameters: Dict[str, Any], + user: User, + session_id: Optional[str] = None + ) -> Dict[str, Any]: + """Ejecutar herramienta""" + + # Comprobar si la herramienta existe + tool = tool_registry.get_tool(tool_name) + if not tool: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Tool '{tool_name}' not found" + ) + + # Comprobar permisos + user_permissions = [perm.value for perm in user.permissions] + for required_perm in tool.required_permissions: + if required_perm not in user_permissions: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Permission '{required_perm}' required for tool '{tool_name}'" + ) + + # Actualizar la sesión + if session_id: + session = await self.get_session(session_id) + if session: + session["tool_usage_count"] += 1 + + # Ejecutar la herramienta + try: + result = await self._execute_tool_logic(tool_name, parameters, user) + + return { + "success": True, + "tool": tool_name, + "result": result, + "timestamp": datetime.utcnow().isoformat() + } + + except Exception as e: + return { + "success": False, + "tool": tool_name, + "error": str(e), + "timestamp": datetime.utcnow().isoformat() + } + + async def _execute_tool_logic( + self, + tool_name: str, + parameters: Dict[str, Any], + user: User + ) -> Any: + """Lógica de ejecución de la herramienta""" + + if tool_name == "create_item": + return await self._create_item(parameters) + + elif tool_name == "search_items": + return await self._search_items(parameters) + + elif tool_name == "analyze_items": + return await self._analyze_items(parameters) + + elif tool_name == "manage_users": + return await self._manage_users(parameters, user) + + else: + raise ValueError(f"Tool '{tool_name}' implementation not found") + + async def _create_item(self, parameters: Dict[str, Any]) -> Dict[str, Any]: + """Implementación de la herramienta de creación de item""" + from src.schemas.items import ItemCreate + + try: + item_create = ItemCreate(**parameters) + created_item = await self.item_crud.create(item_create) + + return { + "action": "create_item", + "item": created_item.dict(), + "message": f"Item '{created_item.name}' created successfully" + } + except Exception as e: + raise ValueError(f"Failed to create item: {str(e)}") + + async def _search_items(self, parameters: Dict[str, Any]) -> Dict[str, Any]: + """Implementación de la herramienta de búsqueda de items""" + query = parameters.get("query", "") + category = parameters.get("category") + min_price = parameters.get("min_price") + max_price = parameters.get("max_price") + limit = parameters.get("limit", 10) + + # Implementación de la lógica de búsqueda + all_items = await self.item_crud.get_all() + filtered_items = [] + + for item in all_items: + # Búsqueda por texto + if query.lower() not in item.name.lower() and query.lower() not in (item.description or "").lower(): + continue + + # Filtro de categoría + if category and getattr(item, 'category', None) != category: + continue + + # Filtro de precio + if min_price is not None and item.price < min_price: + continue + if max_price is not None and item.price > max_price: + continue + + filtered_items.append(item) + + # Límite de resultados + result_items = filtered_items[:limit] + + return { + "action": "search_items", + "query": query, + "total_found": len(filtered_items), + "returned_count": len(result_items), + "items": [item.dict() for item in result_items] + } + + async def _analyze_items(self, parameters: Dict[str, Any]) -> Dict[str, Any]: + """Implementación de la herramienta de análisis de items""" + analysis_type = parameters.get("analysis_type") + date_range = parameters.get("date_range", {}) + + all_items = await self.item_crud.get_all() + + if analysis_type == "price_distribution": + prices = [item.price for item in all_items] + if not prices: + return {"analysis": "price_distribution", "result": "No items found"} + + return { + "analysis": "price_distribution", + "result": { + "total_items": len(prices), + "min_price": min(prices), + "max_price": max(prices), + "average_price": sum(prices) / len(prices), + "price_ranges": { + "under_100k": len([p for p in prices if p < 100000]), + "100k_to_500k": len([p for p in prices if 100000 <= p < 500000]), + "500k_to_1m": len([p for p in prices if 500000 <= p < 1000000]), + "over_1m": len([p for p in prices if p >= 1000000]) + } + } + } + + elif analysis_type == "category_breakdown": + categories = {} + for item in all_items: + category = getattr(item, 'category', 'uncategorized') + categories[category] = categories.get(category, 0) + 1 + + return { + "analysis": "category_breakdown", + "result": { + "total_categories": len(categories), + "categories": categories + } + } + + else: + raise ValueError(f"Unknown analysis type: {analysis_type}") + + async def _manage_users(self, parameters: Dict[str, Any], requesting_user: User) -> Dict[str, Any]: + """Implementación de la herramienta de gestión de usuarios""" + action = parameters.get("action") + + # Comprobar permisos de administrador + if Permission.MANAGE_USERS not in requesting_user.permissions: + raise ValueError("Insufficient permissions for user management") + + if action == "list": + users = [User(**user.dict()) for user in user_db.users.values()] + return { + "action": "list_users", + "total_users": len(users), + "users": [user.dict() for user in users] + } + + elif action == "create": + user_data = parameters.get("user_data", {}) + from src.auth.models import UserCreate + + user_create = UserCreate(**user_data) + created_user = user_db.create_user(user_create) + + return { + "action": "create_user", + "user": User(**created_user.dict()).dict(), + "message": f"User '{created_user.username}' created successfully" + } + + else: + raise ValueError(f"Unknown user management action: {action}") + +# Instancia global del servidor MCP +mcp_server = MCPServer() +``` + +## Paso 6: Implementar los endpoints del MCP + +### Router del MCP (`src/api/routes/mcp.py`) + +```python +from typing import Dict, Any, Optional +from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks +from pydantic import BaseModel + +from src.auth.dependencies import get_current_user_flexible, RequireMCPTools +from src.auth.models import User +from src.mcp.server import mcp_server +from src.mcp.tools import ToolCategory + +router = APIRouter(prefix="/mcp", tags=["MCP"]) + +class ToolExecuteRequest(BaseModel): + """Petición de ejecución de herramienta""" + tool_name: str + parameters: Dict[str, Any] + session_id: Optional[str] = None + +class SessionCreateResponse(BaseModel): + """Respuesta de creación de sesión""" + session_id: str + message: str + +@router.post("/session", response_model=SessionCreateResponse) +async def create_mcp_session( + current_user: User = Depends(RequireMCPTools) +): + """Crear sesión MCP""" + session_id = await mcp_server.create_session(current_user) + + return SessionCreateResponse( + session_id=session_id, + message=f"MCP session created (User: {current_user.username})" + ) + +@router.delete("/session/{session_id}") +async def close_mcp_session( + session_id: str, + current_user: User = Depends(RequireMCPTools) +): + """Cerrar sesión MCP""" + session = await mcp_server.get_session(session_id) + + if not session: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Session not found" + ) + + # Comprobar el propietario de la sesión + if session["user_id"] != current_user.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Cannot close another user's session" + ) + + await mcp_server.close_session(session_id) + + return {"message": "Session closed successfully"} + +@router.get("/tools") +async def list_mcp_tools( + category: Optional[ToolCategory] = None, + current_user: User = Depends(RequireMCPTools) +): + """Listar las herramientas MCP disponibles""" + tools = await mcp_server.list_tools(current_user) + + if category: + tools = [tool for tool in tools if tool["category"] == category] + + return { + "user": current_user.username, + "total_tools": len(tools), + "tools": tools + } + +@router.post("/execute") +async def execute_mcp_tool( + request: ToolExecuteRequest, + background_tasks: BackgroundTasks, + current_user: User = Depends(RequireMCPTools) +): + """Ejecutar herramienta MCP""" + + # Comprobar la sesión (opcional) + if request.session_id: + session = await mcp_server.get_session(request.session_id) + if not session: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Session not found" + ) + + if session["user_id"] != current_user.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Cannot use another user's session" + ) + + # Ejecutar la herramienta + result = await mcp_server.execute_tool( + tool_name=request.tool_name, + parameters=request.parameters, + user=current_user, + session_id=request.session_id + ) + + # Registrar el uso de la herramienta en segundo plano + background_tasks.add_task( + log_tool_usage, + current_user.id, + request.tool_name, + result["success"] + ) + + return result + +@router.get("/sessions") +async def list_user_sessions( + current_user: User = Depends(RequireMCPTools) +): + """Listar sesiones activas del usuario""" + user_sessions = [] + + for session_id, session_data in mcp_server.active_sessions.items(): + if session_data["user_id"] == current_user.id: + user_sessions.append({ + "session_id": session_id, + "created_at": session_data["created_at"], + "tool_usage_count": session_data["tool_usage_count"], + "last_activity": session_data["last_activity"] + }) + + return { + "user": current_user.username, + "active_sessions": len(user_sessions), + "sessions": user_sessions + } + +@router.get("/stats") +async def get_mcp_stats( + current_user: User = Depends(RequireMCPTools) +): + """Estadísticas de uso de MCP""" + total_sessions = len(mcp_server.active_sessions) + user_sessions = len([ + s for s in mcp_server.active_sessions.values() + if s["user_id"] == current_user.id + ]) + + return { + "user_stats": { + "username": current_user.username, + "active_sessions": user_sessions, + "permissions": [perm.value for perm in current_user.permissions] + }, + "server_stats": { + "total_active_sessions": total_sessions, + "available_tools": len(await mcp_server.list_tools(current_user)) + } + } + +async def log_tool_usage(user_id: str, tool_name: str, success: bool): + """Registrar uso de herramienta (tarea en segundo plano)""" + import logging + + logger = logging.getLogger("mcp.usage") + logger.info( + f"Tool usage - User: {user_id}, Tool: {tool_name}, Success: {success}" + ) +``` + +## Paso 7: Integración y prueba de la aplicación + +### Aplicación principal (`src/main.py`) + +```python +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from src.auth.routes import router as auth_router +from src.api.routes.items import router as items_router +from src.api.routes.mcp import router as mcp_router +from src.core.config import settings + +app = FastAPI( + title="AI Integrated API", + description="AI model integrated MCP-based API server", + version="1.0.0" +) + +# Configuración CORS +app.add_middleware( + CORSMiddleware, + allow_origins=settings.ALLOWED_HOSTS, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Incluir routers +app.include_router(auth_router) +app.include_router(items_router, prefix="/api/v1") +app.include_router(mcp_router, prefix="/api/v1") + +@app.get("/") +async def root(): + return { + "message": "AI Integrated API with MCP Support", + "version": "1.0.0", + "endpoints": { + "authentication": "/auth", + "items": "/api/v1/items", + "mcp": "/api/v1/mcp", + "docs": "/docs" + } + } + +@app.get("/health") +async def health_check(): + """Endpoint de health check""" + return { + "status": "healthy", + "version": "1.0.0", + "services": { + "auth": "operational", + "mcp": "operational", + "database": "operational" + } + } +``` + +### Ejecutar el servidor y probar + +
+ +```console +$ cd ai-integrated-api +$ fastkit runserver +Starting FastAPI server at 127.0.0.1:8000... + +# Login de usuario +$ curl -X POST "http://localhost:8000/auth/login" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "username=admin&password=admin123" + +{ + "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "token_type": "bearer", + "expires_in": 1800, + "user": { + "id": "123e4567-e89b-12d3-a456-426614174000", + "email": "admin@example.com", + "username": "admin", + "role": "admin", + "permissions": ["read:items", "write:items", ...] + } +} + +# Crear sesión MCP +$ curl -X POST "http://localhost:8000/api/v1/mcp/session" \ + -H "Authorization: Bearer YOUR_ACCESS_TOKEN" + +{ + "session_id": "abc123-def456-ghi789", + "message": "MCP session created (User: admin)" +} + +# Listar las herramientas disponibles +$ curl "http://localhost:8000/api/v1/mcp/tools" \ + -H "Authorization: Bearer YOUR_ACCESS_TOKEN" + +{ + "user": "admin", + "total_tools": 4, + "tools": [ + { + "name": "create_item", + "description": "Create a new item", + "category": "data_management", + "parameters": {...}, + "examples": [...] + }, + ... + ] +} + +# Ejecutar herramienta MCP (crear item) +$ curl -X POST "http://localhost:8000/api/v1/mcp/execute" \ + -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "tool_name": "create_item", + "parameters": { + "name": "AI generated item", + "description": "MCP through AI generated item", + "price": 500000, + "category": "ai_generated" + }, + "session_id": "abc123-def456-ghi789" + }' + +{ + "success": true, + "tool": "create_item", + "result": { + "action": "create_item", + "item": { + "id": 1, + "name": "AI generated item", + "description": "MCP through AI generated item", + "price": 500000, + "category": "ai_generated", + "created_at": "2024-01-01T12:00:00Z" + }, + "message": "Item 'AI generated item' created successfully" + }, + "timestamp": "2024-01-01T12:00:00.123456Z" +} + +# Ejecutar herramienta MCP (buscar item) +$ curl -X POST "http://localhost:8000/api/v1/mcp/execute" \ + -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "tool_name": "search_items", + "parameters": { + "query": "AI", + "limit": 5 + } + }' +``` + +
+ +## Paso 8: Ejemplo de cliente IA + +### Ejemplo de cliente MCP en Python + +```python +# client_example.py +import asyncio +import aiohttp +from typing import Dict, Any, List + +class MCPClient: + """Ejemplo de cliente MCP""" + + def __init__(self, base_url: str, api_key: str): + self.base_url = base_url + self.api_key = api_key + self.session_id = None + self.session = None + + async def __aenter__(self): + self.session = aiohttp.ClientSession( + headers={"X-API-Key": self.api_key} + ) + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + if self.session_id: + await self.close_session() + if self.session: + await self.session.close() + + async def create_session(self) -> str: + """Crear sesión MCP""" + async with self.session.post(f"{self.base_url}/api/v1/mcp/session") as resp: + data = await resp.json() + self.session_id = data["session_id"] + return self.session_id + + async def close_session(self): + """Cerrar sesión MCP""" + if self.session_id: + async with self.session.delete(f"{self.base_url}/api/v1/mcp/session/{self.session_id}"): + pass + self.session_id = None + + async def list_tools(self) -> List[Dict[str, Any]]: + """Listar herramientas disponibles""" + async with self.session.get(f"{self.base_url}/api/v1/mcp/tools") as resp: + data = await resp.json() + return data["tools"] + + async def execute_tool(self, tool_name: str, parameters: Dict[str, Any]) -> Dict[str, Any]: + """Ejecutar herramienta""" + payload = { + "tool_name": tool_name, + "parameters": parameters, + "session_id": self.session_id + } + + async with self.session.post( + f"{self.base_url}/api/v1/mcp/execute", + json=payload + ) as resp: + return await resp.json() + + async def ai_assistant_workflow(self, user_request: str) -> str: + """Simulación del flujo de un asistente IA""" + + # 1. Crear sesión + await self.create_session() + print(f"Session created: {self.session_id}") + + # 2. Analizar la petición del usuario y seleccionar la herramienta adecuada + if "Create item" in user_request or "Create" in user_request: + # Petición de creación de item + result = await self.execute_tool("create_item", { + "name": "AI recommended item", + "description": "AI generated item based on user request", + "price": 100000, + "category": "ai_recommended" + }) + + if result["success"]: + item_name = result["result"]["item"]["name"] + return f"✅ '{item_name}' item created successfully!" + else: + return f"❌ Item creation failed: {result.get('error', 'Unknown error')}" + + elif "Search" in user_request or "Find" in user_request: + # Petición de búsqueda + search_query = "Item" # En la práctica se extrae mediante NLP + result = await self.execute_tool("search_items", { + "query": search_query, + "limit": 5 + }) + + if result["success"]: + items = result["result"]["items"] + item_list = "\n".join([f"- {item['name']} (₩{item['price']:,})" for item in items]) + return f"🔍 Search results ({len(items)} items):\n{item_list}" + else: + return f"❌ Search failed: {result.get('error', 'Unknown error')}" + + elif "Analyze" in user_request: + # Petición de análisis + result = await self.execute_tool("analyze_items", { + "analysis_type": "price_distribution" + }) + + if result["success"]: + analysis = result["result"]["result"] + return f"📊 Price analysis:\nAverage price: ₩{analysis['average_price']:,.0f}\nMinimum: ₩{analysis['min_price']:,} - Maximum: ₩{analysis['max_price']:,}" + else: + return f"❌ Analysis failed: {result.get('error', 'Unknown error')}" + + else: + return "Sorry, I couldn't find a tool to handle that request." + +async def main(): + """Prueba del cliente""" + async with MCPClient("http://localhost:8000", "your-api-key-here") as client: + + # Listar herramientas disponibles + tools = await client.list_tools() + print(f"Available tools: {len(tools)}") + for tool in tools: + print(f"- {tool['name']}: {tool['description']}") + + print("\n" + "="*50 + "\n") + + # Simulación de asistente IA + test_requests = [ + "Create a new item", + "Search for items", + "Analyze price distribution" + ] + + for request in test_requests: + print(f"User request: {request}") + response = await client.ai_assistant_workflow(request) + print(f"AI response: {response}") + print("-" * 30) + +if __name__ == "__main__": + asyncio.run(main()) +``` + + + + + +## Resumen + +En este tutorial hemos implementado la integración con MCP (Model Context Protocol) con: + +- ✅ Construcción de un sistema de autenticación basado en JWT +- ✅ Implementación de control de acceso basado en roles (RBAC) +- ✅ Implementación del servidor MCP y el sistema de herramientas +- ✅ Gestión de contexto basada en sesiones +- ✅ Comunicación API segura con modelos de IA +- ✅ Gestión de permisos y trazabilidad de uso de herramientas +- ✅ Implementación de un ejemplo real de cliente IA + +¡Ahora puedes construir un sistema basado en MCP completo donde los modelos de IA puedan aprovechar de forma segura y eficiente las funcionalidades de tu API! diff --git a/docs/es/user-guide/adding-routes.md b/docs/es/user-guide/adding-routes.md new file mode 100644 index 0000000..5dd867e --- /dev/null +++ b/docs/es/user-guide/adding-routes.md @@ -0,0 +1,581 @@ +# Añadir rutas + +Aprende a añadir nuevas rutas de API a un proyecto FastAPI existente. + +## Añadir una ruta básica + +### Usar el comando `addroute` + +El comando `addroute` de FastAPI-fastkit facilita añadir nuevas rutas: + +
+ +```console +$ fastkit addroute users my-awesome-api + Adding New Route +┌──────────────────┬──────────────────────────────────────────┐ +│ Project │ my-awesome-api │ +│ Route Name │ users │ +│ Target Directory │ ~/my-awesome-api │ +└──────────────────┴──────────────────────────────────────────┘ + +Do you want to add route 'users' to project 'my-awesome-api'? [Y/n]: y + +╭──────────────────────── Info ────────────────────────╮ +│ ℹ Updated main.py to include the API router │ +╰──────────────────────────────────────────────────────╯ +╭─────────────────────── Success ───────────────────────╮ +│ ✨ Successfully added new route 'users' to project │ +│ `my-awesome-api` │ +╰───────────────────────────────────────────────────────╯ +``` + +
+ +## Qué se crea + +Cuando añades una ruta, FastAPI-fastkit crea automáticamente: + +### 1. Archivo de ruta: `src/api/routes/users.py` + +```python +from typing import List +from fastapi import APIRouter, HTTPException, status +from src.schemas.users import User, UserCreate, UserUpdate +from src.crud.users import users_crud + +router = APIRouter() + +@router.get("/", response_model=List[User]) +def read_users(): + """Get all users""" + return users_crud.get_all() + +@router.post("/", response_model=User, status_code=status.HTTP_201_CREATED) +def create_user(user: UserCreate): + """Create a new user""" + return users_crud.create(user) + +@router.get("/{user_id}", response_model=User) +def read_user(user_id: int): + """Get a specific user""" + user = users_crud.get_by_id(user_id) + if user is None: + raise HTTPException(status_code=404, detail="User not found") + return user + +@router.put("/{user_id}", response_model=User) +def update_user(user_id: int, user: UserUpdate): + """Update a user""" + updated_user = users_crud.update(user_id, user) + if updated_user is None: + raise HTTPException(status_code=404, detail="User not found") + return updated_user + +@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_user(user_id: int): + """Delete a user""" + success = users_crud.delete(user_id) + if not success: + raise HTTPException(status_code=404, detail="User not found") +``` + +### 2. Operaciones CRUD: `src/crud/users.py` + +```python +from typing import List, Optional +from src.schemas.users import User, UserCreate, UserUpdate + +class UsersCRUD: + def __init__(self): + self._users: List[User] = [] + self._next_id = 1 + + def get_all(self) -> List[User]: + """Get all users""" + return self._users + + def get_by_id(self, user_id: int) -> Optional[User]: + """Get user by ID""" + return next((user for user in self._users if user.id == user_id), None) + + def create(self, user: UserCreate) -> User: + """Create a new user""" + new_user = User( + id=self._next_id, + title=user.title, + description=user.description + ) + self._next_id += 1 + self._users.append(new_user) + return new_user + + def update(self, user_id: int, user: UserUpdate) -> Optional[User]: + """Update an existing user""" + existing_user = self.get_by_id(user_id) + if existing_user: + update_data = user.dict(exclude_unset=True) + for field, value in update_data.items(): + setattr(existing_user, field, value) + return existing_user + return None + + def delete(self, user_id: int) -> bool: + """Delete a user""" + user = self.get_by_id(user_id) + if user: + self._users.remove(user) + return True + return False + +users_crud = UsersCRUD() +``` + +### 3. Esquemas Pydantic: `src/schemas/users.py` + +```python +from typing import Optional +from pydantic import BaseModel + +class UserBase(BaseModel): + title: str + description: Optional[str] = None + +class UserCreate(UserBase): + pass + +class UserUpdate(BaseModel): + title: Optional[str] = None + description: Optional[str] = None + +class User(UserBase): + id: int + + class Config: + from_attributes = True +``` + +### 4. Registro del router + +El comando actualiza automáticamente `src/api/api.py` para incluir el nuevo router: + +```python +from fastapi import APIRouter +from src.api.routes import items, users + +api_router = APIRouter() + +api_router.include_router(items.router, prefix="/items", tags=["items"]) +api_router.include_router(users.router, prefix="/users", tags=["users"]) +``` + +## Endpoints de API generados + +Tras añadir la ruta `users`, tendrás estos endpoints: + +| Método | Endpoint | Descripción | +|---|---|---| +| `GET` | `/api/v1/users/` | Obtener todos los usuarios | +| `POST` | `/api/v1/users/` | Crear un usuario nuevo | +| `GET` | `/api/v1/users/{user_id}` | Obtener un usuario concreto | +| `PUT` | `/api/v1/users/{user_id}` | Actualizar un usuario | +| `DELETE` | `/api/v1/users/{user_id}` | Eliminar un usuario | + +## Probar las nuevas rutas + +### 1. Iniciar el servidor + +
+ +```console +$ fastkit runserver +INFO: Uvicorn running on http://127.0.0.1:8000 +``` + +
+ +### 2. Comprobar la documentación de la API + +Entra en [http://127.0.0.1:8000/docs](http://127.0.0.1:8000/docs) para ver tus nuevos endpoints en la documentación interactiva. + +### 3. Probar con curl + +**Crear un usuario:** +
+ +```console +$ curl -X POST "http://127.0.0.1:8000/api/v1/users/" \ + -H "Content-Type: application/json" \ + -d '{"title": "John Doe", "description": "Software Developer"}' + +{ + "id": 1, + "title": "John Doe", + "description": "Software Developer" +} +``` + +
+ +**Obtener todos los usuarios:** +
+ +```console +$ curl http://127.0.0.1:8000/api/v1/users/ + +[ + { + "id": 1, + "title": "John Doe", + "description": "Software Developer" + } +] +``` + +
+ +**Obtener un usuario concreto:** +
+ +```console +$ curl http://127.0.0.1:8000/api/v1/users/1 + +{ + "id": 1, + "title": "John Doe", + "description": "Software Developer" +} +``` + +
+ +## Personalizar el código generado + +El código generado se puede personalizar por completo. Algunas modificaciones habituales: + +### 1. Esquema de usuario ampliado + +Modifica `src/schemas/users.py` para datos de usuario más realistas: + +```python +from typing import Optional +from datetime import datetime +from pydantic import BaseModel, EmailStr, Field + +class UserBase(BaseModel): + email: EmailStr + username: str = Field(..., min_length=3, max_length=50) + full_name: Optional[str] = None + is_active: bool = True + +class UserCreate(UserBase): + password: str = Field(..., min_length=8) + +class UserUpdate(BaseModel): + email: Optional[EmailStr] = None + username: Optional[str] = Field(None, min_length=3, max_length=50) + full_name: Optional[str] = None + is_active: Optional[bool] = None + +class User(UserBase): + id: int + created_at: datetime + + class Config: + from_attributes = True + +class UserInDB(User): + hashed_password: str +``` + +### 2. CRUD con validación + +Actualiza `src/crud/users.py` con una validación más cuidada: + +```python +from typing import List, Optional +from datetime import datetime +import hashlib +from src.schemas.users import UserCreate, UserUpdate, UserInDB + +class UsersCRUD: + def __init__(self): + self._users: List[UserInDB] = [] + self._next_id = 1 + + def _hash_password(self, password: str) -> str: + """Simple password hashing (use bcrypt in production)""" + return hashlib.sha256(password.encode()).hexdigest() + + def get_by_email(self, email: str) -> Optional[UserInDB]: + """Get user by email""" + return next((user for user in self._users if user.email == email), None) + + def get_by_username(self, username: str) -> Optional[UserInDB]: + """Get user by username""" + return next((user for user in self._users if user.username == username), None) + + def create(self, user: UserCreate) -> UserInDB: + """Create a new user with validation""" + # Comprobar duplicados + if self.get_by_email(user.email): + raise ValueError("Email already registered") + if self.get_by_username(user.username): + raise ValueError("Username already taken") + + new_user = UserInDB( + id=self._next_id, + email=user.email, + username=user.username, + full_name=user.full_name, + is_active=user.is_active, + created_at=datetime.now(), + hashed_password=self._hash_password(user.password) + ) + self._next_id += 1 + self._users.append(new_user) + return new_user + +users_crud = UsersCRUD() +``` + +### 3. Ruta con manejo de errores + +Actualiza `src/api/routes/users.py` con un mejor manejo de errores: + +```python +from typing import List +from fastapi import APIRouter, HTTPException, status +from src.schemas.users import User, UserCreate, UserUpdate +from src.crud.users import users_crud + +router = APIRouter() + +@router.post("/", response_model=User, status_code=status.HTTP_201_CREATED) +def create_user(user: UserCreate): + """Create a new user""" + try: + new_user = users_crud.create(user) + # Devolver el usuario sin el hash de la contraseña + return User(**new_user.dict()) + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e) + ) + +@router.get("/{user_id}", response_model=User) +def read_user(user_id: int): + """Get a specific user""" + user = users_crud.get_by_id(user_id) + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"User with id {user_id} not found" + ) + return User(**user.dict()) +``` + +## Añadir varias rutas + +Puedes añadir varias rutas para construir una API completa: + +
+ +```console +# Añadir más rutas de recursos (nombre de ruta primero, directorio del proyecto después) +$ fastkit addroute products my-awesome-api +$ fastkit addroute orders my-awesome-api +$ fastkit addroute categories my-awesome-api + +# Cada una crea la estructura CRUD completa +``` + +
+ +Esto crea una API completa con: + +- `/api/v1/users/` - Gestión de usuarios +- `/api/v1/products/` - Catálogo de productos +- `/api/v1/orders/` - Procesamiento de pedidos +- `/api/v1/categories/` - Gestión de categorías + +## Organización de las rutas + +### Agrupar endpoints relacionados + +Puedes organizar las rutas por dominio: + +```python +# src/api/api.py +from fastapi import APIRouter +from src.api.routes import users, products, orders, categories + +api_router = APIRouter() + +# Gestión de usuarios +api_router.include_router( + users.router, + prefix="/users", + tags=["User Management"] +) + +# E-commerce +api_router.include_router( + products.router, + prefix="/products", + tags=["E-commerce"] +) +api_router.include_router( + orders.router, + prefix="/orders", + tags=["E-commerce"] +) +api_router.include_router( + categories.router, + prefix="/categories", + tags=["E-commerce"] +) +``` + +### Añadir dependencias a las rutas + +Añade autenticación u otras dependencias: + +```python +from fastapi import APIRouter, Depends +from src.core.auth import get_current_user + +router = APIRouter() + +@router.get("/profile", response_model=User) +def get_user_profile(current_user: User = Depends(get_current_user)): + """Get current user's profile""" + return current_user + +@router.post("/", response_model=User) +def create_user( + user: UserCreate, + current_user: User = Depends(get_current_user) +): + """Create a new user (admin only)""" + if not current_user.is_admin: + raise HTTPException(status_code=403, detail="Admin access required") + return users_crud.create(user) +``` + +## Buenas prácticas + +### 1. Nombres consistentes + +Sigue convenciones de nombres consistentes: + +- **Nombres de ruta**: usa sustantivos en plural (`users`, `products`, `orders`) +- **Nombres de esquema**: usa singular (`User`, `Product`, `Order`) +- **Clases CRUD**: termina con `CRUD` (`UsersCRUD`, `ProductsCRUD`) + +### 2. Manejo de errores + +Maneja los errores siempre con cuidado: + +```python +@router.post("/", response_model=User) +def create_user(user: UserCreate): + try: + return users_crud.create(user) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + raise HTTPException(status_code=500, detail="Internal server error") +``` + +### 3. Documentación + +Añade docstrings completos: + +```python +@router.get("/{user_id}", response_model=User) +def read_user(user_id: int): + """ + Get a specific user by ID. + + Args: + user_id: The unique identifier for the user + + Returns: + User: The user object with all details + + Raises: + HTTPException: 404 if user not found + """ + user = users_crud.get_by_id(user_id) + if not user: + raise HTTPException(status_code=404, detail="User not found") + return user +``` + +### 4. Pruebas + +Prueba siempre tus nuevas rutas: + +```python +# tests/test_users.py +from fastapi.testclient import TestClient +from src.main import app + +client = TestClient(app) + +def test_create_user(): + user_data = { + "email": "test@example.com", + "username": "testuser", + "password": "securepassword123" + } + response = client.post("/api/v1/users/", json=user_data) + assert response.status_code == 201 + assert response.json()["email"] == user_data["email"] + +def test_get_user(): + response = client.get("/api/v1/users/1") + assert response.status_code == 200 +``` + +## Solución de problemas + +### La ruta no aparece + +Si tu ruta no aparece en la documentación de la API: + +1. **Comprueba el registro del router** en `src/api/api.py` +2. **Reinicia el servidor** tras añadir rutas +3. **Comprueba si hay errores de import** en el archivo de la ruta + +### Errores de import + +Si recibes errores de import: + +1. **Comprueba que la estructura de archivos** coincide con la esperada +2. **Verifica los imports de esquemas** en los archivos de ruta y CRUD +3. **Asegúrate de que existen todos los `__init__.py`** + +### El servidor no arranca + +Si el servidor no arranca tras añadir rutas: + +1. **Comprueba errores de sintaxis** en los archivos generados +2. **Verifica la compatibilidad de esquemas** entre archivos +3. **Revisa los logs** buscando mensajes de error concretos + +## Próximos pasos + +Ahora que sabes añadir rutas: + +1. **[Tu primer proyecto](../tutorial/first-project.md)**: Construye una API de blog completa +2. **[Referencia de la CLI](cli-reference.md)**: Aprende todos los comandos disponibles +3. **[Usar plantillas](using-templates.md)**: Explora plantillas de proyecto ya preparadas + +!!! tip "Consejos para desarrollar rutas" + - Prueba siempre las rutas nuevas en la documentación interactiva (`/docs`) + - Usa códigos de estado HTTP con significado + - Implementa manejo de errores adecuado en todos los endpoints + - Mantén los handlers simples y delega la lógica de negocio a las clases CRUD diff --git a/docs/es/user-guide/choosing-a-starter.md b/docs/es/user-guide/choosing-a-starter.md new file mode 100644 index 0000000..bf02f9a --- /dev/null +++ b/docs/es/user-guide/choosing-a-starter.md @@ -0,0 +1,145 @@ +# ¿Qué starter elegir? + +FastAPI-fastkit ofrece varias formas de arrancar un proyecto. Esta página es una **ayuda para decidir** dirigida a quienes empiezan: elige un camino aquí y luego salta a [Inicio rápido](quick-start.md) para crear realmente el proyecto. + +Si no lo tienes claro, la respuesta corta es: + +> **Empieza con `fastkit init --interactive` y elige el preset `domain-starter`.** Es la opción recomendada para las APIs modernas. + +El resto de esta página explica por qué y cuándo elegir otra cosa. + +## TL;DR — elige por tipo de usuario + +| Eres... | Empieza por | +|---|---| +| Nuevo en FastAPI y quieres una guía paso a paso | `fastkit init --interactive` (preset: **`domain-starter`**) | +| Quieres una demo CRUD funcionando para leerla y modificarla | `fastkit startdemo fastapi-default` | +| Quieres el scaffold más pequeño posible | `fastkit init --interactive` (preset: **`minimal`**) | +| Escribes un prototipo rápido / un script de un solo archivo | `fastkit init --interactive` (preset: **`single-module`**) | +| Necesitas una base de datos real (PostgreSQL + SQLAlchemy + Alembic) | `fastkit startdemo fastapi-psql-orm` | +| Quieres un layout de dominio orientado a producción para una API mediana | `fastkit init --interactive` (preset: **`domain-starter`**) | + +## `startdemo` vs `init --interactive` — ¿en qué se diferencian? + +Estas son las dos puertas de entrada principales. Sirven a propósitos distintos. + +### `fastkit startdemo