From ecae2ee6881c0d6ec34c2d3ad0cf67670d260baa Mon Sep 17 00:00:00 2001 From: Vetrichelvan Date: Sun, 28 Sep 2025 17:49:13 +0530 Subject: [PATCH 1/2] feat: mongodb setup with beanie ODM --- README.md | 6 ++- src/scaffoldr/cli/generate.py | 5 +++ src/scaffoldr/core/constants/__init__.py | 3 +- src/scaffoldr/core/constants/const.py | 5 +++ src/scaffoldr/core/utils/helper.py | 20 +++++++-- .../{{ project_name }}/README.md.jinja | 7 ++++ .../{{ project_name }}/pyproject.toml.jinja | 6 +++ ...if database %}database.py{% endif %}.jinja | 42 +++++++++++++++++++ test_cli.sh | 19 ++++++--- 9 files changed, 101 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 0246775..a2c507c 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ scaffoldr --no-banner generate my-project When you generate a FastAPI project, you get: - **Complete FastAPI Application**: Pre-configured with proper structure -- **Database Integration**: SQLAlchemy with Alembic migrations +- **Database Integration**: SQLAlchemy with Alembic migrations or MongoDB with Beanie ODM - **File Storage**: Built-in file upload/download endpoints - **API Documentation**: Auto-generated OpenAPI/Swagger docs - **Development Tools**: Pre-configured with ruff, mypy, pytest @@ -117,7 +117,9 @@ my-project/ │ ├── core/ # Core components (config, utils, etc.) │ ├── features/ # Feature modules (business logic) │ ├── services/ # External service integrations -│ └── database/ # Data access layer +{% if database %} +│ └── database/ # Data access layer (SQLAlchemy or MongoDB) +{% endif %} ├── tests/ # Comprehensive test suite ├── scripts/ # Development scripts ├── .github/ # GitHub workflows and templates diff --git a/src/scaffoldr/cli/generate.py b/src/scaffoldr/cli/generate.py index 5171500..64851d6 100644 --- a/src/scaffoldr/cli/generate.py +++ b/src/scaffoldr/cli/generate.py @@ -38,6 +38,10 @@ def generate( if use_cloud: cloud_type = Helper.cloud_type() + database_type = None + if use_database: + database_type = Helper.database_type() + project_name = project_name.replace(" ", "-").lower() # Check if directory already exists if destination == ".": @@ -71,6 +75,7 @@ def generate( "use_docker": docker, "cloud_type": cloud_type, "database": use_database, + "database_type": database_type, "framework": framework, } diff --git a/src/scaffoldr/core/constants/__init__.py b/src/scaffoldr/core/constants/__init__.py index 0b3b682..8f5c30a 100644 --- a/src/scaffoldr/core/constants/__init__.py +++ b/src/scaffoldr/core/constants/__init__.py @@ -1,8 +1,9 @@ from .art import ascii_art -from .const import CloudTypes, Frameworks, console +from .const import CloudTypes, DatabaseTypes, Frameworks, console __all__ = [ "CloudTypes", + "DatabaseTypes", "Frameworks", "ascii_art", "console", diff --git a/src/scaffoldr/core/constants/const.py b/src/scaffoldr/core/constants/const.py index 86b14da..a2ba583 100644 --- a/src/scaffoldr/core/constants/const.py +++ b/src/scaffoldr/core/constants/const.py @@ -18,3 +18,8 @@ class CloudTypes(str, Enum): GCP = "gcp" AZURE = "azure" NONE = "none" + + +class DatabaseTypes(str, Enum): + SQLALCHEMY = "sqlalchemy" + MONGODB = "mongodb" diff --git a/src/scaffoldr/core/utils/helper.py b/src/scaffoldr/core/utils/helper.py index 0e0da2e..4514d2d 100644 --- a/src/scaffoldr/core/utils/helper.py +++ b/src/scaffoldr/core/utils/helper.py @@ -3,7 +3,7 @@ import typer from copier import subprocess -from scaffoldr.core.constants.const import CloudTypes +from scaffoldr.core.constants.const import CloudTypes, DatabaseTypes, console if TYPE_CHECKING: from collections.abc import Iterable @@ -40,6 +40,20 @@ def cloud_type() -> str: cloud_type: str = cast("str", typer.prompt(f"Cloud type: {' | '.join(cloud_types)}")) if cloud_type in cloud_types: return cloud_type - raise ValueError( - f"Invalid cloud type: {cloud_type}. Must be one of: {' | '.join(cloud_types)}", + console.print(f"[red]Error:[/red] Invalid cloud type: {cloud_type}") + raise typer.Exit(1) + + @staticmethod + def database_type() -> str: + """ + Prompt for database type selection. + """ + database_types = list(DatabaseTypes) + database_type: str = cast( + "str", + typer.prompt(f"Database type: {' | '.join(database_types)}"), ) + if database_type in database_types: + return database_type + console.print(f"[red]Error:[/red] Invalid database type: {database_type}") + raise typer.Exit(1) diff --git a/templates/fastapi_template/{{ project_name }}/README.md.jinja b/templates/fastapi_template/{{ project_name }}/README.md.jinja index ac62da4..4f67708 100644 --- a/templates/fastapi_template/{{ project_name }}/README.md.jinja +++ b/templates/fastapi_template/{{ project_name }}/README.md.jinja @@ -6,8 +6,15 @@ - **FastAPI**: Modern, fast web framework for building APIs - **Pydantic**: Data validation and serialization +{% if database %} +{% if database_type == 'sqlalchemy' %} - **SQLAlchemy**: SQL toolkit and ORM - **Alembic**: Database migration tool +{% elif database_type == 'mongodb' %} +- **Beanie**: MongoDB ODM for Python +- **Motor**: Asynchronous MongoDB driver +{% endif %} +{% endif %} - **Uvicorn**: ASGI web server - **Granian**: High-performance web server {% if cloud_type == 'aws' %} diff --git a/templates/fastapi_template/{{ project_name }}/pyproject.toml.jinja b/templates/fastapi_template/{{ project_name }}/pyproject.toml.jinja index dc5725b..d218d45 100644 --- a/templates/fastapi_template/{{ project_name }}/pyproject.toml.jinja +++ b/templates/fastapi_template/{{ project_name }}/pyproject.toml.jinja @@ -39,8 +39,14 @@ dependencies = [ "azure-identity>=1.25.0", {% endif %} {% if database %} + {% if database_type == 'sqlalchemy' %} "aiosqlite>=0.21.0", "sqlalchemy[asyncio]>=2.0.0", + {% elif database_type == 'mongodb' %} + "beanie>=2.0.0", + "motor>=3.7.1", + "pymongo>=4.15.1", + {% endif %} {% endif %} "fastapi>=0.117.1", "granian[reload]>=2.5.4", diff --git a/templates/fastapi_template/{{ project_name }}/src/{{ project_slug }}/connection/{% if database %}database.py{% endif %}.jinja b/templates/fastapi_template/{{ project_name }}/src/{{ project_slug }}/connection/{% if database %}database.py{% endif %}.jinja index b32303a..6db57db 100644 --- a/templates/fastapi_template/{{ project_name }}/src/{{ project_slug }}/connection/{% if database %}database.py{% endif %}.jinja +++ b/templates/fastapi_template/{{ project_name }}/src/{{ project_slug }}/connection/{% if database %}database.py{% endif %}.jinja @@ -1,3 +1,4 @@ +{% if database_type == 'sqlalchemy' %} from collections.abc import AsyncGenerator, Callable from functools import wraps from typing import Any, cast @@ -74,3 +75,44 @@ def inject_session(func: Callable[..., Any]) -> Callable[..., Any]: return None # This should never be reached, but for type safety return cast("Callable[..., Any]", wrapper) +{% elif database_type == 'mongodb' %} +from beanie import init_beanie +from motor.motor_asyncio import AsyncIOMotorClient +from typing import Optional + +from {{ project_slug }}.core.config import settings + + +class MongoDB: + """MongoDB connection manager using Beanie ODM.""" + + def __init__(self) -> None: + """Initialize the MongoDB connection.""" + self.client: Optional[AsyncIOMotorClient] = None + self.database = None + + async def connect(self) -> None: + """Connect to MongoDB and initialize Beanie.""" + self.client = AsyncIOMotorClient(settings.database.URL) + self.database = self.client[settings.database.DATABASE_NAME] + + # Initialize Beanie with document models + # Note: Document models should be imported here when they exist + await init_beanie( + database=self.database, + document_models=[], # Add your document models here + ) + + async def close(self) -> None: + """Close the MongoDB connection.""" + if self.client: + self.client.close() + + async def get_database(self): + """Get the database instance.""" + return self.database + + +# Global MongoDB instance +mongodb = MongoDB() +{% endif %} diff --git a/test_cli.sh b/test_cli.sh index 6735041..1fd7143 100755 --- a/test_cli.sh +++ b/test_cli.sh @@ -22,6 +22,7 @@ test_config() { local docker="$9" local use_cloud="${10}" local use_database="${11}" + local database_type="${12:-sqlalchemy}" echo "Testing: $config_name" @@ -38,13 +39,13 @@ test_config() { fi if [ "$use_cloud" = "true" ]; then - cmd="$cmd --use-cloud --cloud-type aws" + cmd="$cmd --use-cloud" else cmd="$cmd --no-cloud" fi if [ "$use_database" = "true" ]; then - cmd="$cmd --use-database" + cmd="$cmd --use-database --database-type $database_type" else cmd="$cmd --no-database" fi @@ -94,13 +95,13 @@ test_config() { # Test configurations echo "Testing basic FastAPI configuration..." -test_config "fastapi-basic" "test-fastapi-basic" "fastapi" "$TEST_DIR" "3.13" "test@example.com" "Test User" "Test project" "true" "true" "true" +test_config "fastapi-basic" "test-fastapi-basic" "fastapi" "$TEST_DIR" "3.13" "test@example.com" "Test User" "Test project" "true" "true" "true" "sqlalchemy" echo "Testing FastAPI without Docker..." -test_config "fastapi-no-docker" "test-fastapi-no-docker" "fastapi" "$TEST_DIR" "3.13" "test@example.com" "Test User" "Test project" "false" "true" "true" +test_config "fastapi-no-docker" "test-fastapi-no-docker" "fastapi" "$TEST_DIR" "3.13" "test@example.com" "Test User" "Test project" "false" "true" "true" "sqlalchemy" echo "Testing FastAPI without cloud..." -test_config "fastapi-no-cloud" "test-fastapi-no-cloud" "fastapi" "$TEST_DIR" "3.13" "test@example.com" "Test User" "Test project" "true" "false" "true" +test_config "fastapi-no-cloud" "test-fastapi-no-cloud" "fastapi" "$TEST_DIR" "3.13" "test@example.com" "Test User" "Test project" "true" "false" "true" "sqlalchemy" echo "Testing FastAPI without database..." test_config "fastapi-no-db" "test-fastapi-no-db" "fastapi" "$TEST_DIR" "3.13" "test@example.com" "Test User" "Test project" "true" "true" "false" @@ -112,6 +113,12 @@ echo "Testing Python 3.12..." test_config "fastapi-py312" "test-fastapi-py312" "fastapi" "$TEST_DIR" "3.12" "test@example.com" "Test User" "Test project" "true" "true" "true" echo "Testing different destination..." -test_config "fastapi-custom-dest" "test-fastapi-dest" "fastapi" "/tmp" "3.13" "test@example.com" "Test User" "Test project" "true" "true" "true" +test_config "fastapi-custom-dest" "test-fastapi-dest" "fastapi" "/tmp" "3.13" "test@example.com" "Test User" "Test project" "true" "true" "true" "sqlalchemy" + +echo "Testing FastAPI with MongoDB..." +test_config "fastapi-mongodb" "test-fastapi-mongodb" "fastapi" "$TEST_DIR" "3.13" "test@example.com" "Test User" "Test project" "true" "true" "true" "mongodb" + +echo "Testing FastAPI with MongoDB and no cloud..." +test_config "fastapi-mongodb-no-cloud" "test-fastapi-mongodb-no-cloud" "fastapi" "$TEST_DIR" "3.13" "test@example.com" "Test User" "Test project" "true" "false" "true" "mongodb" echo "🧪 Testing complete!" From ed865a8f9b77fbd9dfbbd288e7db026e9945d09e Mon Sep 17 00:00:00 2001 From: Vetrichelvan Date: Sun, 28 Sep 2025 18:17:01 +0530 Subject: [PATCH 2/2] feat: add MongoDB support with configuration and testing setup --- .../{{ project_name }}/.env.jinja | 7 +++++++ .../{{ project_slug }}/api/lifespan.py.jinja | 15 ++++++++++++++- .../connection/__init__.py.jinja | 5 ++++- .../core/config/base.py.jinja | 5 +++++ .../{{ project_name }}/tests/conftest.py.jinja | 18 +++++++++++++++++- .../tests/test_health.py.jinja | 16 ++++++++++++++++ .../tests/unit/test_config.py.jinja | 11 +++++++++++ 7 files changed, 74 insertions(+), 3 deletions(-) diff --git a/templates/fastapi_template/{{ project_name }}/.env.jinja b/templates/fastapi_template/{{ project_name }}/.env.jinja index 955964f..129b7ba 100644 --- a/templates/fastapi_template/{{ project_name }}/.env.jinja +++ b/templates/fastapi_template/{{ project_name }}/.env.jinja @@ -13,8 +13,15 @@ {{ project_slug|upper }}_LOG_GRANIAN_ERROR_LEVEL=40 {% if database %} +{% if database_ype == 'postgresql' %} +{{ project_slug|upper }}_DB_URL=postgresql+asyncpg://user:password@localhost:5432/{{ project_slug }} +{{ project_slug|upper }}_DB_ECHO=false +{% elif database_type == 'sqlite' %} {{ project_slug|upper }}_DB_URL=sqlite+aiosqlite:///./{{ project_name|upper }}.db {{ project_slug|upper }}_DB_ECHO=false +{% elif database_type == 'mongodb' %} +{{ project_slug|upper }}_DB_URL=mongodb://localhost:27017 +{% endif %} {% endif %} {% if cloud_type %} diff --git a/templates/fastapi_template/{{ project_name }}/src/{{ project_slug }}/api/lifespan.py.jinja b/templates/fastapi_template/{{ project_name }}/src/{{ project_slug }}/api/lifespan.py.jinja index 824363a..18da82f 100644 --- a/templates/fastapi_template/{{ project_name }}/src/{{ project_slug }}/api/lifespan.py.jinja +++ b/templates/fastapi_template/{{ project_name }}/src/{{ project_slug }}/api/lifespan.py.jinja @@ -4,7 +4,11 @@ from contextlib import asynccontextmanager from fastapi import FastAPI {% if database %} +{% if database_type == 'sqlalchemy' %} from {{ project_slug }}.connection import Base, database +{% elif database_type == 'mongodb' %} +from {{ project_slug }}.connection import mongodb +{% endif %} {% endif %} {% if framework == 'fastapi' %} @@ -12,10 +16,19 @@ from {{ project_slug }}.connection import Base, database @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncGenerator[None]: {% if database %} +{% if database_type == 'sqlalchemy' %} async with database.engine.begin() as conn: # run_sync executes the given function in a sync context using the connection await conn.run_sync(Base.metadata.create_all) - {% endif %} +{% elif database_type == 'mongodb' %} + await mongodb.connect() +{% endif %} +{% endif %} yield +{% if database %} +{% if database_type == 'mongodb' %} + await mongodb.close() +{% endif %} +{% endif %} {% endif %} diff --git a/templates/fastapi_template/{{ project_name }}/src/{{ project_slug }}/connection/__init__.py.jinja b/templates/fastapi_template/{{ project_name }}/src/{{ project_slug }}/connection/__init__.py.jinja index abddc43..e47d697 100644 --- a/templates/fastapi_template/{{ project_name }}/src/{{ project_slug }}/connection/__init__.py.jinja +++ b/templates/fastapi_template/{{ project_name }}/src/{{ project_slug }}/connection/__init__.py.jinja @@ -1,4 +1,7 @@ -{% if database %} +{% if database_type == 'sqlalchemy' %} from .database import database, inject_session, Base __all__ = ["Base", "database", "inject_session"] +{% elif database_type == 'mongodb' %} +from .database import mongodb +__all__ = ["mongodb"] {% endif %} diff --git a/templates/fastapi_template/{{ project_name }}/src/{{ project_slug }}/core/config/base.py.jinja b/templates/fastapi_template/{{ project_name }}/src/{{ project_slug }}/core/config/base.py.jinja index fa2973f..3139c5f 100644 --- a/templates/fastapi_template/{{ project_name }}/src/{{ project_slug }}/core/config/base.py.jinja +++ b/templates/fastapi_template/{{ project_name }}/src/{{ project_slug }}/core/config/base.py.jinja @@ -125,11 +125,16 @@ class CloudSettings(BaseSettings): class DatabaseSettings(BaseSettings): + {% if database_type == 'sqlalchemy' %} URL: str = "sqlite+aiosqlite:///./{{ project_name|upper }}.db" ECHO: Annotated[ bool, AfterValidator(true_bool_validator), ] = False + {% elif database_type == 'mongodb' %} + URL: str = "mongodb://localhost:27017" + DATABASE_NAME: str = "{{ project_name|lower }}_db" + {% endif %} model_config: ClassVar[SettingsConfigDict] = SettingsConfigDict( extra="ignore", diff --git a/templates/fastapi_template/{{ project_name }}/tests/conftest.py.jinja b/templates/fastapi_template/{{ project_name }}/tests/conftest.py.jinja index 9a64892..96bea7c 100644 --- a/templates/fastapi_template/{{ project_name }}/tests/conftest.py.jinja +++ b/templates/fastapi_template/{{ project_name }}/tests/conftest.py.jinja @@ -4,11 +4,16 @@ from typing import Generator import pytest from fastapi.testclient import TestClient -from sqlalchemy.ext.asyncio import AsyncSession from {{ project_slug }}.api.application import app {% if database %} +{% if database_type == 'sqlalchemy' %} +from sqlalchemy.ext.asyncio import AsyncSession + from {{ project_slug }}.connection import database +{% elif database_type == 'mongodb' %} +from {{ project_slug }}.connection import mongodb +{% endif %} {% endif %} @@ -28,6 +33,7 @@ def client() -> Generator[TestClient, None, None]: {% if database %} +{% if database_type == 'sqlalchemy' %} @pytest.fixture(scope="function") async def db_session() -> AsyncGenerator[AsyncSession, None]: """Create a database session for testing.""" @@ -40,4 +46,14 @@ async def db_session() -> AsyncGenerator[AsyncSession, None]: except Exception: await session.rollback() raise +{% elif database_type == 'mongodb' %} +@pytest.fixture(scope="function") +async def mongodb_client(): + """Create a MongoDB client for testing.""" + await mongodb.connect() + try: + yield mongodb + finally: + await mongodb.close() +{% endif %} {% endif %} diff --git a/templates/fastapi_template/{{ project_name }}/tests/test_health.py.jinja b/templates/fastapi_template/{{ project_name }}/tests/test_health.py.jinja index ba0c9e2..84fd3a4 100644 --- a/templates/fastapi_template/{{ project_name }}/tests/test_health.py.jinja +++ b/templates/fastapi_template/{{ project_name }}/tests/test_health.py.jinja @@ -3,8 +3,10 @@ import pytest from fastapi.testclient import TestClient {% if database %} +{% if database_type == 'sqlalchemy' %} from sqlalchemy.ext.asyncio import AsyncSession {% endif %} +{% endif %} class TestHealth: @@ -19,6 +21,7 @@ class TestHealth: assert data["status"] == "success" {% if database %} + {% if database_type == 'sqlalchemy' %} @pytest.mark.asyncio async def test_health_endpoint_with_database( self, client: TestClient, db_session: AsyncSession @@ -30,4 +33,17 @@ class TestHealth: assert "status" in data assert data["status"] == "success" # Note: Database health check not implemented in basic health endpoint + {% elif database_type == 'mongodb' %} + @pytest.mark.asyncio + async def test_health_endpoint_with_mongodb( + self, client: TestClient, mongodb_client: None, + ) -> None: + """Test health check with MongoDB connectivity.""" + response = client.get("/") + assert response.status_code == 200 + data = response.json() + assert "status" in data + assert data["status"] == "success" + # Note: Database health check not implemented in basic health endpoint + {% endif %} {% endif %} diff --git a/templates/fastapi_template/{{ project_name }}/tests/unit/test_config.py.jinja b/templates/fastapi_template/{{ project_name }}/tests/unit/test_config.py.jinja index a54eadf..cbcd3d4 100644 --- a/templates/fastapi_template/{{ project_name }}/tests/unit/test_config.py.jinja +++ b/templates/fastapi_template/{{ project_name }}/tests/unit/test_config.py.jinja @@ -39,6 +39,7 @@ class TestSettings: assert settings.server.PORT > 0 {% if database %} + {% if database_type == 'sqlalchemy' %} def test_database_settings(self) -> None: """Test database settings have expected attributes.""" settings = get_settings() @@ -47,4 +48,14 @@ class TestSettings: assert isinstance(settings.database.URL, str) assert isinstance(settings.database.ECHO, bool) assert "sqlite" in settings.database.URL # Should contain sqlite for basic setup + {% elif database_type == 'mongodb' %} + def test_database_settings(self) -> None: + """Test MongoDB database settings have expected attributes.""" + settings = get_settings() + assert hasattr(settings.database, 'URL') + assert hasattr(settings.database, 'DATABASE_NAME') + assert isinstance(settings.database.URL, str) + assert isinstance(settings.database.DATABASE_NAME, str) + assert "mongodb" in settings.database.URL # Should contain mongodb for MongoDB setup + {% endif %} {% endif %}