Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion docs/en/reference/faq.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,10 @@ Interactive mode walks you through these steps in order:

1. **Project information** — name, author, email, description.
2. **Architecture preset** — picks the project layout. The recommended
default is `domain-starter`; press Enter to accept it.
default is `domain-starter`; press Enter to accept it. See the
[preset / feature matrix](preset-feature-matrix.md) for the exact
layout each preset produces and which feature combinations require
manual wiring.
3. **Feature selections** — database, authentication, background tasks,
caching, monitoring, testing, utilities, deployment.
4. **Package manager and custom packages** — pip / uv / pdm / poetry,
Expand Down
78 changes: 78 additions & 0 deletions docs/en/reference/preset-feature-matrix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# Architecture preset / feature matrix

Interactive `fastkit init --interactive` asks for an **architecture preset**
([issue #44](https://github.com/bnbong/FastAPI-fastkit/issues/44)) before
collecting feature selections. The preset shapes the generated project's
layout — different presets ship a different base template and put generated
config files in different locations so they sit next to the existing
structure rather than in a parallel `src/config/` tree.

This page is the source of truth for what each preset does, where files
land, and which feature combinations require manual wiring.

## Preset → base template

| Preset | Base template | Description |
|---|---|---|
| `minimal` | `fastapi-empty` | Smallest viable FastAPI app — placeholder `main.py` is regenerated from your feature selections. |
| `single-module` | `fastapi-single-module` | Single-file FastAPI app — `main.py` is regenerated. |
| `classic-layered` | `fastapi-default` | Layered split (`api/routes`, `crud`, `schemas`, `core`). Shipped `main.py` is preserved. |
| `domain-starter` | `fastapi-domain-starter` | Domain-oriented (`src/app/domains/<concept>/`). Shipped `main.py` is preserved. **Recommended default.** |

## Generated file locations

| Preset | `main.py` overlay | Database config target | Auth config target |
|---|---|---|---|
| `minimal` | regenerated at `src/main.py` | `src/config/database.py` | `src/config/auth.py` |
| `single-module` | regenerated at `src/main.py` | `src/config/database.py` | `src/config/auth.py` |
| `classic-layered` | preserved (template-shipped) | `src/core/database.py` | `src/core/auth.py` |
| `domain-starter` | preserved (template-shipped) | `src/app/core/database.py` | `src/app/core/auth.py` |

## Database / auth feature support per preset

These features are supported across **every** preset — the package install
always succeeds; the difference is whether the dynamic `main.py` overlay
also wires them up automatically.

| Feature | `minimal` / `single-module` | `classic-layered` / `domain-starter` |
|---|---|---|
| **Database** (PostgreSQL, MySQL, SQLite, MongoDB) | Generates the config module **and** stubs `await init_db()` calls in the regenerated `main.py`. | Generates the config module at the preset's path. The shipped `main.py` is **preserved**, so wire `get_db()` into routers manually. |
| **Authentication** (JWT, FastAPI-Users, OAuth2, Session-based) | Generates the auth config module. JWT also imports `HTTPBearer` in the regenerated `main.py`. | Generates the auth config module at the preset's path. No imports added to `main.py` — wire dependencies manually. |
| **Background tasks** (Celery, Dramatiq) | Packages installed; no main.py overlay today. | Same. |
| **Caching** (Redis) | Packages installed; no main.py overlay today. | Same. |
| **CORS** (utility) | `CORSMiddleware` added to the regenerated `main.py` with `allow_origins=['*']`. | **Already wired** in the shipped `main.py` (conditional on `settings.all_cors_origins`). Activate by setting `BACKEND_CORS_ORIGINS` in `.env` — no code edits required. |
| **Testing** (Basic / Coverage / Advanced) | `pytest.ini` is generated at the project root. | Same. |
| **Deployment** (Docker, docker-compose) | `Dockerfile` and/or `docker-compose.yml` written at the project root. | Same. |

## When you'll see a "Preset compatibility" warning

For presets that **preserve the shipped `main.py`** (`classic-layered`,
`domain-starter`), some feature selections won't be auto-wired into the
app. The CLI surfaces a one-shot warning at the end of generation listing
which selections need manual wiring:

| Selected feature | Triggers a warning under `classic-layered` / `domain-starter`? |
|---|---|
| `CORS` (utility) | ❌ — already wired in the shipped `main.py`. Just populate `BACKEND_CORS_ORIGINS` in `.env`. |
| `Rate-Limiting` (utility) | ✅ — `slowapi` limiter setup is not added |
| `Prometheus` (monitoring) | ✅ — `Instrumentator().instrument(app)` is not called |
| Any database / auth selection | ⚠️ — config files are generated, but you must `Depends()` them into your routers |

For `minimal` and `single-module` presets the dynamic `main.py` overlay
handles CORS, rate-limiting, and Prometheus instrumentation automatically;
no warnings fire.

## Unsupported combinations (stay safe)

The strategist deliberately **does not** attempt to splice generated code
into a template-shipped `main.py`. Doing so would risk producing broken
imports or duplicating routers. The contract is:

- Selected packages are always installed (so `pip freeze` matches the
user's intent).
- Generated config modules always land at the preset-appropriate path.
- For preserve-main presets, the user is told which selections still need
manual wiring instead of getting silently broken code.

If you need full auto-wiring of every feature, pick `minimal` or
`single-module` — they regenerate `main.py` from feature flags.
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ nav:
- Translation Guide: contributing/translation-guide.md
- Reference:
- FAQ: reference/faq.md
- Architecture Preset Matrix: reference/preset-feature-matrix.md
- Template Quality Assurance: reference/template-quality-assurance.md
- Changelog: changelog.md

Expand Down
3 changes: 3 additions & 0 deletions src/fastapi_fastkit/backend/project_builder/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,11 @@
# --------------------------------------------------------------------------
from .config_generator import DynamicConfigGenerator
from .dependency_collector import DependencyCollector
from .preset_layout import PresetLayoutStrategist, PresetProfile

__all__ = [
"DependencyCollector",
"DynamicConfigGenerator",
"PresetLayoutStrategist",
"PresetProfile",
]
20 changes: 14 additions & 6 deletions src/fastapi_fastkit/backend/project_builder/config_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -447,12 +447,20 @@ def _generate_fastapi_users_config(self) -> str:

return "\n".join(content)

def generate_docker_files(self) -> None:
"""Generate Dockerfile and docker-compose.yml."""
def generate_docker_files(self, app_module: str = "src.main:app") -> None:
"""Generate Dockerfile and docker-compose.yml.

The ``app_module`` is the ``module:attr`` string baked into the
Dockerfile's ``CMD`` (and into docker-compose's `command` if the
compose generator ever needs it). Architecture presets that put
the FastAPI app at a non-default location (e.g. ``domain-starter``
ships ``src/app/main.py``) must pass the matching dotted path so
the generated container actually starts.
"""
deployment = self.config.get("deployment", [])

if "Docker" in deployment:
dockerfile_content = self._generate_dockerfile()
dockerfile_content = self._generate_dockerfile(app_module=app_module)
dockerfile_path = self.project_dir / "Dockerfile"
with open(dockerfile_path, "w") as f:
f.write(dockerfile_content)
Expand All @@ -463,8 +471,8 @@ def generate_docker_files(self) -> None:
with open(compose_path, "w") as f:
f.write(compose_content)

def _generate_dockerfile(self) -> str:
"""Generate Dockerfile content."""
def _generate_dockerfile(self, app_module: str = "src.main:app") -> str:
"""Generate Dockerfile content with a layout-aware uvicorn target."""
content = []
content.append(
"# --------------------------------------------------------------------------"
Expand All @@ -487,7 +495,7 @@ def _generate_dockerfile(self) -> str:
content.append("")
content.append("# Run application")
content.append(
'CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"]'
f'CMD ["uvicorn", "{app_module}", "--host", "0.0.0.0", "--port", "8000"]'
)
content.append("")

Expand Down
203 changes: 203 additions & 0 deletions src/fastapi_fastkit/backend/project_builder/preset_layout.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
# --------------------------------------------------------------------------
# Architecture-preset layout strategy for interactive project generation.
#
# Maps each architecture preset (issue #44) to the actual generation
# decisions interactive ``init`` makes:
#
# - which template ships as the base scaffold,
# - whether the dynamic ``main.py`` overlay should overwrite the shipped one,
# - where database / auth config files should land so they sit next to the
# template's existing structure rather than in a parallel ``src/config``,
# - and which feature combinations need a "you must wire this up manually"
# warning because the dynamic ``main.py`` overlay isn't applied.
#
# Keeping every preset's layout knowledge in one place lets the CLI flow stay
# linear ("ask the strategist where to write the file") instead of growing a
# branching maze of preset-specific if/else blocks.
#
# @author bnbong bbbong9@gmail.com
# --------------------------------------------------------------------------
from __future__ import annotations

from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, List, Tuple

# Canonical preset id used when a caller doesn't supply one. Picked to
# preserve pre-#45 behaviour: interactive ``init`` historically deployed
# ``fastapi-empty`` and regenerated ``src/main.py`` from feature flags.
_FALLBACK_PRESET_ID: str = "minimal"


@dataclass(frozen=True)
class PresetProfile:
"""Per-preset generation decisions. Treat as a value object."""

preset_id: str
base_template: str
regenerate_main: bool
main_py_relpath: str
db_config_relpath: str
auth_config_relpath: str
# Hint shown when ``regenerate_main`` is False and the user picked a
# feature whose dynamic main.py overlay won't run. Empty string means
# "no special note for this preset".
manual_wiring_note: str = ""
extra_warning_targets: Tuple[str, ...] = field(default_factory=tuple)


_PRESET_PROFILES: Dict[str, PresetProfile] = {
"minimal": PresetProfile(
preset_id="minimal",
base_template="fastapi-empty",
regenerate_main=True,
main_py_relpath="src/main.py",
db_config_relpath="src/config/database.py",
auth_config_relpath="src/config/auth.py",
),
"single-module": PresetProfile(
preset_id="single-module",
base_template="fastapi-single-module",
regenerate_main=True,
main_py_relpath="src/main.py",
db_config_relpath="src/config/database.py",
auth_config_relpath="src/config/auth.py",
),
"classic-layered": PresetProfile(
preset_id="classic-layered",
base_template="fastapi-default",
regenerate_main=False,
main_py_relpath="src/main.py",
db_config_relpath="src/core/database.py",
auth_config_relpath="src/core/auth.py",
# CORS is intentionally NOT in this list: fastapi-default's shipped
# main.py already imports CORSMiddleware and adds it conditionally
# on settings.all_cors_origins, so the user only has to populate
# BACKEND_CORS_ORIGINS in .env — no code edits needed.
manual_wiring_note=(
"fastapi-default's shipped src/main.py is preserved. The "
"selections below need manual wiring there (CORS is already "
"wired — set BACKEND_CORS_ORIGINS in .env to activate it)."
),
extra_warning_targets=("Rate-Limiting", "Prometheus"),
),
"domain-starter": PresetProfile(
preset_id="domain-starter",
base_template="fastapi-domain-starter",
regenerate_main=False,
main_py_relpath="src/app/main.py",
db_config_relpath="src/app/core/database.py",
auth_config_relpath="src/app/core/auth.py",
manual_wiring_note=(
"fastapi-domain-starter's shipped src/app/main.py is preserved. "
"The selections below need manual wiring there (CORS is already "
"wired — set BACKEND_CORS_ORIGINS in .env to activate it)."
),
extra_warning_targets=("Rate-Limiting", "Prometheus"),
),
}


class PresetLayoutStrategist:
"""Single source of truth for preset → generation-layout decisions."""

def __init__(self, preset_id: str | None) -> None:
# Empty / None / unknown ids fall back to ``minimal`` so older callers
# that pre-date the architecture-preset prompt keep working.
canonical = (preset_id or _FALLBACK_PRESET_ID).strip()
self.profile: PresetProfile = _PRESET_PROFILES.get(
canonical, _PRESET_PROFILES[_FALLBACK_PRESET_ID]
)

@classmethod
def supported_presets(cls) -> List[str]:
"""Return the ordered list of preset ids the strategist understands."""
return list(_PRESET_PROFILES.keys())

@property
def preset_id(self) -> str:
return self.profile.preset_id

@property
def base_template(self) -> str:
return self.profile.base_template

@property
def should_regenerate_main(self) -> bool:
return self.profile.regenerate_main

def main_py_target(self, project_dir: str) -> Path:
"""Absolute path where the dynamic main.py overlay should land."""
return Path(project_dir) / self.profile.main_py_relpath

@property
def app_module(self) -> str:
"""Return the ``module:attr`` string uvicorn / Docker should target.

Derived from ``main_py_relpath`` so docker generation, runserver,
and any future container-orchestration code all agree on the
entrypoint a given preset produces.
"""
# Strip the trailing ``.py`` and convert path separators to dots.
relpath = self.profile.main_py_relpath
if relpath.endswith(".py"):
relpath = relpath[: -len(".py")]
module_part = relpath.replace("/", ".").replace("\\", ".")
return f"{module_part}:app"

def db_config_target(self, project_dir: str) -> Path:
"""Absolute path for the generated database config module."""
return Path(project_dir) / self.profile.db_config_relpath

def auth_config_target(self, project_dir: str) -> Path:
"""Absolute path for the generated authentication config module."""
return Path(project_dir) / self.profile.auth_config_relpath

def compatibility_warnings(self, config: Dict[str, Any]) -> List[str]:
"""Return user-facing warnings for unsupported preset/feature mixes.

The dynamic ``main.py`` overlay (CORS middleware wiring, Prometheus
instrumentation, rate-limit hookup) only runs for presets that
regenerate ``main.py``. For the other presets we keep the
template-shipped ``main.py`` intact and surface a single warning
listing the affected features so users know to wire them up
themselves rather than assuming the package install was enough.
"""
if self.profile.regenerate_main:
return []

affected = self._affected_overlay_targets(config)
if not affected:
return []

warnings: List[str] = []
if self.profile.manual_wiring_note:
warnings.append(self.profile.manual_wiring_note)
warnings.append(
"Affected selections (packages installed, but no dynamic main.py "
"edits applied for the '"
+ self.profile.preset_id
+ "' preset): "
+ ", ".join(affected)
)
return warnings

def _affected_overlay_targets(self, config: Dict[str, Any]) -> List[str]:
"""Detect which of the user's selections rely on main.py overlay."""
triggered: List[str] = []
utilities = set(config.get("utilities") or [])

for target in self.profile.extra_warning_targets:
if target in {"CORS", "Rate-Limiting"}:
if target in utilities:
triggered.append(target)
elif target == "Prometheus":
if config.get("monitoring") == "Prometheus":
triggered.append(target)
return triggered


__all__ = [
"PresetLayoutStrategist",
"PresetProfile",
]
Loading
Loading