├── tests ├── __init__.py ├── conftest.py ├── test_render.py ├── test_string_utils.py └── test_type_registry.py ├── fastapi_forge ├── __init__.py ├── utils │ ├── __init__.py │ └── string_utils.py ├── frontend │ ├── __init__.py │ ├── panels │ │ ├── __init__.py │ │ ├── item_editor_panel.py │ │ ├── left_panel.py │ │ └── enum_editor_panel.py │ ├── components │ │ ├── __init__.py │ │ ├── header.py │ │ ├── item_create.py │ │ └── item_row.py │ ├── validation.py │ ├── modals │ │ ├── __init__.py │ │ ├── enum_modal.py │ │ └── relation_modal.py │ ├── notifications.py │ ├── constants.py │ └── main.py ├── constants.py ├── template │ ├── {{cookiecutter.project_name}} │ │ ├── tests │ │ │ ├── __init__.py │ │ │ ├── endpoint_tests │ │ │ │ └── __init__.py │ │ │ ├── test_utils.py │ │ │ ├── factories.py │ │ │ └── conftest.py │ │ ├── migrations │ │ │ ├── versions │ │ │ │ └── __init__.py │ │ │ ├── script.py.mako │ │ │ └── env.py │ │ ├── README.md │ │ ├── {{cookiecutter.project_name}} │ │ │ ├── __init__.py │ │ │ ├── services │ │ │ │ ├── __init__.py │ │ │ │ ├── taskiq │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── taskiq_lifetime.py │ │ │ │ │ ├── scheduler.py │ │ │ │ │ ├── tasks.py │ │ │ │ │ └── broker.py │ │ │ │ ├── redis │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── redis_dependencies.py │ │ │ │ │ └── redis_lifetime.py │ │ │ │ └── rabbitmq │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── rabbitmq_lifetime.py │ │ │ │ │ └── rabbitmq_dependencies.py │ │ │ ├── utils │ │ │ │ ├── __init__.py │ │ │ │ └── auth_utils.py │ │ │ ├── dependencies │ │ │ │ ├── __init__.py │ │ │ │ └── auth_dependencies.py │ │ │ ├── routes │ │ │ │ ├── health_routes.py │ │ │ │ ├── __init__.py │ │ │ │ ├── demo_routes.py │ │ │ │ └── auth_routes.py │ │ │ ├── models │ │ │ │ └── __init__.py │ │ │ ├── __main__.py │ │ │ ├── db │ │ │ │ ├── __init__.py │ │ │ │ ├── db_dependencies.py │ │ │ │ └── db_lifetime.py │ │ │ ├── constants.py │ │ │ ├── exceptions.py │ │ │ ├── dtos │ │ │ │ ├── auth_dtos.py │ │ │ │ └── __init__.py │ │ │ ├── daos │ │ │ │ └── __init__.py │ │ │ ├── middleware.py │ │ │ ├── main.py │ │ │ └── settings.py │ │ ├── observability │ │ │ └── prometheus │ │ │ │ └── prometheus.yaml │ │ ├── Dockerfile │ │ ├── alembic.ini │ │ ├── Makefile │ │ ├── forge-config.yaml │ │ ├── .github │ │ │ └── workflows │ │ │ │ └── check.yaml │ │ ├── .env.example │ │ ├── pyproject.toml │ │ ├── .gitignore │ │ └── docker-compose.yaml │ ├── local_extensions.py │ ├── cookiecutter.json │ └── hooks │ │ └── post_gen_project.py ├── logger.py ├── project_io │ ├── io │ │ ├── __init__.py │ │ ├── protocols.py │ │ └── writer.py │ ├── exporter │ │ ├── __init__.py │ │ ├── protocols.py │ │ └── yaml_exporter.py │ ├── loader │ │ ├── __init__.py │ │ ├── protocols.py │ │ ├── yaml_loader.py │ │ └── database_loader.py │ ├── artifact_builder │ │ ├── protocols.py │ │ ├── __init__.py │ │ ├── utils.py │ │ └── fastapi_builder.py │ ├── database │ │ ├── __init__.py │ │ ├── protocols.py │ │ └── schema.py │ └── __init__.py ├── render │ ├── engines │ │ ├── __init__.py │ │ ├── protocols.py │ │ └── jinja2_engine.py │ ├── templates │ │ ├── enums.py │ │ ├── dao.py │ │ ├── __init__.py │ │ ├── model.py │ │ ├── dto.py │ │ └── routes.py │ ├── renderers │ │ ├── enums.py │ │ ├── protocols.py │ │ └── __init__.py │ ├── __init__.py │ ├── registry.py │ ├── manager.py │ └── filters.py ├── core │ ├── project_validators │ │ ├── __init__.py │ │ ├── protocols.py │ │ └── validators.py │ ├── template_processors │ │ ├── __init__.py │ │ ├── protocols.py │ │ └── processors.py │ ├── cookiecutter_adapter │ │ ├── __init__.py │ │ ├── protocols.py │ │ └── adapters.py │ ├── __init__.py │ └── build.py ├── enums.py ├── __main__.py └── type_info_registry.py ├── docker-compose.yaml ├── .github └── workflows │ ├── check.yaml │ ├── release.yaml │ └── cli_test.yaml ├── LICENSE ├── Makefile ├── pyproject.toml ├── .gitignore └── README.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /fastapi_forge/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /fastapi_forge/utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /fastapi_forge/frontend/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /fastapi_forge/constants.py: -------------------------------------------------------------------------------- 1 | TAB = " " 2 | -------------------------------------------------------------------------------- /fastapi_forge/frontend/panels/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /fastapi_forge/frontend/components/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /fastapi_forge/template/{{cookiecutter.project_name}}/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /fastapi_forge/template/{{cookiecutter.project_name}}/migrations/versions/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /fastapi_forge/template/{{cookiecutter.project_name}}/tests/endpoint_tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /fastapi_forge/template/{{cookiecutter.project_name}}/README.md: -------------------------------------------------------------------------------- 1 | # {{cookiecutter.project_name}} -------------------------------------------------------------------------------- /fastapi_forge/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /fastapi_forge/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/services/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /fastapi_forge/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /fastapi_forge/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/dependencies/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /fastapi_forge/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/services/taskiq/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /fastapi_forge/logger.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | logging.basicConfig(level=logging.INFO) 4 | logger = logging.getLogger(__name__) 5 | -------------------------------------------------------------------------------- /fastapi_forge/project_io/io/__init__.py: -------------------------------------------------------------------------------- 1 | __all__ = ["AsyncDryRunWriter", "AsyncIOWriter", "IOWriter"] 2 | 3 | from .writer import AsyncDryRunWriter, AsyncIOWriter, IOWriter 4 | -------------------------------------------------------------------------------- /fastapi_forge/render/engines/__init__.py: -------------------------------------------------------------------------------- 1 | __all__ = ["Jinja2Engine", "TemplateEngine"] 2 | 3 | from .jinja2_engine import Jinja2Engine 4 | from .protocols import TemplateEngine 5 | -------------------------------------------------------------------------------- /fastapi_forge/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/services/redis/__init__.py: -------------------------------------------------------------------------------- 1 | from .redis_dependencies import GetRedis 2 | 3 | __all__ = ["GetRedis"] 4 | -------------------------------------------------------------------------------- /fastapi_forge/render/templates/enums.py: -------------------------------------------------------------------------------- 1 | ENUMS_TEMPLATE = """ 2 | from enum import StrEnum, auto 3 | 4 | {% for enum in enums %} 5 | {{ enum.class_definition }} 6 | {% endfor %} 7 | """ 8 | -------------------------------------------------------------------------------- /fastapi_forge/project_io/exporter/__init__.py: -------------------------------------------------------------------------------- 1 | __all__ = ["ProjectExporter", "YamlProjectExporter"] 2 | 3 | from .protocols import ProjectExporter 4 | from .yaml_exporter import YamlProjectExporter 5 | -------------------------------------------------------------------------------- /fastapi_forge/core/project_validators/__init__.py: -------------------------------------------------------------------------------- 1 | __all__ = ["ProjectNameValidator", "ProjectValidator"] 2 | 3 | from .protocols import ProjectValidator 4 | from .validators import ProjectNameValidator 5 | -------------------------------------------------------------------------------- /fastapi_forge/core/template_processors/__init__.py: -------------------------------------------------------------------------------- 1 | __all__ = [ 2 | "DefaultTemplateProcessor", 3 | "TemplateProcessor", 4 | ] 5 | 6 | from .processors import DefaultTemplateProcessor 7 | from .protocols import TemplateProcessor 8 | -------------------------------------------------------------------------------- /fastapi_forge/project_io/loader/__init__.py: -------------------------------------------------------------------------------- 1 | __all__ = ["DatabaseProjectLoader", "ProjectLoader", "YamlProjectLoader"] 2 | 3 | from .database_loader import DatabaseProjectLoader 4 | from .protocols import ProjectLoader 5 | from .yaml_loader import YamlProjectLoader 6 | -------------------------------------------------------------------------------- /fastapi_forge/project_io/artifact_builder/protocols.py: -------------------------------------------------------------------------------- 1 | from abc import abstractmethod 2 | from typing import Protocol 3 | 4 | 5 | class ArtifactBuilder(Protocol): 6 | @abstractmethod 7 | async def build_artifacts(self) -> None: 8 | raise NotImplementedError 9 | -------------------------------------------------------------------------------- /fastapi_forge/template/local_extensions.py: -------------------------------------------------------------------------------- 1 | from cookiecutter.utils import simple_filter 2 | from fastapi_forge.utils.string_utils import camel_to_snake as _camel_to_snake 3 | 4 | 5 | @simple_filter 6 | def camel_to_snake(value: str) -> str: 7 | return _camel_to_snake(value) 8 | -------------------------------------------------------------------------------- /fastapi_forge/project_io/artifact_builder/__init__.py: -------------------------------------------------------------------------------- 1 | __all__ = ["ArtifactBuilder", "FastAPIArtifactBuilder", "insert_relation_fields"] 2 | 3 | from .fastapi_builder import FastAPIArtifactBuilder 4 | from .protocols import ArtifactBuilder 5 | from .utils import insert_relation_fields 6 | -------------------------------------------------------------------------------- /fastapi_forge/template/{{cookiecutter.project_name}}/observability/prometheus/prometheus.yaml: -------------------------------------------------------------------------------- 1 | global: 2 | scrape_interval: 15s 3 | 4 | scrape_configs: 5 | - job_name: "metrics" 6 | metrics_path: /metrics 7 | static_configs: 8 | - targets: ["{{ cookiecutter.project_name }}-api:8000"] -------------------------------------------------------------------------------- /fastapi_forge/core/cookiecutter_adapter/__init__.py: -------------------------------------------------------------------------------- 1 | __all__ = [ 2 | "CookiecutterAdapter", 3 | "DryRunCookiecutterAdapter", 4 | "OverwriteCookiecutterAdapter", 5 | ] 6 | 7 | from .adapters import DryRunCookiecutterAdapter, OverwriteCookiecutterAdapter 8 | from .protocols import CookiecutterAdapter 9 | -------------------------------------------------------------------------------- /fastapi_forge/project_io/loader/protocols.py: -------------------------------------------------------------------------------- 1 | from abc import abstractmethod 2 | from typing import Protocol 3 | 4 | from fastapi_forge.schemas import ProjectSpec 5 | 6 | 7 | class ProjectLoader(Protocol): 8 | @abstractmethod 9 | def load(self) -> ProjectSpec: 10 | raise NotImplementedError 11 | -------------------------------------------------------------------------------- /fastapi_forge/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/services/rabbitmq/__init__.py: -------------------------------------------------------------------------------- 1 | from {{cookiecutter.project_name}}.services.rabbitmq.rabbitmq_dependencies import GetRabbitMQ, get_rabbitmq, RabbitMQServiceMock 2 | 3 | __all__ = ["GetRabbitMQ", "get_rabbitmq", "RabbitMQServiceMock"] 4 | -------------------------------------------------------------------------------- /fastapi_forge/frontend/validation.py: -------------------------------------------------------------------------------- 1 | def raise_if_missing_fields(required: list[tuple[str, str | None]]): 2 | missing_required = [field_name for field_name, kwarg in required if not kwarg] 3 | if missing_required: 4 | msg = f"Missing fields: {', '.join(missing_required)}." 5 | raise ValueError(msg) 6 | -------------------------------------------------------------------------------- /fastapi_forge/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/routes/health_routes.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | 3 | 4 | router = APIRouter() 5 | 6 | 7 | @router.get("/health") 8 | async def health_check() -> bool: 9 | """Return True if the service is healthy.""" 10 | 11 | return True 12 | -------------------------------------------------------------------------------- /fastapi_forge/core/__init__.py: -------------------------------------------------------------------------------- 1 | __all__ = [ 2 | "CookiecutterAdapter", 3 | "OverwriteCookiecutterAdapter", 4 | "ProjectBuildDirector", 5 | "build_fastapi_project", 6 | ] 7 | 8 | from .build import ProjectBuildDirector, build_fastapi_project 9 | from .cookiecutter_adapter import CookiecutterAdapter, OverwriteCookiecutterAdapter 10 | -------------------------------------------------------------------------------- /fastapi_forge/core/project_validators/protocols.py: -------------------------------------------------------------------------------- 1 | from abc import abstractmethod 2 | from typing import Protocol 3 | 4 | from fastapi_forge.schemas import ProjectSpec 5 | 6 | 7 | class ProjectValidator(Protocol): 8 | @abstractmethod 9 | def validate(self, project_spec: ProjectSpec) -> None: 10 | raise NotImplementedError 11 | -------------------------------------------------------------------------------- /fastapi_forge/project_io/exporter/protocols.py: -------------------------------------------------------------------------------- 1 | from abc import abstractmethod 2 | from typing import Protocol 3 | 4 | from fastapi_forge.schemas import ProjectSpec 5 | 6 | 7 | class ProjectExporter(Protocol): 8 | @abstractmethod 9 | async def export_project(self, project_spec: ProjectSpec) -> None: 10 | raise NotImplementedError 11 | -------------------------------------------------------------------------------- /fastapi_forge/core/template_processors/protocols.py: -------------------------------------------------------------------------------- 1 | from abc import abstractmethod 2 | from typing import Any, Protocol 3 | 4 | from fastapi_forge.schemas import ProjectSpec 5 | 6 | 7 | class TemplateProcessor(Protocol): 8 | @abstractmethod 9 | def process(self, spec: ProjectSpec) -> dict[str, Any]: 10 | raise NotImplementedError 11 | -------------------------------------------------------------------------------- /fastapi_forge/project_io/database/__init__.py: -------------------------------------------------------------------------------- 1 | __all__ = [ 2 | "DatabaseInspector", 3 | "PostgresInspector", 4 | "SchemaInspectionResult", 5 | "SchemaInspector", 6 | ] 7 | 8 | from .postgres_inspector import PostgresInspector 9 | from .protocols import DatabaseInspector 10 | from .schema import SchemaInspectionResult, SchemaInspector 11 | -------------------------------------------------------------------------------- /fastapi_forge/render/renderers/enums.py: -------------------------------------------------------------------------------- 1 | from enum import StrEnum, auto 2 | 3 | 4 | class RendererType(StrEnum): 5 | MODEL = auto() 6 | ROUTER = auto() 7 | DAO = auto() 8 | DTO = auto() 9 | TEST_POST = auto() 10 | TEST_GET = auto() 11 | TEST_GET_ID = auto() 12 | TEST_PATCH = auto() 13 | TEST_DELETE = auto() 14 | ENUM = auto() 15 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from fastapi_forge.type_info_registry import TypeInfoRegistry, enum_registry 4 | 5 | 6 | @pytest.fixture(autouse=True) 7 | def clear_enum_registry() -> None: 8 | enum_registry.clear() 9 | 10 | 11 | @pytest.fixture 12 | def type_info_registry() -> TypeInfoRegistry: 13 | """Test.""" 14 | return TypeInfoRegistry() 15 | -------------------------------------------------------------------------------- /fastapi_forge/frontend/modals/__init__.py: -------------------------------------------------------------------------------- 1 | from fastapi_forge.frontend.modals.enum_modal import ( 2 | AddEnumValueModal, 3 | UpdateEnumValueModal, 4 | ) 5 | from fastapi_forge.frontend.modals.field_modal import AddFieldModal, UpdateFieldModal 6 | from fastapi_forge.frontend.modals.relation_modal import ( 7 | AddRelationModal, 8 | UpdateRelationModal, 9 | ) 10 | -------------------------------------------------------------------------------- /fastapi_forge/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/models/__init__.py: -------------------------------------------------------------------------------- 1 | {%- for model in cookiecutter.models.models -%} 2 | from {{cookiecutter.project_name}}.models.{{ model.name }}_models import {{ model.name_cc }} 3 | {% endfor %} 4 | 5 | __all__ = [ 6 | {% for model in cookiecutter.models.models %} 7 | "{{ model.name_cc }}", 8 | {% endfor %} 9 | ] 10 | -------------------------------------------------------------------------------- /fastapi_forge/project_io/io/protocols.py: -------------------------------------------------------------------------------- 1 | from abc import abstractmethod 2 | from pathlib import Path 3 | from typing import Protocol 4 | 5 | 6 | class IOWriter(Protocol): 7 | @abstractmethod 8 | async def write_file(self, path: Path, content: str) -> None: 9 | raise NotImplementedError 10 | 11 | @abstractmethod 12 | async def write_directory(self, path: Path) -> None: 13 | raise NotImplementedError 14 | -------------------------------------------------------------------------------- /fastapi_forge/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/services/taskiq/taskiq_lifetime.py: -------------------------------------------------------------------------------- 1 | from {{cookiecutter.project_name}}.services.taskiq.broker import broker 2 | 3 | 4 | async def setup_taskiq() -> None: 5 | if not broker.is_worker_process: 6 | await broker.startup() 7 | 8 | 9 | async def shutdown_taskiq() -> None: 10 | if not broker.is_worker_process: 11 | await broker.shutdown() 12 | -------------------------------------------------------------------------------- /fastapi_forge/core/cookiecutter_adapter/protocols.py: -------------------------------------------------------------------------------- 1 | from abc import abstractmethod 2 | from pathlib import Path 3 | from typing import Any, Protocol 4 | 5 | 6 | class CookiecutterAdapter(Protocol): 7 | @abstractmethod 8 | def generate( 9 | self, 10 | template_path: Path, 11 | output_dir: Path, 12 | extra_context: dict[str, Any] | None = None, 13 | ) -> None: 14 | raise NotImplementedError 15 | -------------------------------------------------------------------------------- /fastapi_forge/render/__init__.py: -------------------------------------------------------------------------------- 1 | from .engines.jinja2_engine import Jinja2Engine 2 | from .manager import RenderManager 3 | from .registry import RendererRegistry 4 | 5 | 6 | def create_jinja_render_manager(project_name: str) -> RenderManager: 7 | jinja_engine = Jinja2Engine() 8 | jinja_engine.add_global("project_name", project_name) 9 | return RenderManager( 10 | engine=jinja_engine, 11 | renderers=RendererRegistry.get_renderers(), 12 | ) 13 | -------------------------------------------------------------------------------- /fastapi_forge/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/__main__.py: -------------------------------------------------------------------------------- 1 | from {{cookiecutter.project_name}}.settings import settings 2 | 3 | 4 | 5 | if __name__ == "__main__": 6 | import uvicorn 7 | 8 | uvicorn.run( 9 | "{{cookiecutter.project_name}}.main:get_app", 10 | host=settings.host, 11 | port=settings.port, 12 | log_level=settings.log_level, 13 | reload=settings.reload, 14 | lifespan="on", 15 | factory=True, 16 | ) 17 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | postgres: 3 | image: postgres:17.4-bookworm 4 | hostname: forge 5 | container_name: forge 6 | environment: 7 | POSTGRES_PASSWORD: forge 8 | POSTGRES_USER: forge 9 | POSTGRES_DB: forge 10 | volumes: 11 | - ./init.sql:/docker-entrypoint-initdb.d/init.sql 12 | ports: 13 | - "5432:5432" 14 | restart: always 15 | healthcheck: 16 | test: pg_isready -U postgres 17 | interval: 2s 18 | timeout: 3s 19 | retries: 40 20 | -------------------------------------------------------------------------------- /fastapi_forge/render/templates/dao.py: -------------------------------------------------------------------------------- 1 | DAO_TEMPLATE = """ 2 | from {{ project_name }}.daos.base_daos import BaseDAO 3 | 4 | from {{ project_name }}.models.{{ model.name }}_models import {{ model.name_cc }} 5 | from {{ project_name }}.dtos.{{ model.name }}_dtos import {{ model.name_cc }}InputDTO, {{ model.name_cc }}UpdateDTO 6 | 7 | 8 | class {{ model.name_cc }}DAO( 9 | BaseDAO[ 10 | {{ model.name_cc }}, 11 | {{ model.name_cc }}InputDTO, 12 | {{ model.name_cc }}UpdateDTO, 13 | ] 14 | ): 15 | \"\"\"{{ model.name_cc }} DAO.\"\"\" 16 | """ 17 | -------------------------------------------------------------------------------- /fastapi_forge/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/services/redis/redis_dependencies.py: -------------------------------------------------------------------------------- 1 | from fastapi import Request, Depends 2 | import redis.asyncio as redis 3 | from typing import AsyncGenerator, Annotated 4 | 5 | 6 | async def get_redis(request: Request) -> AsyncGenerator[redis.Redis, None]: 7 | """Get Redis.""" 8 | redis_client: redis.Redis = request.app.state.redis 9 | try: 10 | yield redis_client 11 | finally: 12 | await redis_client.aclose() 13 | 14 | 15 | GetRedis = Annotated[redis.Redis, Depends(get_redis)] 16 | -------------------------------------------------------------------------------- /fastapi_forge/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/db/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | import sqlalchemy as sa 4 | from sqlalchemy.orm import DeclarativeBase 5 | from sqlalchemy.sql.elements import NamedColumn 6 | 7 | 8 | meta = sa.MetaData() 9 | 10 | 11 | class Base(DeclarativeBase): 12 | """Base model for all other models.""" 13 | 14 | metadata = meta 15 | 16 | __tablename__: str 17 | 18 | @classmethod 19 | def get_primary_key_column(cls) -> NamedColumn[Any]: 20 | return next(iter(cls.__table__.primary_key)) 21 | 22 | -------------------------------------------------------------------------------- /fastapi_forge/render/engines/protocols.py: -------------------------------------------------------------------------------- 1 | from abc import abstractmethod 2 | from collections.abc import Callable 3 | from typing import Any, Protocol 4 | 5 | 6 | class TemplateEngine(Protocol): 7 | @abstractmethod 8 | def add_filter(self, name: str, filter_func: Callable[[Any], Any]) -> None: 9 | raise NotImplementedError 10 | 11 | @abstractmethod 12 | def add_global(self, name: str, value: Any) -> None: 13 | raise NotImplementedError 14 | 15 | @abstractmethod 16 | def render(self, template: str, context: dict[str, Any]) -> str: 17 | raise NotImplementedError 18 | -------------------------------------------------------------------------------- /fastapi_forge/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/constants.py: -------------------------------------------------------------------------------- 1 | {% if cookiecutter.use_rabbitmq %} 2 | from {{cookiecutter.project_name}}.services.rabbitmq.rabbitmq_dependencies import QueueConfig 3 | {% endif %} 4 | 5 | {% if cookiecutter.use_builtin_auth %} 6 | # Auth 7 | CREATE_TOKEN_EXPIRE_MINUTES = 30 8 | {% endif %} 9 | {% if cookiecutter.use_rabbitmq %} 10 | # RabbitMQ 11 | QUEUE_CONFIGS = [ 12 | QueueConfig( 13 | exchange_name="demo.exchange", 14 | queue_name="demo.message.send", 15 | routing_key="demo.message.send", 16 | ) 17 | ] 18 | {% endif %} 19 | 20 | -------------------------------------------------------------------------------- /fastapi_forge/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/services/taskiq/scheduler.py: -------------------------------------------------------------------------------- 1 | from taskiq import TaskiqScheduler 2 | from taskiq.schedule_sources import LabelScheduleSource 3 | from taskiq_redis import RedisScheduleSource 4 | 5 | from {{cookiecutter.project_name}}.services.taskiq.broker import broker 6 | from {{cookiecutter.project_name}}.settings import settings 7 | 8 | redis_source = RedisScheduleSource(str(settings.redis.url)) 9 | 10 | 11 | scheduler = TaskiqScheduler( 12 | broker, 13 | [ 14 | redis_source, 15 | LabelScheduleSource(broker), 16 | ], 17 | ) 18 | -------------------------------------------------------------------------------- /fastapi_forge/render/renderers/protocols.py: -------------------------------------------------------------------------------- 1 | from abc import abstractmethod 2 | from typing import Any, Protocol 3 | 4 | from fastapi_forge.render.engines.protocols import TemplateEngine 5 | from fastapi_forge.schemas import CustomEnum, Model 6 | 7 | Renderable = Model | list[CustomEnum] 8 | 9 | 10 | class Renderer(Protocol): 11 | engine: TemplateEngine 12 | 13 | def __init__(self, engine: TemplateEngine) -> None: 14 | self.engine = engine 15 | 16 | @abstractmethod 17 | def render(self, data: Renderable, **kwargs: Any) -> str: 18 | """Render the given data using the template engine.""" 19 | raise NotImplementedError 20 | -------------------------------------------------------------------------------- /fastapi_forge/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/services/redis/redis_lifetime.py: -------------------------------------------------------------------------------- 1 | import redis.asyncio as redis 2 | from fastapi import FastAPI 3 | from {{cookiecutter.project_name}}.settings import settings 4 | 5 | 6 | async def setup_redis(app: FastAPI) -> None: 7 | """Setup Redis.""" 8 | app.state.redis = redis.Redis.from_url( 9 | str(settings.redis.url), 10 | max_connections=settings.redis.max_connections, 11 | ) 12 | 13 | 14 | async def shutdown_redis(app: FastAPI) -> None: 15 | """Shutdown Redis.""" 16 | redis_client: redis.Redis = app.state.redis 17 | await redis_client.aclose() 18 | -------------------------------------------------------------------------------- /fastapi_forge/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/services/taskiq/tasks.py: -------------------------------------------------------------------------------- 1 | from pydantic import create_model 2 | from taskiq import TaskiqDepends 3 | 4 | from {{cookiecutter.project_name}}.services.rabbitmq import GetRabbitMQ 5 | from {{cookiecutter.project_name}}.services.taskiq.broker import broker 6 | 7 | 8 | @broker.task 9 | async def demo_task( 10 | hello: str, 11 | world: str, 12 | rabbitmq: GetRabbitMQ = TaskiqDepends(), 13 | ) -> None: 14 | await rabbitmq.send_demo_message( 15 | payload=create_model( 16 | "DemoMessage", 17 | hello=(str, hello), 18 | world=(str, world), 19 | )(), 20 | ) 21 | -------------------------------------------------------------------------------- /.github/workflows/check.yaml: -------------------------------------------------------------------------------- 1 | name: Lint & Test 2 | 3 | on: [push] 4 | 5 | jobs: 6 | lint: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v4 10 | - name: Install uv 11 | uses: astral-sh/setup-uv@v5 12 | - name: "Set up Python" 13 | uses: actions/setup-python@v5 14 | with: 15 | python-version-file: "pyproject.toml" 16 | - name: Install the project 17 | run: uv sync --all-extras --dev 18 | - name: Lint with Ruff 19 | run: uv run ruff check . 20 | - name: Static type check with mypy 21 | run: uv run mypy fastapi_forge 22 | - name: Run tests 23 | run: uv run pytest tests -s -v 24 | -------------------------------------------------------------------------------- /fastapi_forge/render/templates/__init__.py: -------------------------------------------------------------------------------- 1 | __all__ = [ 2 | "DAO_TEMPLATE", 3 | "DTO_TEMPLATE", 4 | "ENUMS_TEMPLATE", 5 | "MODEL_TEMPLATE", 6 | "ROUTERS_TEMPLATE", 7 | "TEST_DELETE_TEMPLATE", 8 | "TEST_GET_ID_TEMPLATE", 9 | "TEST_GET_TEMPLATE", 10 | "TEST_PATCH_TEMPLATE", 11 | "TEST_POST_TEMPLATE", 12 | ] 13 | 14 | from .dao import DAO_TEMPLATE 15 | from .dto import DTO_TEMPLATE 16 | from .enums import ENUMS_TEMPLATE 17 | from .model import MODEL_TEMPLATE 18 | from .routes import ROUTERS_TEMPLATE 19 | from .tests import ( 20 | TEST_DELETE_TEMPLATE, 21 | TEST_GET_ID_TEMPLATE, 22 | TEST_GET_TEMPLATE, 23 | TEST_PATCH_TEMPLATE, 24 | TEST_POST_TEMPLATE, 25 | ) 26 | -------------------------------------------------------------------------------- /fastapi_forge/render/registry.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, Any, ClassVar 2 | 3 | from .renderers.enums import RendererType 4 | 5 | if TYPE_CHECKING: 6 | from .renderers import Renderer 7 | 8 | 9 | class RendererRegistry: 10 | _renderers: ClassVar[dict[RendererType, type["Renderer"]]] = {} 11 | 12 | @classmethod 13 | def register(cls, renderer_type: RendererType) -> Any: 14 | def decorator(renderer_class: type["Renderer"]) -> type["Renderer"]: 15 | cls._renderers[renderer_type] = renderer_class 16 | return renderer_class 17 | 18 | return decorator 19 | 20 | @classmethod 21 | def get_renderers(cls) -> dict[RendererType, type["Renderer"]]: 22 | return cls._renderers.copy() 23 | -------------------------------------------------------------------------------- /fastapi_forge/template/cookiecutter.json: -------------------------------------------------------------------------------- 1 | { 2 | "project_name": { 3 | "default": "fastapi_forge" 4 | }, 5 | "use_postgres": { 6 | "default": true 7 | }, 8 | "use_alembic": { 9 | "default": true 10 | }, 11 | "use_builtin_auth": { 12 | "default": true 13 | }, 14 | "use_redis": { 15 | "default": true 16 | }, 17 | "use_rabbitmq": { 18 | "default": true 19 | }, 20 | "use_taskiq": { 21 | "default": true 22 | }, 23 | "use_prometheus": { 24 | "default": true 25 | }, 26 | "use_logfire": { 27 | "default": true 28 | }, 29 | "models": { 30 | "models": [] 31 | }, 32 | "auth_model": null, 33 | "_extensions": [ 34 | "local_extensions.camel_to_snake" 35 | ] 36 | } -------------------------------------------------------------------------------- /fastapi_forge/template/{{cookiecutter.project_name}}/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS builder 2 | 3 | ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy 4 | 5 | WORKDIR /app 6 | 7 | RUN --mount=type=cache,target=/root/.cache/uv \ 8 | --mount=type=bind,source=uv.lock,target=uv.lock \ 9 | --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ 10 | uv sync --frozen --no-install-project --no-dev 11 | 12 | ADD . /app 13 | 14 | RUN --mount=type=cache,target=/root/.cache/uv \ 15 | uv sync --frozen --no-dev 16 | 17 | 18 | FROM python:3.12-slim-bookworm 19 | 20 | ENV PATH="/app/.venv/bin:$PATH" 21 | ENV PYTHONPATH="/app" 22 | 23 | 24 | COPY --from=builder --chown=app:app /app /app 25 | 26 | WORKDIR /app 27 | 28 | CMD ["python3.12", "-m", "{{cookiecutter.project_name}}"] 29 | -------------------------------------------------------------------------------- /fastapi_forge/template/{{cookiecutter.project_name}}/migrations/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from typing import Sequence, Union 9 | 10 | from alembic import op 11 | import sqlalchemy as sa 12 | ${imports if imports else ""} 13 | 14 | # revision identifiers, used by Alembic. 15 | revision: str = ${repr(up_revision)} 16 | down_revision: Union[str, None] = ${repr(down_revision)} 17 | branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} 18 | depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} 19 | 20 | 21 | def upgrade() -> None: 22 | ${upgrades if upgrades else "pass"} 23 | 24 | 25 | def downgrade() -> None: 26 | ${downgrades if downgrades else "pass"} 27 | -------------------------------------------------------------------------------- /fastapi_forge/project_io/loader/yaml_loader.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Any 3 | 4 | import yaml 5 | 6 | from fastapi_forge.schemas import ProjectSpec 7 | 8 | from .protocols import ProjectLoader 9 | 10 | 11 | class YamlProjectLoader(ProjectLoader): 12 | def __init__(self, project_path: Path): 13 | self.project_path = project_path 14 | 15 | def _load_project_to_dict(self) -> dict[str, Any]: 16 | if not self.project_path.exists(): 17 | raise FileNotFoundError( 18 | f"Project config file not found: {self.project_path}" 19 | ) 20 | 21 | with self.project_path.open() as stream: 22 | return yaml.safe_load(stream)["project"] 23 | 24 | def load(self) -> ProjectSpec: 25 | return ProjectSpec(**self._load_project_to_dict()) 26 | -------------------------------------------------------------------------------- /fastapi_forge/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/db/db_dependencies.py: -------------------------------------------------------------------------------- 1 | from typing import AsyncGenerator 2 | 3 | from sqlalchemy import exc as sa_exc 4 | from sqlalchemy.ext.asyncio import AsyncSession 5 | from starlette.requests import Request 6 | from typing import Annotated 7 | from fastapi import Depends 8 | 9 | 10 | async def get_db_session(request: Request) -> AsyncGenerator[AsyncSession, None]: 11 | """Get database session.""" 12 | session: AsyncSession = request.app.state.db_session_factory() 13 | 14 | try: 15 | yield session 16 | except sa_exc.DBAPIError: 17 | await session.rollback() 18 | raise 19 | finally: 20 | await session.commit() 21 | await session.close() 22 | 23 | 24 | GetDBSession = Annotated[AsyncSession, Depends(get_db_session)] 25 | -------------------------------------------------------------------------------- /fastapi_forge/utils/string_utils.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | import inflect 4 | 5 | p = inflect.engine() 6 | 7 | 8 | def camel_to_snake(s: str) -> str: 9 | s = re.sub("(.)([A-Z][a-z]+)", r"\1_\2", s) 10 | s = re.sub("__([A-Z])", r"_\1", s) 11 | s = re.sub("([a-z0-9])([A-Z])", r"\1_\2", s) 12 | return s.lower() 13 | 14 | 15 | def snake_to_camel(s: str) -> str: 16 | s = s.removesuffix("_id") 17 | words = s.split("_") 18 | return "".join(word.capitalize() for word in words) 19 | 20 | 21 | def pluralize(s: str) -> str: 22 | is_singular = not p.singular_noun(s) 23 | if is_singular: 24 | return p.plural(s) 25 | return s 26 | 27 | 28 | def number_to_word(v: int | str) -> str: 29 | words = p.number_to_words(v) # type: ignore 30 | word: str = words[0] if isinstance(words, list) else words 31 | return word.replace(" ", "_") 32 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | deploy: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - name: Check out repository 13 | uses: actions/checkout@v4 14 | with: 15 | fetch-depth: 0 16 | 17 | - name: Set up Python 18 | uses: actions/setup-python@v4 19 | with: 20 | python-version: "3.12" 21 | 22 | - name: Install dependencies 23 | run: | 24 | python -m pip install --upgrade pip 25 | python -m pip install build twine 26 | 27 | - name: Build the package 28 | run: python -m build 29 | 30 | - name: Publish to PyPI 31 | env: 32 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} 33 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 34 | run: python -m twine upload dist/* -------------------------------------------------------------------------------- /fastapi_forge/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/services/taskiq/broker.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | import taskiq_fastapi 3 | from taskiq import AsyncBroker, InMemoryBroker 4 | from taskiq.serializers import ORJSONSerializer 5 | from taskiq_aio_pika import AioPikaBroker 6 | from taskiq_redis import RedisAsyncResultBackend 7 | 8 | from {{cookiecutter.project_name}}.settings import settings 9 | 10 | broker: AsyncBroker 11 | 12 | if settings.env == "test": 13 | broker = InMemoryBroker(await_inplace=True) 14 | else: 15 | result_backend: RedisAsyncResultBackend[Any] = RedisAsyncResultBackend(str(settings.redis.url)) 16 | broker = AioPikaBroker( 17 | str(settings.rabbitmq.url), 18 | ).with_result_backend(result_backend) 19 | broker.with_serializer(ORJSONSerializer()) 20 | 21 | taskiq_fastapi.init(broker, "{{cookiecutter.project_name}}.main:get_app") 22 | -------------------------------------------------------------------------------- /fastapi_forge/core/template_processors/processors.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from fastapi_forge.logger import logger 4 | from fastapi_forge.schemas import ProjectSpec 5 | 6 | from .protocols import TemplateProcessor 7 | 8 | 9 | class DefaultTemplateProcessor(TemplateProcessor): 10 | def process(self, spec: ProjectSpec) -> dict[str, Any]: 11 | context = { 12 | **spec.model_dump(exclude={"models"}), 13 | "models": {"models": [model.model_dump() for model in spec.models]}, 14 | } 15 | 16 | if spec.use_builtin_auth: 17 | auth_user = spec.get_auth_model() 18 | if auth_user: 19 | context["auth_model"] = auth_user.model_dump() 20 | else: 21 | logger.warning("No auth model found. Skipping authentication setup.") 22 | context["use_builtin_auth"] = False 23 | 24 | return context 25 | -------------------------------------------------------------------------------- /fastapi_forge/project_io/database/protocols.py: -------------------------------------------------------------------------------- 1 | from abc import abstractmethod 2 | from typing import Any, Protocol 3 | 4 | 5 | class DatabaseInspector(Protocol): 6 | @abstractmethod 7 | def validate_connection_string(self, connection_string: str) -> str: 8 | raise NotImplementedError 9 | 10 | @abstractmethod 11 | def fetch_enums(self, schema: str) -> dict[str, list[str]]: 12 | raise NotImplementedError 13 | 14 | @abstractmethod 15 | def fetch_enum_columns(self, schema: str) -> list[tuple[Any, ...]]: 16 | raise NotImplementedError 17 | 18 | @abstractmethod 19 | def fetch_schema_tables(self, schema: str) -> list[tuple[Any, ...]]: 20 | raise NotImplementedError 21 | 22 | @abstractmethod 23 | def get_connection_string(self) -> str: 24 | raise NotImplementedError 25 | 26 | @abstractmethod 27 | def get_db_name(self) -> str: 28 | raise NotImplementedError 29 | -------------------------------------------------------------------------------- /fastapi_forge/project_io/exporter/yaml_exporter.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import yaml 4 | 5 | from fastapi_forge.schemas import ProjectSpec 6 | 7 | from ..io import IOWriter 8 | from .protocols import ProjectExporter 9 | 10 | 11 | class YamlProjectExporter(ProjectExporter): 12 | def __init__(self, io_writer: IOWriter): 13 | self.io_writer = io_writer 14 | 15 | async def export_project(self, project_spec: ProjectSpec) -> None: 16 | yaml_structure = { 17 | "project": project_spec.model_dump( 18 | round_trip=True, # exclude computed fields 19 | ), 20 | } 21 | file_path = Path.cwd() / f"{project_spec.project_name}.yaml" 22 | await self.io_writer.write_file( 23 | file_path, 24 | yaml.dump( 25 | yaml_structure, 26 | default_flow_style=False, 27 | sort_keys=False, 28 | ), 29 | ) 30 | -------------------------------------------------------------------------------- /fastapi_forge/core/project_validators/validators.py: -------------------------------------------------------------------------------- 1 | from pathlib import PurePath 2 | 3 | from fastapi_forge.schemas import ProjectSpec 4 | 5 | from .protocols import ProjectValidator 6 | 7 | 8 | class ProjectNameValidator(ProjectValidator): 9 | def validate(self, project_spec: ProjectSpec) -> None: 10 | project_name = project_spec.project_name 11 | 12 | if not project_name: 13 | msg = "Project name cannot be empty" 14 | raise ValueError(msg) 15 | if not project_name.isidentifier(): 16 | raise ValueError( 17 | f"Invalid project name: {project_name}. Must be a valid identifier." 18 | ) 19 | 20 | if PurePath(project_name).is_absolute(): 21 | raise ValueError( 22 | f"Project name cannot be an absolute path: {project_name}." 23 | ) 24 | 25 | if not project_name.isascii(): 26 | raise ValueError(f"Project name must be ASCII: {project_name}.") 27 | -------------------------------------------------------------------------------- /fastapi_forge/project_io/artifact_builder/utils.py: -------------------------------------------------------------------------------- 1 | from fastapi_forge.enums import FieldDataTypeEnum 2 | from fastapi_forge.schemas import ModelField, ModelFieldMetadata, ProjectSpec 3 | 4 | 5 | def insert_relation_fields(project_spec: ProjectSpec) -> None: 6 | """Adds ModelFields to a model, based on its relationships.""" 7 | for model in project_spec.models: 8 | field_names_set = {field.name for field in model.fields} 9 | for relation in model.relationships: 10 | if relation.field_name in field_names_set: 11 | continue 12 | model.fields.append( 13 | ModelField( 14 | name=relation.field_name, 15 | type=FieldDataTypeEnum.UUID, 16 | nullable=relation.nullable, 17 | unique=relation.unique, 18 | index=relation.index, 19 | metadata=ModelFieldMetadata(is_foreign_key=True), 20 | ), 21 | ) 22 | -------------------------------------------------------------------------------- /fastapi_forge/frontend/components/header.py: -------------------------------------------------------------------------------- 1 | from nicegui import ui 2 | 3 | 4 | class Header(ui.header): 5 | def __init__(self): 6 | super().__init__() 7 | self.dark_mode = ui.dark_mode(value=True) 8 | self._build() 9 | 10 | def _build(self) -> None: 11 | with self: 12 | ui.button( 13 | icon="eva-github", 14 | color="white", 15 | on_click=lambda: ui.navigate.to( 16 | "https://github.com/mslaursen/fastapi-forge", 17 | ), 18 | ).classes("self-center", remove="bg-white").tooltip( 19 | "Drop a ⭐️ if you like FastAPI Forge!", 20 | ) 21 | 22 | ui.label(text="FastAPI Forge").classes( 23 | "font-bold ml-auto self-center text-2xl", 24 | ) 25 | 26 | ui.button( 27 | icon="dark_mode", 28 | color="white", 29 | on_click=lambda: self.dark_mode.toggle(), 30 | ).classes("ml-auto", remove="bg-white") 31 | -------------------------------------------------------------------------------- /fastapi_forge/render/manager.py: -------------------------------------------------------------------------------- 1 | from .engines import TemplateEngine 2 | from .renderers import Renderer, RendererType 3 | 4 | 5 | class RenderManager: 6 | def __init__( 7 | self, 8 | engine: TemplateEngine, 9 | renderers: dict[RendererType, type[Renderer]], 10 | ): 11 | self.engine = engine 12 | self.renderers = renderers 13 | self._renderers: dict[RendererType, Renderer] = {} 14 | 15 | def get_renderer(self, renderer_type: RendererType) -> Renderer: 16 | """Get a renderer instance for the specified type.""" 17 | if renderer_type not in self.renderers: 18 | raise ValueError( 19 | f"No renderer registered for renderer type: {renderer_type}" 20 | ) 21 | 22 | if renderer_type not in self._renderers: 23 | renderer_class = self.renderers[renderer_type] 24 | renderer_instance = renderer_class(self.engine) 25 | self._renderers[renderer_type] = renderer_instance 26 | 27 | return self._renderers[renderer_type] 28 | -------------------------------------------------------------------------------- /fastapi_forge/core/cookiecutter_adapter/adapters.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Any 3 | 4 | from cookiecutter.main import cookiecutter 5 | 6 | from fastapi_forge.logger import logger 7 | 8 | from .protocols import CookiecutterAdapter 9 | 10 | 11 | class OverwriteCookiecutterAdapter(CookiecutterAdapter): 12 | def generate( 13 | self, 14 | template_path: Path, 15 | output_dir: Path, 16 | extra_context: dict[str, Any] | None = None, 17 | ) -> None: 18 | cookiecutter( 19 | template=str(template_path), 20 | output_dir=str(output_dir), 21 | no_input=True, 22 | overwrite_if_exists=True, 23 | extra_context=extra_context, 24 | ) 25 | 26 | 27 | class DryRunCookiecutterAdapter(CookiecutterAdapter): 28 | def generate( 29 | self, 30 | template_path: Path, 31 | output_dir: Path, 32 | extra_context: dict[str, Any] | None = None, 33 | ) -> None: 34 | logger.info(f"Dry run mode kwargs: {template_path=}, {output_dir=}") 35 | -------------------------------------------------------------------------------- /fastapi_forge/frontend/panels/item_editor_panel.py: -------------------------------------------------------------------------------- 1 | from nicegui import ui 2 | 3 | from fastapi_forge.frontend.panels.enum_editor_panel import EnumEditorPanel 4 | from fastapi_forge.frontend.panels.model_editor_panel import ModelEditorPanel 5 | from fastapi_forge.frontend.state import state 6 | 7 | 8 | class ItemEditorPanel: 9 | def __init__(self): 10 | self._build() 11 | state.display_item_editor_fn = self._display_item_editor_panel 12 | 13 | def _build(self) -> None: 14 | self._display_item_editor_panel() 15 | 16 | @ui.refreshable 17 | def _display_item_editor_panel(self) -> None: 18 | with ui.column().classes("w-full h-full items-center justify-center mt-4"): 19 | if state.show_models: 20 | ModelEditorPanel().classes( 21 | "shadow-2xl dark:shadow-none min-w-[700px] max-w-[800px]" 22 | ) 23 | if state.show_enums: 24 | EnumEditorPanel().classes( 25 | "shadow-2xl dark:shadow-none min-w-[500px] max-w-[600px]" 26 | ) 27 | -------------------------------------------------------------------------------- /fastapi_forge/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/db/db_lifetime.py: -------------------------------------------------------------------------------- 1 | from {{cookiecutter.project_name}}.settings import settings 2 | from {{cookiecutter.project_name}}.db import meta 3 | 4 | from fastapi import FastAPI 5 | from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine 6 | 7 | 8 | async def setup_db(app: FastAPI) -> None: 9 | """Setup database.""" 10 | 11 | engine = create_async_engine( 12 | str(settings.db.url), 13 | echo=settings.db.echo, 14 | ) 15 | session_factory = async_sessionmaker( 16 | engine, 17 | expire_on_commit=False, 18 | ) 19 | 20 | app.state.db_engine = engine 21 | app.state.db_session_factory = session_factory 22 | 23 | {%- if not cookiecutter.use_alembic %} 24 | async with engine.begin() as conn: 25 | await conn.run_sync(meta.create_all) 26 | {% endif %} 27 | await engine.dispose() 28 | 29 | 30 | async def shutdown_db(app: FastAPI) -> None: 31 | """Shutdown database.""" 32 | 33 | await app.state.db_engine.dispose() 34 | -------------------------------------------------------------------------------- /fastapi_forge/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/exceptions.py: -------------------------------------------------------------------------------- 1 | from fastapi import HTTPException 2 | from starlette import status 3 | 4 | 5 | class Http401(HTTPException): 6 | """Unauthorized 401.""" 7 | 8 | def __init__(self, detail: str = "Unauthorized."): 9 | self.status_code = status.HTTP_401_UNAUTHORIZED 10 | self.detail = detail 11 | 12 | 13 | class Http403(HTTPException): 14 | """Forbidden 403.""" 15 | 16 | def __init__(self, detail: str = "Forbidden."): 17 | self.status_code = status.HTTP_403_FORBIDDEN 18 | self.detail = detail 19 | 20 | 21 | class Http404(HTTPException): 22 | """Not found 404.""" 23 | 24 | def __init__(self, detail: str = "Not found."): 25 | self.status_code = status.HTTP_404_NOT_FOUND 26 | self.detail = detail 27 | 28 | 29 | class Http500(HTTPException): 30 | """Internal server error 500.""" 31 | 32 | def __init__(self, detail: str = "Internal server error."): 33 | self.status_code = status.HTTP_500_INTERNAL_SERVER_ERROR 34 | self.detail = detail -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 mslaursen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /fastapi_forge/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/routes/__init__.py: -------------------------------------------------------------------------------- 1 | from {{cookiecutter.project_name}}.routes.health_routes import router as health_router 2 | from {{cookiecutter.project_name}}.routes.demo_routes import router as demo_router 3 | {% for model in cookiecutter.models.models if model.metadata.create_endpoints -%} 4 | from {{cookiecutter.project_name}}.routes.{{ model.name }}_routes import router as {{ model.name }}_router 5 | {% endfor %} 6 | {% if cookiecutter.use_builtin_auth %} 7 | from {{cookiecutter.project_name}}.routes.auth_routes import router as auth_router 8 | {% endif %} 9 | 10 | from fastapi import APIRouter 11 | 12 | 13 | base_router = APIRouter(prefix="/api/v1") 14 | 15 | base_router.include_router(health_router, tags=["health"]) 16 | base_router.include_router(demo_router, tags=["demo"]) 17 | {% for model in cookiecutter.models.models if model.metadata.create_endpoints -%} 18 | base_router.include_router({{ model.name }}_router, tags=["{{ model.name }}"]) 19 | {% endfor %} 20 | {% if cookiecutter.use_builtin_auth %} 21 | base_router.include_router(auth_router, tags=["auth"]) 22 | {% endif %} 23 | -------------------------------------------------------------------------------- /fastapi_forge/project_io/io/writer.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import aiofiles 4 | 5 | from fastapi_forge.logger import logger 6 | 7 | from .protocols import IOWriter 8 | 9 | 10 | class AsyncIOWriter(IOWriter): 11 | async def write_file(self, path: Path, content: str) -> None: 12 | try: 13 | async with aiofiles.open(path, "w") as file: 14 | await file.write(content) 15 | logger.info(f"File written successfully: {path}") 16 | except OSError: 17 | logger.error(f"Error writing file {path}") 18 | 19 | async def write_directory(self, path: Path) -> None: 20 | try: 21 | path.mkdir(parents=True, exist_ok=True) 22 | logger.info(f"Directory created successfully: {path}") 23 | except OSError: 24 | logger.error(f"Error creating directory {path}") 25 | 26 | 27 | class AsyncDryRunWriter(IOWriter): 28 | async def write_file(self, path: Path, content: str) -> None: 29 | logger.info(f"Dry run: {path} would be written") 30 | 31 | async def write_directory(self, path: Path) -> None: 32 | logger.info(f"Dry run: {path} directory would be created") 33 | -------------------------------------------------------------------------------- /fastapi_forge/render/engines/jinja2_engine.py: -------------------------------------------------------------------------------- 1 | # render/engines/jinja2_engine.py 2 | from collections.abc import Callable 3 | from typing import Any 4 | 5 | from jinja2 import Environment 6 | 7 | from ..filters import JinjaFilters 8 | from .protocols import TemplateEngine 9 | 10 | 11 | class Jinja2Engine(TemplateEngine): 12 | def __init__(self) -> None: 13 | self.env = Environment() 14 | self._register_core_filters() 15 | 16 | def _register_core_filters(self) -> None: 17 | """Register all built-in filters""" 18 | self.add_filter( 19 | "generate_field", 20 | JinjaFilters.generate_field, 21 | ) 22 | self.add_filter( 23 | "generate_relationship", 24 | JinjaFilters.generate_relationship, 25 | ) 26 | 27 | def add_filter(self, name: str, filter_func: Callable[[Any], Any]) -> None: 28 | self.env.filters[name] = filter_func 29 | 30 | def add_global(self, name: str, value: Any) -> None: 31 | self.env.globals[name] = value 32 | 33 | def render(self, template: str, context: dict[str, Any]) -> str: 34 | return self.env.from_string(template).render(**context) 35 | -------------------------------------------------------------------------------- /tests/test_render.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from fastapi_forge.enums import FieldDataTypeEnum 4 | from fastapi_forge.render import create_jinja_render_manager 5 | from fastapi_forge.schemas import Model, ModelField 6 | 7 | render_manager = create_jinja_render_manager("test_project") 8 | 9 | 10 | @pytest.mark.parametrize( 11 | "noun, expected", 12 | [ 13 | ("tooth", "teeth"), 14 | ("teeth", "teeth"), 15 | ("person", "people"), 16 | ("people", "people"), 17 | ("game_zone", "game-zones"), 18 | ("user", "users"), 19 | ("auth_user", "auth-users"), 20 | ("hardware_setup", "hardware-setups"), 21 | ], 22 | ) 23 | def test_render_post_test(noun: str, expected: str) -> None: 24 | model = Model( 25 | name=noun, 26 | fields=[ 27 | ModelField( 28 | name="id", 29 | type=FieldDataTypeEnum.UUID, 30 | primary_key=True, 31 | unique=True, 32 | ), 33 | ], 34 | ) 35 | model_renderer = render_manager.get_renderer("test_post") 36 | render = model_renderer.render(model) 37 | assert f'URI = "/api/v1/{expected}/"' in render 38 | -------------------------------------------------------------------------------- /fastapi_forge/render/templates/model.py: -------------------------------------------------------------------------------- 1 | MODEL_TEMPLATE = """ 2 | import sqlalchemy as sa 3 | from sqlalchemy.orm import Mapped, mapped_column, relationship 4 | from sqlalchemy.dialects.postgresql import JSONB 5 | from uuid import UUID 6 | import uuid 7 | from typing import Any, Annotated 8 | from datetime import datetime, timezone, timedelta 9 | from {{ project_name }} import enums 10 | 11 | 12 | {% set unique_relationships = model.relationships | unique(attribute='target') %} 13 | {% for relation in unique_relationships if relation.target != model.name_cc -%} 14 | from {{ project_name }}.models.{{ relation.target_model }}_models import {{ relation.target }} 15 | {% endfor %} 16 | 17 | 18 | from {{ project_name }}.db import Base 19 | 20 | class {{ model.name_cc }}(Base): 21 | \"\"\"{{ model.name_cc }} model.\"\"\" 22 | 23 | __tablename__ = "{{ model.name }}" 24 | 25 | {% for field in model.fields_sorted -%} 26 | {{ field | generate_field(model.relationships if field.metadata.is_foreign_key else None) }} 27 | {% endfor %} 28 | 29 | {% for relation in model.relationships -%} 30 | {{ relation | generate_relationship(model.name_cc == relation.target) }} 31 | {% endfor %} 32 | 33 | {{ model.table_args }} 34 | """ 35 | -------------------------------------------------------------------------------- /fastapi_forge/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/dtos/auth_dtos.py: -------------------------------------------------------------------------------- 1 | {%- if cookiecutter.use_builtin_auth %} 2 | from uuid import UUID 3 | from pydantic import BaseModel, SecretStr, EmailStr 4 | from {{ cookiecutter.project_name }} import enums 5 | from {{ cookiecutter.project_name }}.dtos import BaseOrmModel 6 | 7 | 8 | class TokenData(BaseModel): 9 | """Token data.""" 10 | 11 | user_id: UUID 12 | 13 | 14 | class UserLoginDTO(BaseModel): 15 | """DTO for user login.""" 16 | 17 | email: str 18 | password: SecretStr 19 | 20 | 21 | class UserCreateDTO(BaseModel): 22 | """DTO for user creation.""" 23 | 24 | email: EmailStr 25 | password: SecretStr 26 | 27 | 28 | class UserCreateResponseDTO(BaseOrmModel): 29 | """DTO for created user response.""" 30 | 31 | {% for field in cookiecutter.auth_model.fields if not (field.metadata.is_created_at_timestamp or field.metadata.is_updated_at_timestamp or field.name == "password") -%} 32 | {{ field.name }}: {{ field.type_info.python_type }}{% if field.nullable %} | None{% endif %} 33 | {% endfor %} 34 | 35 | 36 | class LoginResponse(BaseModel): 37 | """Response model for login.""" 38 | 39 | access_token: str 40 | {% endif %} -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | default: help 2 | 3 | .PHONY: help 4 | help: # Show help for each of the Makefile recipes. 5 | @grep -E '^[a-zA-Z0-9 -]+:.*#' Makefile | sort | while read -r l; do printf "\033[1;32m$$(echo $$l | cut -f 1 -d':')\033[00m:$$(echo $$l | cut -f 2- -d'#')\n"; done 6 | 7 | .PHONY: start 8 | start: # Start the FastAPI Forge application. 9 | python -m fastapi_forge start 10 | 11 | .PHONY: start-example 12 | start-example: # Start the FastAPI Forge application with an example project. 13 | python -m fastapi_forge start --use-example 14 | 15 | .PHONY: version 16 | version: # Show the version of FastAPI Forge. 17 | python -m fastapi_forge version 18 | 19 | .PHONY: lint 20 | lint: # Run linters on the codebase. 21 | uv run ruff format 22 | uv run ruff check . --fix --unsafe-fixes 23 | uv run mypy fastapi_forge 24 | 25 | .PHONY: test 26 | test: # Run all tests in the codebase. 27 | uv run pytest tests -s -v 28 | 29 | .PHONY: test-filter 30 | test-filter: # Run tests with a specific filter. 31 | uv run pytest tests -v -s -k $(filter) 32 | 33 | .PHONY: test-coverage 34 | test-coverage: # Run tests with coverage. 35 | uv run pytest --cov=fastapi_forge tests/ 36 | 37 | .PHONY: db 38 | db: # Start the database container. 39 | docker compose up 40 | -------------------------------------------------------------------------------- /fastapi_forge/frontend/notifications.py: -------------------------------------------------------------------------------- 1 | from nicegui import ui 2 | from pydantic import ValidationError 3 | 4 | 5 | def notify_validation_error(e: ValidationError) -> None: 6 | msg = e.errors()[0].get("msg", "Something went wrong.") 7 | ui.notify(msg, type="negative") 8 | 9 | 10 | def notify_value_error(e: ValueError) -> None: 11 | ui.notify(str(e), type="negative") 12 | 13 | 14 | def notify_model_exists(model_name: str) -> None: 15 | ui.notify( 16 | f"Model '{model_name}' already exists.", 17 | type="negative", 18 | ) 19 | 20 | 21 | def notify_enum_exists(enum_name: str) -> None: 22 | ui.notify( 23 | f"Enum '{enum_name}' already exists.", 24 | type="negative", 25 | ) 26 | 27 | 28 | def notify_field_exists(field_name: str, model_name: str) -> None: 29 | ui.notify( 30 | f"Model' {model_name}' already has field '{field_name}'.", 31 | type="negative", 32 | ) 33 | 34 | 35 | def notify_enum_value_exists(value_name: str, enum_name: str) -> None: 36 | ui.notify( 37 | f"Enum' {enum_name}' already has value '{value_name}'.", 38 | type="negative", 39 | ) 40 | 41 | 42 | def notify_something_went_wrong() -> None: 43 | ui.notify( 44 | "Something went wrong...", 45 | type="warning", 46 | ) 47 | -------------------------------------------------------------------------------- /.github/workflows/cli_test.yaml: -------------------------------------------------------------------------------- 1 | name: Test CLI Generated Project 2 | 3 | on: [push] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | services: 9 | postgres: 10 | image: postgres:17.4-bookworm 11 | env: 12 | POSTGRES_PASSWORD: postgres 13 | POSTGRES_USER: postgres 14 | POSTGRES_DB: postgres 15 | ports: 16 | - 5432 17 | options: >- 18 | --health-cmd "pg_isready" 19 | --health-interval 10s 20 | --health-timeout 5s 21 | --health-retries 5 22 | steps: 23 | - uses: actions/checkout@v4 24 | - name: Install uv 25 | uses: astral-sh/setup-uv@v5 26 | - name: "Set up Python" 27 | uses: actions/setup-python@v5 28 | with: 29 | python-version-file: "pyproject.toml" 30 | - name: Install the project 31 | run: uv sync --all-extras --dev 32 | - name: Generate example project 33 | run: uv run -m fastapi_forge start --use-example --no-ui --yes 34 | - name: Run tests 35 | working-directory: ./game_zone 36 | env: 37 | GAME_ZONE_PG_HOST: localhost 38 | GAME_ZONE_PG_PORT: ${{ job.services.postgres.ports['5432'] }} 39 | GAME_ZONE_PG_USER: postgres 40 | GAME_ZONE_PG_PASSWORD: postgres 41 | GAME_ZONE_PG_DATABASE: postgres 42 | run: uv run pytest ./tests -v -s 43 | -------------------------------------------------------------------------------- /fastapi_forge/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/daos/__init__.py: -------------------------------------------------------------------------------- 1 | {% for model in cookiecutter.models.models if model.metadata.create_daos -%} 2 | from {{cookiecutter.project_name}}.daos.{{ model.name }}_daos import {{ model.name_cc }}DAO 3 | {% endfor %} 4 | from {{cookiecutter.project_name}}.db.db_dependencies import GetDBSession 5 | from fastapi import Depends 6 | from typing import Annotated 7 | 8 | 9 | class AllDAOs: 10 | """ 11 | A centralized container for all DAOs used in the application. 12 | This class provides an organized way to access different DAOs as properties. 13 | 14 | Example: 15 | To add a new DAO, define a property method that returns 16 | an instance of the desired DAO: 17 | 18 | >>> @property 19 | >>> def user(self) -> UserDAO: 20 | >>> return UserDAO(self.session) 21 | 22 | This allows you to access the `UserDAO` like so: 23 | 24 | >>> @router.post("/myroute") 25 | >>> async def my_route(daos: GetDAOs) -> ...: 26 | >>> await daos.user.create(...) 27 | """ 28 | 29 | def __init__(self, session: GetDBSession): 30 | self.session = session 31 | 32 | {% for model in cookiecutter.models.models if model.metadata.create_daos %} 33 | @property 34 | def {{ model.name }}(self) -> {{ model.name_cc }}DAO: 35 | return {{ model.name_cc }}DAO(self.session) 36 | {% endfor %} 37 | 38 | 39 | GetDAOs = Annotated[AllDAOs, Depends()] 40 | -------------------------------------------------------------------------------- /tests/test_string_utils.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from fastapi_forge.utils import string_utils 4 | 5 | 6 | @pytest.mark.parametrize( 7 | "value, expected", 8 | [ 9 | ("HTTPMethod", "http_method"), 10 | ("simpleTest", "simple_test"), 11 | ("already_snake", "already_snake"), 12 | ("", ""), 13 | ("A", "a"), 14 | ("a", "a"), 15 | ("ALL_CAPS", "all_caps"), 16 | ("CamelCase", "camel_case"), 17 | ("camelCase", "camel_case"), 18 | ("PascalCase", "pascal_case"), 19 | ("MixedCaseString", "mixed_case_string"), 20 | ("XML2JSON", "xml2_json"), 21 | ("JSON2XML", "json2_xml"), 22 | ("userID42", "user_id42"), 23 | ("item2Buy", "item2_buy"), 24 | ("HTTPServer", "http_server"), 25 | ("RESTAPI", "restapi"), 26 | ("JSONData", "json_data"), 27 | ("XMLParser", "xml_parser"), 28 | ("ABC123DEF456", "abc123_def456"), 29 | ("GetHTTPResponseCode", "get_http_response_code"), 30 | ("ProcessHTMLDocument", "process_html_document"), 31 | ("ABCD", "abcd"), 32 | ("ABCDEF", "abcdef"), 33 | ("ABCdEF", "ab_cd_ef"), 34 | ("_internalField", "_internal_field"), 35 | ("__privateField", "__private_field"), 36 | ("preserve_existing", "preserve_existing"), 37 | ("mixed_Case_With_Underscores", "mixed_case_with_underscores"), 38 | ], 39 | ) 40 | def test_camel_to_snake(value: str, expected: str) -> None: 41 | assert string_utils.camel_to_snake(value) == expected 42 | -------------------------------------------------------------------------------- /fastapi_forge/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/services/rabbitmq/rabbitmq_lifetime.py: -------------------------------------------------------------------------------- 1 | import aio_pika 2 | from aio_pika.abc import AbstractChannel, AbstractRobustConnection 3 | from aio_pika.pool import Pool 4 | from fastapi import FastAPI 5 | 6 | from {{cookiecutter.project_name}}.services.rabbitmq.rabbitmq_dependencies import QueueConfig, init_consumer 7 | from {{cookiecutter.project_name}}.settings import settings 8 | 9 | 10 | async def setup_rabbitmq( 11 | app: FastAPI, 12 | configs: list[QueueConfig], 13 | ) -> None: 14 | """Setup RabbitMQ.""" 15 | 16 | async def get_connection() -> AbstractRobustConnection: 17 | return await aio_pika.connect_robust(settings.rabbitmq.url) 18 | 19 | connection_pool: Pool[AbstractRobustConnection] = Pool( 20 | get_connection, max_size=settings.rabbitmq.connection_pool_size 21 | ) 22 | 23 | async def get_channel() -> AbstractChannel: 24 | async with connection_pool.acquire() as connection: 25 | return await connection.channel() 26 | 27 | channel_pool: Pool[aio_pika.Channel] = Pool(get_channel, max_size=settings.rabbitmq.channel_pool_size) 28 | 29 | for config in configs: 30 | await init_consumer(channel_pool, config) 31 | 32 | app.state.rabbitmq_connection_pool = connection_pool 33 | app.state.rabbitmq_channel_pool = channel_pool 34 | 35 | 36 | async def shutdown_rabbitmq(app: FastAPI) -> None: 37 | await app.state.rabbitmq_channel_pool.close() 38 | await app.state.rabbitmq_connection_pool.close() 39 | -------------------------------------------------------------------------------- /fastapi_forge/render/templates/dto.py: -------------------------------------------------------------------------------- 1 | DTO_TEMPLATE = """ 2 | from datetime import datetime, timezone, timedelta 3 | 4 | 5 | from pydantic import BaseModel, ConfigDict, Field 6 | from fastapi import Depends 7 | from uuid import UUID 8 | from typing import Annotated, Any 9 | from {{ project_name }}.dtos import BaseOrmModel 10 | from {{ project_name }} import enums 11 | 12 | 13 | class {{ model.name_cc }}DTO(BaseOrmModel): 14 | \"\"\"{{ model.name_cc }} DTO.\"\"\" 15 | 16 | {% for field in model.fields_sorted -%} 17 | {{ field.name }}: {{ field.type_info.python_type }}{% if field.nullable %} | None{% endif %} 18 | {% endfor %} 19 | 20 | 21 | class {{ model.name_cc }}InputDTO(BaseModel): 22 | \"\"\"{{ model.name_cc }} input DTO.\"\"\" 23 | 24 | {% for field in model.fields_sorted if not (field.metadata.is_created_at_timestamp or field.metadata.is_updated_at_timestamp or (field.primary_key and not model.is_composite)) -%} 25 | {{ field.name }}: {{ field.type_info.python_type }}{% if field.nullable %} | None{% endif %} {% if field.default_value and not model.is_composite %}= {% if field.type_enum %}enums.{{ field.type_enum }}.{% endif %}{{ field.default_value }}{% endif %} 26 | {% endfor %} 27 | 28 | 29 | class {{ model.name_cc }}UpdateDTO(BaseModel): 30 | \"\"\"{{ model.name_cc }} update DTO.\"\"\" 31 | 32 | {% for field in model.fields_sorted if not (field.metadata.is_created_at_timestamp or field.metadata.is_updated_at_timestamp or field.primary_key) -%} 33 | {{ field.name }}: {{ field.type_info.python_type }} | None = None 34 | {% endfor %} 35 | """ 36 | -------------------------------------------------------------------------------- /fastapi_forge/template/{{cookiecutter.project_name}}/alembic.ini: -------------------------------------------------------------------------------- 1 | [alembic] 2 | script_location = migrations 3 | 4 | file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s 5 | 6 | prepend_sys_path = . 7 | 8 | version_path_separator = os 9 | 10 | [post_write_hooks] 11 | # post_write_hooks defines scripts or Python functions that are run 12 | # on newly generated revision scripts. See the documentation for further 13 | # detail and examples 14 | 15 | # format using "black" - use the console_scripts runner, against the "black" entrypoint 16 | # hooks = black 17 | # black.type = console_scripts 18 | # black.entrypoint = black 19 | # black.options = -l 79 REVISION_SCRIPT_FILENAME 20 | 21 | # lint with attempts to fix using "ruff" - use the exec runner, execute a binary 22 | # hooks = ruff 23 | # ruff.type = exec 24 | # ruff.executable = %(here)s/.venv/bin/ruff 25 | # ruff.options = --fix REVISION_SCRIPT_FILENAME 26 | 27 | # Logging configuration 28 | [loggers] 29 | keys = root,sqlalchemy,alembic 30 | 31 | [handlers] 32 | keys = console 33 | 34 | [formatters] 35 | keys = generic 36 | 37 | [logger_root] 38 | level = WARNING 39 | handlers = console 40 | qualname = 41 | 42 | [logger_sqlalchemy] 43 | level = WARNING 44 | handlers = 45 | qualname = sqlalchemy.engine 46 | 47 | [logger_alembic] 48 | level = INFO 49 | handlers = 50 | qualname = alembic 51 | 52 | [handler_console] 53 | class = StreamHandler 54 | args = (sys.stderr,) 55 | level = NOTSET 56 | formatter = generic 57 | 58 | [formatter_generic] 59 | format = %(levelname)-5.5s [%(name)s] %(message)s 60 | datefmt = %H:%M:%S 61 | -------------------------------------------------------------------------------- /fastapi_forge/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/dtos/__init__.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel, ConfigDict, Field 2 | from fastapi import Depends 3 | from uuid import UUID 4 | from typing import Annotated 5 | 6 | 7 | ############# 8 | # Base DTOs # 9 | ############# 10 | 11 | 12 | class BaseOrmModel(BaseModel): 13 | """Base ORM model.""" 14 | 15 | model_config = ConfigDict(from_attributes=True) 16 | 17 | 18 | ###################### 19 | # Data Response DTOs # 20 | ###################### 21 | 22 | 23 | class DataResponse[T: BaseModel](BaseModel): 24 | """Model for response data.""" 25 | 26 | data: T | None = None 27 | 28 | 29 | class CreatedResponse(BaseModel): 30 | """Model for created objects, returning the id.""" 31 | 32 | id: UUID 33 | 34 | 35 | class EmptyResponse(BaseModel): 36 | """Model for empty response.""" 37 | 38 | data: None = None 39 | 40 | 41 | ################### 42 | # Pagination DTOs # 43 | ################### 44 | 45 | 46 | class PaginationParams(BaseModel): 47 | """DTO for offset pagination.""" 48 | 49 | offset: int = Field(0, ge=0) 50 | limit: int = Field(20, le=20, ge=1) 51 | 52 | 53 | class PaginationParamsSortBy(PaginationParams): 54 | """DTO for offset pagination with sorting.""" 55 | 56 | sort_by: str 57 | sort_order: str = "asc" 58 | 59 | 60 | class OffsetPaginationMetadata(BaseModel): 61 | """DTO for offset pagination metadata.""" 62 | 63 | total: int 64 | 65 | 66 | class OffsetResults[T: BaseModel](BaseModel): 67 | """DTO for offset paginated response.""" 68 | 69 | data: list[T] 70 | pagination: OffsetPaginationMetadata 71 | 72 | 73 | Pagination = Annotated[PaginationParams, Depends()] 74 | -------------------------------------------------------------------------------- /fastapi_forge/template/{{cookiecutter.project_name}}/tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import sqlalchemy as sa 2 | from sqlalchemy.ext.asyncio import create_async_engine 3 | 4 | from {{cookiecutter.project_name}}.settings import settings 5 | 6 | 7 | async def create_test_db() -> None: 8 | """Create a test database.""" 9 | engine = create_async_engine( 10 | str(settings.db.url.with_path("/postgres")), 11 | isolation_level="AUTOCOMMIT", 12 | ) 13 | 14 | async with engine.begin() as conn: 15 | exists = await conn.scalar( 16 | sa.text("SELECT 1 FROM pg_database WHERE datname = :dbname"), 17 | {"dbname": settings.db.database}, 18 | ) 19 | 20 | if exists: 21 | await drop_test_db() 22 | 23 | async with engine.connect() as conn: 24 | await conn.execute( 25 | sa.text(f'CREATE DATABASE "{settings.db.database}"'), 26 | ) 27 | 28 | 29 | async def drop_test_db() -> None: 30 | """Drop the test database, terminating all connections first.""" 31 | engine = create_async_engine( 32 | str(settings.db.url.with_path("/postgres")), 33 | isolation_level="AUTOCOMMIT", 34 | ) 35 | 36 | async with engine.connect() as conn: 37 | await conn.execute( 38 | sa.text( 39 | """ 40 | SELECT pg_terminate_backend(pg_stat_activity.pid) 41 | FROM pg_stat_activity 42 | WHERE pg_stat_activity.datname = :dbname 43 | AND pid <> pg_backend_pid(); 44 | """ 45 | ), 46 | {"dbname": settings.db.database}, 47 | ) 48 | 49 | await conn.execute( 50 | sa.text(f'DROP DATABASE "{settings.db.database}"'), 51 | ) 52 | -------------------------------------------------------------------------------- /fastapi_forge/template/{{cookiecutter.project_name}}/Makefile: -------------------------------------------------------------------------------- 1 | DOCKER_CMD = docker compose exec -it api 2 | 3 | default: help 4 | 5 | .PHONY: help 6 | help: # Show help for each of the Makefile recipes. 7 | @grep -E '^[a-zA-Z0-9 -]+:.*#' Makefile | sort | while read -r l; do printf "\033[1;32m$$(echo $$l | cut -f 1 -d':')\033[00m:$$(echo $$l | cut -f 2- -d'#')\n"; done 8 | 9 | .PHONY: run 10 | run: # Run the application. 11 | uv run -m {{cookiecutter.project_name}} 12 | 13 | .PHONY: up 14 | up: # Start the project with Docker Compose. 15 | docker-compose up --watch 16 | 17 | .PHONY: down 18 | down: # Stop the project with Docker Compose. 19 | docker-compose down 20 | 21 | .PHONY: test 22 | test: # Run all tests in the codebase. 23 | $(DOCKER_CMD) pytest ./tests -v -s 24 | 25 | .PHONY: test-filter 26 | test-filter: # Run tests with a specific filter. 27 | $(DOCKER_CMD) pytest ./tests -v -s -k $(filter) 28 | 29 | .PHONY: test-coverage 30 | test-coverage: # Run tests with coverage. 31 | $(DOCKER_CMD) pytest --cov={{cookiecutter.project_name}} tests/ 32 | 33 | .PHONY: mig-gen 34 | mig-gen: # Generate a new migration file. 35 | $(DOCKER_CMD) alembic revision --autogenerate -m "$(name)" 36 | 37 | .PHONY: mig-head 38 | mig-head: # Migrate to the latest version. 39 | $(DOCKER_CMD) alembic upgrade head 40 | 41 | .PHONY: mig-up 42 | mig-up: # Migrate to the next version. 43 | $(DOCKER_CMD) alembic upgrade +1 44 | 45 | .PHONY: mig-down 46 | mig-down: # Migrate to the previous version. 47 | $(DOCKER_CMD) alembic downgrade -1 48 | 49 | .PHONY: lint 50 | lint: # Run linters on the codebase. 51 | uv run ruff format {{cookiecutter.project_name}} tests 52 | uv run ruff check {{cookiecutter.project_name}} tests --fix --unsafe-fixes 53 | uv run mypy {{cookiecutter.project_name}} -------------------------------------------------------------------------------- /fastapi_forge/template/{{cookiecutter.project_name}}/forge-config.yaml: -------------------------------------------------------------------------------- 1 | # Used for post project generation tasks 2 | # You may delete this file! 3 | 4 | paths: 5 | use_postgres: 6 | enabled: {{cookiecutter.use_postgres | lower}} 7 | paths: [] 8 | 9 | use_alembic: 10 | enabled: {{cookiecutter.use_alembic | lower}} 11 | paths: 12 | - migrations 13 | 14 | use_builtin_auth: 15 | enabled: {{cookiecutter.use_builtin_auth | lower}} 16 | paths: 17 | - {{cookiecutter.project_name}}/dependencies/auth_dependencies.py 18 | - {{cookiecutter.project_name}}/dtos/auth_dtos.py 19 | - {{cookiecutter.project_name}}/routes/auth_routes.py 20 | - {{cookiecutter.project_name}}/utils/auth_utils.py 21 | 22 | use_redis: 23 | enabled: {{cookiecutter.use_redis | lower}} 24 | paths: 25 | - {{cookiecutter.project_name}}/services/redis 26 | 27 | use_rabbitmq: 28 | enabled: {{cookiecutter.use_rabbitmq | lower}} 29 | paths: 30 | - {{cookiecutter.project_name}}/services/rabbitmq 31 | 32 | use_taskiq: 33 | enabled: {{cookiecutter.use_taskiq | lower}} 34 | paths: 35 | - {{cookiecutter.project_name}}/services/taskiq 36 | 37 | use_prometheus: 38 | enabled: {{cookiecutter.use_prometheus | lower}} 39 | paths: 40 | - observability/prometheus 41 | 42 | use_logfire: 43 | enabled: {{cookiecutter.use_logfire | lower}} 44 | paths: 45 | - observability/logfire 46 | 47 | constants: 48 | requires_all: 49 | - use_builtin_auth 50 | - use_rabbitmq 51 | paths: 52 | - {{cookiecutter.project_name}}/constants.py 53 | 54 | observability: 55 | requires_all: 56 | - use_prometheus 57 | - use_logfire 58 | paths: 59 | - observability 60 | 61 | 62 | 63 | -------------------------------------------------------------------------------- /fastapi_forge/template/{{cookiecutter.project_name}}/.github/workflows/check.yaml: -------------------------------------------------------------------------------- 1 | name: Lint & Test 2 | 3 | on: [push] 4 | 5 | jobs: 6 | lint: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v4 10 | - name: Install uv 11 | uses: astral-sh/setup-uv@v5 12 | - name: "Set up Python" 13 | uses: actions/setup-python@v5 14 | with: 15 | python-version-file: "pyproject.toml" 16 | - name: Install the project 17 | run: uv sync --all-extras --dev 18 | - name: Lint with Ruff 19 | run: uv run ruff check . 20 | - name: Static type check with mypy 21 | run: uv run mypy {{ cookiecutter.project_name }} 22 | {%- if cookiecutter.use_postgres %} 23 | test: 24 | runs-on: ubuntu-latest 25 | services: 26 | postgres: 27 | image: postgres:17.4-bookworm 28 | env: 29 | POSTGRES_PASSWORD: postgres 30 | POSTGRES_USER: postgres 31 | POSTGRES_DB: postgres 32 | ports: 33 | - 5432 34 | options: >- 35 | --health-cmd "pg_isready" 36 | --health-interval 10s 37 | --health-timeout 5s 38 | --health-retries 5 39 | steps: 40 | - uses: actions/checkout@v4 41 | - name: Install uv 42 | uses: astral-sh/setup-uv@v5 43 | - name: "Set up Python" 44 | uses: actions/setup-python@v5 45 | with: 46 | python-version-file: "pyproject.toml" 47 | - name: Install the project 48 | run: uv sync --all-extras --dev 49 | - name: Run tests 50 | env: 51 | {{ cookiecutter.project_name|upper|replace('-', '_') }}_PG_HOST: localhost 52 | {{ cookiecutter.project_name|upper|replace('-', '_') }}_PG_PORT: {% raw %}${{ job.services.postgres.ports['5432'] }}{% endraw %} 53 | run: uv run pytest ./tests -v -s 54 | {%- endif %} 55 | -------------------------------------------------------------------------------- /fastapi_forge/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/dependencies/auth_dependencies.py: -------------------------------------------------------------------------------- 1 | {% if cookiecutter.use_builtin_auth %} 2 | from typing import Annotated 3 | 4 | from fastapi import Depends, HTTPException, Request 5 | from fastapi.security import HTTPBearer as _HTTPBearer 6 | 7 | from {{cookiecutter.project_name}} import exceptions 8 | from {{cookiecutter.project_name}}.daos import GetDAOs 9 | from {{cookiecutter.project_name}}.dtos.{{ cookiecutter.auth_model.name }}_dtos import {{ cookiecutter.auth_model.name_cc }}DTO 10 | from {{cookiecutter.project_name}}.utils import auth_utils 11 | 12 | 13 | class HTTPBearer(_HTTPBearer): 14 | """ 15 | HTTPBearer with access token. 16 | Returns access token as str. 17 | """ 18 | 19 | async def __call__(self, request: Request) -> str | None: 20 | """Return access token.""" 21 | try: 22 | obj = await super().__call__(request) 23 | return obj.credentials if obj else None 24 | except HTTPException: 25 | msg = "Missing token." 26 | raise exceptions.Http401(msg) 27 | 28 | 29 | auth_scheme = HTTPBearer() 30 | 31 | 32 | def get_token(token: str = Depends(auth_scheme)) -> str: 33 | """Return access token as str.""" 34 | return token 35 | 36 | 37 | GetToken = Annotated[str, Depends(get_token)] 38 | 39 | 40 | async def get_current_user( 41 | token: GetToken, 42 | daos: GetDAOs, 43 | ) -> {{ cookiecutter.auth_model.name_cc }}DTO: 44 | """Get current user from token data.""" 45 | token_data = auth_utils.decode_token(token) 46 | 47 | user = await daos.{{ cookiecutter.auth_model.name }}.filter_first(id=token_data.user_id) 48 | 49 | if not user: 50 | msg = "Decoded user not found." 51 | raise exceptions.Http404(msg) 52 | 53 | return {{ cookiecutter.auth_model.name_cc }}DTO.model_validate(user) 54 | 55 | 56 | GetCurrentUser = Annotated[{{ cookiecutter.auth_model.name_cc }}DTO, Depends(get_current_user)] 57 | {% endif %} -------------------------------------------------------------------------------- /fastapi_forge/project_io/__init__.py: -------------------------------------------------------------------------------- 1 | __all__ = [ 2 | "ArtifactBuilder", 3 | "AsyncIOWriter", 4 | "DatabaseInspector", 5 | "DatabaseProjectLoader", 6 | "FastAPIArtifactBuilder", 7 | "IOWriter", 8 | "PostgresInspector", 9 | "ProjectExporter", 10 | "ProjectLoader", 11 | "YamlProjectExporter", 12 | "YamlProjectLoader", 13 | "create_fastapi_artifact_builder", 14 | "create_postgres_project_loader", 15 | "create_yaml_project_exporter", 16 | "insert_relation_fields", 17 | "load_from_database", 18 | "load_from_yaml", 19 | ] 20 | from pathlib import Path 21 | 22 | from fastapi_forge.schemas import ProjectSpec 23 | 24 | from .artifact_builder import ( 25 | ArtifactBuilder, 26 | FastAPIArtifactBuilder, 27 | insert_relation_fields, 28 | ) 29 | from .database import DatabaseInspector, PostgresInspector 30 | from .exporter import ProjectExporter, YamlProjectExporter 31 | from .io import AsyncDryRunWriter, AsyncIOWriter 32 | from .loader import DatabaseProjectLoader, ProjectLoader, YamlProjectLoader 33 | 34 | 35 | def load_from_yaml(path: str) -> ProjectSpec: 36 | return YamlProjectLoader(Path(path)).load() 37 | 38 | 39 | def load_from_database(conn_str: str, schema: str = "public") -> ProjectSpec: 40 | inspector = PostgresInspector(conn_str) 41 | return DatabaseProjectLoader(inspector, schema).load() 42 | 43 | 44 | def create_fastapi_artifact_builder( 45 | spec: ProjectSpec, dry_run: bool = False 46 | ) -> FastAPIArtifactBuilder: 47 | return FastAPIArtifactBuilder( 48 | project_spec=spec, 49 | io_writer=AsyncDryRunWriter() if dry_run else AsyncIOWriter(), 50 | ) 51 | 52 | 53 | def create_yaml_project_exporter() -> YamlProjectExporter: 54 | return YamlProjectExporter( 55 | io_writer=AsyncIOWriter(), 56 | ) 57 | 58 | 59 | def create_postgres_project_loader( 60 | conn_string: str, schema: str = "public" 61 | ) -> DatabaseProjectLoader: 62 | inspector = PostgresInspector(conn_string) 63 | return DatabaseProjectLoader(inspector, schema) 64 | -------------------------------------------------------------------------------- /fastapi_forge/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/middleware.py: -------------------------------------------------------------------------------- 1 | from fastapi.middleware.cors import CORSMiddleware 2 | from fastapi import FastAPI 3 | from {{cookiecutter.project_name}}.settings import settings 4 | {% if cookiecutter.use_prometheus %} 5 | from prometheus_fastapi_instrumentator import Instrumentator 6 | {% endif %} 7 | {% if cookiecutter.use_logfire %} 8 | import logfire 9 | from loguru import logger 10 | {% endif %} 11 | 12 | def _add_cors_middleware(app: FastAPI) -> None: 13 | """Add CORS Middleware.""" 14 | app.add_middleware(CORSMiddleware, allow_origins=["*"]) 15 | {% if cookiecutter.use_prometheus %} 16 | def _add_prometheus_middleware(app: FastAPI) -> None: 17 | """Add Prometheus Middleware.""" 18 | if not settings.prometheus.enabled: 19 | return 20 | instrumenter = Instrumentator().instrument(app) 21 | instrumenter.expose(app) 22 | {% endif %} 23 | {% if cookiecutter.use_logfire %} 24 | def _add_logfire_middleware(app: FastAPI) -> None: 25 | """Add Logfire Middleware.""" 26 | if not settings.logfire.enabled: 27 | return 28 | if not settings.logfire.write_token: 29 | logger.warning( 30 | "Logfire is enabled but no write token is provided. " 31 | "Skipping Logfire middleware." 32 | ) 33 | return 34 | logfire.configure( 35 | token=settings.logfire.write_token.get_secret_value(), 36 | environment=settings.env, 37 | send_to_logfire="if-token-present", 38 | service_name="game_zone", 39 | ) 40 | logfire.instrument_fastapi(app, capture_headers=True) 41 | logfire.instrument_asyncpg() 42 | logfire.instrument_system_metrics() 43 | logger.configure(handlers=[logfire.loguru_handler()]) 44 | {% endif %} 45 | def add_middleware(app: FastAPI) -> None: 46 | """Add all middlewares.""" 47 | _add_cors_middleware(app) 48 | {%- if cookiecutter.use_prometheus %} 49 | _add_prometheus_middleware(app) 50 | {% endif %} 51 | {%- if cookiecutter.use_logfire %} 52 | _add_logfire_middleware(app) 53 | {% endif %} -------------------------------------------------------------------------------- /fastapi_forge/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/routes/demo_routes.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | from {{cookiecutter.project_name}} import exceptions 3 | from {{cookiecutter.project_name}}.services.redis import GetRedis 4 | {% if cookiecutter.use_rabbitmq %} 5 | from {{cookiecutter.project_name}}.services.rabbitmq import GetRabbitMQ 6 | {% endif %} 7 | from pydantic import BaseModel 8 | from typing import Any 9 | 10 | {% if cookiecutter.use_taskiq %} 11 | from datetime import UTC, datetime, timedelta 12 | from {{cookiecutter.project_name}}.services.taskiq import tasks 13 | from {{cookiecutter.project_name}}.services.taskiq.scheduler import redis_source 14 | {% endif %} 15 | 16 | router = APIRouter(prefix="/demo") 17 | 18 | {% if cookiecutter.use_rabbitmq %} 19 | class RabbitMQDemoMessage(BaseModel): 20 | key: str 21 | value: str 22 | {% endif %} 23 | 24 | {% if cookiecutter.use_redis %} 25 | @router.post("/set-redis") 26 | async def set_redis_value(key: str, value: str, redis: GetRedis,) -> None: 27 | await redis.set(key, value) 28 | 29 | @router.get("/get-redis") 30 | async def get_redis_value(key: str, redis: GetRedis,) -> dict[str, Any]: 31 | value = await redis.get(key) 32 | if value is None: 33 | raise exceptions.Http404(detail="Key not found in Redis") 34 | return {"key": key, "value": value} 35 | {% endif %} 36 | 37 | {% if cookiecutter.use_rabbitmq %} 38 | @router.post("/send-rabbitmq") 39 | async def send_rabbitmq_message( 40 | message: RabbitMQDemoMessage, 41 | rabbitmq: GetRabbitMQ, 42 | ) -> None: 43 | await rabbitmq.send_demo_message(message) 44 | {% endif %} 45 | 46 | {% if cookiecutter.use_taskiq %} 47 | @router.post("/taskiq-kiq") 48 | async def kick_taskiq_message() -> None: 49 | await tasks.demo_task.kiq(hello="hello taskiq", world="world taskiq") 50 | 51 | 52 | @router.post("/taskiq-scheduled") 53 | async def schedule_taskiq_message(delay_seconds: int = 10) -> None: 54 | await tasks.demo_task.schedule_by_time( 55 | redis_source, 56 | datetime.now(UTC) + timedelta(seconds=delay_seconds), 57 | hello="hello taskiq scheduled", 58 | world="world taskiq scheduled", 59 | ) 60 | {% endif %} -------------------------------------------------------------------------------- /fastapi_forge/frontend/components/item_create.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Callable 2 | 3 | from nicegui import ui 4 | 5 | from fastapi_forge.frontend.state import state 6 | 7 | 8 | class _RowCreate(ui.row): 9 | def __init__( 10 | self, 11 | *, 12 | input_placeholder: str, 13 | input_tooltip: str, 14 | button_tooltip: str, 15 | on_add_item: Callable[[str], None], 16 | ): 17 | super().__init__(wrap=False) 18 | self.input_placeholder = input_placeholder 19 | self.input_tooltip = input_tooltip 20 | self.button_tooltip = button_tooltip 21 | self.on_add_item = on_add_item 22 | 23 | self._build() 24 | 25 | def _build(self) -> None: 26 | with self.classes("w-full flex items-center justify-between"): 27 | self.item_input = ( 28 | ui.input(placeholder=self.input_placeholder) 29 | .classes("self-center") 30 | .tooltip( 31 | self.input_tooltip, 32 | ) 33 | ) 34 | self.add_button = ( 35 | ui.button(icon="add", on_click=self._add_item) 36 | .classes("self-center") 37 | .tooltip(self.button_tooltip) 38 | ) 39 | 40 | def _add_item(self) -> None: 41 | if not self.item_input.value: 42 | return 43 | value: str = self.item_input.value 44 | item_name = value.strip() 45 | if item_name: 46 | self.on_add_item(item_name) 47 | self.item_input.value = "" 48 | 49 | 50 | class ModelCreate(_RowCreate): 51 | def __init__(self): 52 | super().__init__( 53 | input_placeholder="Model name", 54 | input_tooltip="Model names should be singular and snake_case (e.g. 'auth_user').", 55 | button_tooltip="Add Model", 56 | on_add_item=state.add_model, 57 | ) 58 | 59 | 60 | class EnumCreate(_RowCreate): 61 | def __init__(self): 62 | super().__init__( 63 | input_placeholder="Enum name", 64 | input_tooltip="Enums can be used as data types for model fields.", 65 | button_tooltip="Add Enum", 66 | on_add_item=state.add_enum, 67 | ) 68 | -------------------------------------------------------------------------------- /fastapi_forge/project_io/database/schema.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | import psycopg2 4 | from pydantic.dataclasses import dataclass 5 | 6 | from fastapi_forge.logger import logger 7 | 8 | from .protocols import DatabaseInspector 9 | 10 | 11 | @dataclass 12 | class SchemaInspectionResult: 13 | database_name: str 14 | schema_data: dict[str, list[dict[str, Any]]] 15 | enums: dict[str, list[str]] 16 | enum_usage: dict[str, list[dict[str, Any]]] 17 | 18 | 19 | class SchemaInspector: 20 | def __init__(self, inspector: DatabaseInspector): 21 | self.inspector = inspector 22 | 23 | def inspect_schema(self, schema: str = "public") -> SchemaInspectionResult: 24 | logger.info( 25 | f"Querying database schema from: {self.inspector.get_connection_string()}" 26 | ) 27 | try: 28 | enums = self.inspector.fetch_enums(schema) 29 | enum_columns = self.inspector.fetch_enum_columns(schema) 30 | enum_usage = self._build_enum_usage(enum_columns) 31 | tables = self.inspector.fetch_schema_tables(schema) 32 | 33 | return SchemaInspectionResult( 34 | database_name=self.inspector.get_db_name(), 35 | schema_data={ 36 | f"{table_schema}.{table_name}": columns 37 | for table_schema, table_name, columns in tables 38 | }, 39 | enums=enums, 40 | enum_usage=enum_usage, 41 | ) 42 | 43 | except psycopg2.Error as e: 44 | raise ValueError(f"Database error: {e}") from e 45 | 46 | @staticmethod 47 | def _build_enum_usage( 48 | enum_columns: list[tuple[Any, ...]], 49 | ) -> dict[str, list[dict[str, Any]]]: 50 | usage: dict[str, list[dict[str, Any]]] = {} 51 | for schema, table, column, data_type, enum_type in enum_columns: 52 | if enum_type not in usage: 53 | usage[enum_type] = [] 54 | usage[enum_type].append( 55 | { 56 | "schema": schema, 57 | "table": table, 58 | "column": column, 59 | "data_type": data_type, 60 | } 61 | ) 62 | return usage 63 | -------------------------------------------------------------------------------- /fastapi_forge/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/utils/auth_utils.py: -------------------------------------------------------------------------------- 1 | {%- if cookiecutter.use_builtin_auth %} 2 | from datetime import datetime, timedelta, timezone 3 | from typing import Any 4 | from {{cookiecutter.project_name}}.dtos.auth_dtos import TokenData 5 | import jwt 6 | import json 7 | from uuid import UUID 8 | from {{cookiecutter.project_name}} import exceptions 9 | from {{cookiecutter.project_name}}.settings import settings 10 | from passlib.context import CryptContext 11 | from {{cookiecutter.project_name}}.constants import CREATE_TOKEN_EXPIRE_MINUTES 12 | 13 | 14 | class Encoder(json.JSONEncoder): 15 | def default(self, obj: Any) -> Any: 16 | if isinstance(obj, UUID): 17 | return str(obj) 18 | 19 | 20 | pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") 21 | 22 | 23 | def hash_password(password: str) -> str: 24 | """Hash a password.""" 25 | return pwd_context.hash(password) 26 | 27 | 28 | def verify_password(plain_password: str, hashed_password: str) -> bool: 29 | """Verify a password.""" 30 | return pwd_context.verify(plain_password, hashed_password) 31 | 32 | 33 | def _encode_token(data: TokenData, expires_at: datetime) -> str: 34 | """Encode a token.""" 35 | 36 | to_encode = data.model_dump() 37 | to_encode["exp"] = expires_at 38 | 39 | return jwt.encode( 40 | to_encode, 41 | settings.jwt.secret.get_secret_value(), 42 | algorithm=settings.jwt.algorithm, 43 | json_encoder=Encoder, 44 | ) 45 | 46 | 47 | def create_access_token(data: TokenData) -> str: 48 | """Create an access token.""" 49 | 50 | return _encode_token( 51 | data, 52 | datetime.now(timezone.utc) 53 | + timedelta( 54 | minutes=CREATE_TOKEN_EXPIRE_MINUTES, 55 | ), 56 | ) 57 | 58 | 59 | def decode_token(token: str) -> TokenData: 60 | """Decode a token, returning the payload.""" 61 | 62 | try: 63 | payload = jwt.decode( 64 | token, 65 | settings.jwt.secret.get_secret_value(), 66 | algorithms=[settings.jwt.algorithm], 67 | ) 68 | return TokenData(**payload) 69 | 70 | except jwt.exceptions.PyJWTError: 71 | raise exceptions.Http401(detail="Invalid token") 72 | {% endif %} 73 | -------------------------------------------------------------------------------- /fastapi_forge/template/{{cookiecutter.project_name}}/migrations/env.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from logging.config import fileConfig 3 | 4 | from sqlalchemy import pool 5 | from sqlalchemy.engine import Connection 6 | from sqlalchemy.ext.asyncio import async_engine_from_config 7 | 8 | from {{cookiecutter.project_name}}.settings import settings 9 | from {{cookiecutter.project_name}}.models import * # noqa 10 | from {{cookiecutter.project_name}}.db import meta 11 | 12 | from alembic import context 13 | 14 | config = context.config 15 | config.set_main_option("sqlalchemy.url", str(settings.db.url)) 16 | 17 | if config.config_file_name is not None: 18 | fileConfig(config.config_file_name) 19 | 20 | target_metadata = meta 21 | 22 | def run_migrations_offline() -> None: 23 | """Run migrations in 'offline' mode. 24 | 25 | This configures the context with just a URL 26 | and not an Engine, though an Engine is acceptable 27 | here as well. By skipping the Engine creation 28 | we don't even need a DBAPI to be available. 29 | 30 | Calls to context.execute() here emit the given string to the 31 | script output. 32 | 33 | """ 34 | url = config.get_main_option("sqlalchemy.url") 35 | context.configure( 36 | url=url, 37 | target_metadata=target_metadata, 38 | literal_binds=True, 39 | dialect_opts={"paramstyle": "named"}, 40 | ) 41 | 42 | with context.begin_transaction(): 43 | context.run_migrations() 44 | 45 | 46 | def do_run_migrations(connection: Connection) -> None: 47 | context.configure(connection=connection, target_metadata=target_metadata) 48 | 49 | with context.begin_transaction(): 50 | context.run_migrations() 51 | 52 | 53 | async def run_async_migrations() -> None: 54 | """In this scenario we need to create an Engine 55 | and associate a connection with the context. 56 | 57 | """ 58 | 59 | connectable = async_engine_from_config( 60 | config.get_section(config.config_ini_section, {}), 61 | prefix="sqlalchemy.", 62 | poolclass=pool.NullPool, 63 | ) 64 | 65 | async with connectable.connect() as connection: 66 | await connection.run_sync(do_run_migrations) 67 | 68 | await connectable.dispose() 69 | 70 | 71 | def run_migrations_online() -> None: 72 | """Run migrations in 'online' mode.""" 73 | 74 | asyncio.run(run_async_migrations()) 75 | 76 | 77 | if context.is_offline_mode(): 78 | run_migrations_offline() 79 | else: 80 | run_migrations_online() 81 | -------------------------------------------------------------------------------- /fastapi_forge/enums.py: -------------------------------------------------------------------------------- 1 | from enum import StrEnum 2 | from functools import lru_cache 3 | 4 | 5 | class FieldDataTypeEnum(StrEnum): 6 | STRING = "String" 7 | INTEGER = "Integer" 8 | FLOAT = "Float" 9 | BOOLEAN = "Boolean" 10 | DATETIME = "DateTime" 11 | UUID = "UUID" 12 | JSONB = "JSONB" 13 | ENUM = "Enum" 14 | 15 | @classmethod 16 | @lru_cache 17 | def get_type_mappings(cls) -> dict[str, list[str]]: 18 | return { 19 | cls.STRING: [ 20 | "character varying", 21 | "text", 22 | "varchar", 23 | "char", 24 | "user-defined", 25 | ], 26 | cls.INTEGER: [ 27 | "integer", 28 | "int", 29 | "serial", 30 | "smallint", 31 | "bigint", 32 | "bigserial", 33 | ], 34 | cls.FLOAT: [ 35 | "real", 36 | "float4", 37 | "double precision", 38 | "float8", 39 | ], 40 | cls.BOOLEAN: ["boolean", "bool"], 41 | cls.DATETIME: [ 42 | "timestamp", 43 | "timestamp with time zone", 44 | "timestamp without time zone", 45 | "date", 46 | "datetime", 47 | "time", 48 | ], 49 | cls.UUID: ["uuid"], 50 | cls.JSONB: ["json", "jsonb"], 51 | } 52 | 53 | @classmethod 54 | def get_custom_types(cls) -> dict[str, "FieldDataTypeEnum"]: 55 | return {} 56 | 57 | @classmethod 58 | def from_db_type(cls, db_type: str) -> "FieldDataTypeEnum": 59 | db_type = db_type.lower() 60 | 61 | custom_types = cls.get_custom_types() 62 | if db_type in custom_types: 63 | return custom_types[db_type] 64 | 65 | for field_type, patterns in cls.get_type_mappings().items(): 66 | if any(pattern in db_type for pattern in patterns): 67 | return field_type if isinstance(field_type, cls) else cls(field_type) 68 | 69 | raise ValueError( 70 | f"Unsupported database type: {db_type}. " 71 | f"Supported types are: {list(cls.get_type_mappings().keys())}" 72 | ) 73 | 74 | 75 | class OnDeleteEnum(StrEnum): 76 | CASCADE = "CASCADE" 77 | SET_NULL = "SET NULL" 78 | 79 | 80 | class HTTPMethodEnum(StrEnum): 81 | GET = "get" 82 | GET_ID = "get_id" 83 | POST = "post" 84 | PATCH = "patch" 85 | DELETE = "delete" 86 | -------------------------------------------------------------------------------- /fastapi_forge/template/{{cookiecutter.project_name}}/.env.example: -------------------------------------------------------------------------------- 1 | {{ cookiecutter.project_name|upper|replace('-', '_') }}_HOST="0.0.0.0" 2 | {{ cookiecutter.project_name|upper|replace('-', '_') }}_PORT=8000 3 | {{ cookiecutter.project_name|upper|replace('-', '_') }}_DEBUG=True 4 | {{ cookiecutter.project_name|upper|replace('-', '_') }}_LOG_LEVEL="info" 5 | {{ cookiecutter.project_name|upper|replace('-', '_') }}_RELOAD=True 6 | {{ cookiecutter.project_name|upper|replace('-', '_') }}_WORKERS=1 7 | {% if cookiecutter.use_postgres %} 8 | {{ cookiecutter.project_name|upper|replace('-', '_') }}_PG_HOST={{ cookiecutter.project_name }}-pg 9 | {{ cookiecutter.project_name|upper|replace('-', '_') }}_PG_PORT=5432 10 | {{ cookiecutter.project_name|upper|replace('-', '_') }}_PG_USER={{ cookiecutter.project_name }} 11 | {{ cookiecutter.project_name|upper|replace('-', '_') }}_PG_PASSWORD={{ cookiecutter.project_name }} 12 | {{ cookiecutter.project_name|upper|replace('-', '_') }}_PG_DATABASE={{ cookiecutter.project_name }} 13 | {% endif %} 14 | {%- if cookiecutter.use_redis %} 15 | {{ cookiecutter.project_name|upper|replace('-', '_') }}_REDIS_HOST="redis" 16 | {{ cookiecutter.project_name|upper|replace('-', '_') }}_REDIS_PORT=6379 17 | {{ cookiecutter.project_name|upper|replace('-', '_') }}_REDIS_PASSWORD="" 18 | {{ cookiecutter.project_name|upper|replace('-', '_') }}_REDIS_MAX_CONNECTIONS=50 19 | {% endif %} 20 | {%- if cookiecutter.use_rabbitmq %} 21 | {{ cookiecutter.project_name|upper|replace('-', '_') }}_RABBITMQ_HOST="rabbitmq" 22 | {{ cookiecutter.project_name|upper|replace('-', '_') }}_RABBITMQ_PORT=5672 23 | {{ cookiecutter.project_name|upper|replace('-', '_') }}_RABBITMQ_USER="user" 24 | {{ cookiecutter.project_name|upper|replace('-', '_') }}_RABBITMQ_PASSWORD="password" 25 | {{ cookiecutter.project_name|upper|replace('-', '_') }}_RABBITMQ_VHOST="/" 26 | {{ cookiecutter.project_name|upper|replace('-', '_') }}_RABBITMQ_CONNECTION_POOL_SIZE=2 27 | {{ cookiecutter.project_name|upper|replace('-', '_') }}_RABBITMQ_CHANNEL_POOL_SIZE=10 28 | {% endif %} 29 | {%- if cookiecutter.use_builtin_auth %} 30 | {{ cookiecutter.project_name|upper|replace('-', '_') }}_JWT_SECRET="secret" 31 | {{ cookiecutter.project_name|upper|replace('-', '_') }}_JWT_ALGORITHM="HS256" 32 | {% endif %} 33 | {%- if cookiecutter.use_prometheus %} 34 | {{ cookiecutter.project_name|upper|replace('-', '_') }}_PROMETHEUS_ENABLED=True 35 | {% endif %} 36 | {%- if cookiecutter.use_logfire %} 37 | {{ cookiecutter.project_name|upper|replace('-', '_') }}_LOGFIRE_ENABLED=True 38 | {{ cookiecutter.project_name|upper|replace('-', '_') }}_LOGFIRE_WRITE_TOKEN="" 39 | {% endif %} 40 | -------------------------------------------------------------------------------- /fastapi_forge/render/templates/routes.py: -------------------------------------------------------------------------------- 1 | ROUTERS_TEMPLATE = """ 2 | from fastapi import APIRouter 3 | from {{ project_name }}.daos import GetDAOs 4 | from {{ project_name }}.dtos.{{ model.name }}_dtos import {{ model.name_cc }}InputDTO, {{ model.name_cc }}DTO, {{ model.name_cc }}UpdateDTO 5 | from {{ project_name }}.dtos import ( 6 | DataResponse, 7 | Pagination, 8 | OffsetResults, 9 | CreatedResponse, 10 | EmptyResponse, 11 | ) 12 | from uuid import UUID 13 | 14 | router = APIRouter(prefix="/{{ model.name_plural_hyphen }}") 15 | 16 | 17 | @router.post("/", status_code=201) 18 | async def create_{{ model.name }}( 19 | input_dto: {{ model.name_cc }}InputDTO, 20 | daos: GetDAOs, 21 | ) -> DataResponse[{{ model.name_cc }}DTO]: 22 | \"\"\"Create a new {{ model.name_cc }}.\"\"\" 23 | 24 | created_obj = await daos.{{ model.name }}.create(input_dto) 25 | return DataResponse( 26 | data={{ model.name_cc }}DTO.model_validate(created_obj) 27 | ) 28 | 29 | 30 | @router.patch("/{ {{- model.name }}_id}") 31 | async def update_{{ model.name }}( 32 | {{ model.name }}_id: UUID, 33 | update_dto: {{ model.name_cc }}UpdateDTO, 34 | daos: GetDAOs, 35 | ) -> EmptyResponse: 36 | \"\"\"Update {{ model.name_cc }}.\"\"\" 37 | 38 | await daos.{{ model.name }}.update({{ model.name }}_id, update_dto) 39 | return EmptyResponse() 40 | 41 | 42 | @router.delete("/{ {{- model.name }}_id}") 43 | async def delete_{{ model.name }}( 44 | {{ model.name }}_id: UUID, 45 | daos: GetDAOs, 46 | ) -> EmptyResponse: 47 | \"\"\"Delete a {{ model.name_cc }} by id.\"\"\" 48 | 49 | await daos.{{ model.name }}.delete({{ model.primary_key.name }}={{ model.name }}_id) 50 | return EmptyResponse() 51 | 52 | 53 | @router.get("/") 54 | async def get_{{ model.name }}_paginated( 55 | daos: GetDAOs, 56 | pagination: Pagination, 57 | ) -> OffsetResults[{{ model.name_cc }}DTO]: 58 | \"\"\"Get all {{ model.name_plural_cc }} paginated.\"\"\" 59 | 60 | return await daos.{{ model.name }}.get_offset_results( 61 | out_dto={{ model.name_cc }}DTO, 62 | pagination=pagination, 63 | ) 64 | 65 | 66 | @router.get("/{ {{- model.name }}_id}") 67 | async def get_{{ model.name }}( 68 | {{ model.name }}_id: UUID, 69 | daos: GetDAOs, 70 | ) -> DataResponse[{{ model.name_cc }}DTO]: 71 | \"\"\"Get a {{ model.name_cc }} by id.\"\"\" 72 | 73 | {{ model.name }} = await daos.{{ model.name }}.filter_first({{ model.primary_key.name }}={{ model.name }}_id) 74 | return DataResponse(data={{ model.name_cc }}DTO.model_validate({{ model.name }})) 75 | """ 76 | -------------------------------------------------------------------------------- /fastapi_forge/frontend/constants.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from fastapi_forge.enums import FieldDataTypeEnum 4 | from fastapi_forge.schemas import ModelField 5 | 6 | SELECTED_MODEL_TEXT_COLOR = "text-black-500 dark:text-amber-300" 7 | SELECTED_ENUM_TEXT_COLOR = "text-black-500 dark:text-amber-300" 8 | ITEM_ROW_TRUNCATE_LEN = 17 9 | 10 | FIELD_COLUMNS: list[dict[str, Any]] = [ 11 | { 12 | "name": "name", 13 | "label": "Name", 14 | "field": "name", 15 | "required": True, 16 | "align": "left", 17 | }, 18 | {"name": "type", "label": "Type", "field": "type", "align": "left"}, 19 | { 20 | "name": "primary_key", 21 | "label": "Primary Key", 22 | "field": "primary_key", 23 | "align": "center", 24 | }, 25 | {"name": "nullable", "label": "Nullable", "field": "nullable", "align": "center"}, 26 | {"name": "unique", "label": "Unique", "field": "unique", "align": "center"}, 27 | {"name": "index", "label": "Index", "field": "index", "align": "center"}, 28 | ] 29 | 30 | ENUM_COLUMNS: list[dict[str, Any]] = [ 31 | { 32 | "name": "name", 33 | "label": "Name", 34 | "field": "name", 35 | "required": True, 36 | "align": "left", 37 | }, 38 | { 39 | "name": "value", 40 | "label": "Value", 41 | "field": "value", 42 | "required": True, 43 | "align": "left", 44 | }, 45 | ] 46 | 47 | RELATIONSHIP_COLUMNS: list[dict[str, Any]] = [ 48 | { 49 | "name": "field_name", 50 | "label": "Field Name", 51 | "field": "field_name", 52 | "required": True, 53 | "align": "left", 54 | }, 55 | { 56 | "name": "target_model", 57 | "label": "Target Model", 58 | "field": "target_model", 59 | "align": "left", 60 | }, 61 | { 62 | "name": "on_delete", 63 | "label": "On Delete", 64 | "field": "on_delete", 65 | "align": "left", 66 | }, 67 | {"name": "nullable", "label": "Nullable", "field": "nullable", "align": "center"}, 68 | {"name": "index", "label": "Index", "field": "index", "align": "center"}, 69 | {"name": "unique", "label": "Unique", "field": "unique", "align": "center"}, 70 | ] 71 | 72 | 73 | DEFAULT_AUTH_USER_FIELDS: list[ModelField] = [ 74 | ModelField( 75 | name="email", 76 | type=FieldDataTypeEnum.STRING, 77 | unique=True, 78 | index=True, 79 | ), 80 | ModelField( 81 | name="password", 82 | type=FieldDataTypeEnum.STRING, 83 | ), 84 | ] 85 | DEFAULT_AUTH_USER_ROLE_ENUM_NAME = "UserRole" 86 | -------------------------------------------------------------------------------- /fastapi_forge/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/main.py: -------------------------------------------------------------------------------- 1 | from loguru import logger 2 | 3 | from contextlib import asynccontextmanager 4 | from typing import AsyncGenerator 5 | from fastapi import FastAPI 6 | from {{cookiecutter.project_name}}.settings import settings 7 | from {{cookiecutter.project_name}}.routes import base_router 8 | from {{cookiecutter.project_name}}.middleware import add_middleware 9 | {% if cookiecutter.use_postgres %} 10 | from {{cookiecutter.project_name}}.db import db_lifetime 11 | {% endif %} 12 | {% if cookiecutter.use_redis -%} 13 | from {{cookiecutter.project_name}}.services.redis import redis_lifetime 14 | {% endif %} 15 | {% if cookiecutter.use_rabbitmq -%} 16 | from {{cookiecutter.project_name}}.services.rabbitmq import rabbitmq_lifetime 17 | from {{cookiecutter.project_name}}.constants import QUEUE_CONFIGS 18 | {% endif %} 19 | {% if cookiecutter.use_taskiq %} 20 | from {{cookiecutter.project_name}}.services.taskiq import taskiq_lifetime 21 | {% endif %} 22 | 23 | @asynccontextmanager 24 | async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: 25 | """Lifespan.""" 26 | {% if cookiecutter.use_postgres %} 27 | await db_lifetime.setup_db(app) 28 | {% endif %} 29 | {%- if cookiecutter.use_redis -%} 30 | await redis_lifetime.setup_redis(app) 31 | {% endif %} 32 | {%- if cookiecutter.use_rabbitmq -%} 33 | await rabbitmq_lifetime.setup_rabbitmq(app, configs=QUEUE_CONFIGS) 34 | {% endif %} 35 | {%- if cookiecutter.use_taskiq %} 36 | await taskiq_lifetime.setup_taskiq() 37 | {% endif %} 38 | 39 | yield 40 | 41 | {% if cookiecutter.use_postgres -%} 42 | await db_lifetime.shutdown_db(app) 43 | {% endif %} 44 | {%- if cookiecutter.use_redis -%} 45 | await redis_lifetime.shutdown_redis(app) 46 | {% endif %} 47 | {%- if cookiecutter.use_rabbitmq -%} 48 | await rabbitmq_lifetime.shutdown_rabbitmq(app) 49 | {% endif %} 50 | {%- if cookiecutter.use_taskiq %} 51 | await taskiq_lifetime.shutdown_taskiq() 52 | {% endif %} 53 | 54 | 55 | def get_app() -> FastAPI: 56 | """Get FastAPI app.""" 57 | 58 | if settings.env != "test": 59 | logger.info( 60 | settings.model_dump_json(indent=2), 61 | ) 62 | {% if cookiecutter.use_alembic %} 63 | logger.info("Alembic enabled - see Makefile for migration commands") 64 | {% endif %} 65 | {%- if cookiecutter.use_prometheus %} 66 | logger.info( 67 | "Prometheus enabled - metrics available at /metrics, " 68 | "and queryable at localhost:9090" 69 | ) 70 | {% endif %} 71 | app = FastAPI(lifespan=lifespan) 72 | add_middleware(app) 73 | app.include_router(base_router) 74 | return app 75 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools", "setuptools-scm"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "fastapi-forge" 7 | description = "Generate FastAPI projects based on database schema." 8 | readme = "README.md" 9 | requires-python = ">=3.12" 10 | dynamic = ["version"] 11 | dependencies = [ 12 | "click", 13 | "nicegui", 14 | "cookiecutter", 15 | "pytest", 16 | "ruff", 17 | "psycopg2-binary>=2.9.10", 18 | "inflect>=7.5.0", 19 | "pytest-cov>=6.1.1", 20 | "mypy>=1.15.0", 21 | ] 22 | authors = [{ name = "mslaursen", email = "mslaursendk@gmail.com" }] 23 | 24 | [project.urls] 25 | Repository = "https://github.com/mslaursen/fastapi-forge.git" 26 | 27 | [tool.setuptools_scm] 28 | 29 | [project.scripts] 30 | fastapi-forge = "fastapi_forge.__main__:main" 31 | 32 | [tool.ruff] 33 | exclude = ["template"] 34 | target-version = "py312" 35 | line-length = 88 36 | indent-width = 4 37 | 38 | [tool.ruff.lint] 39 | select = ["ALL"] 40 | ignore = [ 41 | #### modules 42 | "ANN", # 43 | "COM", # 44 | "C90", # 45 | "DJ", # 46 | "EXE", # 47 | "T10", # 48 | "TID", # 49 | 50 | #### specific rules 51 | "A002", 52 | "ARG002", 53 | "BLE001", 54 | "D100", 55 | "D101", 56 | "D102", 57 | "D103", 58 | "D104", 59 | "D105", 60 | "D106", 61 | "D107", 62 | "D200", 63 | "D203", 64 | "D205", 65 | "D212", 66 | "D400", 67 | "D401", 68 | "D415", 69 | "E402", 70 | "E501", 71 | "EM101", 72 | "EM102", 73 | "ERA001", 74 | "FBT001", 75 | "FBT002", 76 | "FBT003", 77 | "G004", 78 | "N805", 79 | "T201", 80 | "TRY003", 81 | "TRY201", 82 | "TRY203", 83 | "TD002", 84 | "TD003", 85 | "FIX002", 86 | "PLR0911", 87 | "PLR0912", 88 | "PLR0913", 89 | "PGH003", 90 | "S701", 91 | ] 92 | 93 | [tool.ruff.format] 94 | skip-magic-trailing-comma = false 95 | quote-style = "double" 96 | indent-style = "space" 97 | line-ending = "auto" 98 | 99 | [tool.ruff.lint.per-file-ignores] 100 | "tests/*" = ["S101", "PT006"] 101 | "__init__.py" = ["F401"] 102 | 103 | [tool.mypy] 104 | warn_return_any = false 105 | namespace_packages = true 106 | strict = true 107 | ignore_missing_imports = true 108 | pretty = true 109 | show_error_codes = true 110 | implicit_reexport = true 111 | disable_error_code = ["prop-decorator", "override", "import-untyped"] 112 | plugins = ["pydantic.mypy"] 113 | exclude = ["fastapi_forge/frontend", "fastapi_forge/template"] 114 | 115 | [[tool.mypy.overrides]] 116 | module = ["fastapi_forge/frontend.*"] 117 | follow_imports = "silent" 118 | 119 | [tool.pytest] 120 | testpaths = ["tests", "integration"] 121 | -------------------------------------------------------------------------------- /fastapi_forge/frontend/main.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from nicegui import native, ui 4 | 5 | from fastapi_forge.core import build_fastapi_project 6 | from fastapi_forge.enums import FieldDataTypeEnum 7 | from fastapi_forge.frontend.components.header import Header 8 | from fastapi_forge.frontend.panels.item_editor_panel import ItemEditorPanel 9 | from fastapi_forge.frontend.panels.left_panel import LeftPanel 10 | from fastapi_forge.frontend.panels.project_config_panel import ProjectConfigPanel 11 | from fastapi_forge.frontend.state import state 12 | from fastapi_forge.schemas import ( 13 | CustomEnum, 14 | CustomEnumValue, 15 | Model, 16 | ModelField, 17 | ProjectSpec, 18 | ) 19 | 20 | 21 | def setup_ui() -> None: 22 | """Setup basic UI configuration""" 23 | ui.add_head_html( 24 | '', 25 | ) 26 | ui.button.default_props("round flat dense") 27 | ui.input.default_props("dense") 28 | Header() 29 | 30 | 31 | def create_ui_components() -> None: 32 | """Create all UI components""" 33 | ItemEditorPanel() 34 | 35 | LeftPanel().classes("shadow-xl dark:shadow-none") 36 | ProjectConfigPanel().classes("shadow-xl dark:shadow-none") 37 | 38 | 39 | def run_ui(reload: bool) -> None: 40 | """Run the NiceGUI application""" 41 | ui.run( 42 | reload=reload, 43 | title="FastAPI Forge", 44 | port=native.find_open_port(8777, 8999), 45 | ) 46 | 47 | 48 | def init( 49 | *, 50 | reload: bool = False, 51 | no_ui: bool = False, 52 | dry_run: bool = False, 53 | project_spec: ProjectSpec | None = None, 54 | ) -> None: 55 | if project_spec: 56 | if no_ui: 57 | asyncio.run(build_fastapi_project(project_spec, dry_run=dry_run)) 58 | return 59 | 60 | state.initialize_from_project(project_spec) 61 | 62 | setup_ui() 63 | create_ui_components() 64 | run_ui(reload) 65 | 66 | 67 | if __name__ in {"__main__", "__mp_main__"}: 68 | project_spec = ProjectSpec( 69 | project_name="reload", 70 | models=[ 71 | Model( 72 | name="test_model", 73 | fields=[ 74 | ModelField( 75 | name="id", 76 | primary_key=True, 77 | type=FieldDataTypeEnum.UUID, 78 | ), 79 | ], 80 | ), 81 | ], 82 | custom_enums=[ 83 | CustomEnum( 84 | name="MyEnum", 85 | values=[ 86 | CustomEnumValue( 87 | name="FOO", 88 | value="auto()", 89 | ), 90 | ], 91 | ) 92 | ], 93 | ) 94 | init(reload=True, project_spec=project_spec) 95 | -------------------------------------------------------------------------------- /fastapi_forge/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/routes/auth_routes.py: -------------------------------------------------------------------------------- 1 | {%- if cookiecutter.use_builtin_auth %} 2 | from fastapi import APIRouter 3 | from {{cookiecutter.project_name}}.dtos.auth_dtos import UserLoginDTO, UserCreateDTO, UserCreateResponseDTO, LoginResponse, TokenData 4 | from {{cookiecutter.project_name}}.dtos.{{ cookiecutter.auth_model.name }}_dtos import {{ cookiecutter.auth_model.name_cc }}DTO, {{ cookiecutter.auth_model.name_cc }}InputDTO 5 | from {{cookiecutter.project_name}}.dtos import DataResponse, CreatedResponse 6 | from {{cookiecutter.project_name}}.daos import GetDAOs 7 | from {{cookiecutter.project_name}} import exceptions 8 | from {{cookiecutter.project_name}}.utils import auth_utils 9 | from {{cookiecutter.project_name}}.dependencies.auth_dependencies import GetCurrentUser 10 | 11 | 12 | router = APIRouter(prefix="/auth") 13 | 14 | 15 | @router.post("/login-email", status_code=201) 16 | async def login( 17 | input_dto: UserLoginDTO, 18 | daos: GetDAOs, 19 | ) -> DataResponse[LoginResponse]: 20 | """Login by email and password.""" 21 | 22 | user = await daos.{{ cookiecutter.auth_model.name }}.filter_first(email=input_dto.email) 23 | 24 | if user is None: 25 | raise exceptions.Http401("Wrong email or password") 26 | 27 | is_valid_password = auth_utils.verify_password( 28 | input_dto.password.get_secret_value(), user.password 29 | ) 30 | 31 | if not is_valid_password: 32 | raise exceptions.Http401("Wrong email or password") 33 | 34 | token = auth_utils.create_access_token( 35 | data=TokenData( 36 | user_id=user.{{ cookiecutter.auth_model.primary_key.name }} 37 | ) 38 | ) 39 | 40 | return DataResponse(data=LoginResponse(access_token=token)) 41 | 42 | 43 | @router.post("/register", status_code=201) 44 | async def register( 45 | input_dto: UserCreateDTO, 46 | daos: GetDAOs, 47 | ) -> DataResponse[UserCreateResponseDTO]: 48 | """Register by email and password.""" 49 | 50 | existing_obj = await daos.{{ cookiecutter.auth_model.name }}.filter_first(email=input_dto.email) 51 | 52 | if existing_obj: 53 | raise exceptions.Http401("User already exists") 54 | 55 | created_obj = await daos.{{ cookiecutter.auth_model.name }}.create( 56 | {{ cookiecutter.auth_model.name_cc }}InputDTO( 57 | email=input_dto.email, 58 | password=auth_utils.hash_password( 59 | input_dto.password.get_secret_value(), 60 | ), 61 | ) 62 | ) 63 | 64 | return DataResponse( 65 | data=UserCreateResponseDTO.model_validate(created_obj) 66 | ) 67 | 68 | 69 | @router.get("/users/me", status_code=200) 70 | async def get_current_user( 71 | current_user: GetCurrentUser, 72 | ) -> DataResponse[{{ cookiecutter.auth_model.name_cc }}DTO]: 73 | """Get current user.""" 74 | 75 | return DataResponse(data=current_user) 76 | {% endif %} 77 | -------------------------------------------------------------------------------- /fastapi_forge/render/filters.py: -------------------------------------------------------------------------------- 1 | from fastapi_forge.schemas import ModelField, ModelRelationship 2 | 3 | 4 | class JinjaFilters: 5 | @staticmethod 6 | def generate_field( 7 | field: ModelField, relationships: list[ModelRelationship] | None = None 8 | ) -> str: 9 | target = None 10 | if field.metadata.is_foreign_key and relationships is not None: 11 | target = next( 12 | ( 13 | relation 14 | for relation in relationships 15 | if relation.field_name == field.name 16 | ), 17 | None, 18 | ) 19 | 20 | target_data = None 21 | if target: 22 | target_data = (target.target_model, target.on_delete) 23 | 24 | if relationships is not None and target is None: 25 | raise ValueError(f"Target was not found for Foreign Key {field.name}") 26 | 27 | type_info = field.type_info 28 | args = [ 29 | f"{'sa.' if type_info.sqlalchemy_prefix else ''}{type_info.sqlalchemy_type}" 30 | ] 31 | 32 | if field.metadata.is_foreign_key and target_data: 33 | target_model, on_delete = target_data 34 | args.append( 35 | f'sa.ForeignKey("{target_model + ".id"}", ondelete="{on_delete}")' 36 | ) 37 | if field.primary_key: 38 | args.append("primary_key=True") 39 | if field.unique: 40 | args.append("unique=True") 41 | if field.index: 42 | args.append("index=True") 43 | if field.default_value: 44 | if field.type_enum: 45 | args.append(f"default=enums.{field.type_enum}.{field.default_value}") 46 | else: 47 | args.append(f"default={field.default_value}") 48 | if field.extra_kwargs: 49 | for k, v in field.extra_kwargs.items(): 50 | args.append(f"{k}={v}") 51 | 52 | return f""" 53 | {field.name}: Mapped[{field.type_info.python_type}{" | None" if field.nullable else ""}] = mapped_column( 54 | {",\n ".join(args)} 55 | ) 56 | """.strip() 57 | 58 | @staticmethod 59 | def generate_relationship( 60 | relation: ModelRelationship, is_self_reference: bool = False 61 | ) -> str: 62 | """Generates a relationship field for SQLAlchemy.""" 63 | args = [] 64 | args.append(f"foreign_keys=[{relation.field_name}]") 65 | if relation.back_populates: 66 | args.append(f'back_populates="{relation.back_populates}"') 67 | args.append("uselist=False") 68 | 69 | target_repr = ( 70 | relation.target if not is_self_reference else f'"{relation.target}"' 71 | ) 72 | return f""" 73 | {relation.field_name_no_id}: Mapped[{target_repr}] = relationship( 74 | {",\n ".join(args)} 75 | ) 76 | """.strip() 77 | -------------------------------------------------------------------------------- /tests/test_type_registry.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from fastapi_forge.enums import FieldDataTypeEnum 4 | from fastapi_forge.schemas import CustomEnum, CustomEnumValue 5 | from fastapi_forge.type_info_registry import TypeInfo, TypeInfoRegistry, enum_registry 6 | 7 | ########################## 8 | # TypeInfoRegistry tests # 9 | ########################## 10 | 11 | 12 | def test_registry_operations(type_info_registry: TypeInfoRegistry) -> None: 13 | type_info_registry.register( 14 | FieldDataTypeEnum.STRING, 15 | TypeInfo( 16 | sqlalchemy_type="String", 17 | sqlalchemy_prefix=True, 18 | python_type="str", 19 | ), 20 | ) 21 | assert type_info_registry.get(FieldDataTypeEnum.STRING) 22 | assert len(type_info_registry.all()) == 1 23 | 24 | assert FieldDataTypeEnum.STRING in type_info_registry 25 | 26 | type_info_registry.clear() 27 | assert len(type_info_registry.all()) == 0 28 | 29 | assert FieldDataTypeEnum.STRING not in type_info_registry 30 | 31 | 32 | def test_registry_get_not_found(type_info_registry: TypeInfoRegistry) -> None: 33 | with pytest.raises(KeyError) as exc_info: 34 | type_info_registry.get(FieldDataTypeEnum.BOOLEAN) 35 | 36 | assert "Key 'Boolean' not found." in str(exc_info.value) 37 | 38 | 39 | def test_key_already_registered(type_info_registry: TypeInfoRegistry) -> None: 40 | type_info_registry.register( 41 | FieldDataTypeEnum.STRING, 42 | TypeInfo( 43 | sqlalchemy_type="String", 44 | sqlalchemy_prefix=True, 45 | python_type="str", 46 | ), 47 | ) 48 | with pytest.raises(KeyError) as exc_info: 49 | type_info_registry.register( 50 | FieldDataTypeEnum.STRING, 51 | TypeInfo( 52 | sqlalchemy_type="String", 53 | sqlalchemy_prefix=True, 54 | python_type="str", 55 | ), 56 | ) 57 | assert "TypeInfoRegistry: Key 'String' is already registered." in str( 58 | exc_info.value 59 | ) 60 | 61 | 62 | ############################## 63 | # EnumTypeInfoRegistry tests # 64 | ############################## 65 | 66 | 67 | def test_custom_enum_register() -> None: 68 | enum = CustomEnum(name="HTTPMethod") 69 | assert enum.name in enum_registry 70 | assert len(enum_registry.all()) == 1 71 | 72 | type_info = enum_registry.get(enum.name) 73 | assert type_info.sqlalchemy_type == 'Enum(enums.HTTPMethod, name="http_method")' 74 | assert type_info.faker_field_value is None 75 | 76 | 77 | def test_custom_enum_register_w_values() -> None: 78 | enum = CustomEnum( 79 | name="HTTPMethod", 80 | values=[ 81 | CustomEnumValue(name="GET", value="auto()"), 82 | CustomEnumValue(name="POST", value="auto()"), 83 | ], 84 | ) 85 | 86 | type_info = enum_registry.get(enum.name) 87 | assert type_info.sqlalchemy_type == 'Enum(enums.HTTPMethod, name="http_method")' 88 | assert type_info.faker_field_value == "enums.HTTPMethod.GET" 89 | 90 | 91 | def test_duplicate_custom_enum() -> None: 92 | CustomEnum(name="TEST") 93 | with pytest.raises(KeyError) as exc_info: 94 | CustomEnum(name="TEST") 95 | 96 | assert "EnumTypeInfoRegistry: Key 'TEST' is already registered." in str( 97 | exc_info.value 98 | ) 99 | -------------------------------------------------------------------------------- /fastapi_forge/template/{{cookiecutter.project_name}}/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "{{cookiecutter.project_name}}" 3 | version = "0.1.0" 4 | description = "Add your description here" 5 | readme = "README.md" 6 | requires-python = ">=3.12" 7 | dependencies = [ 8 | "fastapi>=0.115.8", 9 | "uvicorn>=0.34.0", 10 | "pydantic-settings>=2.7.1", 11 | "pydantic>=2.10.6", 12 | "email-validator>=2.2.0", 13 | "loguru>=0.7.3", 14 | "yarl>=1.18.3", 15 | "ruff>=0.9.4", 16 | "mypy>=1.15.0", 17 | {%- if cookiecutter.use_postgres -%} 18 | "sqlalchemy[asyncio]>=2.0.37", 19 | "asyncpg>=0.30.0", 20 | {% endif %} 21 | {%- if cookiecutter.use_builtin_auth -%} 22 | "pyjwt>=2.10.1", 23 | "passlib>=1.7.4", 24 | {% endif %} 25 | {%- if cookiecutter.use_alembic -%} 26 | "alembic>=1.14.1", 27 | {%- endif %} 28 | "pytest>=8.3.4", 29 | "pytest-env>=1.1.5", 30 | "pytest-cov>=6.1.1", 31 | "httpx==0.28.1", 32 | "factory-boy>=3.3.3", 33 | {%- if cookiecutter.use_redis -%} 34 | "redis>=5.2.1", 35 | "fakeredis>=2.28.1", 36 | {% endif %} 37 | {%- if cookiecutter.use_rabbitmq -%} 38 | "aio-pika>=9.5.5", 39 | {%- endif %} 40 | {%- if cookiecutter.use_taskiq -%} 41 | "taskiq>=0.11.16", 42 | "taskiq-aio-pika>=0.4.1", 43 | "taskiq-redis>=1.0.4", 44 | "taskiq-fastapi>=0.3.4", 45 | "orjson>=3.10.16", 46 | {%- endif %} 47 | {%- if cookiecutter.use_prometheus -%} 48 | "prometheus-fastapi-instrumentator>=7.1.0", 49 | {%- endif %} 50 | {%- if cookiecutter.use_logfire -%} 51 | "logfire[aio-pika,asyncpg,fastapi,httpx,sqlalchemy,system-metrics]>=3.16.0", 52 | {%- endif %} 53 | ] 54 | 55 | [tool.pytest.ini_options] 56 | env = [ 57 | "{{ cookiecutter.project_name|upper|replace('-', '_') }}_PG_DATABASE=test", 58 | "{{ cookiecutter.project_name|upper|replace('-', '_') }}_ENV=test", 59 | ] 60 | 61 | [tool.ruff] 62 | exclude = ["migrations",".venv/", "Lib"] 63 | target-version = "py312" 64 | line-length = 88 65 | indent-width = 4 66 | 67 | [tool.ruff.lint] 68 | select = ["ALL"] 69 | ignore = [ 70 | #### modules 71 | "ANN", # 72 | "COM", # 73 | "C90", # 74 | "DJ", # 75 | "EXE", # 76 | "T10", # 77 | "TID", # 78 | 79 | #### specific rules 80 | "A001", 81 | "A002", 82 | "ARG002", 83 | "ARG001", 84 | "B008", 85 | "B904", 86 | "BLE001", 87 | "D100", 88 | "D101", 89 | "D102", 90 | "D103", 91 | "D104", 92 | "D105", 93 | "D106", 94 | "D107", 95 | "D200", 96 | "D203", 97 | "D205", 98 | "D212", 99 | "D400", 100 | "D401", 101 | "D404", 102 | "D415", 103 | "E402", 104 | "E501", 105 | "EM102", 106 | "FBT001", 107 | "FBT002", 108 | "FBT003", 109 | "N805", 110 | "T201", 111 | "TRY003", 112 | "TRY201", 113 | "TRY203", 114 | "TRY300", 115 | "TD002", 116 | "TD003", 117 | "FIX002", 118 | "PLR0913", 119 | "PLR2004", 120 | "PGH003", 121 | "RUF012", 122 | "S701", 123 | ] 124 | 125 | [tool.ruff.format] 126 | quote-style = "double" 127 | indent-style = "space" 128 | skip-magic-trailing-comma = false 129 | line-ending = "auto" 130 | 131 | [tool.ruff.lint.per-file-ignores] 132 | "tests/*" = ["S101", "PT006"] 133 | "__init__.py" = ["F401"] 134 | 135 | [tool.mypy] 136 | warn_return_any = false 137 | namespace_packages = true 138 | strict = true 139 | ignore_missing_imports = true 140 | pretty = true 141 | show_error_codes = true 142 | implicit_reexport = true 143 | disable_error_code = ["prop-decorator", "override", "import-untyped"] 144 | plugins = ["pydantic.mypy", {% if cookiecutter.use_postgres -%}"sqlalchemy.ext.mypy.plugin"{% endif %}] 145 | exclude = ["migrations"] 146 | -------------------------------------------------------------------------------- /fastapi_forge/frontend/modals/enum_modal.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from collections.abc import Callable 3 | 4 | from nicegui import ui 5 | 6 | from fastapi_forge.frontend.notifications import notify_value_error 7 | from fastapi_forge.frontend.state import state 8 | from fastapi_forge.schemas import CustomEnumValue 9 | 10 | 11 | class BaseEnumValueModal(ui.dialog, ABC): 12 | title: str 13 | 14 | def __init__(self, *args, **kwargs): 15 | super().__init__(*args, **kwargs) 16 | self._build() 17 | 18 | @abstractmethod 19 | def _build_action_buttons(self) -> None: 20 | pass 21 | 22 | def _build(self) -> None: 23 | with self, ui.card().classes("w-full max-w-md no-shadow rounded-lg"): 24 | with ui.row().classes("w-full justify-between items-center p-4 border-b"): 25 | ui.label(self.title).classes("text-xl font-semibold") 26 | 27 | with ( 28 | ui.column().classes("w-full p-6 space-y-4"), 29 | ui.grid(columns=2).classes("w-full gap-4"), 30 | ): 31 | self.value_name = ( 32 | ui.input(label="Name").props("outlined dense").classes("w-full") 33 | ) 34 | self.value_value = ( 35 | ui.input(label="Value").props("outlined dense").classes("w-full") 36 | ).tooltip( 37 | "Set to auto(), or any string value without including quotes." 38 | ) 39 | 40 | with ui.row().classes("w-full justify-end p-4 border-t gap-2"): 41 | self._build_action_buttons() 42 | 43 | def reset(self) -> None: 44 | self.value_name.value = "" 45 | self.value_value.value = "" 46 | 47 | 48 | class AddEnumValueModal(BaseEnumValueModal): 49 | title = "Add Enum Value" 50 | 51 | def __init__(self, on_add_value: Callable): 52 | super().__init__() 53 | self.on_add_value = on_add_value 54 | 55 | def _build_action_buttons(self) -> None: 56 | ui.button("Cancel", on_click=self.close) 57 | ui.button( 58 | self.title, 59 | on_click=self._handle_add, 60 | ) 61 | 62 | def _handle_add(self) -> None: 63 | try: 64 | self.on_add_value( 65 | name=self.value_name.value, 66 | value=self.value_value.value, 67 | ) 68 | self.close() 69 | except ValueError as exc: 70 | notify_value_error(exc) 71 | return 72 | 73 | 74 | class UpdateEnumValueModal(BaseEnumValueModal): 75 | title = "Update Enum Value" 76 | 77 | def __init__(self, on_update_value: Callable): 78 | super().__init__() 79 | self.on_update_value = on_update_value 80 | 81 | def _build_action_buttons(self) -> None: 82 | ui.button("Cancel", on_click=self.close) 83 | ui.button( 84 | self.title, 85 | on_click=self._handle_update, 86 | ) 87 | 88 | def _handle_update(self) -> None: 89 | if not state.selected_enum_value: 90 | return 91 | try: 92 | self.on_update_value( 93 | name=self.value_name.value, 94 | value=self.value_value.value, 95 | ) 96 | self.close() 97 | except ValueError as exc: 98 | notify_value_error(exc) 99 | return 100 | 101 | def _set_value(self, enum_value: CustomEnumValue) -> None: 102 | state.selected_enum_value = enum_value 103 | if enum_value: 104 | self.value_name.value = enum_value.name 105 | self.value_value.value = enum_value.value 106 | 107 | def open(self, enum_value: CustomEnumValue | None = None) -> None: 108 | if enum_value: 109 | self._set_value(enum_value) 110 | super().open() 111 | -------------------------------------------------------------------------------- /fastapi_forge/template/{{cookiecutter.project_name}}/tests/factories.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import inspect 3 | from sqlalchemy.ext.asyncio import ( 4 | AsyncSession, 5 | ) 6 | from {{cookiecutter.project_name}}.db import Base 7 | from {{cookiecutter.project_name}} import enums 8 | import factory 9 | from typing import Any 10 | from datetime import datetime, timezone, timedelta 11 | from uuid import uuid4 12 | 13 | 14 | 15 | {% for model in cookiecutter.models.models -%} 16 | from {{cookiecutter.project_name}}.models.{{ model.name }}_models import {{ model.name_cc }} 17 | {% endfor %} 18 | 19 | 20 | class BaseFactory[Model: Base](factory.Factory): 21 | """ 22 | This is the base factory class for all factories. 23 | 24 | Inherit from this class to create a new factory that provides a way to create 25 | new instances of a specific model, used for testing purposes. 26 | 27 | Example: 28 | >>> class UserFactory(BaseFactory[User]): 29 | >>> ... 30 | >>> class Meta: 31 | >>> model = User 32 | """ 33 | session: AsyncSession 34 | 35 | class Meta: 36 | abstract = True 37 | 38 | @classmethod 39 | async def create(cls, *args: Any, **kwargs: Any) -> Model: 40 | """Create and commit a new instance of the model.""" 41 | instance = await super().create(*args, **kwargs) 42 | await cls.session.commit() 43 | return instance 44 | 45 | @classmethod 46 | async def create_batch(cls, size: int, *args: Any, **kwargs: Any) -> list[Model]: 47 | """Create a batch of new instances of the model.""" 48 | return [await cls.create(*args, **kwargs) for _ in range(size)] 49 | 50 | @classmethod 51 | def _create( 52 | cls, 53 | model_class: type["BaseFactory[Model]"], 54 | *args: Any, 55 | **kwargs: Any, 56 | ) -> asyncio.Task["BaseFactory[Model]"]: 57 | async def maker_coroutine() -> "BaseFactory[Model]": 58 | for key, value in kwargs.items(): 59 | if inspect.isawaitable(value): 60 | kwargs[key] = await value 61 | return await cls._create_model(model_class, *args, **kwargs) 62 | 63 | return asyncio.create_task(maker_coroutine()) 64 | 65 | @classmethod 66 | async def _create_model( 67 | cls, 68 | model_class: type["BaseFactory[Model]"], 69 | *args: Any, 70 | **kwargs: Any, 71 | ) -> "BaseFactory[Model]": 72 | """Create a new instance of the model.""" 73 | model = model_class(*args, **kwargs) 74 | cls.session.add(model) 75 | return model 76 | 77 | 78 | ################### 79 | # Factory classes # 80 | ################### 81 | 82 | 83 | {% for model in cookiecutter.models.models %} 84 | class {{ model.name_cc }}Factory(BaseFactory[{{ model.name_cc }}]): 85 | """{{ model.name }} factory.""" 86 | class Meta: 87 | model = {{ model.name_cc }} 88 | 89 | {%- for field in model.fields if field.type_info.faker_field_value %} 90 | {%- if not field.primary_key and not field.metadata.is_foreign_key %} 91 | {{ field.name }} = {{ field.type_info.faker_field_value }} 92 | {%- endif %} 93 | {%- endfor %} 94 | 95 | {%- if model.relationships %} 96 | @classmethod 97 | async def _create_model( 98 | cls, model_class: type[BaseFactory[{{ model.name_cc }}]], *args: Any, **kwargs: Any 99 | ) -> BaseFactory[{{ model.name_cc }}]: 100 | """Create a new instance of the model.""" 101 | 102 | {%- for relationship in model.relationships %} 103 | if "{{ relationship.field_name_no_id }}" not in kwargs: 104 | kwargs["{{ relationship.field_name_no_id }}"] = await {{ relationship.target }}Factory.create() 105 | {%- endfor %} 106 | return await super()._create_model(model_class, *args, **kwargs) 107 | {%- endif %} 108 | {% endfor %} 109 | -------------------------------------------------------------------------------- /fastapi_forge/core/build.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Callable 2 | from pathlib import Path 3 | from time import perf_counter 4 | 5 | import click 6 | 7 | from fastapi_forge.logger import logger 8 | from fastapi_forge.project_io import ArtifactBuilder, create_fastapi_artifact_builder 9 | from fastapi_forge.schemas import ProjectSpec 10 | 11 | from .cookiecutter_adapter import ( 12 | CookiecutterAdapter, 13 | DryRunCookiecutterAdapter, 14 | OverwriteCookiecutterAdapter, 15 | ) 16 | from .project_validators import ProjectNameValidator, ProjectValidator 17 | from .template_processors import DefaultTemplateProcessor, TemplateProcessor 18 | 19 | 20 | class ProjectBuildDirector: 21 | def __init__( 22 | self, 23 | builder: ArtifactBuilder, 24 | template_processor: TemplateProcessor, 25 | template_generator: CookiecutterAdapter, 26 | template_resolver: Callable[[], Path], 27 | project_validator: ProjectValidator | None = None, 28 | ): 29 | self.builder = builder 30 | self.validator = project_validator 31 | self.template_processor = template_processor 32 | self.template_generator = template_generator 33 | self.template_resolver = template_resolver 34 | 35 | async def build(self, spec: ProjectSpec) -> None: 36 | if self.validator: 37 | self.validator.validate(spec) 38 | await self.builder.build_artifacts() 39 | 40 | context = self.template_processor.process(spec) 41 | template_path = self.template_resolver() 42 | 43 | self.template_generator.generate( 44 | template_path=template_path, 45 | output_dir=Path.cwd().resolve(), 46 | extra_context=context, 47 | ) 48 | 49 | 50 | def _get_template_path() -> Path: 51 | template_path = Path(__file__).resolve().parent.parent / "template" 52 | if not template_path.exists(): 53 | raise RuntimeError(f"Template directory not found: {template_path}") 54 | if not template_path.is_dir(): 55 | raise RuntimeError(f"Template path is not a directory: {template_path}") 56 | return template_path 57 | 58 | 59 | async def build_fastapi_project( 60 | spec: ProjectSpec, 61 | dry_run: bool = False, 62 | ) -> None: 63 | start_time = perf_counter() 64 | 65 | template_generator = ( 66 | OverwriteCookiecutterAdapter() if not dry_run else DryRunCookiecutterAdapter() 67 | ) 68 | 69 | try: 70 | director = ProjectBuildDirector( 71 | builder=create_fastapi_artifact_builder(spec, dry_run=dry_run), 72 | project_validator=ProjectNameValidator(), 73 | template_processor=DefaultTemplateProcessor(), 74 | template_generator=template_generator, 75 | template_resolver=_get_template_path, 76 | ) 77 | 78 | await director.build(spec) 79 | 80 | build_time = perf_counter() - start_time 81 | logger.info(f"Project build completed in {build_time:.2f} seconds") 82 | 83 | click.secho("\n🎉 Project generated successfully !", fg="green", bold=True) 84 | click.echo("\n🚀 Next steps to get started:\n") 85 | 86 | steps = [ 87 | ("Navigate to your project directory", "cd your_project_name"), 88 | ("Start the development environment", "make up # or docker-compose up"), 89 | ("(Optional) Run tests", "make test"), 90 | ("Access the API documentation", "http://localhost:8000/docs"), 91 | ] 92 | 93 | for i, (desc, cmd) in enumerate(steps, 1): 94 | click.echo(f"{i}. {desc}:") 95 | click.secho(f" {cmd}", fg="cyan") 96 | 97 | click.echo("\n💡 Pro tip: Run 'make help' to see all available commands") 98 | click.secho("\n✨ Happy coding with your new FastAPI project!", fg="magenta") 99 | 100 | except Exception as error: 101 | logger.error(f"Project build failed: {error}") 102 | raise 103 | -------------------------------------------------------------------------------- /fastapi_forge/template/hooks/post_gen_project.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | import yaml 3 | import os 4 | import shutil 5 | from fastapi_forge.logger import logger 6 | 7 | 8 | def run_command(command: list[str]) -> None: 9 | subprocess.run(command, check=True) 10 | 11 | 12 | def uv_init() -> None: 13 | run_command(["uv", "lock"]) 14 | 15 | 16 | def lint() -> None: 17 | run_command(["make", "lint"]) 18 | 19 | 20 | def make_env() -> None: 21 | run_command(["cp", ".env.example", ".env"]) 22 | 23 | 24 | def _process_paths(paths: list[str], cwd: str) -> tuple[list[str], list[str]]: 25 | files = [] 26 | folders = [] 27 | for path in paths: 28 | if not path: 29 | continue 30 | full_path = os.path.join(cwd, path) 31 | if path.endswith((".py", ".yaml")): 32 | files.append(full_path) 33 | else: 34 | folders.append(full_path) 35 | return files, folders 36 | 37 | 38 | def _get_delete_flagged() -> tuple[list[str], list[str]]: 39 | files = [] 40 | folders = [] 41 | cwd = os.getcwd() 42 | 43 | try: 44 | with open("forge-config.yaml") as stream: 45 | config = yaml.safe_load(stream) or {} 46 | paths_config = config.get("paths", {}) 47 | 48 | for item in paths_config.values(): 49 | if "enabled" in item and not item["enabled"]: 50 | new_files, new_folders = _process_paths(item.get("paths", []), cwd) 51 | files.extend(new_files) 52 | folders.extend(new_folders) 53 | 54 | if "requires_all" in item: 55 | conditions_met = all( 56 | paths_config.get(req, {}).get("enabled", False) is False 57 | for req in item["requires_all"] 58 | ) 59 | if conditions_met: 60 | new_files, new_folders = _process_paths( 61 | item.get("paths", []), cwd 62 | ) 63 | files.extend(new_files) 64 | folders.extend(new_folders) 65 | 66 | except Exception as e: 67 | logger.error(f"Error reading config file: {e}") 68 | 69 | return files, folders 70 | 71 | 72 | def _is_empty_init(dirpath: str) -> bool: 73 | init_file = os.path.join(dirpath, "__init__.py") 74 | try: 75 | with open(init_file, "r") as f: 76 | return not any( 77 | line.strip() and not line.strip().startswith("#") 78 | for line in f.read().splitlines() 79 | ) 80 | except OSError: 81 | return False 82 | 83 | 84 | def delete_empty_init_folders(root_dir: str = "{{cookiecutter.project_name}}") -> None: 85 | for dirpath, dirnames, filenames in os.walk(root_dir, topdown=False): 86 | if dirpath != root_dir and set(filenames) == {"__init__.py"} and not dirnames: 87 | if _is_empty_init(dirpath): 88 | os.remove(os.path.join(dirpath, "__init__.py")) 89 | os.rmdir(dirpath) 90 | logger.info(f"Deleted empty package: {dirpath}") 91 | 92 | 93 | def cleanup() -> None: 94 | files, folders = _get_delete_flagged() 95 | 96 | for path in files: 97 | try: 98 | if os.path.exists(path): 99 | os.remove(path) 100 | logger.info(f"Deleted file: {path}") 101 | except OSError as exc: 102 | logger.error(f"Error deleting file {path}: {exc}") 103 | 104 | for path in folders: 105 | try: 106 | if os.path.exists(path): 107 | shutil.rmtree(path) 108 | logger.info(f"Deleted folder: {path}") 109 | except OSError as exc: 110 | logger.error(f"Error deleting folder {path}: {exc}") 111 | 112 | delete_empty_init_folders() 113 | 114 | 115 | if __name__ == "__main__": 116 | cleanup() 117 | uv_init() 118 | make_env() 119 | lint() 120 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # UV 98 | # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | #uv.lock 102 | 103 | # poetry 104 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 105 | # This is especially recommended for binary packages to ensure reproducibility, and is more 106 | # commonly ignored for libraries. 107 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 108 | #poetry.lock 109 | 110 | # pdm 111 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 112 | #pdm.lock 113 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 114 | # in version control. 115 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control 116 | .pdm.toml 117 | .pdm-python 118 | .pdm-build/ 119 | 120 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 121 | __pypackages__/ 122 | 123 | # Celery stuff 124 | celerybeat-schedule 125 | celerybeat.pid 126 | 127 | # SageMath parsed files 128 | *.sage.py 129 | 130 | # Environments 131 | .venv 132 | env/ 133 | venv/ 134 | ENV/ 135 | env.bak/ 136 | venv.bak/ 137 | 138 | # Spyder project settings 139 | .spyderproject 140 | .spyproject 141 | 142 | # Rope project settings 143 | .ropeproject 144 | 145 | # mkdocs documentation 146 | /site 147 | 148 | # mypy 149 | .mypy_cache/ 150 | .dmypy.json 151 | dmypy.json 152 | 153 | # Pyre type checker 154 | .pyre/ 155 | 156 | # pytype static type analyzer 157 | .pytype/ 158 | 159 | # Cython debug symbols 160 | cython_debug/ 161 | 162 | # PyCharm 163 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 164 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 165 | # and can be added to the global gitignore or merged into this file. For a more nuclear 166 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 167 | #.idea/ 168 | 169 | # PyPI configuration file 170 | .pypirc 171 | 172 | # Miscellaneous 173 | delete_me* 174 | game_zone* -------------------------------------------------------------------------------- /fastapi_forge/template/{{cookiecutter.project_name}}/.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # UV 98 | # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | #uv.lock 102 | 103 | # poetry 104 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 105 | # This is especially recommended for binary packages to ensure reproducibility, and is more 106 | # commonly ignored for libraries. 107 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 108 | #poetry.lock 109 | 110 | # pdm 111 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 112 | #pdm.lock 113 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 114 | # in version control. 115 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control 116 | .pdm.toml 117 | .pdm-python 118 | .pdm-build/ 119 | 120 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 121 | __pypackages__/ 122 | 123 | # Celery stuff 124 | celerybeat-schedule 125 | celerybeat.pid 126 | 127 | # SageMath parsed files 128 | *.sage.py 129 | 130 | # Environments 131 | .env 132 | .venv 133 | env/ 134 | venv/ 135 | ENV/ 136 | env.bak/ 137 | venv.bak/ 138 | 139 | # Spyder project settings 140 | .spyderproject 141 | .spyproject 142 | 143 | # Rope project settings 144 | .ropeproject 145 | 146 | # mkdocs documentation 147 | /site 148 | 149 | # mypy 150 | .mypy_cache/ 151 | .dmypy.json 152 | dmypy.json 153 | 154 | # Pyre type checker 155 | .pyre/ 156 | 157 | # pytype static type analyzer 158 | .pytype/ 159 | 160 | # Cython debug symbols 161 | cython_debug/ 162 | 163 | # PyCharm 164 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 165 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 166 | # and can be added to the global gitignore or merged into this file. For a more nuclear 167 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 168 | #.idea/ 169 | 170 | # PyPI configuration file 171 | .pypirc -------------------------------------------------------------------------------- /fastapi_forge/__main__.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from pathlib import Path 3 | 4 | import click 5 | 6 | from fastapi_forge.frontend.main import init 7 | from fastapi_forge.project_io import ( 8 | YamlProjectLoader, 9 | create_postgres_project_loader, 10 | ) 11 | 12 | 13 | def confirm_uv_installed() -> bool: 14 | """Show UV requirement warning and get confirmation.""" 15 | click.secho( 16 | "\n⚠️ Important Requirement (use the '--yes' option to skip)", 17 | fg="yellow", 18 | bold=True, 19 | ) 20 | click.echo("Generated projects require UV to be installed.") 21 | click.secho( 22 | "GitHub: https://docs.astral.sh/uv/getting-started/installation", 23 | fg="blue", 24 | underline=True, 25 | ) 26 | 27 | if not click.confirm( 28 | "\nDo you have UV installed and ready to use?", 29 | default=True, 30 | ): 31 | click.secho( 32 | "\n❌ Please install UV first and restart this command.", 33 | fg="red", 34 | ) 35 | click.echo("Verify with: uv --version") 36 | return False 37 | return True 38 | 39 | 40 | @click.group( 41 | help="FastAPI Forge CLI - A tool for generating FastAPI projects.", 42 | context_settings={"help_option_names": ["-h", "--help"]}, 43 | ) 44 | @click.version_option(package_name="fastapi-forge") 45 | @click.option("-v", "--verbose", count=True, help="Increase verbosity level.") 46 | @click.pass_context 47 | def main(ctx: click.Context, verbose: int) -> None: 48 | """FastAPI Forge CLI entry point.""" 49 | ctx.ensure_object(dict) 50 | ctx.obj["verbose"] = verbose 51 | 52 | 53 | @main.command( 54 | help="Start FastAPI Forge - Generate a new FastAPI project.", 55 | ) 56 | @click.option( 57 | "--use-example", 58 | is_flag=True, 59 | help="Generate a new project using a prebuilt example provided by FastAPI Forge.", 60 | ) 61 | @click.option( 62 | "--no-ui", 63 | is_flag=True, 64 | help="Generate the project directly in the terminal without launching the UI (default: False).", 65 | ) 66 | @click.option( 67 | "--dry-run", 68 | is_flag=True, 69 | help="Perform a dry run without generating any files (requires --no-ui).", 70 | ) 71 | @click.option( 72 | "--from-yaml", 73 | type=click.Path( 74 | exists=True, 75 | dir_okay=False, 76 | readable=True, 77 | path_type=Path, 78 | ), 79 | help="Generate a project using a custom configuration from a YAML file.", 80 | ) 81 | @click.option( 82 | "--yes", 83 | is_flag=True, 84 | help="Automatically confirm all prompts (use with caution).", 85 | ) 86 | @click.option( 87 | "--conn-string", 88 | help="Generate a project from a PostgreSQL connection string " 89 | "(e.g., postgresql://user:password@host:port/dbname)", 90 | ) 91 | @click.pass_context 92 | def start( 93 | _: click.Context, 94 | use_example: bool = False, 95 | no_ui: bool = False, 96 | dry_run: bool = False, 97 | yes: bool = False, 98 | from_yaml: Path | None = None, 99 | conn_string: str | None = None, 100 | ) -> None: 101 | """Start FastAPI Forge.""" 102 | if not yes and not confirm_uv_installed(): 103 | sys.exit(1) 104 | 105 | option_count = sum([use_example, bool(from_yaml), bool(conn_string)]) 106 | if option_count > 1: 107 | raise click.UsageError( 108 | "Only one of '--use-example', '--from-yaml', or '--conn-string' can be used." 109 | ) 110 | 111 | if no_ui and option_count < 1: 112 | raise click.UsageError( 113 | "Option '--no-ui' requires one of '--use-example', '--from-yaml', or '--conn-string'." 114 | ) 115 | 116 | if dry_run and not no_ui: 117 | raise click.UsageError("Option '--dry-run' requires '--no-ui' to be set.") 118 | 119 | project_spec = None 120 | 121 | if from_yaml: 122 | project_spec = YamlProjectLoader(project_path=from_yaml).load() 123 | elif conn_string: 124 | project_spec = create_postgres_project_loader(conn_string).load() 125 | elif use_example: 126 | base_path = Path(__file__).parent / "example-projects" 127 | path = base_path / "game_zone.yaml" 128 | project_spec = YamlProjectLoader(project_path=path).load() 129 | 130 | init(project_spec=project_spec, no_ui=no_ui, dry_run=dry_run) 131 | 132 | 133 | if __name__ in {"__main__", "__mp_main__"}: 134 | main() 135 | -------------------------------------------------------------------------------- /fastapi_forge/render/renderers/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from fastapi_forge.schemas import CustomEnum, Model 4 | 5 | from ..engines import TemplateEngine 6 | from ..registry import RendererRegistry 7 | from ..templates import ( 8 | DAO_TEMPLATE, 9 | DTO_TEMPLATE, 10 | ENUMS_TEMPLATE, 11 | MODEL_TEMPLATE, 12 | ROUTERS_TEMPLATE, 13 | TEST_DELETE_TEMPLATE, 14 | TEST_GET_ID_TEMPLATE, 15 | TEST_GET_TEMPLATE, 16 | TEST_PATCH_TEMPLATE, 17 | TEST_POST_TEMPLATE, 18 | ) 19 | from .enums import RendererType 20 | from .protocols import Renderer 21 | 22 | 23 | @RendererRegistry.register(RendererType.MODEL) 24 | class ModelRenderer(Renderer): 25 | def __init__(self, engine: TemplateEngine) -> None: 26 | self.engine = engine 27 | 28 | def render(self, data: Model, **kwargs: Any) -> str: 29 | return self.engine.render( 30 | MODEL_TEMPLATE, 31 | {"model": data, **kwargs}, 32 | ) 33 | 34 | 35 | @RendererRegistry.register(RendererType.ROUTER) 36 | class RouterRenderer(Renderer): 37 | def __init__(self, engine: TemplateEngine) -> None: 38 | self.engine = engine 39 | 40 | def render(self, data: Model, **kwargs: Any) -> str: 41 | return self.engine.render( 42 | ROUTERS_TEMPLATE, 43 | {"model": data, **kwargs}, 44 | ) 45 | 46 | 47 | @RendererRegistry.register(RendererType.DAO) 48 | class DAORenderer(Renderer): 49 | def __init__(self, engine: TemplateEngine) -> None: 50 | self.engine = engine 51 | 52 | def render(self, data: Model, **kwargs: Any) -> str: 53 | return self.engine.render( 54 | DAO_TEMPLATE, 55 | {"model": data, **kwargs}, 56 | ) 57 | 58 | 59 | @RendererRegistry.register(RendererType.DTO) 60 | class DTORenderer(Renderer): 61 | def __init__(self, engine: TemplateEngine) -> None: 62 | self.engine = engine 63 | 64 | def render(self, data: Model, **kwargs: Any) -> str: 65 | return self.engine.render( 66 | DTO_TEMPLATE, 67 | {"model": data, **kwargs}, 68 | ) 69 | 70 | 71 | @RendererRegistry.register(RendererType.TEST_POST) 72 | class TestPostRenderer(Renderer): 73 | def __init__(self, engine: TemplateEngine) -> None: 74 | self.engine = engine 75 | 76 | def render(self, data: Model, **kwargs: Any) -> str: 77 | return self.engine.render( 78 | TEST_POST_TEMPLATE, 79 | {"model": data, **kwargs}, 80 | ) 81 | 82 | 83 | @RendererRegistry.register(RendererType.TEST_GET) 84 | class TestGetRenderer(Renderer): 85 | def __init__(self, engine: TemplateEngine) -> None: 86 | self.engine = engine 87 | 88 | def render(self, data: Model, **kwargs: Any) -> str: 89 | return self.engine.render( 90 | TEST_GET_TEMPLATE, 91 | {"model": data, **kwargs}, 92 | ) 93 | 94 | 95 | @RendererRegistry.register(RendererType.TEST_GET_ID) 96 | class TestGetIdRenderer(Renderer): 97 | def __init__(self, engine: TemplateEngine) -> None: 98 | self.engine = engine 99 | 100 | def render(self, data: Model, **kwargs: Any) -> str: 101 | return self.engine.render( 102 | TEST_GET_ID_TEMPLATE, 103 | {"model": data, **kwargs}, 104 | ) 105 | 106 | 107 | @RendererRegistry.register(RendererType.TEST_PATCH) 108 | class TestPatchRenderer(Renderer): 109 | def __init__(self, engine: TemplateEngine) -> None: 110 | self.engine = engine 111 | 112 | def render(self, data: Model, **kwargs: Any) -> str: 113 | return self.engine.render( 114 | TEST_PATCH_TEMPLATE, 115 | {"model": data, **kwargs}, 116 | ) 117 | 118 | 119 | @RendererRegistry.register(RendererType.TEST_DELETE) 120 | class TestDeleteRenderer(Renderer): 121 | def __init__(self, engine: TemplateEngine) -> None: 122 | self.engine = engine 123 | 124 | def render(self, data: Model, **kwargs: Any) -> str: 125 | return self.engine.render( 126 | TEST_DELETE_TEMPLATE, 127 | {"model": data, **kwargs}, 128 | ) 129 | 130 | 131 | @RendererRegistry.register(RendererType.ENUM) 132 | class EnumRenderer(Renderer): 133 | def __init__(self, engine: TemplateEngine) -> None: 134 | self.engine = engine 135 | 136 | def render(self, data: list[CustomEnum], **kwargs: Any) -> str: 137 | return self.engine.render( 138 | ENUMS_TEMPLATE, 139 | {"enums": data, **kwargs}, 140 | ) 141 | -------------------------------------------------------------------------------- /fastapi_forge/frontend/panels/left_panel.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from nicegui import ui 4 | from pydantic import ValidationError 5 | 6 | from fastapi_forge.frontend.components.item_create import EnumCreate, ModelCreate 7 | from fastapi_forge.frontend.components.item_row import EnumRow, ModelRow 8 | from fastapi_forge.frontend.constants import ( 9 | SELECTED_ENUM_TEXT_COLOR, 10 | SELECTED_MODEL_TEXT_COLOR, 11 | ) 12 | from fastapi_forge.frontend.notifications import notify_validation_error 13 | from fastapi_forge.frontend.state import state 14 | from fastapi_forge.project_io import create_yaml_project_exporter 15 | 16 | 17 | class NavigationTabs(ui.row): 18 | def __init__(self): 19 | super().__init__() 20 | self._build() 21 | 22 | def _build(self) -> None: 23 | with self.classes("w-full"): 24 | self.toggle = ( 25 | ui.toggle( 26 | {"models": "Models", "enums": "Enums"}, 27 | value="models", 28 | on_change=self._on_toggle_change, 29 | ) 30 | .props("rounded spread") 31 | .classes("w-full") 32 | ) 33 | 34 | def _on_toggle_change(self) -> None: 35 | if self.toggle.value == "models": 36 | state.switch_item_editor(show_models=True) 37 | else: 38 | state.switch_item_editor(show_enums=True) 39 | 40 | 41 | class ExportButton: 42 | def __init__(self): 43 | self._build() 44 | 45 | def _build(self) -> None: 46 | ui.button( 47 | "Export", 48 | on_click=self._export_project, 49 | icon="file_download", 50 | ).classes("w-full py-3 text-lg font-bold").tooltip( 51 | "Generates a YAML file containing the project configuration.", 52 | ) 53 | 54 | async def _export_project(self) -> None: 55 | """Export the project configuration to a YAML file.""" 56 | try: 57 | spec = state.get_project_spec() 58 | exporter = create_yaml_project_exporter() 59 | await exporter.export_project(spec) 60 | ui.notify( 61 | "Project configuration exported to " 62 | f"{Path.cwd() / spec.project_name}.yaml", 63 | type="positive", 64 | ) 65 | except ValidationError as exc: 66 | notify_validation_error(exc) 67 | except FileNotFoundError as exc: 68 | ui.notify(f"File not found: {exc}", type="negative") 69 | except Exception as exc: 70 | ui.notify(f"An unexpected error occurred: {exc}", type="negative") 71 | 72 | 73 | class LeftPanel(ui.left_drawer): 74 | def __init__(self): 75 | super().__init__(value=True, elevated=False, bottom_corner=True) 76 | 77 | state.render_content_fn = self._render_content 78 | 79 | self._build() 80 | 81 | def _build(self) -> None: 82 | self.clear() 83 | with self, ui.column().classes("items-align content-start w-full"): 84 | NavigationTabs() 85 | 86 | self._render_content() 87 | 88 | ExportButton() 89 | 90 | @ui.refreshable 91 | def _render_content(self) -> None: 92 | with ui.column(): 93 | EnumCreate() if state.show_enums else ModelCreate() 94 | 95 | self.content_list = ui.column().classes("items-align content-start w-full") 96 | 97 | if state.show_models: 98 | self._render_models_list() 99 | elif state.show_enums: 100 | self._render_enums_list() 101 | 102 | def _render_models_list(self) -> None: 103 | with self.content_list: 104 | for model in state.models: 105 | ModelRow( 106 | model, 107 | color=( 108 | SELECTED_MODEL_TEXT_COLOR 109 | if model == state.selected_model 110 | else None 111 | ), 112 | icon="security" if model.metadata.is_auth_model else None, 113 | ) 114 | 115 | def _render_enums_list(self) -> None: 116 | with self.content_list: 117 | for custom_enum in state.custom_enums: 118 | EnumRow( 119 | custom_enum, 120 | color=( 121 | SELECTED_ENUM_TEXT_COLOR 122 | if custom_enum == state.selected_enum 123 | else None 124 | ), 125 | ) 126 | -------------------------------------------------------------------------------- /fastapi_forge/template/{{cookiecutter.project_name}}/docker-compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | api: 3 | &api 4 | build: 5 | context: . 6 | image: {{ cookiecutter.project_name }}:latest 7 | container_name: {{ cookiecutter.project_name }}-api 8 | restart: always 9 | ports: 10 | - "8000:8000" 11 | env_file: 12 | - .env 13 | {%- if cookiecutter.use_postgres or cookiecutter.use_redis or cookiecutter.use_rabbitmq %} 14 | depends_on: 15 | {%- if cookiecutter.use_postgres %} 16 | postgres: 17 | condition: service_healthy 18 | {%- endif %} 19 | {%- if cookiecutter.use_redis %} 20 | redis: 21 | condition: service_healthy 22 | {%- endif %} 23 | {%- if cookiecutter.use_rabbitmq %} 24 | rabbitmq: 25 | condition: service_healthy 26 | {%- endif %} 27 | {%- endif %} 28 | {%- if cookiecutter.use_alembic %} 29 | volumes: 30 | - ./migrations:/app/migrations 31 | {%- endif %} 32 | develop: 33 | watch: 34 | - action: sync 35 | path: ./{{cookiecutter.project_name}} 36 | target: /app/{{cookiecutter.project_name}} 37 | - action: sync 38 | path: ./tests 39 | target: /app/tests 40 | - action: rebuild 41 | path: pyproject.toml 42 | - action: rebuild 43 | path: uv.lock 44 | 45 | {% if cookiecutter.use_taskiq -%} 46 | taskiq-worker: 47 | <<: *api 48 | container_name: {{ cookiecutter.project_name }}-taskiq-worker 49 | ports: [] 50 | command: [ taskiq, worker, -fsd, {{cookiecutter.project_name}}.services.taskiq.broker:broker, -w, "1", --max-fails, "1"] 51 | 52 | taskiq-scheduler: 53 | <<: *api 54 | container_name: {{ cookiecutter.project_name }}-taskiq-scheduler 55 | ports: [] 56 | command: [ taskiq, scheduler, -fsd, {{cookiecutter.project_name}}.services.taskiq.scheduler:scheduler ] 57 | {% endif %} 58 | {%- if cookiecutter.use_postgres %} 59 | postgres: 60 | image: postgres:17.4-bookworm 61 | hostname: {{ cookiecutter.project_name }}-pg 62 | container_name: {{ cookiecutter.project_name }}-pg 63 | environment: 64 | POSTGRES_PASSWORD: {{ cookiecutter.project_name }} 65 | POSTGRES_USER: {{ cookiecutter.project_name }} 66 | POSTGRES_DB: {{ cookiecutter.project_name }} 67 | volumes: 68 | - {{ cookiecutter.project_name }}-pg-data:/var/lib/postgresql/data 69 | restart: always 70 | healthcheck: 71 | test: pg_isready -U {{ cookiecutter.project_name }} 72 | interval: 2s 73 | timeout: 3s 74 | retries: 40 75 | {% endif %} 76 | {%- if cookiecutter.use_redis %} 77 | redis: 78 | image: bitnami/redis:7.4 79 | container_name: {{ cookiecutter.project_name }}-redis 80 | ports: 81 | - "6379:6379" 82 | restart: always 83 | environment: 84 | ALLOW_EMPTY_PASSWORD: "yes" 85 | healthcheck: 86 | test: redis-cli ping 87 | interval: 2s 88 | timeout: 3s 89 | retries: 40 90 | volumes: 91 | - {{ cookiecutter.project_name }}-redis-data:/bitnami/redis/data 92 | {% endif %} 93 | {% if cookiecutter.use_rabbitmq -%} 94 | rabbitmq: 95 | image: rabbitmq:3.8.27-management-alpine 96 | container_name: {{ cookiecutter.project_name }}-rabbitmq 97 | ports: 98 | - "15672:15672" 99 | - "5672:5672" 100 | environment: 101 | RABBITMQ_DEFAULT_USER: user 102 | RABBITMQ_DEFAULT_PASS: password 103 | RABBITMQ_DEFAULT_VHOST: / 104 | healthcheck: 105 | test: rabbitmq-diagnostics -q check_running 106 | interval: 2s 107 | timeout: 3s 108 | retries: 40 109 | volumes: 110 | - {{ cookiecutter.project_name }}-rabbitmq-data:/var/lib/rabbitmq 111 | {% endif %} 112 | {% if cookiecutter.use_prometheus -%} 113 | prometheus: 114 | image: prom/prometheus:v3.3.0 115 | restart: always 116 | volumes: 117 | - ./observability/prometheus/prometheus.yaml:/etc/prometheus/prometheus.yaml 118 | command: 119 | - "--config.file=/etc/prometheus/prometheus.yaml" 120 | ports: 121 | - 9090:9090 122 | {% endif %} 123 | {%- if cookiecutter.use_postgres or cookiecutter.use_redis or cookiecutter.use_rabbitmq %} 124 | volumes: 125 | {%- if cookiecutter.use_postgres %} 126 | {{ cookiecutter.project_name }}-pg-data: 127 | {%- endif %} 128 | {%- if cookiecutter.use_redis %} 129 | {{ cookiecutter.project_name }}-redis-data: 130 | {% endif %} 131 | {%- if cookiecutter.use_rabbitmq -%} 132 | {{ cookiecutter.project_name }}-rabbitmq-data: 133 | {% endif %} 134 | {%- endif %} -------------------------------------------------------------------------------- /fastapi_forge/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/settings.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | from pydantic_settings import SettingsConfigDict 3 | from pydantic_settings import BaseSettings as PydanticBaseSettings 4 | from pydantic import SecretStr 5 | from yarl import URL 6 | 7 | PREFIX = "{{ cookiecutter.project_name|upper|replace('-', '_') }}_" 8 | 9 | DOTENV = pathlib.Path(__file__).parent.parent / ".env" 10 | 11 | 12 | class BaseSettings(PydanticBaseSettings): 13 | """Base settings.""" 14 | 15 | model_config = SettingsConfigDict( 16 | env_file=".env", env_file_encoding="utf-8", extra="ignore" 17 | ) 18 | 19 | {% if cookiecutter.use_postgres %} 20 | class DBSettings(BaseSettings): 21 | """Configuration for PostgreSQL connection.""" 22 | 23 | host: str = "localhost" 24 | port: int = 5432 25 | user: str = "postgres" 26 | password: SecretStr = SecretStr("postgres") 27 | database: str = "postgres" 28 | pool_size: int = 15 29 | echo: bool = False 30 | 31 | model_config = SettingsConfigDict( 32 | env_file=".env", 33 | env_prefix=f"{PREFIX}PG_", 34 | ) 35 | 36 | @property 37 | def url(self) -> URL: 38 | """Generates a URL for the PostgreSQL connection.""" 39 | 40 | return URL.build( 41 | scheme="postgresql+asyncpg", 42 | host=self.host, 43 | port=self.port, 44 | user=self.user, 45 | password=self.password.get_secret_value(), 46 | path=f"/{self.database}", 47 | ) 48 | {% endif %} 49 | {% if cookiecutter.use_redis %} 50 | class RedisSettings(BaseSettings): 51 | """Configuration for Redis.""" 52 | 53 | host: str = "redis" 54 | port: int = 6379 55 | password: SecretStr = SecretStr("") 56 | max_connections: int = 50 57 | 58 | @property 59 | def url(self) -> URL: 60 | """Generates a URL for the Redis connection.""" 61 | 62 | return URL.build( 63 | scheme="redis" , 64 | host=self.host, 65 | port=self.port, 66 | password=self.password.get_secret_value(), 67 | ) 68 | 69 | model_config = SettingsConfigDict(env_file=".env", env_prefix=f"{PREFIX}REDIS_") 70 | 71 | {% endif %} 72 | {% if cookiecutter.use_rabbitmq %} 73 | class RabbitMQSettings(BaseSettings): 74 | """Configuration for RabbitMQ.""" 75 | 76 | host: str = "rabbitmq" 77 | port: int = 5672 78 | user: str = "user" 79 | password: SecretStr = SecretStr("password") 80 | vhost: str = "/" 81 | connection_pool_size: int = 2 82 | channel_pool_size: int = 10 83 | 84 | model_config = SettingsConfigDict(env_file=".env", env_prefix=f"{PREFIX}RABBITMQ_") 85 | 86 | 87 | @property 88 | def url(self) -> URL: 89 | """Generates a URL for RabbitMQ connection.""" 90 | return URL.build( 91 | scheme="amqp", 92 | host=self.host, 93 | port=self.port, 94 | user=self.user, 95 | password=self.password.get_secret_value(), 96 | path=self.vhost, 97 | ) 98 | {% endif %} 99 | {% if cookiecutter.use_builtin_auth %} 100 | class JWTSettings(BaseSettings): 101 | """Configuration for JWT.""" 102 | 103 | secret: SecretStr = SecretStr("") 104 | algorithm: str = "HS256" 105 | 106 | model_config = SettingsConfigDict( 107 | env_file=".env", env_prefix=f"{PREFIX}JWT_" 108 | ) 109 | {% endif %} 110 | 111 | {% if cookiecutter.use_prometheus %} 112 | class PrometheusSettings(BaseSettings): 113 | enabled: bool = True 114 | 115 | model_config = SettingsConfigDict( 116 | env_file=".env", env_prefix=f"{PREFIX}PROMETHEUS_" 117 | ) 118 | {% endif %} 119 | 120 | {% if cookiecutter.use_logfire %} 121 | class LogfireSettings(BaseSettings): 122 | enabled: bool = True 123 | write_token: SecretStr = SecretStr("") 124 | 125 | model_config = SettingsConfigDict( 126 | env_file=".env", env_prefix=f"{PREFIX}LOGFIRE_" 127 | ) 128 | {% endif %} 129 | 130 | class Settings(BaseSettings): 131 | """Main settings.""" 132 | 133 | env: str = "local" 134 | host: str = "localhost" 135 | port: int = 8000 136 | workers: int = 1 137 | log_level: str = "info" 138 | reload: bool = False 139 | 140 | {% if cookiecutter.use_postgres -%} 141 | db: DBSettings = DBSettings() 142 | {% endif %} 143 | {%- if cookiecutter.use_redis -%} 144 | redis: RedisSettings = RedisSettings() 145 | {% endif %} 146 | {%- if cookiecutter.use_builtin_auth -%} 147 | jwt: JWTSettings = JWTSettings() 148 | {% endif %} 149 | {%- if cookiecutter.use_rabbitmq -%} 150 | rabbitmq: RabbitMQSettings = RabbitMQSettings() 151 | {% endif %} 152 | {%- if cookiecutter.use_prometheus -%} 153 | prometheus: PrometheusSettings = PrometheusSettings() 154 | {% endif %} 155 | {%- if cookiecutter.use_logfire -%} 156 | logfire: LogfireSettings = LogfireSettings() 157 | {% endif %} 158 | model_config = SettingsConfigDict( 159 | env_file=DOTENV, 160 | env_prefix=PREFIX, 161 | ) 162 | 163 | 164 | settings = Settings() 165 | -------------------------------------------------------------------------------- /fastapi_forge/project_io/artifact_builder/fastapi_builder.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from pathlib import Path 3 | 4 | from fastapi_forge.enums import HTTPMethodEnum 5 | from fastapi_forge.logger import logger 6 | from fastapi_forge.render import create_jinja_render_manager 7 | from fastapi_forge.render.manager import RenderManager 8 | from fastapi_forge.render.renderers.enums import RendererType 9 | from fastapi_forge.schemas import ( 10 | Model, 11 | ProjectSpec, 12 | ) 13 | from fastapi_forge.utils.string_utils import camel_to_snake 14 | 15 | from ..io import IOWriter 16 | from .protocols import ArtifactBuilder 17 | from .utils import insert_relation_fields 18 | 19 | TEST_RENDERERS: dict[HTTPMethodEnum, RendererType] = { 20 | HTTPMethodEnum.GET: RendererType.TEST_GET, 21 | HTTPMethodEnum.GET_ID: RendererType.TEST_GET_ID, 22 | HTTPMethodEnum.POST: RendererType.TEST_POST, 23 | HTTPMethodEnum.PATCH: RendererType.TEST_PATCH, 24 | HTTPMethodEnum.DELETE: RendererType.TEST_DELETE, 25 | } 26 | 27 | 28 | class FastAPIArtifactBuilder(ArtifactBuilder): 29 | def __init__( 30 | self, 31 | project_spec: ProjectSpec, 32 | io_writer: IOWriter, 33 | base_path: Path | None = None, 34 | render_manager: RenderManager | None = None, 35 | ) -> None: 36 | self.project_spec = project_spec 37 | self.io_writer = io_writer 38 | self.project_name = project_spec.project_name 39 | self.base_path = base_path or Path.cwd() 40 | self.project_dir = self.base_path / self.project_name 41 | self.package_dir = self.project_dir / self.project_name 42 | self.render_manager = render_manager or create_jinja_render_manager( 43 | project_name=self.project_name 44 | ) 45 | insert_relation_fields(self.project_spec) 46 | 47 | async def build_artifacts(self) -> None: 48 | """Builds the project artifacts based on the project specification.""" 49 | logger.info(f"Building project artifacts for '{self.project_name}'...") 50 | await self._init_project_directories() 51 | 52 | tasks = [] 53 | 54 | if self.project_spec.custom_enums: 55 | tasks.append(self._write_enums()) 56 | 57 | for model in self.project_spec.models: 58 | tasks.append(self._write_artifact("models", model, RendererType.MODEL)) 59 | 60 | metadata = model.metadata 61 | if metadata.create_dtos: 62 | tasks.append(self._write_artifact("dtos", model, RendererType.DTO)) 63 | if metadata.create_daos: 64 | tasks.append(self._write_artifact("daos", model, RendererType.DAO)) 65 | if metadata.create_endpoints: 66 | tasks.append(self._write_artifact("routes", model, RendererType.ROUTER)) 67 | if metadata.create_tests: 68 | tasks.append(self._write_tests(model)) 69 | 70 | await asyncio.gather(*tasks) 71 | logger.info(f"Project artifacts for '{self.project_name}' built successfully.") 72 | 73 | async def _init_project_directories(self) -> None: 74 | await self.io_writer.write_directory(self.project_dir) 75 | await self.io_writer.write_directory(self.package_dir) 76 | 77 | async def _create_module_path(self, module: str) -> Path: 78 | path = self.package_dir / module 79 | await self.io_writer.write_directory(path) 80 | return path 81 | 82 | async def _write_artifact( 83 | self, module: str, model: Model, renderer_type: RendererType 84 | ) -> None: 85 | path = await self._create_module_path(module) 86 | file_name = f"{camel_to_snake(model.name)}_{module}.py" 87 | renderer = self.render_manager.get_renderer(renderer_type) 88 | content = renderer.render(model) 89 | await self.io_writer.write_file(path / file_name, content) 90 | 91 | async def _write_tests(self, model: Model) -> None: 92 | test_dir = ( 93 | self.project_dir / "tests" / "endpoint_tests" / camel_to_snake(model.name) 94 | ) 95 | await self.io_writer.write_directory(test_dir) 96 | await self.io_writer.write_file( 97 | test_dir / "__init__.py", "# Automatically generated by FastAPI Forge\n" 98 | ) 99 | 100 | tasks = [] 101 | for method, renderer_type in TEST_RENDERERS.items(): 102 | method_suffix = "id" if method == HTTPMethodEnum.GET_ID else "" 103 | file_name = ( 104 | f"test_{method.value.replace('_id', '')}" 105 | f"_{camel_to_snake(model.name)}" 106 | f"{f'_{method_suffix}' if method_suffix else ''}" 107 | ".py" 108 | ) 109 | renderer = self.render_manager.get_renderer(renderer_type) 110 | tasks.append( 111 | self.io_writer.write_file(test_dir / file_name, renderer.render(model)) 112 | ) 113 | 114 | await asyncio.gather(*tasks) 115 | 116 | async def _write_enums(self) -> None: 117 | path = self.package_dir / "enums.py" 118 | renderer = self.render_manager.get_renderer(RendererType.ENUM) 119 | content = renderer.render(self.project_spec.custom_enums) 120 | await self.io_writer.write_file(path, content) 121 | -------------------------------------------------------------------------------- /fastapi_forge/frontend/components/item_row.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Callable 2 | 3 | from nicegui import ui 4 | from pydantic import BaseModel 5 | 6 | from fastapi_forge.frontend.constants import ITEM_ROW_TRUNCATE_LEN 7 | from fastapi_forge.frontend.state import state 8 | from fastapi_forge.schemas import CustomEnum, Model 9 | 10 | 11 | class _ItemRow[T: BaseModel](ui.row): 12 | def __init__( 13 | self, 14 | item: T, 15 | color: str | None = None, 16 | icon: str | None = None, 17 | *, 18 | is_selected: bool, 19 | on_select: Callable[[T], None], 20 | on_delete: Callable[[T], None], 21 | on_update_name: Callable[[T, str], None], 22 | get_name: Callable[[T], str] = lambda x: x.name, # type: ignore 23 | ): 24 | super().__init__(wrap=False) 25 | self.item = item 26 | self.is_selected_row = is_selected 27 | self.color = color 28 | self.icon = icon 29 | self.is_editing = False 30 | 31 | self.on_select = on_select 32 | self.on_delete = on_delete 33 | self.on_update_name = on_update_name 34 | self.get_name = get_name 35 | 36 | self._build() 37 | 38 | def _build(self) -> None: 39 | self.on("click", lambda: self.on_select(self.item)) 40 | base_classes = "w-full flex items-center justify-between cursor-pointer p-2 rounded transition-all" 41 | if self.is_selected_row: 42 | base_classes += " bg-blue-100 dark:bg-blue-900 border-l-4 border-blue-500" 43 | else: 44 | base_classes += " hover:bg-gray-100 dark:hover:bg-gray-800" 45 | 46 | with self.classes(base_classes): 47 | with ui.row().classes("flex-nowrap gap-2 min-w-fit"): 48 | if self.icon: 49 | ui.icon(self.icon, color="green", size="20px").classes( 50 | "self-center" 51 | ) 52 | full_name = self.get_name(self.item) 53 | 54 | if len(full_name) > ITEM_ROW_TRUNCATE_LEN: 55 | truncated_name = ( 56 | (full_name[:ITEM_ROW_TRUNCATE_LEN] + "...") 57 | if len(full_name) > ITEM_ROW_TRUNCATE_LEN 58 | else full_name 59 | ) 60 | self.name_label = ( 61 | ui.label(text=truncated_name) 62 | .classes("self-center truncate") 63 | .tooltip(full_name) 64 | ) 65 | else: 66 | self.name_label = ui.label(text=full_name).classes( 67 | "self-center truncate" 68 | ) 69 | 70 | if self.color: 71 | self.name_label.classes(add=self.color) 72 | 73 | self.name_input = ( 74 | ui.input(value=self.get_name(self.item)) 75 | .classes("self-center") 76 | .bind_visibility_from(self, "is_editing") 77 | ) 78 | self.name_label.bind_visibility_from(self, "is_editing", lambda x: not x) 79 | 80 | with ui.row().classes("flex-nowrap gap-2 min-w-fit"): 81 | self.edit_button = ( 82 | ui.button(icon="edit") 83 | .on("click.stop", self._toggle_edit) 84 | .bind_visibility_from(self, "is_editing", lambda x: not x) 85 | .classes("min-w-fit") 86 | ) 87 | 88 | self.save_button = ( 89 | ui.button(icon="save") 90 | .on("click.stop", self._save_item) 91 | .bind_visibility_from(self, "is_editing") 92 | .classes("min-w-fit") 93 | ) 94 | 95 | ui.button(icon="delete").on( 96 | "click.stop", lambda: self.on_delete(self.item) 97 | ).classes("min-w-fit") 98 | 99 | def _toggle_edit(self) -> None: 100 | self.is_editing = not self.is_editing 101 | 102 | def _save_item(self) -> None: 103 | new_name = self.name_input.value.strip() 104 | if new_name: 105 | self.on_update_name(self.item, new_name) 106 | self.is_editing = False 107 | 108 | 109 | class ModelRow(_ItemRow): 110 | def __init__( 111 | self, 112 | model: Model, 113 | color: str | None = None, 114 | icon: str | None = None, 115 | ): 116 | super().__init__( 117 | item=model, 118 | color=color, 119 | icon=icon, 120 | is_selected=model == state.selected_model, 121 | on_select=state.select_model, 122 | on_delete=state.delete_model, 123 | on_update_name=state.update_model_name, 124 | ) 125 | 126 | 127 | class EnumRow(_ItemRow): 128 | def __init__( 129 | self, 130 | custom_enum: CustomEnum, 131 | color: str | None = None, 132 | icon: str | None = None, 133 | ): 134 | super().__init__( 135 | item=custom_enum, 136 | color=color, 137 | icon=icon, 138 | is_selected=custom_enum == state.selected_enum, 139 | on_select=state.select_enum, 140 | on_delete=state.delete_enum, 141 | on_update_name=state.update_enum_name, 142 | ) 143 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🚀 FastAPI-Forge 2 | ⚡ UI Based FastAPI Project Generator 3 | 4 | ✨ *Define your database models through a UI, select services, and get a complete production-ready containerized project with tests and endpoints!* 5 | 6 | 7 | ## 🔥 Features 8 | 9 | 10 | ### 🖌️ UI Power 11 | - 🖥️ [NiceGUI](https://github.com/zauberzeug/nicegui)-based interface for project design 12 | - 📊 Visual model creation and configuration 13 | - ✅ Checkbox additional services to be integrated 14 | - 🚀 Quick-add common fields 15 | - ⚙️ One-click project generation 16 | 17 | ### ⚡ Auto-Generated Components 18 | - 🗄️ [SQLAlchemy](https://github.com/sqlalchemy/sqlalchemy) Models 19 | - 📦 [Pydantic](https://github.com/pydantic/pydantic) Schemas 20 | - 🌐 RESTful Endpoints (CRUD + more) 21 | - 🧪 Comprehensive Test Suite (pytest) 22 | - 🏗️ DAOs (Database Access Objects) 23 | - 🏭 [Factory Boy](https://github.com/FactoryBoy/factory_boy) Test Factories 24 | - 🐳 [Docker Compose](https://github.com/docker/compose) Setup 25 | 26 | ### 🎛️ Advanced Features 27 | - 🎚️ Custom Enum support as data types 28 | - 📥 YAML project import/export 29 | - 🐘 Convert existing databases into FastAPI projects via connection string! 30 | - Basically lets you quickly create an API for any database. 31 | 32 | ### 🔄 CI/CD Automation 33 | - ⚙️ GitHub Workflows for automated testing and linting 34 | - 🧪 Runs pytest suite 35 | - ✨ Executes code formatting checks 36 | - ✅ Ensures code quality before merging 37 | 38 | ## 🧩 Optional Integrations 39 | 40 | | Category | Technologies | 41 | |----------------|---------------------------------------| 42 | | Messaging | RabbitMQ | 43 | | Caching | Redis | 44 | | Task Queues | Celery, [TaskIQ](https://github.com/taskiq-python/taskiq) | 45 | | Auth | JWT Authentication | 46 | | Monitoring | Prometheus | 47 | | Storage | S3 | 48 | | Migrations | Alembic | 49 | 50 | *Much more to come!* 51 | 52 | ## UI for designing your API projects 53 | ![UI Interface](https://github.com/user-attachments/assets/662c7ff2-7a42-4208-ae63-dd9760145474) 54 | ## Generated project example 55 | ![Generated Project Structure](https://github.com/user-attachments/assets/cc546f56-abd5-4eb1-b469-5940f0558255) 56 | 57 | 58 | 59 | ## ✅ Requirements 60 | - Python 3.12+ 61 | - UV 62 | - Docker and Docker Compose (for running the generated project) 63 | 64 | 65 | ## 🚀 Quick Start 66 | Install FastAPI-Forge: 67 | 68 | ```bash 69 | pip install fastapi-forge 70 | ``` 71 | 72 | ## 🛠 Usage 73 | Start the project generation process: 74 | 75 | ```bash 76 | fastapi-forge start 77 | ``` 78 | 79 | - A web browser will open automatically. 80 | - Define your database schema and service specifications. 81 | - Once done, click `Generate` to build your API. 82 | 83 | To start the generated project and its dependencies in Docker: 84 | 85 | ```bash 86 | make up # Builds and runs your project along with additional services 87 | ``` 88 | 89 | - The project will run using Docker Compose, simplifying your development environment. 90 | - Access the SwaggerUI/OpenAPI docs at: `http://localhost:8000/docs`. 91 | 92 | 93 | ## ⚙️ Command Options 94 | Customize your project generation with these options: 95 | 96 | ### `--use-example` 97 | Quickly spin up a project using one of FastAPI-Forge’s prebuilt example templates: 98 | 99 | ```bash 100 | fastapi-forge start --use-example 101 | ``` 102 | 103 | ### `--no-ui` 104 | Skip the web UI and generate your project directly from the terminal: 105 | 106 | ```bash 107 | fastapi-forge start --no-ui 108 | ``` 109 | 110 | ### `--from-yaml` 111 | Load a custom YAML configuration (can be generated through the UI): 112 | 113 | ```bash 114 | fastapi-forge start --from-yaml=~/path/to/config.yaml 115 | ``` 116 | 117 | ### `--conn-string` 118 | Load an existing Postgres database schema: 119 | 120 | ```bash 121 | fastapi-forge start --conn-string=postgres://user:pass@localhost/db_name 122 | ``` 123 | 124 | ### Combine Options 125 | Load a YAML config and skip the UI: 126 | ```bash 127 | fastapi-forge start --from-yaml=~/Documents/project-config.yaml --no-ui 128 | ``` 129 | 130 | 131 | ## 🧰 Using the Makefile 132 | The generated project includes a `Makefile` to simplify common dev tasks: 133 | 134 | ### Start the Application 135 | ```bash 136 | make up 137 | ``` 138 | 139 | ### Run Tests 140 | Tests are automatically generated based on your schema. Once the app is running (`make up`): 141 | 142 | ```bash 143 | make test 144 | ``` 145 | 146 | ### Run Specific Tests 147 | ```bash 148 | make test-filter filter="test_name" 149 | ``` 150 | 151 | ### Format and Lint Code 152 | Keep your code clean and consistent: 153 | 154 | ```bash 155 | make lint 156 | ``` 157 | 158 | --- 159 | 160 | ## 📦 Database Migrations with Alembic 161 | If you chose Alembic for migrations during project setup, these commands will help manage your database schema: 162 | 163 | ### Generate a New Migration 164 | ```bash 165 | make mig-gen name="add_users_table" 166 | ``` 167 | 168 | ### Apply All Migrations 169 | ```bash 170 | make mig-head 171 | ``` 172 | 173 | ### Apply the Next Migration 174 | ```bash 175 | make mig-up 176 | ``` 177 | 178 | ### Roll Back the Last Migration 179 | ```bash 180 | make mig-down 181 | ``` 182 | -------------------------------------------------------------------------------- /fastapi_forge/template/{{cookiecutter.project_name}}/tests/conftest.py: -------------------------------------------------------------------------------- 1 | from collections.abc import AsyncGenerator 2 | from typing import Any 3 | 4 | import pytest 5 | {% if cookiecutter.use_redis %} 6 | from fakeredis.aioredis import FakeRedis 7 | from {{cookiecutter.project_name}}.services.redis.redis_dependencies import get_redis 8 | {% endif %} 9 | from fastapi import FastAPI 10 | from httpx import ASGITransport, AsyncClient 11 | from sqlalchemy.ext.asyncio import ( 12 | AsyncEngine, 13 | AsyncSession, 14 | async_sessionmaker, 15 | create_async_engine, 16 | ) 17 | 18 | from {{cookiecutter.project_name}}.daos import AllDAOs 19 | from {{cookiecutter.project_name}}.db import meta 20 | from {{cookiecutter.project_name}}.db.db_dependencies import get_db_session 21 | from {{cookiecutter.project_name}}.main import get_app 22 | {% if cookiecutter.use_rabbitmq %} 23 | from {{cookiecutter.project_name}}.services.rabbitmq import ( 24 | RabbitMQServiceMock, 25 | get_rabbitmq, 26 | ) 27 | {% endif %} 28 | 29 | {% if cookiecutter.use_taskiq %} 30 | from {{cookiecutter.project_name}}.services.taskiq.broker import broker 31 | {% endif %} 32 | from {{cookiecutter.project_name}}.settings import settings 33 | from tests.factories import BaseFactory 34 | from tests.test_utils import create_test_db, drop_test_db 35 | 36 | 37 | @pytest.fixture(scope="session") 38 | def anyio_backend() -> str: 39 | """Set the backend for the anyio plugin.""" 40 | return "asyncio" 41 | 42 | 43 | @pytest.fixture(scope="session") 44 | async def engine() -> AsyncGenerator[AsyncEngine, None]: 45 | """ 46 | Create and manage the lifecycle of the test database engine. 47 | 48 | This fixture sets up a test database by creating all required tables 49 | and then tears it down after the tests have finished executing. 50 | It yields an instance of `AsyncEngine` for database operations. 51 | """ 52 | await create_test_db() 53 | engine = create_async_engine(str(settings.db.url)) 54 | 55 | async with engine.begin() as conn: 56 | await conn.run_sync(meta.create_all) 57 | 58 | try: 59 | yield engine 60 | finally: 61 | await engine.dispose() 62 | await drop_test_db() 63 | 64 | 65 | @pytest.fixture 66 | async def db_session(engine: AsyncEngine) -> AsyncGenerator[AsyncSession, None]: 67 | """ 68 | Provide a database session for tests, with automatic cleanup. 69 | 70 | A database session is created for each test using the provided database engine. 71 | Changes made within the session are rolled back after the test completes to 72 | maintain database integrity across tests. 73 | """ 74 | connection = await engine.connect() 75 | tx = await connection.begin() 76 | session_factory = async_sessionmaker(connection, expire_on_commit=False) 77 | session = session_factory() 78 | 79 | try: 80 | yield session 81 | finally: 82 | await session.close() 83 | await tx.rollback() 84 | await connection.close() 85 | 86 | 87 | @pytest.fixture(autouse=True) 88 | def inject_session(db_session: AsyncSession) -> None: 89 | """For each test, inject a database session into the BaseFactory.""" 90 | BaseFactory.session = db_session 91 | 92 | 93 | {% if cookiecutter.use_redis %} 94 | @pytest.fixture 95 | async def mock_redis() -> AsyncGenerator[FakeRedis, None]: 96 | """Provide a fake Redis instance.""" 97 | client = FakeRedis() 98 | yield client 99 | await client.aclose() 100 | {% endif %} 101 | 102 | 103 | {% if cookiecutter.use_rabbitmq %} 104 | @pytest.fixture 105 | def mock_rabbitmq() -> RabbitMQServiceMock: 106 | """Provide a mock RabbitMQ instance.""" 107 | return RabbitMQServiceMock() 108 | {% endif %} 109 | 110 | 111 | @pytest.fixture 112 | def overwritten_deps( 113 | db_session: AsyncSession, 114 | {% if cookiecutter.use_redis %} 115 | mock_redis: FakeRedis, 116 | {% endif %} 117 | {% if cookiecutter.use_rabbitmq %} 118 | mock_rabbitmq: RabbitMQServiceMock, 119 | {% endif %} 120 | ) -> dict[Any, Any]: 121 | """Override dependencies for the test app.""" 122 | return { 123 | get_db_session: lambda: db_session, 124 | {% if cookiecutter.use_redis %} 125 | get_redis: lambda: mock_redis, 126 | {% endif %} 127 | {% if cookiecutter.use_rabbitmq %} 128 | get_rabbitmq: lambda: mock_rabbitmq, 129 | {% endif %} 130 | } 131 | 132 | 133 | @pytest.fixture(scope="session") 134 | def session_app() -> FastAPI: 135 | """Provide the FastAPI app instance (session-wide).""" 136 | return get_app() 137 | 138 | 139 | @pytest.fixture 140 | def app(session_app: FastAPI, overwritten_deps: dict[Any, Any]) -> FastAPI: 141 | """Provide the FastAPI app instance (per test).""" 142 | session_app.dependency_overrides.update(overwritten_deps) 143 | return session_app 144 | 145 | 146 | {% if cookiecutter.use_taskiq %} 147 | @pytest.fixture(autouse=True) 148 | async def init_taskiq_dependencies( 149 | overwritten_deps: dict[Any, Any], 150 | ) -> AsyncGenerator[None, None]: 151 | """Initialize Taskiq dependencies.""" 152 | broker.add_dependency_context(overwritten_deps) 153 | yield 154 | broker.custom_dependency_context = {} 155 | {% endif %} 156 | 157 | 158 | @pytest.fixture 159 | async def client(app: FastAPI) -> AsyncGenerator[AsyncClient, None]: 160 | """Provide a test client for the FastAPI app.""" 161 | async with AsyncClient( 162 | transport=ASGITransport(app=app), base_url="http://test" 163 | ) as ac: 164 | yield ac 165 | 166 | 167 | @pytest.fixture 168 | async def daos(db_session: AsyncSession) -> AllDAOs: 169 | """Provide access to all DAOs.""" 170 | return AllDAOs(db_session) 171 | -------------------------------------------------------------------------------- /fastapi_forge/project_io/loader/database_loader.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from pydantic import ValidationError 4 | 5 | from fastapi_forge.enums import FieldDataTypeEnum, OnDeleteEnum 6 | from fastapi_forge.logger import logger 7 | from fastapi_forge.schemas import ( 8 | CustomEnum, 9 | CustomEnumValue, 10 | Model, 11 | ModelField, 12 | ModelRelationship, 13 | ProjectSpec, 14 | ) 15 | from fastapi_forge.utils.string_utils import number_to_word, snake_to_camel 16 | 17 | from ..database import DatabaseInspector, SchemaInspectionResult, SchemaInspector 18 | from .protocols import ProjectLoader 19 | 20 | 21 | class DatabaseProjectLoader(ProjectLoader): 22 | def __init__( 23 | self, 24 | inspector: DatabaseInspector, 25 | schema: str = "public", 26 | ): 27 | self.inspector = inspector 28 | self.schema = schema 29 | 30 | def load(self) -> ProjectSpec: 31 | schema_inspector = SchemaInspector(self.inspector) 32 | inspection_result = schema_inspector.inspect_schema(self.schema) 33 | return self._convert_to_project_spec(inspection_result) 34 | 35 | def _convert_to_project_spec( 36 | self, inspection: SchemaInspectionResult 37 | ) -> ProjectSpec: 38 | enum_column_lookup = { 39 | f"{col_info['schema']}.{col_info['table']}.{col_info['column']}": enum_type 40 | for enum_type, columns in inspection.enum_usage.items() 41 | for col_info in columns 42 | } 43 | 44 | models = [] 45 | for table_name_full, columns_data in inspection.schema_data.items(): 46 | _, table_name = table_name_full.split(".") 47 | model = self._create_model_from_table( 48 | table_name, table_name_full, columns_data, enum_column_lookup 49 | ) 50 | models.append(model) 51 | 52 | custom_enums = self._create_custom_enums(inspection.enums) 53 | 54 | return ProjectSpec( 55 | project_name=inspection.database_name, 56 | models=models, 57 | custom_enums=custom_enums, 58 | use_postgres=True, 59 | ) 60 | 61 | def _create_model_from_table( 62 | self, 63 | table_name: str, 64 | table_name_full: str, 65 | columns_data: list[dict[str, Any]], 66 | enum_column_lookup: dict[str, str], 67 | ) -> Model: 68 | fields = [] 69 | relationships = [] 70 | 71 | for column in columns_data: 72 | if column.get("foreign_key"): 73 | relationships.append( 74 | ModelRelationship( 75 | **column["foreign_key"], on_delete=OnDeleteEnum.CASCADE 76 | ) 77 | ) 78 | continue 79 | 80 | column_key = f"{table_name_full}.{column['name']}" 81 | enum_type = enum_column_lookup.get(column_key) 82 | data_type = ( 83 | FieldDataTypeEnum.ENUM 84 | if enum_type 85 | else FieldDataTypeEnum.from_db_type(column["type"]) 86 | ) 87 | 88 | if enum_type: 89 | column["type_enum"] = snake_to_camel(enum_type) 90 | 91 | column["type"] = data_type 92 | column["default_value"], column["extra_kwargs"] = ( 93 | self._process_column_defaults(column, data_type) 94 | ) 95 | 96 | fields.append(ModelField(**column)) 97 | 98 | return Model(name=table_name, fields=fields, relationships=relationships) 99 | 100 | @staticmethod 101 | def _process_column_defaults( 102 | column: dict[str, Any], data_type: Any 103 | ) -> tuple[str | None, dict[str, Any] | None]: 104 | default = None 105 | extra_kwargs = None 106 | 107 | if data_type == FieldDataTypeEnum.DATETIME: 108 | column_name = column["name"] 109 | if column.get("default") == "CURRENT_TIMESTAMP": 110 | default = "datetime.now(timezone.utc)" 111 | if "update" in column_name: 112 | extra_kwargs = {"onupdate": "datetime.now(timezone.utc)"} 113 | 114 | return default, extra_kwargs 115 | 116 | def _create_custom_enums(self, db_enums: dict[str, Any]) -> list[CustomEnum]: 117 | custom_enums = [] 118 | for enum_name, enum_values in db_enums.items(): 119 | enum_name_processed = snake_to_camel(enum_name) 120 | custom_enum_values = self._create_enum_values(enum_values) 121 | 122 | custom_enum = CustomEnum( 123 | name=enum_name_processed, values=custom_enum_values 124 | ) 125 | custom_enums.append(custom_enum) 126 | return custom_enums 127 | 128 | def _create_enum_values(self, enum_values: list[str]) -> list[CustomEnumValue]: 129 | custom_enum_values = [] 130 | for value_name in enum_values: 131 | try: 132 | name = value_name 133 | if self._is_int_convertible(value_name): 134 | name = number_to_word(value_name) 135 | 136 | custom_enum_values.append(CustomEnumValue(name=name, value="auto()")) 137 | except ValidationError: 138 | err_msg = f"Validation error for enum values: {enum_values}" 139 | logger.error(err_msg) 140 | # Fallback to placeholder value 141 | custom_enum_values = [ 142 | CustomEnumValue(name="placeholder", value="placeholder") 143 | ] 144 | break 145 | return custom_enum_values 146 | 147 | @staticmethod 148 | def _is_int_convertible(s: str) -> bool: 149 | try: 150 | int(s) 151 | except ValueError: 152 | return False 153 | return True 154 | -------------------------------------------------------------------------------- /fastapi_forge/type_info_registry.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Hashable 2 | from typing import Annotated, Any 3 | 4 | from pydantic import Field 5 | from pydantic.dataclasses import dataclass 6 | 7 | from fastapi_forge.enums import FieldDataTypeEnum 8 | 9 | EnumName = Annotated[str, Field(...)] 10 | 11 | 12 | @dataclass 13 | class TypeInfo: 14 | """ 15 | Stores metadata about a database column type for testing and data generation. 16 | 17 | This class contains information needed to: 18 | - Generate SQLAlchemy column definitions 19 | - Create appropriate Python values for the type 20 | - Generate fake test data 21 | - Define test assertions for the type 22 | 23 | Attributes: 24 | sqlalchemy_type: The SQLAlchemy type name (e.g., 'Integer', 'String') 25 | sqlalchemy_prefix: Whether to prefix the `sqlalchemy_type` with 'sa.' or not. 26 | python_type: The corresponding Python type name (e.g., 'int', 'str') 27 | faker_field_value: The factory field value for this type (can be a Faker method) 28 | test_value: Value to insert into models for post/patch tests. 29 | test_func: A function to call with the `test_value`. 30 | encapsulate_assert: Wraps the `test_value` value (e.g, "UUID" => UUID(test_value))') 31 | 32 | """ 33 | 34 | sqlalchemy_type: str 35 | sqlalchemy_prefix: bool 36 | python_type: str 37 | faker_field_value: str | None = None 38 | test_func: str | None = None 39 | test_value: str | None = None 40 | encapsulate_assert: str | None = None 41 | 42 | 43 | class BaseRegistry[T: Hashable]: 44 | """Base registry class for type information.""" 45 | 46 | def __init__(self) -> None: 47 | self._registry: dict[T, TypeInfo] = {} 48 | 49 | def register(self, key: T, data_type: TypeInfo) -> None: 50 | if key in self: 51 | raise KeyError( 52 | f"{self.__class__.__name__}: Key '{key}' is already registered." 53 | ) 54 | self._registry[key] = data_type 55 | 56 | def get(self, key: T) -> TypeInfo: 57 | if key not in self: 58 | raise KeyError(f"Key '{key}' not found.") 59 | return self._registry[key] 60 | 61 | def remove(self, key: T) -> None: 62 | if key not in self: 63 | raise KeyError(f"Key '{key}' not found.") 64 | del self._registry[key] 65 | 66 | def update_key(self, old_key: T, new_key: T) -> None: 67 | if old_key not in self: 68 | raise KeyError( 69 | f"Key '{old_key}' not found. Available keys: {self._registry.keys()}" 70 | ) 71 | self._registry[new_key] = self._registry.pop(old_key) 72 | 73 | def all(self) -> list[TypeInfo]: 74 | return list(self._registry.values()) 75 | 76 | def clear(self) -> None: 77 | self._registry.clear() 78 | 79 | def __contains__(self, key: Any) -> bool: 80 | return key in self._registry 81 | 82 | def __repr__(self) -> str: 83 | return f"{self.__class__.__name__}({self._registry})" 84 | 85 | 86 | class TypeInfoRegistry(BaseRegistry[FieldDataTypeEnum]): 87 | """Register type info by FieldDataTypeEnum: TypeInfo.""" 88 | 89 | 90 | class EnumTypeInfoRegistry(BaseRegistry[EnumName]): 91 | """Register Enum type info by EnumName: TypeInfo.""" 92 | 93 | 94 | # enums are dynamically registered when a `CustomEnum` model is instantiated 95 | # and should not be registered manually 96 | enum_registry = EnumTypeInfoRegistry() 97 | 98 | 99 | registry = TypeInfoRegistry() 100 | faker_placeholder = "factory.Faker({placeholder})" 101 | 102 | registry.register( 103 | FieldDataTypeEnum.STRING, 104 | TypeInfo( 105 | sqlalchemy_type="String", 106 | sqlalchemy_prefix=True, 107 | python_type="str", 108 | faker_field_value=faker_placeholder.format(placeholder='"text"'), 109 | test_value="'world'", 110 | ), 111 | ) 112 | 113 | 114 | registry.register( 115 | FieldDataTypeEnum.FLOAT, 116 | TypeInfo( 117 | sqlalchemy_type="Float", 118 | sqlalchemy_prefix=True, 119 | python_type="float", 120 | faker_field_value=faker_placeholder.format( 121 | placeholder='"pyfloat", positive=True, min_value=0.1, max_value=100' 122 | ), 123 | test_value="2.0", 124 | ), 125 | ) 126 | 127 | registry.register( 128 | FieldDataTypeEnum.BOOLEAN, 129 | TypeInfo( 130 | sqlalchemy_type="Boolean", 131 | sqlalchemy_prefix=True, 132 | python_type="bool", 133 | faker_field_value=faker_placeholder.format(placeholder='"boolean"'), 134 | test_value="False", 135 | ), 136 | ) 137 | 138 | registry.register( 139 | FieldDataTypeEnum.DATETIME, 140 | TypeInfo( 141 | sqlalchemy_type="DateTime(timezone=True)", 142 | sqlalchemy_prefix=True, 143 | python_type="datetime", 144 | faker_field_value=faker_placeholder.format(placeholder='"date_time"'), 145 | test_value="datetime.now(timezone.utc)", 146 | test_func=".isoformat()", 147 | ), 148 | ) 149 | 150 | registry.register( 151 | FieldDataTypeEnum.UUID, 152 | TypeInfo( 153 | sqlalchemy_type="UUID(as_uuid=True)", 154 | sqlalchemy_prefix=True, 155 | python_type="UUID", 156 | faker_field_value="str(uuid4())", 157 | test_value="str(uuid4())", 158 | encapsulate_assert="UUID", 159 | ), 160 | ) 161 | 162 | registry.register( 163 | FieldDataTypeEnum.JSONB, 164 | TypeInfo( 165 | sqlalchemy_type="JSONB", 166 | sqlalchemy_prefix=False, 167 | python_type="dict[str, Any]", 168 | faker_field_value="{}", 169 | test_value='{"another_key": 123}', 170 | ), 171 | ) 172 | 173 | registry.register( 174 | FieldDataTypeEnum.INTEGER, 175 | TypeInfo( 176 | sqlalchemy_type="Integer", 177 | sqlalchemy_prefix=True, 178 | python_type="int", 179 | faker_field_value=faker_placeholder.format(placeholder='"random_int"'), 180 | test_value="2", 181 | ), 182 | ) 183 | -------------------------------------------------------------------------------- /fastapi_forge/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/services/rabbitmq/rabbitmq_dependencies.py: -------------------------------------------------------------------------------- 1 | import json 2 | from abc import ABC, abstractmethod 3 | from typing import Annotated 4 | 5 | import aio_pika 6 | from aio_pika import ExchangeType, Message 7 | from aio_pika.abc import AbstractChannel, AbstractExchange, AbstractQueue 8 | from aio_pika.pool import Pool 9 | from fastapi import Depends, Request 10 | from loguru import logger 11 | from pydantic import BaseModel 12 | {% if cookiecutter.use_taskiq %} 13 | from taskiq import TaskiqDepends 14 | {% endif %} 15 | 16 | 17 | {% if cookiecutter.use_taskiq %} 18 | def get_rabbitmq_channel_pool( 19 | request: Annotated[Request, TaskiqDepends()], 20 | ) -> Pool[aio_pika.Channel]: 21 | return request.app.state.rabbitmq_channel_pool 22 | {% else %} 23 | def get_rabbitmq_channel_pool( 24 | request: Request, 25 | ) -> Pool[aio_pika.Channel]: 26 | return request.app.state.rabbitmq_channel_pool 27 | {% endif %} 28 | 29 | GetRMQChannelPool = Annotated[ 30 | Pool[aio_pika.Channel], 31 | Depends(get_rabbitmq_channel_pool), 32 | ] 33 | 34 | 35 | class _AbstractRabbitMQService(ABC): 36 | """Abstract RabbitMQ Service.""" 37 | 38 | @abstractmethod 39 | async def _publish( 40 | self, 41 | exchange_name: str, 42 | routing_key: str, 43 | message: BaseModel, 44 | exchange_type: ExchangeType = ExchangeType.TOPIC, 45 | ) -> None: 46 | msg = "Must be implemented in subclasses." 47 | raise NotImplementedError(msg) 48 | 49 | 50 | class RabbitMQService(_AbstractRabbitMQService): 51 | """RabbitMQ Service.""" 52 | 53 | def __init__(self, pool: GetRMQChannelPool): 54 | self.pool = pool 55 | 56 | async def _publish( 57 | self, 58 | exchange_name: str, 59 | routing_key: str, 60 | message: BaseModel, 61 | exchange_type: ExchangeType = ExchangeType.TOPIC, 62 | ) -> None: 63 | async with self.pool.acquire() as conn: 64 | exchange = await conn.declare_exchange( 65 | name=exchange_name, 66 | type=exchange_type, 67 | durable=True, 68 | auto_delete=False, 69 | ) 70 | await exchange.publish( 71 | message=Message( 72 | body=message.model_dump_json().encode("utf-8"), 73 | content_encoding="utf-8", 74 | content_type="application/json", 75 | ), 76 | routing_key=routing_key, 77 | ) 78 | 79 | async def send_demo_message( 80 | self, 81 | payload: BaseModel, 82 | ) -> None: 83 | """Send a demo message.""" 84 | await self._publish( 85 | exchange_name="demo.exchange", 86 | routing_key="demo.message.send", 87 | message=payload, 88 | ) 89 | 90 | 91 | class RabbitMQServiceMock(_AbstractRabbitMQService): 92 | """Mock RabbitMQ Service.""" 93 | 94 | async def _publish( 95 | self, 96 | exchange_name: str, 97 | routing_key: str, 98 | message: BaseModel, 99 | exchange_type: ExchangeType = ExchangeType.TOPIC, 100 | ) -> None: 101 | logger.info( 102 | f"Mock publish to {exchange_name} with routing key {routing_key}: {message}" 103 | ) 104 | 105 | async def send_demo_message( 106 | self, 107 | payload: BaseModel, 108 | ) -> None: 109 | """Send a demo message.""" 110 | await self._publish( 111 | exchange_name="demo.exchange", 112 | routing_key="demo.message.send", 113 | message=payload, 114 | ) 115 | 116 | 117 | def get_rabbitmq( 118 | channel_pool: GetRMQChannelPool, 119 | ) -> RabbitMQService: 120 | """Get RabbitMQ Service.""" 121 | return RabbitMQService(channel_pool) 122 | 123 | 124 | GetRabbitMQ = Annotated[ 125 | RabbitMQService, 126 | Depends(get_rabbitmq), 127 | ] 128 | 129 | 130 | class QueueConfig(BaseModel): 131 | exchange_name: str 132 | queue_name: str 133 | routing_key: str 134 | exchange_type: ExchangeType = ExchangeType.TOPIC 135 | queue_durable: bool = True 136 | 137 | 138 | async def _declare_exchange_and_queue( 139 | channel: AbstractChannel, 140 | exchange_name: str, 141 | queue_name: str, 142 | routing_key: str, 143 | exchange_type: ExchangeType = ExchangeType.TOPIC, 144 | queue_durable: bool = True, 145 | ) -> tuple[AbstractExchange, AbstractQueue]: 146 | """Declare an exchange and a queue, and bind them together.""" 147 | exchange = await channel.declare_exchange( 148 | name=exchange_name, 149 | type=exchange_type, 150 | durable=True, 151 | auto_delete=False, 152 | ) 153 | 154 | queue = await channel.declare_queue(queue_name, durable=queue_durable) 155 | await queue.bind(exchange, routing_key=routing_key) 156 | return exchange, queue 157 | 158 | 159 | async def _message_handler(message: aio_pika.abc.AbstractIncomingMessage) -> None: 160 | """Handle incoming messages from RabbitMQ.""" 161 | async with message.process(): 162 | try: 163 | msg = message.body.decode() 164 | data = json.loads(msg) 165 | logger.info(f"✅ Received message: {data}") 166 | 167 | # handle the message here 168 | except Exception as e: 169 | logger.error(f"❌ Failed to process message: {e}") 170 | 171 | 172 | async def init_consumer( 173 | channel_pool: Pool[aio_pika.Channel], 174 | config: QueueConfig, 175 | ) -> None: 176 | """Initialize a RabbitMQ consumer.""" 177 | async with channel_pool.acquire() as channel: 178 | _, queue = await _declare_exchange_and_queue( 179 | channel, 180 | config.exchange_name, 181 | config.queue_name, 182 | config.routing_key, 183 | config.exchange_type, 184 | config.queue_durable, 185 | ) 186 | 187 | await queue.consume(_message_handler) 188 | logger.info( 189 | f"👂 Consumer started for queue '{config.queue_name}' " 190 | f"with routing key '{config.routing_key}'" 191 | ) 192 | -------------------------------------------------------------------------------- /fastapi_forge/frontend/modals/relation_modal.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from collections.abc import Callable 3 | 4 | from nicegui import ui 5 | 6 | from fastapi_forge.enums import OnDeleteEnum 7 | from fastapi_forge.frontend.notifications import notify_value_error 8 | from fastapi_forge.schemas import Model, ModelRelationship 9 | 10 | 11 | class BaseRelationModal(ui.dialog, ABC): 12 | title: str 13 | 14 | def __init__(self, *args, **kwargs): 15 | super().__init__(*args, **kwargs) 16 | self._build_common_ui() 17 | 18 | def _build_common_ui(self) -> None: 19 | with self, ui.card().classes("w-full max-w-2xl shadow-lg rounded-lg"): 20 | with ui.row().classes("w-full justify-between items-center p-4 border-b"): 21 | ui.label(self.title).classes("text-xl font-semibold") 22 | 23 | with ui.column().classes("w-full p-6 space-y-4"): 24 | with ui.grid(columns=2).classes("w-full gap-4"): 25 | self.field_name = ui.input(label="Field Name").props( 26 | "outlined dense" 27 | ) 28 | self.target_model = ui.select( 29 | label="Target Model", 30 | options=[], 31 | ).props("outlined dense") 32 | self.on_delete = ui.select( 33 | label="On Delete", 34 | options=list(OnDeleteEnum), 35 | value=OnDeleteEnum.CASCADE, 36 | ).props("outlined dense") 37 | self.back_populates = ui.input(label="Back Populates").props( 38 | "outlined dense" 39 | ) 40 | 41 | with ui.row().classes("w-full justify-between gap-4"): 42 | self.nullable = ui.checkbox("Nullable").props("dense") 43 | self.index = ui.checkbox("Index").props("dense") 44 | self.unique = ui.checkbox("Unique").props("dense") 45 | 46 | with ui.row().classes("w-full justify-end p-4 border-t gap-2"): 47 | self._build_action_buttons() 48 | 49 | @abstractmethod 50 | def _build_action_buttons(self) -> None: 51 | pass 52 | 53 | def _reset(self) -> None: 54 | self.field_name.value = "" 55 | self.target_model.value = None 56 | self.back_populates.value = "" 57 | self.on_delete.value = None 58 | self.nullable.value = False 59 | self.index.value = False 60 | self.unique.value = False 61 | 62 | 63 | class AddRelationModal(BaseRelationModal): 64 | title = "Add Relationship" 65 | 66 | def __init__(self, on_add_relation: Callable): 67 | super().__init__() 68 | self.on_add_relation = on_add_relation 69 | 70 | def _build_action_buttons(self) -> None: 71 | ui.button("Cancel", on_click=self.close) 72 | ui.button( 73 | "Add Relation", 74 | on_click=self._add_relation, 75 | ) 76 | 77 | def _add_relation(self) -> None: 78 | try: 79 | self.on_add_relation( 80 | field_name=self.field_name.value, 81 | target_model=self.target_model.value, 82 | back_populates=self.back_populates.value or None, 83 | nullable=self.nullable.value, 84 | index=self.index.value, 85 | unique=self.unique.value, 86 | on_delete=self.on_delete.value, 87 | ) 88 | self.close() 89 | except ValueError as exc: 90 | notify_value_error(exc) 91 | 92 | def open(self, models: list[Model]) -> None: 93 | self.target_model.options = [model.name for model in models] 94 | self.target_model.value = models[0].name if models else None 95 | super().open() 96 | 97 | 98 | class UpdateRelationModal(BaseRelationModal): 99 | title = "Update Relationship" 100 | 101 | def __init__(self, on_update_relation: Callable): 102 | super().__init__() 103 | self.on_update_relation = on_update_relation 104 | self.selected_relation: ModelRelationship | None = None 105 | 106 | def _build_action_buttons(self) -> None: 107 | ui.button("Cancel", on_click=self.close) 108 | ui.button( 109 | "Update Relation", 110 | on_click=self._update_relation, 111 | ) 112 | 113 | def _update_relation(self) -> None: 114 | if not self.selected_relation: 115 | return 116 | 117 | try: 118 | self.on_update_relation( 119 | field_name=self.field_name.value, 120 | target_model=self.target_model.value, 121 | back_populates=self.back_populates.value, 122 | nullable=self.nullable.value, 123 | index=self.index.value, 124 | unique=self.unique.value, 125 | on_delete=self.on_delete.value, 126 | ) 127 | self.close() 128 | except ValueError as exc: 129 | notify_value_error(exc) 130 | 131 | def _set_relation(self, relation: ModelRelationship) -> None: 132 | self.selected_relation = relation 133 | if relation: 134 | self.field_name.value = relation.field_name 135 | self.target_model.value = relation.target_model 136 | self.nullable.value = relation.nullable 137 | self.index.value = relation.index 138 | self.unique.value = relation.unique 139 | self.back_populates.value = relation.back_populates 140 | self.on_delete.value = relation.on_delete 141 | 142 | def open( 143 | self, 144 | relation: ModelRelationship | None = None, 145 | models: list[Model] | None = None, 146 | ) -> None: 147 | if relation and models: 148 | self._set_relation(relation) 149 | self.target_model.options = [model.name for model in models] 150 | default_target_model = next( 151 | (model for model in models if model.name == relation.target_model), 152 | None, 153 | ) 154 | if default_target_model: 155 | self.target_model.value = default_target_model.name 156 | self.target_model.options = [model.name for model in models] 157 | 158 | super().open() 159 | -------------------------------------------------------------------------------- /fastapi_forge/frontend/panels/enum_editor_panel.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from nicegui import ui 4 | from pydantic import ValidationError 5 | 6 | from fastapi_forge.frontend import validation 7 | from fastapi_forge.frontend.constants import ENUM_COLUMNS 8 | from fastapi_forge.frontend.modals import ( 9 | AddEnumValueModal, 10 | UpdateEnumValueModal, 11 | ) 12 | from fastapi_forge.frontend.notifications import ( 13 | notify_enum_value_exists, 14 | notify_validation_error, 15 | ) 16 | from fastapi_forge.frontend.state import state 17 | from fastapi_forge.schemas import CustomEnum, CustomEnumValue 18 | 19 | 20 | class EnumEditorPanel(ui.card): 21 | def __init__(self): 22 | super().__init__() 23 | self.visible = False 24 | 25 | state.select_enum_fn = self.set_selected_enum 26 | state.deselect_enum_fn = self._handle_deselect_enum 27 | 28 | self.add_value_modal = AddEnumValueModal( 29 | on_add_value=self._handle_modal_add_value 30 | ) 31 | self.update_value_modal = UpdateEnumValueModal( 32 | on_update_value=self._handle_update_value 33 | ) 34 | 35 | self._build() 36 | 37 | def _show_code_preview(self) -> None: 38 | if state.selected_enum: 39 | with ( 40 | ui.dialog() as modal, 41 | ui.card().classes("no-shadow border-[1px]"), 42 | ): 43 | ui.code(state.selected_enum.class_definition).classes("w-full") 44 | modal.open() 45 | 46 | def _build(self) -> None: 47 | with self: 48 | with ui.row().classes("w-full justify-between items-center"): 49 | with ui.row().classes("gap-4 items-center"): 50 | self.enum_name_display = ui.label().classes("text-lg font-bold") 51 | ui.button( 52 | icon="visibility", 53 | on_click=self._show_code_preview, 54 | ).tooltip("Preview Python enum code") 55 | 56 | ui.button( 57 | icon="add", on_click=lambda: self.add_value_modal.open() 58 | ).classes("self-end").tooltip("Add Value") 59 | 60 | with ui.expansion("Values", value=True).classes("w-full"): 61 | self.table = ui.table( 62 | columns=ENUM_COLUMNS, 63 | rows=[], 64 | row_key="name", 65 | selection="single", 66 | on_select=lambda e: self._on_select_value(e.selection), 67 | ).classes("w-full no-shadow border-[1px]") 68 | 69 | with ui.row().classes("w-full justify-end gap-2"): 70 | ui.button( 71 | icon="edit", 72 | on_click=lambda: self.update_value_modal.open( 73 | state.selected_enum_value, 74 | ), 75 | ).bind_visibility_from(state, "selected_enum_value") 76 | ui.button( 77 | icon="delete", on_click=self._handle_delete_enum_value 78 | ).bind_visibility_from(state, "selected_enum_value") 79 | 80 | def refresh(self) -> None: 81 | if state.selected_enum is None: 82 | return 83 | self.table.rows = [value.model_dump() for value in state.selected_enum.values] 84 | self.add_value_modal.close() 85 | 86 | def set_selected_enum(self, enum: CustomEnum) -> None: 87 | self.refresh() 88 | self.enum_name_display.text = enum.name 89 | self.visible = True 90 | 91 | def _on_select_value(self, selection: list[dict[str, Any]]) -> None: 92 | if not state.selected_enum or not selection: 93 | return 94 | 95 | name = selection[0].get("name") 96 | state.selected_enum_value = next( 97 | ( 98 | enum_value 99 | for enum_value in state.selected_enum.values 100 | if enum_value.name == name 101 | ), 102 | None, 103 | ) 104 | 105 | def _handle_delete_enum_value(self) -> None: 106 | if state.selected_enum is None or state.selected_enum_value is None: 107 | return 108 | state.selected_enum.values.remove(state.selected_enum_value) 109 | self.refresh() 110 | 111 | def _enum_value_exists(self, value_name: str) -> bool: 112 | return any( 113 | enum_value.name == value_name for enum_value in state.selected_enum.values 114 | ) 115 | 116 | def _handle_modal_add_value(self, *, name: str, value: str) -> None: 117 | if state.selected_enum is None: 118 | return 119 | 120 | try: 121 | validation.raise_if_missing_fields([("Name", name), ("Value", value)]) 122 | except ValueError as exc: 123 | raise exc 124 | 125 | if self._enum_value_exists(name): 126 | notify_enum_value_exists(name, state.selected_enum.name) 127 | return 128 | 129 | try: 130 | enum_value_input = CustomEnumValue( 131 | name=name, 132 | value=value, 133 | ) 134 | 135 | state.selected_enum.values.append(enum_value_input) 136 | self.refresh() 137 | except ValidationError as exc: 138 | notify_validation_error(exc) 139 | 140 | def _handle_update_value(self, *, name: str, value: str) -> None: 141 | if state.selected_enum is None or state.selected_enum_value is None: 142 | return 143 | 144 | try: 145 | validation.raise_if_missing_fields([("Name", name), ("Value", value)]) 146 | except ValueError as exc: 147 | raise exc 148 | 149 | if self._enum_value_exists(name): 150 | notify_enum_value_exists(name, state.selected_enum.name) 151 | return 152 | 153 | try: 154 | enum_value_input = CustomEnumValue( 155 | name=name, 156 | value=value, 157 | ) 158 | 159 | enum_index = state.selected_enum.values.index(state.selected_enum_value) 160 | state.selected_enum.values[enum_index] = enum_value_input 161 | self.refresh() 162 | except ValidationError as exc: 163 | notify_validation_error(exc) 164 | 165 | def _handle_deselect_enum(self) -> None: 166 | self.visible = False 167 | --------------------------------------------------------------------------------