From 2d363ea2752dcb1b52f95e98569673ce5f54ce46 Mon Sep 17 00:00:00 2001 From: vastimofeev Date: Tue, 6 Oct 2026 23:07:10 +0700 Subject: [PATCH 1/2] feat: add domain layout support to addroute --- docs/en/tutorial/domain-starter.md | 169 +++---- docs/en/user-guide/adding-routes.md | 79 +++ docs/en/user-guide/cli-reference.md | 50 +- src/fastapi_fastkit/backend/main.py | 71 +-- .../backend/route_generators.py | 156 ++++++ src/fastapi_fastkit/backend/route_wiring.py | 73 +++ src/fastapi_fastkit/cli.py | 15 +- .../src/app/api/router.py-tpl | 2 +- .../src/app/domains/items/router.py-tpl | 2 +- .../modules/domain/__init__.py-tpl | 3 + .../modules/domain/models.py-tpl | 11 + .../modules/domain/repository.py-tpl | 45 ++ .../modules/domain/router.py-tpl | 66 +++ .../modules/domain/schemas.py-tpl | 16 + .../modules/domain/service.py-tpl | 43 ++ tests/test_backends/test_route_generators.py | 473 ++++++++++++++++++ .../test_cli_config_options.py | 47 +- 17 files changed, 1174 insertions(+), 147 deletions(-) create mode 100644 src/fastapi_fastkit/backend/route_generators.py create mode 100644 src/fastapi_fastkit/backend/route_wiring.py create mode 100644 src/fastapi_fastkit/fastapi_project_template/modules/domain/__init__.py-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/modules/domain/models.py-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/modules/domain/repository.py-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/modules/domain/router.py-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/modules/domain/schemas.py-tpl create mode 100644 src/fastapi_fastkit/fastapi_project_template/modules/domain/service.py-tpl create mode 100644 tests/test_backends/test_route_generators.py diff --git a/docs/en/tutorial/domain-starter.md b/docs/en/tutorial/domain-starter.md index 8b99853..7e0dc3e 100644 --- a/docs/en/tutorial/domain-starter.md +++ b/docs/en/tutorial/domain-starter.md @@ -11,7 +11,7 @@ how to generate it, what each top-level package does, how the bundled - Generating a project with `fastkit startdemo fastapi-domain-starter` - The role of `core`, `db`, `domains`, and `tests` in the layout - How a domain is split into router → service → repository → schemas → models -- The contract for adding a new domain (copy the items folder, register the router) +- Adding a new domain with `fastkit addroute` and isolating its storage in tests - How the bundled `/health` endpoint and `/api/v1/items` CRUD plug into the app ## Prerequisites @@ -83,7 +83,7 @@ orders-api/ │ ├── schemas.py # ItemCreate, ItemRead (pydantic) │ ├── repository.py # ItemRepository over InMemoryStore │ ├── service.py # ItemService + ItemNotFoundError -│ └── router.py # APIRouter(prefix="/items") +│ └── router.py # APIRouter endpoints └── tests/ ├── __init__.py ├── conftest.py # TestClient fixture, store reset @@ -163,7 +163,7 @@ Two pieces: # src/app/api/router.py api_router = APIRouter() api_router.include_router(health.router) -api_router.include_router(items_router.router) +api_router.include_router(items_router.router, prefix="/items", tags=["items"]) ``` ```python @@ -284,7 +284,7 @@ maps `ItemNotFoundError` → `HTTPException(404)`: ```python # src/app/domains/items/router.py -router = APIRouter(prefix="/items", tags=["items"]) +router = APIRouter() def get_item_service() -> ItemService: return ItemService() @@ -297,6 +297,13 @@ def get_item(item_id: int, service: ItemService = Depends(get_item_service)) -> raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) ``` +The shared API router sets the domain prefix and tags: + +```python +# src/app/api/router.py +api_router.include_router(items_router.router, prefix="/items", tags=["items"]) +``` + The full router exposes: | Method | Path | What it does | @@ -325,108 +332,92 @@ $ curl http://127.0.0.1:8000/api/v1/items/999 ## Step 5: Add your next domain -The starter is designed so that **adding a domain is a copy-rename -operation**. Say you want a `users` domain alongside `items`: - -### 1. Copy the `items/` folder +From the generated project's root, add a `users` domain: ```console -$ cp -r src/app/domains/items src/app/domains/users +$ fastkit addroute users ``` -### 2. Rewrite the entity, schemas, and per-file class names - -```python -# src/app/domains/users/models.py -from dataclasses import dataclass +The command reads `[tool.fastapi-fastkit].preset`. For `domain-starter` it +automatically chooses the domain layout, which is displayed before confirmation. +You can also select it explicitly: -@dataclass -class User: - id: int - email: str - is_active: bool = True +```console +$ fastkit addroute users . --layout=domain ``` -```python -# src/app/domains/users/schemas.py -from pydantic import BaseModel, ConfigDict, Field - -class UserCreate(BaseModel): - # Plain ``str`` keeps the snippet drop-in safe. To use pydantic's - # built-in email validation instead, install the optional dependency - # (``pip install 'pydantic[email]'`` — pulls in ``email-validator``) - # and switch ``str`` to ``EmailStr``. - email: str = Field(min_length=3, max_length=320) - is_active: bool = True - -class UserRead(BaseModel): - id: int - email: str - is_active: bool - model_config = ConfigDict(from_attributes=True) +`--layout=classic-layer` generates the traditional `api/routes`, `crud`, and +`schemas` structure. That is also the default for `classic-layered`, `minimal`, +`single-module`, and projects without a preset. An unknown preset falls back to +`classic-layer` with a warning. The option does not change the project's preset. + +### Generated domain + +```text +src/app/domains/users/ +├── __init__.py +├── models.py # Users with id and name +├── schemas.py # UsersCreate and UsersRead +├── repository.py # UsersRepository: typed, in-memory CRUD +├── service.py # UsersService and UsersNotFoundError +└── router.py # endpoints and get_users_service dependency ``` -Rename `Item → User`, `ItemNotFoundError → UserNotFoundError`, -`ItemRepository → UserRepository`, `ItemService → UserService` across -`models.py`, `schemas.py`, `repository.py`, `service.py`, and -`router.py`. Don't forget `prefix="/items"` → `prefix="/users"` and -`tags=["items"]` → `tags=["users"]` in the router. +The new domain uses PascalCase class names derived from the route name, so +there is no need to infer a singular name from `users`. Customize these files +to introduce the fields and behavior your business concept requires. -The repository can keep the same `InMemoryStore`-backed pattern — it's -generic over the entity type: +The router is registered automatically in `src/app/api/router.py` with +`prefix="/users"` and `tags=["users"]`. Domain routers use `APIRouter()` without +these settings. The existing application prefix is preserved, exposing these +endpoints: -```python -# src/app/domains/users/repository.py -_store: InMemoryStore[User] = InMemoryStore() +| Method | Endpoint | Result | +|--------|----------|--------| +| GET | `/api/v1/users` | List entities, 200 | +| GET | `/api/v1/users/{entity_id}` | Read entity, 200 or 404 | +| POST | `/api/v1/users` | Create entity, 201 | +| PUT | `/api/v1/users/{entity_id}` | Replace entity, 200 or 404 | +| DELETE | `/api/v1/users/{entity_id}` | Delete entity, empty 204 or 404 | -class UserRepository: - def __init__(self, store: Optional[InMemoryStore[User]] = None) -> None: - self._store = store if store is not None else _store - # ... same shape as ItemRepository ... -``` +POST and PUT accept `{"name": "Alice"}`; `name` must have 1–120 characters. +The repository assigns an ID starting at 1. PUT preserves that ID. No PATCH +endpoint is generated. -### 3. Update the domain `__init__.py` +The new repository is self-contained; it does not use the bundled items +domain's `db.memory` store. Each explicit repository instance has independent +storage, while the default instance is shared between requests in this domain +and process. CRUD operations are protected by a lock. Data is lost on restart +and is not shared between worker processes; replace the repository when you +need durable storage. -The items domain re-exports its modules so callers can write -`from src.app.domains.items import service`. Mirror that for users: +### Add isolated tests -```python -# src/app/domains/users/__init__.py -from src.app.domains.users import ( # noqa: F401 - models, - repository, - router, - schemas, - service, -) -``` - -### 4. Register the router in the aggregator - -This is the **only file outside `domains/users/` you need to touch**: +The items-specific reset fixture does not reset the new domain. Override its +service dependency with a fresh repository for each test: ```python -# src/app/api/router.py -from src.app.api import health -from src.app.domains.items import router as items_router -from src.app.domains.users import router as users_router # ← add +from src.app.domains.users.repository import UsersRepository +from src.app.domains.users.router import get_users_service +from src.app.domains.users.service import UsersService -api_router = APIRouter() -api_router.include_router(health.router) -api_router.include_router(items_router.router) -api_router.include_router(users_router.router) # ← add -``` - -After a server restart you'll see `/api/v1/users` mounted in `/docs`. +def test_create_user(client): + from src.app.main import app -### 5. Add tests - -Mirror `tests/test_items.py` as `tests/test_users.py` — same -client-driven shape, just hit the new endpoints. The autouse store-reset -fixture in `conftest.py` already keeps each test isolated. + service = UsersService(repository=UsersRepository()) + app.dependency_overrides[get_users_service] = lambda: service + try: + response = client.post("/api/v1/users", json={"name": "Alice"}) + assert response.status_code == 201 + assert response.json() == {"id": 1, "name": "Alice"} + finally: + app.dependency_overrides.pop(get_users_service, None) +``` -If you add a second domain that also uses `InMemoryStore`, broaden the -fixture to reset its store too, or keep one fixture per domain. +Re-running `addroute` preserves existing domain files, creates missing files, +and avoids duplicate imports and registrations. Same-named classic modules +can coexist through import aliases. Check HTTP paths and methods yourself when +combining routers; the generator does not detect overlapping endpoints. ## Step 6: Where to go next @@ -449,6 +440,6 @@ fixture to reset its store too, or keep one fixture per domain. - **Layout**: `core/` for config, `db/` for persistence abstractions, `domains//` for business slices, `api/router.py` as the single aggregation point, `tests/` mirroring runtime modules. -- **Adding a domain**: copy `items/`, rename entity / schemas / classes, - update the `__init__.py` re-exports, register the router in - `src/app/api/router.py`, add a test module. No edits to `main.py`. +- **Adding a domain**: run `fastkit addroute users`, customize the generated + `Users*` CRUD scaffold, and add tests with isolated repositories. + The router is registered automatically. diff --git a/docs/en/user-guide/adding-routes.md b/docs/en/user-guide/adding-routes.md index ddb521e..9683e28 100644 --- a/docs/en/user-guide/adding-routes.md +++ b/docs/en/user-guide/adding-routes.md @@ -2,8 +2,87 @@ Learn how to add new API routes to your existing FastAPI project. +## Choose the route layout + +`addroute` accepts `--layout=classic-layer` or `--layout=domain`. When omitted, +it reads `[tool.fastapi-fastkit].preset`: `domain-starter` selects `domain`; +`classic-layered`, `minimal`, `single-module`, and projects without a preset +select `classic-layer`. An unknown preset falls back to `classic-layer` with +a warning. An explicit option overrides this selection without changing the +project's metadata. + +```console +$ cd my-api +$ fastkit addroute users . --layout=domain +$ fastkit addroute products . --layout=classic-layer +``` + +The selected layout is displayed before confirmation. Files are placed under +the application's actual package (`src/` or `src/app/`), using its recorded +entrypoint when available. + +## Generate a domain CRUD + +For a `domain-starter` project, simply run: + +```console +$ fastkit addroute users +``` + +This creates the following files and registers the domain router automatically: + +```text +src/app/domains/users/ +├── __init__.py +├── models.py # Users(id: int, name: str) +├── schemas.py # UsersCreate for POST/PUT, UsersRead for responses +├── repository.py # typed CRUD operations and in-memory storage +├── service.py # business logic and UsersNotFoundError +└── router.py # HTTP endpoints and overridable service dependency +``` + +The classes are named `Users`, `UsersCreate`, `UsersRead`, `UsersRepository`, +and `UsersService` within each domain; class names use PascalCase (`health_checks` becomes `HealthChecks`), without singularization. +The repository assigns IDs starting at 1. POST and PUT require a `name` between +1 and 120 characters; PUT preserves the existing ID. + +| Method | Domain path | Result | +|--------|-------------|--------| +| GET | `/users` | List entities, 200 | +| GET | `/users/{entity_id}` | Read entity, 200 or 404 | +| POST | `/users` | Create entity, 201 | +| PUT | `/users/{entity_id}` | Replace entity, 200 or 404 | +| DELETE | `/users/{entity_id}` | Delete entity, empty 204 or 404 | + +The domain prefix and tags are set in the shared API router when it calls +`include_router`, as in the classic layout. + +The application's existing API prefix remains in effect (for example, +`/api/v1/users`). Domain routes do not include PATCH. + +The generated repository is self-contained and locks its CRUD operations. Its +default instance is shared between requests **within this domain and process**. +Data is lost on restart and is not shared between worker processes. Adapt the +repository for durable persistence; it has no dependency on the starter's +`db.memory`, ORM, or database configuration. + +For isolated tests, inject a fresh `UsersRepository` into `UsersService`, +then override the router's `get_users_service` FastAPI dependency. Explicitly +created repository instances have independent storage. + +## Re-running the command + +Existing module files are preserved; missing files are created. Imports and +router registrations are not duplicated. Same-named modules in different layouts +can coexist; import aliases avoid Python name collisions. HTTP method/path +conflicts are not checked by the generator. `--layout` does not migrate existing +modules. + ## Basic Route Addition +The following examples describe the `classic-layer` layout. Pass +`--layout=classic-layer` explicitly when using a domain-starter project. + ### Using the `addroute` Command FastAPI-fastkit's `addroute` command makes it easy to add new routes: diff --git a/docs/en/user-guide/cli-reference.md b/docs/en/user-guide/cli-reference.md index 56b1e9d..de27975 100644 --- a/docs/en/user-guide/cli-reference.md +++ b/docs/en/user-guide/cli-reference.md @@ -355,6 +355,7 @@ $ fastkit addroute ROUTE_NAME [PROJECT_DIR] [OPTIONS] | Option | Description | Default | |--------|-------------|---------| +| `--layout [classic-layer\|domain]` | Structure of the generated route module; overrides preset metadata | Automatic from preset | | `--help` | Show command help | - | #### Examples @@ -390,25 +391,56 @@ $ fastkit addroute users my-api #### Generated Files -Creates these files in the project: +With `--layout=classic-layer`, creates these files under the app package: - `src/api/routes/users.py` - Route handlers - `src/crud/users.py` - CRUD operations - `src/schemas/users.py` - Pydantic schemas -Also updates `src/api/api.py` to include the new router. +With `--layout=domain`, creates `domains/users/` containing `__init__.py`, +`models.py`, `schemas.py`, `repository.py`, `service.py`, and `router.py`. +The entity has `id: int` and `name: str`, with a working in-memory repository. + +Both layouts update the existing API router and connect it to the application +if necessary. Paths follow the actual app package, including `src/app/`. + +Without the option, `[tool.fastapi-fastkit].preset = "domain-starter"` selects +`domain`. `classic-layered`, `minimal`, `single-module`, and an absent preset +select `classic-layer`. An unknown preset falls back to `classic-layer` with a +warning. Explicit selection does not change the project's preset. + +```console +$ fastkit addroute users . --layout=domain +$ fastkit addroute products . --layout=classic-layer +``` + +The command displays its selected layout before confirmation. Re-running it +preserves existing files, restores missing files, and avoids duplicate router +registrations. Same-named modules of different layouts can coexist through +import aliases. The generator does not check HTTP method/path conflicts. +Migration is manual. #### Generated Endpoints -Creates full CRUD endpoints: +The domain layout creates these endpoints (shown with `/api/v1` as the app's +existing prefix): | Method | Endpoint | Description | |--------|----------|-------------| -| `GET` | `/api/v1/users/` | Get all users | -| `POST` | `/api/v1/users/` | Create new user | -| `GET` | `/api/v1/users/{user_id}` | Get specific user | -| `PUT` | `/api/v1/users/{user_id}` | Update user | -| `DELETE` | `/api/v1/users/{user_id}` | Delete user | +| `GET` | `/api/v1/users` | List entities, 200 | +| `POST` | `/api/v1/users` | Create entity, 201 | +| `GET` | `/api/v1/users/{entity_id}` | Read entity, 200 or 404 | +| `PUT` | `/api/v1/users/{entity_id}` | Replace entity, 200 or 404 | +| `DELETE` | `/api/v1/users/{entity_id}` | Delete entity, empty 204 or 404 | + +POST and PUT accept a `name` of 1–120 characters. IDs are assigned by the +repository, and PUT preserves them. No PATCH endpoint is generated for domains. +Storage is process-local, lost on restart, and independent between domains; +replace the repository for durable persistence. Tests can inject a fresh +repository through the service and override `get_users_service`. + +The classic layout retains its existing GET/POST/PUT/PATCH/DELETE stubs at +`//` for you to implement. #### Where the code is inserted @@ -429,7 +461,7 @@ api_router.include_router(health.router) ``` Keep those comments in place when you edit the file — `addroute` inserts -directly above `# fastkit:imports` and `# fastkit:routes`. A project that +directly below `# fastkit:imports` and `# fastkit:routes`. A project that lost them (or was generated before the anchors existed) still works: fastkit falls back to an AST-based insertion that finds the import block and the router registrations itself. The anchors simply make the result predictable. diff --git a/src/fastapi_fastkit/backend/main.py b/src/fastapi_fastkit/backend/main.py index 490b9dd..451135e 100644 --- a/src/fastapi_fastkit/backend/main.py +++ b/src/fastapi_fastkit/backend/main.py @@ -18,6 +18,7 @@ from fastapi_fastkit.backend.project_builder.preset_layout import ( app_module_from_main_path, ) +from fastapi_fastkit.backend.route_wiring import register_router, router_alias from fastapi_fastkit.backend.transducer import ( copy_and_convert_template, copy_and_convert_template_file, @@ -1464,6 +1465,16 @@ def _handle_api_router_file( if os.path.exists(api_source): copy_and_convert_template_file(api_source, api_router_file) + # The __init__ template may be empty; initialize the API router. + if os.path.exists(api_router_file): + with open(api_router_file, "r", encoding="utf-8") as f: + content = f.read() + if not content.strip(): + with open(api_router_file, "w", encoding="utf-8") as f: + f.write( + "from fastapi import APIRouter\n\napi_router = APIRouter()\n" + ) + # Update API router to include new route if os.path.exists(api_router_file): _update_api_router(api_router_file, route_name) @@ -1484,23 +1495,28 @@ def _update_api_router(api_router_file: str, route_name: str) -> None: with open(api_router_file, "r", encoding="utf-8") as f: content = f.read() - route_import = f"from .routes import {route_name}" + alias = router_alias(api_router_file, "routes", route_name, route_name) + suffix = f" as {alias}" if alias != route_name else "" + route_import = f"from .routes import {route_name}{suffix}" route_include = ( - f"api_router.include_router({route_name}.router, " + f"api_router.include_router({alias}.router, " f'prefix="/{route_name}", tags=["{route_name}"])' ) if ( route_import in content - and f"api_router.include_router({route_name}.router" in content + and f"api_router.include_router({alias}.router" in content ): return # Already included - content = insert_import_line(content, route_import) - content = insert_statement_line(content, route_include) - - with open(api_router_file, "w", encoding="utf-8") as f: - f.write(content) + register_router( + { + "api_dir": os.path.dirname(api_router_file), + "api_router_file": api_router_file, + }, + route_import, + route_include, + ) debug_log(f"Updated API router to include {route_name}", "info") @@ -1594,7 +1610,9 @@ def _update_main_app( print_warning(f"Failed to update main.py: {e}") -def add_new_route(project_dir: str, route_name: str) -> None: +def add_new_route( + project_dir: str, route_name: str, layout: Optional[str] = None +) -> None: """ Add a new API route to an existing FastAPI project. @@ -1604,37 +1622,20 @@ def add_new_route(project_dir: str, route_name: str) -> None: :param project_dir: Path to the project directory :param route_name: Name of the new route to add + :param layout: Explicit route layout, or None to select from preset metadata :raises BackendExceptions: If route addition fails """ try: - # Setup paths - modules_dir = os.path.join(settings.FASTKIT_TEMPLATE_ROOT, "modules") - layout = resolve_project_layout(project_dir) - src_dir = layout["package_dir"] - - # Ensure project structure exists - target_dirs = _ensure_project_structure(src_dir) - - # Create route files - _create_route_files( - modules_dir, target_dirs, route_name, layout["package_module"] + # Import here to avoid a circular import with route_generators. + from fastapi_fastkit.backend.route_generators import ( + get_route_generator, + resolve_route_layout, ) - # Handle API router file - _handle_api_router_file( - target_dirs, modules_dir, route_name, layout["api_router_file"] - ) - - # Process init files - module_types = ["api/routes", "crud", "schemas"] - _process_init_files(modules_dir, target_dirs, module_types) - - # Update main application - _update_main_app( - src_dir, - route_name, - router_module=layout["api_router_module"], - main_py_path=layout["main"], + route_layout = resolve_route_layout(project_dir, layout) + project_layout = resolve_project_layout(project_dir) + get_route_generator(route_layout).add_new_route( + project_dir, route_name, project_layout ) debug_log(f"Successfully added new route: {route_name}", "info") diff --git a/src/fastapi_fastkit/backend/route_generators.py b/src/fastapi_fastkit/backend/route_generators.py new file mode 100644 index 0000000..d8f01e8 --- /dev/null +++ b/src/fastapi_fastkit/backend/route_generators.py @@ -0,0 +1,156 @@ +# -------------------------------------------------------------------------- +# Route generators for fastkit addroute. +# -------------------------------------------------------------------------- +import keyword +import os +from pathlib import Path +from typing import Dict, Optional, Union + +from fastapi_fastkit.backend import main as backend +from fastapi_fastkit.backend.route_wiring import register_router, router_alias +from fastapi_fastkit.backend.transducer import copy_and_convert_template_file +from fastapi_fastkit.core.exceptions import BackendExceptions +from fastapi_fastkit.core.settings import settings +from fastapi_fastkit.utils.main import print_warning, read_fastkit_metadata + +ROUTE_LAYOUTS = ("classic-layer", "domain") + + +def resolve_route_layout(project_dir: str, layout: Optional[str] = None) -> str: + """Resolve the route layout from the option or project preset.""" + if layout is not None: + if layout not in ROUTE_LAYOUTS: + raise BackendExceptions(f"Unknown route layout: {layout!r}") + return layout + + preset = read_fastkit_metadata(project_dir).get("preset") + if preset == "domain-starter": + return "domain" + if preset and preset not in ("classic-layered", "minimal", "single-module"): + print_warning(f"Unknown preset {preset!r}; using classic-layer route layout.") + return "classic-layer" + + +def _validate_route_name(route_name: str) -> None: + """Validate the route module name.""" + if not route_name.isidentifier() or keyword.iskeyword(route_name): + raise BackendExceptions(f"Route name {route_name!r} is not a Python identifier") + + +class ClassicLayeredRouteGenerator: + """Generate api/routes, crud, and schemas modules.""" + + def add_new_route( + self, project_dir: str, route_name: str, project_layout: Dict[str, str] + ) -> None: + """Create route files and register the router.""" + src_dir = project_layout["package_dir"] + _validate_route_name(route_name) + modules_dir = os.path.join(settings.FASTKIT_TEMPLATE_ROOT, "modules") + target_dirs = backend._ensure_project_structure(src_dir) + backend._create_route_files( + modules_dir, target_dirs, route_name, project_layout["package_module"] + ) + backend._handle_api_router_file( + target_dirs, modules_dir, route_name, project_layout["api_router_file"] + ) + backend._process_init_files( + modules_dir, target_dirs, ["api/routes", "crud", "schemas"] + ) + backend._update_main_app( + src_dir, + route_name, + router_module=project_layout["api_router_module"], + main_py_path=project_layout["main"], + ) + + +class DomainRouteGenerator: + """Generate a domain module.""" + + def add_new_route( + self, project_dir: str, route_name: str, project_layout: Dict[str, str] + ) -> None: + """Create domain files and register the router.""" + package = Path(project_layout["package_dir"]) + _validate_route_name(route_name) + if not package.is_dir(): + raise BackendExceptions(f"Source directory not found at {package}") + + domain_module = _create_domain_files(package, route_name, project_layout) + alias = router_alias( + project_layout["api_router_file"], + domain_module, + "router", + f"{route_name}_router", + ) + register_router( + project_layout, + f"from {domain_module} import router as {alias}", + f'api_router.include_router({alias}.router, prefix="/{route_name}", tags=["{route_name}"])', + ) + backend._update_main_app( + str(package), + route_name, + router_module=project_layout["api_router_module"], + main_py_path=project_layout["main"], + ) + + +def _create_domain_files( + package: Path, route_name: str, project_layout: Dict[str, str] +) -> str: + templates = Path(settings.FASTKIT_TEMPLATE_ROOT) / "modules" / "domain" + filenames = ( + "__init__.py", + "models.py", + "schemas.py", + "repository.py", + "service.py", + "router.py", + ) + for filename in filenames: + if not (templates / f"{filename}-tpl").is_file(): + raise BackendExceptions(f"Missing domain template: {filename}-tpl") + + domain_dir = package / "domains" / route_name + domain_dir.mkdir(parents=True, exist_ok=True) + domains_init = domain_dir.parent / "__init__.py" + if not domains_init.exists(): + domains_init.write_text("", encoding="utf-8") + prefix = project_layout["package_module"] + domain_module = ( + f"{prefix}.domains.{route_name}" if prefix else f"domains.{route_name}" + ) + entity_class = ( + "".join(part[:1].upper() + part[1:] for part in route_name.split("_")) or "_" + ) + if keyword.iskeyword(entity_class): + entity_class += "Entity" + replacements = { + "": route_name, + "": domain_module, + "": entity_class, + } + for filename in filenames: + target = domain_dir / filename + if target.exists(): + print_warning(f"File {target} already exists, skipping...") + continue + if not copy_and_convert_template_file( + str(templates / f"{filename}-tpl"), str(target), replacements + ): + raise BackendExceptions(f"Failed to create domain file: {target}") + + return domain_module + + +def get_route_generator( + route_layout: str, +) -> Union[ClassicLayeredRouteGenerator, DomainRouteGenerator]: + """Return the generator for the selected layout.""" + if route_layout == "classic-layer": + return ClassicLayeredRouteGenerator() + if route_layout == "domain": + return DomainRouteGenerator() + raise BackendExceptions(f"Unknown route layout: {route_layout!r}") diff --git a/src/fastapi_fastkit/backend/route_wiring.py b/src/fastapi_fastkit/backend/route_wiring.py new file mode 100644 index 0000000..0d5a57b --- /dev/null +++ b/src/fastapi_fastkit/backend/route_wiring.py @@ -0,0 +1,73 @@ +"""Router registration helpers.""" + +import ast +from pathlib import Path +from typing import Dict + + +def register_router( + project_layout: Dict[str, str], import_line: str, statement: str +) -> None: + """Register a router in the API router module.""" + + from fastapi_fastkit.backend.main import insert_import_line, insert_statement_line + + api_dir = Path(project_layout["api_dir"]) + api_dir.mkdir(parents=True, exist_ok=True) + init = api_dir / "__init__.py" + if not init.exists(): + init.write_text("", encoding="utf-8") + target = Path(project_layout["api_router_file"]) + content = target.read_text(encoding="utf-8") if target.exists() else "" + if not content.strip(): + content = "from fastapi import APIRouter\n\napi_router = APIRouter()\n" + registration = ast.parse(statement).body[0] + already_registered = False + if isinstance(registration, ast.Expr) and isinstance(registration.value, ast.Call): + expected = registration.value + already_registered = any( + isinstance(node, ast.Call) + and ast.dump(node.func) == ast.dump(expected.func) + and node.args + and expected.args + and ast.dump(node.args[0]) == ast.dump(expected.args[0]) + for node in ast.walk(ast.parse(content)) + ) + updated = insert_import_line(content, import_line) + if not already_registered: + updated = insert_statement_line(updated, statement) + if not target.exists() or updated != content: + target.write_text(updated, encoding="utf-8") + + +def router_alias(file: str, module: str, name: str, preferred: str) -> str: + """Reuse the router import alias or choose an unused name.""" + + target = Path(file) + if not target.exists(): + return preferred + tree = ast.parse(target.read_text(encoding="utf-8")) + occupied: set[str] = set() + for node in tree.body: + if isinstance(node, ast.ImportFrom): + for imported in node.names: + if node.module == module and imported.name == name: + return imported.asname or name + occupied.add(imported.asname or imported.name) + elif isinstance(node, ast.Import): + occupied.update( + item.asname or item.name.split(".")[0] for item in node.names + ) + elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + occupied.add(node.name) + elif isinstance(node, (ast.Assign, ast.AnnAssign)): + targets = node.targets if isinstance(node, ast.Assign) else [node.target] + occupied.update( + target.id for target in targets if isinstance(target, ast.Name) + ) + alias = preferred + counter = 2 + while alias in occupied: + alias = f"{preferred}_{counter}" + counter += 1 + return alias diff --git a/src/fastapi_fastkit/cli.py b/src/fastapi_fastkit/cli.py index b96e0cc..bbe1868 100644 --- a/src/fastapi_fastkit/cli.py +++ b/src/fastapi_fastkit/cli.py @@ -37,6 +37,7 @@ ConfigSchemaError, normalize_project_config, ) +from fastapi_fastkit.backend.route_generators import resolve_route_layout from fastapi_fastkit.backend.scaffolder import ( ProjectScaffolder, ScaffoldOptions, @@ -829,8 +830,16 @@ def _init_from_config( @fastkit_cli.command() @click.argument("route_name") @click.argument("project_dir", default=".") +@click.option( + "--layout", + type=click.Choice(["classic-layer", "domain"]), + default=None, + help="Route layout. Defaults to domain for domain-starter, otherwise classic-layer.", +) @click.pass_context -def addroute(ctx: Context, route_name: str, project_dir: str) -> None: +def addroute( + ctx: Context, route_name: str, project_dir: str, layout: Optional[str] +) -> None: """ Add a new route to the FastAPI project. @@ -879,12 +888,14 @@ def addroute(ctx: Context, route_name: str, project_dir: str) -> None: return try: + route_layout = resolve_route_layout(actual_project_dir, layout) # Show information about the operation table = create_info_table( "Adding New Route", { "Project": project_name, "Route Name": route_name, + "Layout": route_layout, "Target Directory": actual_project_dir, }, ) @@ -903,7 +914,7 @@ def addroute(ctx: Context, route_name: str, project_dir: str) -> None: return # Add the new route - add_new_route(actual_project_dir, route_name) + add_new_route(actual_project_dir, route_name, layout=route_layout) if project_dir == ".": print_success( diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-domain-starter/src/app/api/router.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-domain-starter/src/app/api/router.py-tpl index a7dbf2d..37ef8bb 100644 --- a/src/fastapi_fastkit/fastapi_project_template/fastapi-domain-starter/src/app/api/router.py-tpl +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-domain-starter/src/app/api/router.py-tpl @@ -11,5 +11,5 @@ from src.app.domains.items import router as items_router api_router = APIRouter() api_router.include_router(health.router) -api_router.include_router(items_router.router) +api_router.include_router(items_router.router, prefix="/items", tags=["items"]) # fastkit:routes diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-domain-starter/src/app/domains/items/router.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-domain-starter/src/app/domains/items/router.py-tpl index 6b3fde6..998e188 100644 --- a/src/fastapi_fastkit/fastapi_project_template/fastapi-domain-starter/src/app/domains/items/router.py-tpl +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-domain-starter/src/app/domains/items/router.py-tpl @@ -7,7 +7,7 @@ from fastapi import APIRouter, Depends, HTTPException, Response, status from src.app.domains.items.schemas import ItemCreate, ItemRead from src.app.domains.items.service import ItemNotFoundError, ItemService -router = APIRouter(prefix="/items", tags=["items"]) +router = APIRouter() def get_item_service() -> ItemService: diff --git a/src/fastapi_fastkit/fastapi_project_template/modules/domain/__init__.py-tpl b/src/fastapi_fastkit/fastapi_project_template/modules/domain/__init__.py-tpl new file mode 100644 index 0000000..3bfa6ca --- /dev/null +++ b/src/fastapi_fastkit/fastapi_project_template/modules/domain/__init__.py-tpl @@ -0,0 +1,3 @@ +# -------------------------------------------------------------------------- +# domain. +# -------------------------------------------------------------------------- diff --git a/src/fastapi_fastkit/fastapi_project_template/modules/domain/models.py-tpl b/src/fastapi_fastkit/fastapi_project_template/modules/domain/models.py-tpl new file mode 100644 index 0000000..574fb7f --- /dev/null +++ b/src/fastapi_fastkit/fastapi_project_template/modules/domain/models.py-tpl @@ -0,0 +1,11 @@ +# -------------------------------------------------------------------------- +# domain models. +# -------------------------------------------------------------------------- + +from dataclasses import dataclass + + +@dataclass +class : + id: int + name: str diff --git a/src/fastapi_fastkit/fastapi_project_template/modules/domain/repository.py-tpl b/src/fastapi_fastkit/fastapi_project_template/modules/domain/repository.py-tpl new file mode 100644 index 0000000..c89a58e --- /dev/null +++ b/src/fastapi_fastkit/fastapi_project_template/modules/domain/repository.py-tpl @@ -0,0 +1,45 @@ +# -------------------------------------------------------------------------- +# repository. +# +# Data is stored in memory, lost on restart, and not shared between workers. +# -------------------------------------------------------------------------- + +from threading import Lock + +from .models import + + +class Repository: + """In-memory storage for .""" + + def __init__(self) -> None: + self._data: dict[int, ] = {} + self._next_id = 1 + self._lock = Lock() + + def list_all(self) -> list[]: + with self._lock: + return list(self._data.values()) + + def get(self, entity_id: int) -> | None: + with self._lock: + return self._data.get(entity_id) + + def add(self, name: str) -> : + with self._lock: + entity = (id=self._next_id, name=name) + self._data[entity.id] = entity + self._next_id += 1 + return entity + + def replace(self, entity_id: int, name: str) -> | None: + with self._lock: + if entity_id not in self._data: + return None + entity = (id=entity_id, name=name) + self._data[entity_id] = entity + return entity + + def delete(self, entity_id: int) -> bool: + with self._lock: + return self._data.pop(entity_id, None) is not None diff --git a/src/fastapi_fastkit/fastapi_project_template/modules/domain/router.py-tpl b/src/fastapi_fastkit/fastapi_project_template/modules/domain/router.py-tpl new file mode 100644 index 0000000..e6a5f52 --- /dev/null +++ b/src/fastapi_fastkit/fastapi_project_template/modules/domain/router.py-tpl @@ -0,0 +1,66 @@ +# -------------------------------------------------------------------------- +# API routes. +# -------------------------------------------------------------------------- + +from fastapi import APIRouter, Depends, HTTPException, Response, status +from .schemas import Create, Read +from .service import NotFoundError, Service + +router = APIRouter() + + +def get__service() -> Service: + return Service() + + +@router.get("", response_model=list[Read]) +def list_( + service: Service = Depends(get__service), +) -> list[Read]: + return [Read.model_validate(entity) for entity in service.list_()] + + +@router.get("/{entity_id}", response_model=Read) +def get_( + entity_id: int, service: Service = Depends(get__service) +) -> Read: + try: + return Read.model_validate(service.get_(entity_id)) + except NotFoundError as exc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail=str(exc) + ) from exc + + +@router.post("", response_model=Read, status_code=status.HTTP_201_CREATED) +def create_( + payload: Create, service: Service = Depends(get__service) +) -> Read: + return Read.model_validate(service.create_(payload)) + + +@router.put("/{entity_id}", response_model=Read) +def replace_( + entity_id: int, + payload: Create, + service: Service = Depends(get__service), +) -> Read: + try: + return Read.model_validate(service.replace_(entity_id, payload)) + except NotFoundError as exc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail=str(exc) + ) from exc + + +@router.delete("/{entity_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_( + entity_id: int, service: Service = Depends(get__service) +) -> Response: + try: + service.delete_(entity_id) + except NotFoundError as exc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail=str(exc) + ) from exc + return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/src/fastapi_fastkit/fastapi_project_template/modules/domain/schemas.py-tpl b/src/fastapi_fastkit/fastapi_project_template/modules/domain/schemas.py-tpl new file mode 100644 index 0000000..6b60964 --- /dev/null +++ b/src/fastapi_fastkit/fastapi_project_template/modules/domain/schemas.py-tpl @@ -0,0 +1,16 @@ +# -------------------------------------------------------------------------- +# API schemas. +# -------------------------------------------------------------------------- + +from pydantic import BaseModel, ConfigDict, Field + + +class Create(BaseModel): + name: str = Field(min_length=1, max_length=120) + + +class Read(BaseModel): + id: int + name: str + + model_config = ConfigDict(from_attributes=True) diff --git a/src/fastapi_fastkit/fastapi_project_template/modules/domain/service.py-tpl b/src/fastapi_fastkit/fastapi_project_template/modules/domain/service.py-tpl new file mode 100644 index 0000000..2bee587 --- /dev/null +++ b/src/fastapi_fastkit/fastapi_project_template/modules/domain/service.py-tpl @@ -0,0 +1,43 @@ +# -------------------------------------------------------------------------- +# service. +# -------------------------------------------------------------------------- + +from .models import +from .repository import Repository +from .schemas import Create + +# Share the default repository between requests. +_default_repository = Repository() + + +class NotFoundError(Exception): + """Raised when is not found.""" + + +class Service: + """Business logic for .""" + + def __init__(self, repository: Repository | None = None) -> None: + self._repository = repository if repository is not None else _default_repository + + def list_(self) -> list[]: + return self._repository.list_all() + + def get_(self, entity_id: int) -> : + entity = self._repository.get(entity_id) + if entity is None: + raise NotFoundError(f" {entity_id} does not exist") + return entity + + def create_(self, payload: Create) -> : + return self._repository.add(name=payload.name) + + def replace_(self, entity_id: int, payload: Create) -> : + entity = self._repository.replace(entity_id=entity_id, name=payload.name) + if entity is None: + raise NotFoundError(f" {entity_id} does not exist") + return entity + + def delete_(self, entity_id: int) -> None: + if not self._repository.delete(entity_id=entity_id): + raise NotFoundError(f" {entity_id} does not exist") diff --git a/tests/test_backends/test_route_generators.py b/tests/test_backends/test_route_generators.py new file mode 100644 index 0000000..30784e6 --- /dev/null +++ b/tests/test_backends/test_route_generators.py @@ -0,0 +1,473 @@ +"""Tests for route generators.""" + +import importlib.util +import json +import subprocess +import sys +from pathlib import Path +from typing import Optional +from unittest.mock import patch + +import pytest +from click.testing import CliRunner + +from fastapi_fastkit.backend.main import add_new_route, write_fastkit_metadata +from fastapi_fastkit.backend.route_generators import resolve_route_layout +from fastapi_fastkit.backend.transducer import copy_and_convert_template +from fastapi_fastkit.cli import fastkit_cli +from fastapi_fastkit.core.exceptions import BackendExceptions +from fastapi_fastkit.core.settings import settings + + +def make_project( + root: Path, + nested: bool = True, + preset: Optional[str] = "domain-starter", + aggregator: bool = True, + anchors: bool = True, +) -> Path: + """Create a test project.""" + package = root / "src" / "app" if nested else root / "src" + package.mkdir(parents=True) + prefix = "src.app" if nested else "src" + (root / "src" / "__init__.py").write_text("", encoding="utf-8") + (package / "__init__.py").write_text("", encoding="utf-8") + import_anchor = "# fastkit:imports\n" if anchors else "" + route_anchor = "# fastkit:routes\n" if anchors else "" + main = ( + f"from fastapi import FastAPI\n{import_anchor}\napp = FastAPI()\n{route_anchor}" + ) + if aggregator: + api = package / "api" + api.mkdir() + (api / "__init__.py").write_text("", encoding="utf-8") + router_name = "router" if nested else "api" + (api / f"{router_name}.py").write_text( + f"from fastapi import APIRouter\n{import_anchor}\n" + f"api_router = APIRouter()\n{route_anchor}", + encoding="utf-8", + ) + main = ( + "from fastapi import FastAPI\n" + f"from {prefix}.api.{router_name} import api_router\n{import_anchor}\n" + f"app = FastAPI()\n{route_anchor}" + 'app.include_router(api_router, prefix="/api/v1")\n' + ) + (package / "main.py").write_text(main, encoding="utf-8") + metadata = ( + '[project]\nname = "test-project"\nversion = "0.1.0"\n' + "[tool.fastapi-fastkit]\nmanaged = true\n" + f'app_module = "{prefix}.main:app"\n' + ) + if preset is not None: + metadata += f"preset = {json.dumps(preset)}\n" + (root / "pyproject.toml").write_text(metadata, encoding="utf-8") + return package + + +@pytest.mark.parametrize( + "preset, expected", + [ + ("domain-starter", "domain"), + ("classic-layered", "classic-layer"), + ("minimal", "classic-layer"), + ("single-module", "classic-layer"), + (None, "classic-layer"), + ("", "classic-layer"), + ], +) +def test_layout_from_preset( + tmp_path: Path, preset: Optional[str], expected: str +) -> None: + make_project(tmp_path, preset=preset) + assert resolve_route_layout(str(tmp_path)) == expected + + +@pytest.mark.parametrize("layout", ["classic-layer", "domain"]) +@pytest.mark.parametrize( + "preset", ["domain-starter", "classic-layered", None, "unknown"] +) +def test_explicit_layout_overrides_preset( + tmp_path: Path, layout: str, preset: Optional[str] +) -> None: + make_project(tmp_path, preset=preset) + with patch("fastapi_fastkit.backend.route_generators.print_warning") as warning: + assert resolve_route_layout(str(tmp_path), layout) == layout + warning.assert_not_called() + + +@pytest.mark.parametrize( + "content", [None, "not valid [toml", "[project]\nname = 'legacy'\n"] +) +def test_missing_or_unreadable_metadata_falls_back( + tmp_path: Path, content: Optional[str] +) -> None: + if content is not None: + (tmp_path / "pyproject.toml").write_text(content, encoding="utf-8") + assert resolve_route_layout(str(tmp_path)) == "classic-layer" + + +def test_unknown_preset_warns(tmp_path: Path) -> None: + make_project(tmp_path, preset="future-preset") + with patch("fastapi_fastkit.backend.route_generators.print_warning") as warning: + assert resolve_route_layout(str(tmp_path)) == "classic-layer" + warning.assert_called_once() + assert "future-preset" in warning.call_args.args[0] + + +def test_invalid_layout_does_not_write(tmp_path: Path) -> None: + make_project(tmp_path) + before = snapshot(tmp_path) + with pytest.raises(BackendExceptions, match="Unknown route layout"): + add_new_route(str(tmp_path), "users", layout="invalid") + assert snapshot(tmp_path) == before + + +def snapshot(root: Path) -> dict[str, bytes]: + """Read project files for comparison.""" + return { + str(path.relative_to(root)): path.read_bytes() + for path in root.rglob("*") + if path.is_file() + } + + +def test_shipped_items_domain_is_preserved(tmp_path: Path) -> None: + """Keep the starter items domain unchanged.""" + template = Path(settings.FASTKIT_TEMPLATE_ROOT) / "fastapi-domain-starter" + copy_and_convert_template(str(template), str(tmp_path)) + write_fastkit_metadata( + str(tmp_path), + {"preset": "domain-starter", "app_module": "src.app.main:app"}, + ) + before = snapshot(tmp_path) + add_new_route(str(tmp_path), "items") + assert snapshot(tmp_path) == before + add_new_route(str(tmp_path), "users") + for name, content in before.items(): + if name != str(Path("src/app/api/router.py")): + assert (tmp_path / name).read_bytes() == content + router = (tmp_path / "src/app/api/router.py").read_text(encoding="utf-8") + assert ( + router.count( + 'api_router.include_router(items_router.router, prefix="/items", tags=["items"])' + ) + == 1 + ) + assert ( + router.count( + 'api_router.include_router(users_router.router, prefix="/users", tags=["users"])' + ) + == 1 + ) + + if all(importlib.util.find_spec(name) for name in ("fastapi", "httpx")): + result = subprocess.run( + [ + sys.executable, + "-c", + """ +from fastapi import FastAPI +from fastapi.testclient import TestClient +from src.app.api.router import api_router +app = FastAPI() +app.include_router(api_router, prefix="/api/v1") +client = TestClient(app) +assert client.get("/api/v1/items").status_code == 200 +created = client.post("/api/v1/items", json={"name": "Mug", "price": 9.5}) +assert created.status_code == 201 +assert client.get("/api/v1/items/" + str(created.json()["id"])).status_code == 200 +paths = app.openapi()["paths"] +assert paths["/api/v1/items"]["get"]["tags"] == ["items"] +assert "/api/v1/items/items" not in paths +""", + ], + cwd=tmp_path, + capture_output=True, + text=True, + timeout=30, + ) + assert result.returncode == 0, result.stdout + result.stderr + + +@pytest.mark.parametrize("nested", [False, True]) +@pytest.mark.parametrize("layout", ["classic-layer", "domain"]) +def test_generation_preserves_edits_and_restores_missing_files( + tmp_path: Path, nested: bool, layout: str +) -> None: + package = make_project(tmp_path, nested=nested) + metadata = (tmp_path / "pyproject.toml").read_bytes() + add_new_route(str(tmp_path), "users", layout=layout) + if layout == "domain": + edited = package / "domains/users/service.py" + missing = package / "domains/users/schemas.py" + assert not (package / "crud").exists() + assert not (package / "schemas").exists() + assert not (package / "api/routes").exists() + else: + edited = package / "crud/users.py" + missing = package / "schemas/users.py" + assert not (package / "domains").exists() + edited.write_text("# User-edited implementation\n", encoding="utf-8") + before = snapshot(tmp_path) + add_new_route(str(tmp_path), "users", layout=layout) + assert snapshot(tmp_path) == before + missing.unlink() + add_new_route(str(tmp_path), "users", layout=layout) + assert snapshot(tmp_path) == before + assert (tmp_path / "pyproject.toml").read_bytes() == metadata + + +@pytest.mark.parametrize( + "first, second", [("domain", "classic-layer"), ("classic-layer", "domain")] +) +def test_same_name_layouts_can_coexist(tmp_path: Path, first: str, second: str) -> None: + package = make_project(tmp_path) + add_new_route(str(tmp_path), "users", layout=first) + add_new_route(str(tmp_path), "users", layout=second) + assert (package / "domains/users/router.py").exists() + assert (package / "api/routes/users.py").exists() + before = snapshot(tmp_path) + add_new_route(str(tmp_path), "users", layout=first) + add_new_route(str(tmp_path), "users", layout=second) + assert snapshot(tmp_path) == before + + +@pytest.mark.parametrize("directory", ["api/routes", "crud", "schemas"]) +def test_partial_classic_module_allows_domain(tmp_path: Path, directory: str) -> None: + package = make_project(tmp_path) + target = package / directory / "users.py" + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text("# Existing module\n", encoding="utf-8") + add_new_route(str(tmp_path), "users", layout="domain") + assert target.read_text() == "# Existing module\n" + assert (package / "domains/users/router.py").exists() + + +@pytest.mark.parametrize( + "name, class_name", [("checkups", "Checkups"), ("health_checks", "HealthChecks")] +) +def test_domain_uses_subject_names(tmp_path: Path, name: str, class_name: str) -> None: + package = make_project(tmp_path) + add_new_route(str(tmp_path), name) + assert f"class {class_name}:" in (package / f"domains/{name}/models.py").read_text() + assert ( + f"class {class_name}Create" + in (package / f"domains/{name}/schemas.py").read_text() + ) + assert ( + f"def get_{name}_service" in (package / f"domains/{name}/router.py").read_text() + ) + + +@pytest.mark.parametrize("nested", [False, True]) +@pytest.mark.parametrize("anchors", [False, True]) +@pytest.mark.parametrize("aggregator", [False, True]) +def test_generated_domain_crud( + tmp_path: Path, nested: bool, anchors: bool, aggregator: bool +) -> None: + """Run CRUD checks in a separate process to avoid src import collisions.""" + if any(importlib.util.find_spec(name) is None for name in ("fastapi", "httpx")): + pytest.skip("Generated application runtime requires fastapi and httpx") + make_project(tmp_path, nested=nested, aggregator=aggregator, anchors=anchors) + add_new_route(str(tmp_path), "users") + add_new_route(str(tmp_path), "groups") + add_new_route(str(tmp_path), "users") + prefix = "src.app" if nested else "src" + api_prefix = "/api/v1" if aggregator else "" + script = f"PACKAGE = {prefix!r}\nAPI = {api_prefix!r}\n" + CRUD_CHECK + result = subprocess.run( + [sys.executable, "-c", script], + cwd=tmp_path, + capture_output=True, + text=True, + timeout=30, + ) + assert result.returncode == 0, result.stdout + result.stderr + + +CRUD_CHECK = """ +import importlib +from concurrent.futures import ThreadPoolExecutor +from fastapi.testclient import TestClient + +app = importlib.import_module(PACKAGE + ".main").app +repository = importlib.import_module(PACKAGE + ".domains.users.repository") +service = importlib.import_module(PACKAGE + ".domains.users.service") +router = importlib.import_module(PACKAGE + ".domains.users.router") +client = TestClient(app) +users = API + "/users" +groups = API + "/groups" +assert client.get(users).json() == [] +created = client.post(users, json={"name": "Alice"}) +assert created.status_code == 201 +assert created.json() == {"id": 1, "name": "Alice"} +assert client.get(users + "/1").json() == created.json() +assert client.get(users).json() == [created.json()] +for payload in ({}, {"name": ""}, {"name": "a" * 121}, {"name": None}): + assert client.post(users, json=payload).status_code == 422 + assert client.put(users + "/1", json=payload).status_code == 422 +assert client.post(users, json={"name": "a" * 120}).status_code == 201 +assert client.post(users, json={"name": "a"}).json()["id"] == 3 +updated = client.put(users + "/1", json={"name": "Bob"}) +assert updated.status_code == 200 +assert updated.json() == {"id": 1, "name": "Bob"} +assert client.get(users + "/1").json() == updated.json() +assert client.get(groups).json() == [] +assert client.post(groups, json={"name": "Team"}).json() == {"id": 1, "name": "Team"} +deleted = client.delete(users + "/1") +assert deleted.status_code == 204 and deleted.content == b"" +assert client.get(users + "/1").status_code == 404 +assert client.get(users + "/999").status_code == 404 +assert client.put(users + "/999", json={"name": "Missing"}).status_code == 404 +assert client.delete(users + "/999").status_code == 404 +assert client.delete(users + "/1").status_code == 404 +assert client.get(groups + "/1").status_code == 200 +assert client.post(users, json={"name": "Next"}).json()["id"] == 4 +assert client.patch(users + "/2", json={"name": "No PATCH"}).status_code == 405 +schema = client.get("/openapi.json").json() +assert set(schema["paths"][users]) == {"get", "post"} +assert set(schema["paths"][users + "/{entity_id}"]) == {"get", "put", "delete"} +operations = [op["operationId"] for path in schema["paths"].values() for op in path.values()] +assert len(set(operations)) == len(operations) +assert schema["paths"][users]["get"]["tags"] == ["users"] + +isolated = repository.UsersRepository() +assert isolated.list_all() == [] +assert isolated.add(name="Isolated").id == 1 +assert repository.UsersRepository().list_all() == [] +app.dependency_overrides[router.get_users_service] = lambda: service.UsersService(isolated) +assert client.get(users).json() == [{"id": 1, "name": "Isolated"}] +app.dependency_overrides.clear() +assert len(client.get(users).json()) == 3 +parallel = repository.UsersRepository() +with ThreadPoolExecutor(max_workers=8) as pool: + entities = list(pool.map(lambda i: parallel.add(name=str(i)), range(100))) +assert {entity.id for entity in entities} == set(range(1, 101)) +assert len(parallel.list_all()) == 100 +""" + + +@pytest.mark.parametrize("nested", [False, True]) +def test_classic_without_aggregator_imports(tmp_path: Path, nested: bool) -> None: + if importlib.util.find_spec("fastapi") is None: + pytest.skip("Generated application runtime requires fastapi") + make_project(tmp_path, nested=nested, preset="minimal", aggregator=False) + add_new_route(str(tmp_path), "users") + prefix = "src.app" if nested else "src" + result = subprocess.run( + [ + sys.executable, + "-c", + f"from {prefix}.main import app; assert app.openapi()['paths']", + ], + cwd=tmp_path, + capture_output=True, + text=True, + timeout=30, + ) + assert result.returncode == 0, result.stdout + result.stderr + + +@pytest.mark.parametrize( + "preset, option, expected", + [ + ("domain-starter", [], "domain"), + ("domain-starter", ["--layout=classic-layer"], "classic-layer"), + ("minimal", ["--layout=domain"], "domain"), + ("single-module", [], "classic-layer"), + ], +) +def test_cli_selects_and_displays_layout( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + preset: str, + option: list[str], + expected: str, +) -> None: + package = make_project(tmp_path, preset=preset) + monkeypatch.chdir(tmp_path) + result = CliRunner().invoke( + fastkit_cli, ["addroute", "users", ".", *option], input="Y\n" + ) + assert result.exit_code == 0, result.output + assert "Layout" in result.output and expected in result.output + assert (package / "domains/users/router.py").exists() == (expected == "domain") + assert (package / "api/routes/users.py").exists() == (expected == "classic-layer") + + +def test_cli_invalid_layout_and_cancellation_do_not_write( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + make_project(tmp_path) + monkeypatch.chdir(tmp_path) + before = snapshot(tmp_path) + runner = CliRunner() + invalid = runner.invoke(fastkit_cli, ["addroute", "users", "--layout=invalid"]) + assert invalid.exit_code == 2 and "Invalid value" in invalid.output + cancelled = runner.invoke( + fastkit_cli, ["addroute", "users", "--layout=domain"], input="N\n" + ) + assert "cancelled" in cancelled.output.lower() + assert snapshot(tmp_path) == before + + +@pytest.mark.parametrize("domain_first", [False, True]) +def test_router_alias_collisions(tmp_path: Path, domain_first: bool) -> None: + make_project(tmp_path) + routes = [("users", "domain"), ("users_router", "classic-layer")] + if not domain_first: + routes.reverse() + for name, layout in routes: + add_new_route(str(tmp_path), name, layout) + before = snapshot(tmp_path) + for name, layout in routes: + add_new_route(str(tmp_path), name, layout) + assert snapshot(tmp_path) == before + if importlib.util.find_spec("fastapi") is None: + pytest.skip("Generated application runtime requires fastapi") + result = subprocess.run( + [ + sys.executable, + "-c", + "from src.app.main import app; paths = app.openapi()['paths']; assert '/api/v1/users' in paths; assert '/api/v1/users_router/' in paths", + ], + cwd=tmp_path, + capture_output=True, + text=True, + timeout=30, + ) + assert result.returncode == 0, result.stdout + result.stderr + + +@pytest.mark.parametrize("legacy", [False, True]) +def test_existing_domain_registration_is_preserved( + tmp_path: Path, legacy: bool +) -> None: + package = make_project(tmp_path) + add_new_route(str(tmp_path), "users") + aggregator = package / "api/router.py" + if legacy: + router = package / "domains/users/router.py" + router.write_text( + router.read_text().replace( + "router = APIRouter()", + 'router = APIRouter(prefix="/users", tags=["users"])', + ) + ) + aggregator.write_text( + aggregator.read_text().replace( + 'include_router(users_router.router, prefix="/users", tags=["users"])', + "include_router(users_router.router)", + ) + ) + else: + aggregator.write_text( + aggregator.read_text().replace( + 'prefix="/users", tags=["users"]', + 'prefix="/custom-users", tags=["custom"]', + ) + ) + before = snapshot(tmp_path) + add_new_route(str(tmp_path), "users") + assert snapshot(tmp_path) == before diff --git a/tests/test_cli_operations/test_cli_config_options.py b/tests/test_cli_operations/test_cli_config_options.py index e9603d1..cc52251 100644 --- a/tests/test_cli_operations/test_cli_config_options.py +++ b/tests/test_cli_operations/test_cli_config_options.py @@ -305,17 +305,29 @@ def test_addroute_targets_the_domain_starter_layout(self, tmp_path: Path) -> Non # then assert "Successfully added new route" in result.output, result.output - assert (project_path / "src" / "app" / "api" / "routes" / "user.py").exists() - # the classic layout must not be created alongside it + assert ( + project_path / "src" / "app" / "domains" / "user" / "router.py" + ).exists() + assert not (project_path / "src" / "app" / "api" / "routes").exists() + assert not (project_path / "src" / "app" / "crud").exists() + assert not (project_path / "src" / "app" / "schemas").exists() + # the old package root must not be created alongside it assert not (project_path / "src" / "api").exists() router_content = ( project_path / "src" / "app" / "api" / "router.py" ).read_text() - assert "from .routes import user" in router_content + assert ( + "from src.app.domains.user import router as user_router" in router_content + ) + assert ( + 'api_router.include_router(user_router.router, prefix="/user", tags=["user"])' + in router_content + ) + @pytest.mark.parametrize("route_layout", ["domain", "classic-layer"]) def test_addroute_imports_resolve_for_the_domain_starter_layout( - self, tmp_path: Path + self, tmp_path: Path, route_layout: str ) -> None: # given project_path = self._generate( @@ -324,16 +336,31 @@ def test_addroute_imports_resolve_for_the_domain_starter_layout( os.chdir(project_path) # when - result = self.runner.invoke(fastkit_cli, ["addroute", "user", "."], input="Y\n") + result = self.runner.invoke( + fastkit_cli, + ["addroute", "user", ".", "--layout", route_layout], + input="Y\n", + ) # then assert "Successfully added new route" in result.output, result.output - route_content = ( - project_path / "src" / "app" / "api" / "routes" / "user.py" - ).read_text() - assert "from src.app.crud.user import *" in route_content - assert "from src.app.schemas.user import *" in route_content + package = project_path / "src" / "app" + if route_layout == "domain": + route_content = (package / "domains" / "user" / "router.py").read_text() + assert ( + "from src.app.domains.user.schemas import UserCreate, UserRead" + in route_content + ) + assert ( + "from src.app.domains.user.service import UserNotFoundError, UserService" + in route_content + ) + else: + route_content = (package / "api" / "routes" / "user.py").read_text() + assert "from src.app.crud.user import *" in route_content + assert "from src.app.schemas.user import *" in route_content assert "" not in route_content + assert "" not in route_content assert _unresolved_project_imports(project_path) == [] def test_addroute_imports_resolve_for_the_classic_layout( From d371fee0c84a2b11435140eed5acb027e1b96aa4 Mon Sep 17 00:00:00 2001 From: vastimofeev Date: Wed, 7 Oct 2026 11:17:16 +0700 Subject: [PATCH 2/2] fix: address domain generation review feedback --- docs/en/tutorial/domain-starter.md | 21 ++- docs/en/user-guide/adding-routes.md | 12 +- docs/en/user-guide/cli-reference.md | 5 +- src/fastapi_fastkit/backend/main.py | 6 - .../backend/route_generators.py | 11 +- src/fastapi_fastkit/backend/route_wiring.py | 64 +++++-- .../tests/conftest.py-tpl | 20 ++- .../modules/domain/__init__.py-tpl | 7 + .../modules/domain/repository.py-tpl | 5 + .../modules/domain/service.py-tpl | 4 + tests/test_backends/test_route_generators.py | 164 +++++++++++++++++- tests/test_backends/test_route_wiring.py | 50 ++++++ 12 files changed, 332 insertions(+), 37 deletions(-) create mode 100644 tests/test_backends/test_route_wiring.py diff --git a/docs/en/tutorial/domain-starter.md b/docs/en/tutorial/domain-starter.md index 7e0dc3e..1ca7144 100644 --- a/docs/en/tutorial/domain-starter.md +++ b/docs/en/tutorial/domain-starter.md @@ -197,8 +197,9 @@ service. Mirrors the runtime layout — one test module per surface that has behavior worth pinning. The starter ships: -- `conftest.py` — autouse fixture that resets the items store between - tests, plus a `client` fixture wrapping `TestClient(app)`. +- `conftest.py` — autouse fixture that resets the items store and calls + `service.reset_store()` for generated domains between tests, plus a `client` + fixture wrapping `TestClient(app)`. - `test_health.py` — verifies `GET /api/v1/health` returns 200 + `{"status": "ok"}`. - `test_items.py` — full CRUD coverage of the items endpoints, @@ -338,8 +339,10 @@ From the generated project's root, add a `users` domain: $ fastkit addroute users ``` -The command reads `[tool.fastapi-fastkit].preset`. For `domain-starter` it -automatically chooses the domain layout, which is displayed before confirmation. +The command reads `[tool.fastapi-fastkit].preset`, or infers it from `template` +when `preset` is absent. Projects created with `startdemo`, interactive `init`, +or `init --config` therefore choose the domain layout for domain-starter. +The selected layout is displayed before confirmation. You can also select it explicitly: ```console @@ -348,8 +351,8 @@ $ fastkit addroute users . --layout=domain `--layout=classic-layer` generates the traditional `api/routes`, `crud`, and `schemas` structure. That is also the default for `classic-layered`, `minimal`, -`single-module`, and projects without a preset. An unknown preset falls back to -`classic-layer` with a warning. The option does not change the project's preset. +`single-module`, and projects without preset or template metadata. An unknown +preset falls back to `classic-layer` with a warning. The option does not change the project's preset. ### Generated domain @@ -416,7 +419,11 @@ def test_create_user(client): Re-running `addroute` preserves existing domain files, creates missing files, and avoids duplicate imports and registrations. Same-named classic modules -can coexist through import aliases. Check HTTP paths and methods yourself when +can coexist through import aliases. Generated packages re-export their modules, +and repositories expose `reset()` to clear data and restart IDs. The starter's +autouse fixture calls `service.reset_store()` for generated domains. In older +projects, add this call to the existing reset fixture or use isolated repositories +through dependency overrides. Check HTTP paths and methods yourself when combining routers; the generator does not detect overlapping endpoints. ## Step 6: Where to go next diff --git a/docs/en/user-guide/adding-routes.md b/docs/en/user-guide/adding-routes.md index 9683e28..ab1bf66 100644 --- a/docs/en/user-guide/adding-routes.md +++ b/docs/en/user-guide/adding-routes.md @@ -6,8 +6,10 @@ Learn how to add new API routes to your existing FastAPI project. `addroute` accepts `--layout=classic-layer` or `--layout=domain`. When omitted, it reads `[tool.fastapi-fastkit].preset`: `domain-starter` selects `domain`; -`classic-layered`, `minimal`, `single-module`, and projects without a preset -select `classic-layer`. An unknown preset falls back to `classic-layer` with +`classic-layered`, `minimal`, and `single-module` select `classic-layer`. +If `preset` is missing, it infers the preset from `template`, so projects created +with `startdemo` or interactive `init` also select `domain` for +`fastapi-domain-starter`. Without either value, it selects `classic-layer`. An unknown preset falls back to `classic-layer` with a warning. An explicit option overrides this selection without changing the project's metadata. @@ -67,8 +69,10 @@ repository for durable persistence; it has no dependency on the starter's `db.memory`, ORM, or database configuration. For isolated tests, inject a fresh `UsersRepository` into `UsersService`, -then override the router's `get_users_service` FastAPI dependency. Explicitly -created repository instances have independent storage. +then override the router's `get_users_service` FastAPI dependency. +`UsersRepository.reset()` clears that repository and restarts IDs at 1. +`service.reset_store()` resets the default repository shared between requests. +Explicitly created repository instances have independent storage. ## Re-running the command diff --git a/docs/en/user-guide/cli-reference.md b/docs/en/user-guide/cli-reference.md index de27975..22be0fd 100644 --- a/docs/en/user-guide/cli-reference.md +++ b/docs/en/user-guide/cli-reference.md @@ -405,8 +405,9 @@ Both layouts update the existing API router and connect it to the application if necessary. Paths follow the actual app package, including `src/app/`. Without the option, `[tool.fastapi-fastkit].preset = "domain-starter"` selects -`domain`. `classic-layered`, `minimal`, `single-module`, and an absent preset -select `classic-layer`. An unknown preset falls back to `classic-layer` with a +`domain`. When `preset` is missing, the preset is inferred from `template`; +`fastapi-domain-starter` also selects `domain`. Other presets and projects without +either metadata value select `classic-layer`. An unknown preset falls back to `classic-layer` with a warning. Explicit selection does not change the project's preset. ```console diff --git a/src/fastapi_fastkit/backend/main.py b/src/fastapi_fastkit/backend/main.py index 451135e..0008c7f 100644 --- a/src/fastapi_fastkit/backend/main.py +++ b/src/fastapi_fastkit/backend/main.py @@ -1503,12 +1503,6 @@ def _update_api_router(api_router_file: str, route_name: str) -> None: f'prefix="/{route_name}", tags=["{route_name}"])' ) - if ( - route_import in content - and f"api_router.include_router({alias}.router" in content - ): - return # Already included - register_router( { "api_dir": os.path.dirname(api_router_file), diff --git a/src/fastapi_fastkit/backend/route_generators.py b/src/fastapi_fastkit/backend/route_generators.py index d8f01e8..53587a3 100644 --- a/src/fastapi_fastkit/backend/route_generators.py +++ b/src/fastapi_fastkit/backend/route_generators.py @@ -7,6 +7,7 @@ from typing import Dict, Optional, Union from fastapi_fastkit.backend import main as backend +from fastapi_fastkit.backend.project_builder.preset_layout import PresetLayoutStrategist from fastapi_fastkit.backend.route_wiring import register_router, router_alias from fastapi_fastkit.backend.transducer import copy_and_convert_template_file from fastapi_fastkit.core.exceptions import BackendExceptions @@ -17,13 +18,19 @@ def resolve_route_layout(project_dir: str, layout: Optional[str] = None) -> str: - """Resolve the route layout from the option or project preset.""" + """Resolve the route layout from the option or project metadata.""" if layout is not None: if layout not in ROUTE_LAYOUTS: raise BackendExceptions(f"Unknown route layout: {layout!r}") return layout - preset = read_fastkit_metadata(project_dir).get("preset") + metadata = read_fastkit_metadata(project_dir) + preset = metadata.get("preset") + if not preset: + template = metadata.get("template") + domain_template = PresetLayoutStrategist("domain-starter").base_template + if template == domain_template: + return "domain" if preset == "domain-starter": return "domain" if preset and preset not in ("classic-layered", "minimal", "single-module"): diff --git a/src/fastapi_fastkit/backend/route_wiring.py b/src/fastapi_fastkit/backend/route_wiring.py index 0d5a57b..7667a40 100644 --- a/src/fastapi_fastkit/backend/route_wiring.py +++ b/src/fastapi_fastkit/backend/route_wiring.py @@ -4,6 +4,27 @@ from pathlib import Path from typing import Dict +from fastapi_fastkit.utils.main import print_info + + +def _parse_module(content: str) -> ast.Module: + try: + return ast.parse(content) + except SyntaxError: + statements: list[ast.stmt] = [] + for line in content.splitlines(): + try: + statements.extend(ast.parse(line.strip()).body) + except SyntaxError: + continue + return ast.Module(body=statements, type_ignores=[]) + + +def _router_argument(call: ast.Call) -> ast.expr | None: + if call.args: + return call.args[0] + return next((item.value for item in call.keywords if item.arg == "router"), None) + def register_router( project_layout: Dict[str, str], import_line: str, statement: str @@ -22,19 +43,42 @@ def register_router( if not content.strip(): content = "from fastapi import APIRouter\n\napi_router = APIRouter()\n" registration = ast.parse(statement).body[0] - already_registered = False + existing = None if isinstance(registration, ast.Expr) and isinstance(registration.value, ast.Call): expected = registration.value - already_registered = any( - isinstance(node, ast.Call) - and ast.dump(node.func) == ast.dump(expected.func) - and node.args - and expected.args - and ast.dump(node.args[0]) == ast.dump(expected.args[0]) - for node in ast.walk(ast.parse(content)) + expected_router = _router_argument(expected) + existing = next( + ( + node + for node in ast.walk(_parse_module(content)) + if isinstance(node, ast.Call) + and ast.dump(node.func) == ast.dump(expected.func) + and (argument := _router_argument(node)) is not None + and expected_router is not None + and ast.dump(argument) == ast.dump(expected_router) + ), + None, ) + if existing is not None: + current_options = { + item.arg: ast.dump(item.value) + for item in existing.keywords + if item.arg != "router" + } + expected_options = { + item.arg: ast.dump(item.value) + for item in expected.keywords + if item.arg != "router" + } + if ( + current_options != expected_options + or existing.args[1:] != expected.args[1:] + ): + print_info( + f"Keeping existing router registration in {target} with its current prefix and options." + ) updated = insert_import_line(content, import_line) - if not already_registered: + if existing is None: updated = insert_statement_line(updated, statement) if not target.exists() or updated != content: target.write_text(updated, encoding="utf-8") @@ -46,7 +90,7 @@ def router_alias(file: str, module: str, name: str, preferred: str) -> str: target = Path(file) if not target.exists(): return preferred - tree = ast.parse(target.read_text(encoding="utf-8")) + tree = _parse_module(target.read_text(encoding="utf-8")) occupied: set[str] = set() for node in tree.body: if isinstance(node, ast.ImportFrom): diff --git a/src/fastapi_fastkit/fastapi_project_template/fastapi-domain-starter/tests/conftest.py-tpl b/src/fastapi_fastkit/fastapi_project_template/fastapi-domain-starter/tests/conftest.py-tpl index 6502b41..7530cfb 100644 --- a/src/fastapi_fastkit/fastapi_project_template/fastapi-domain-starter/tests/conftest.py-tpl +++ b/src/fastapi_fastkit/fastapi_project_template/fastapi-domain-starter/tests/conftest.py-tpl @@ -2,19 +2,31 @@ # pytest fixtures for . # -------------------------------------------------------------------------- from collections.abc import Generator +from importlib import import_module +from pkgutil import iter_modules import pytest from fastapi.testclient import TestClient +from src.app import domains from src.app.domains.items.repository import ItemRepository from src.app.main import app -@pytest.fixture(autouse=True) -def reset_items_store() -> Generator[None, None, None]: - """Each test starts with an empty items store.""" +def reset_stores() -> None: ItemRepository().reset() + for module in iter_modules(domains.__path__, prefix=f"{domains.__name__}."): + if module.ispkg: + domain = import_module(module.name) + reset = getattr(getattr(domain, "service", None), "reset_store", None) + if reset is not None: + reset() + + +@pytest.fixture(autouse=True) +def reset_domain_stores() -> Generator[None, None, None]: + reset_stores() yield - ItemRepository().reset() + reset_stores() @pytest.fixture diff --git a/src/fastapi_fastkit/fastapi_project_template/modules/domain/__init__.py-tpl b/src/fastapi_fastkit/fastapi_project_template/modules/domain/__init__.py-tpl index 3bfa6ca..8f34a13 100644 --- a/src/fastapi_fastkit/fastapi_project_template/modules/domain/__init__.py-tpl +++ b/src/fastapi_fastkit/fastapi_project_template/modules/domain/__init__.py-tpl @@ -1,3 +1,10 @@ # -------------------------------------------------------------------------- # domain. # -------------------------------------------------------------------------- +from import ( # noqa: F401 + models, + repository, + router, + schemas, + service, +) diff --git a/src/fastapi_fastkit/fastapi_project_template/modules/domain/repository.py-tpl b/src/fastapi_fastkit/fastapi_project_template/modules/domain/repository.py-tpl index c89a58e..4423ac4 100644 --- a/src/fastapi_fastkit/fastapi_project_template/modules/domain/repository.py-tpl +++ b/src/fastapi_fastkit/fastapi_project_template/modules/domain/repository.py-tpl @@ -43,3 +43,8 @@ class Repository: def delete(self, entity_id: int) -> bool: with self._lock: return self._data.pop(entity_id, None) is not None + + def reset(self) -> None: + with self._lock: + self._data.clear() + self._next_id = 1 diff --git a/src/fastapi_fastkit/fastapi_project_template/modules/domain/service.py-tpl b/src/fastapi_fastkit/fastapi_project_template/modules/domain/service.py-tpl index 2bee587..bf849cf 100644 --- a/src/fastapi_fastkit/fastapi_project_template/modules/domain/service.py-tpl +++ b/src/fastapi_fastkit/fastapi_project_template/modules/domain/service.py-tpl @@ -10,6 +10,10 @@ from .schemas import Create _default_repository = Repository() +def reset_store() -> None: + _default_repository.reset() + + class NotFoundError(Exception): """Raised when is not found.""" diff --git a/tests/test_backends/test_route_generators.py b/tests/test_backends/test_route_generators.py index 30784e6..23c0b57 100644 --- a/tests/test_backends/test_route_generators.py +++ b/tests/test_backends/test_route_generators.py @@ -12,11 +12,16 @@ from click.testing import CliRunner from fastapi_fastkit.backend.main import add_new_route, write_fastkit_metadata -from fastapi_fastkit.backend.route_generators import resolve_route_layout +from fastapi_fastkit.backend.route_generators import ( + DomainRouteGenerator, + get_route_generator, + resolve_route_layout, +) +from fastapi_fastkit.backend.scaffolder import ProjectScaffolder, ScaffoldOptions from fastapi_fastkit.backend.transducer import copy_and_convert_template from fastapi_fastkit.cli import fastkit_cli from fastapi_fastkit.core.exceptions import BackendExceptions -from fastapi_fastkit.core.settings import settings +from fastapi_fastkit.core.settings import FastkitConfig, settings def make_project( @@ -471,3 +476,158 @@ def test_existing_domain_registration_is_preserved( before = snapshot(tmp_path) add_new_route(str(tmp_path), "users") assert snapshot(tmp_path) == before + + +@pytest.mark.parametrize( + "template, expected", + [ + ("fastapi-domain-starter", "domain"), + ("fastapi-default", "classic-layer"), + ("fastapi-empty", "classic-layer"), + ("fastapi-single-module", "classic-layer"), + ("unknown", "classic-layer"), + ], +) +def test_layout_from_template(tmp_path: Path, template: str, expected: str) -> None: + make_project(tmp_path, preset=None) + metadata = tmp_path / "pyproject.toml" + metadata.write_text(metadata.read_text() + f'template = "{template}"\n') + assert resolve_route_layout(str(tmp_path)) == expected + + +@pytest.mark.parametrize("preset", ["minimal", "domain-starter", "unknown"]) +def test_preset_takes_priority_over_template(tmp_path: Path, preset: str) -> None: + make_project(tmp_path, preset=preset) + metadata = tmp_path / "pyproject.toml" + metadata.write_text(metadata.read_text() + 'template = "fastapi-domain-starter"\n') + assert resolve_route_layout(str(tmp_path)) == ( + "domain" if preset == "domain-starter" else "classic-layer" + ) + assert resolve_route_layout(str(tmp_path), "classic-layer") == "classic-layer" + + +def scaffold_starter(root: Path) -> Path: + config = FastkitConfig() + config.USER_WORKSPACE = str(root) + options = ScaffoldOptions( + project_name="starter", + author="Test", + author_email="test@example.com", + description="Test", + package_manager="pip", + template="fastapi-domain-starter", + with_venv=False, + with_install=False, + assume_yes=True, + ) + result = ProjectScaffolder(config, options).run() + assert "preset" not in result.metadata + return Path(result.project_dir) + + +def test_scaffold_without_preset_generates_domain(tmp_path: Path) -> None: + project = scaffold_starter(tmp_path) + assert resolve_route_layout(str(project)) == "domain" + add_new_route(str(project), "users") + assert (project / "src/app/domains/users/router.py").exists() + assert not (project / "src/app/api/routes").exists() + + +def test_generated_domains_reset_between_tests(tmp_path: Path) -> None: + if any( + importlib.util.find_spec(name) is None + for name in ("fastapi", "httpx", "pydantic_settings") + ): + pytest.skip("Starter runtime dependencies are required") + project = scaffold_starter(tmp_path) + add_new_route(str(project), "users") + test_file = project / "tests/test_generated_users.py" + test_file.write_text( + """ +from src.app.domains.users import models, repository, router, schemas, service + +def test_first(client): + assert client.get('/api/v1/users').json() == [] + assert client.post('/api/v1/users', json={'name': 'first'}).json()['id'] == 1 + isolated = repository.UsersRepository() + isolated.add(name='isolated') + isolated.reset() + assert isolated.list_all() == [] + assert isolated.add(name='fresh').id == 1 + assert len(client.get('/api/v1/users').json()) == 1 + +def test_second(client): + assert client.get('/api/v1/users').json() == [] + assert client.post('/api/v1/users', json={'name': 'second'}).json()['id'] == 1 +""", + encoding="utf-8", + ) + result = subprocess.run( + [sys.executable, "-m", "pytest", "tests", "-q", "--override-ini", "addopts="], + cwd=project, + capture_output=True, + text=True, + timeout=60, + ) + assert result.returncode == 0, result.stdout + result.stderr + + +@pytest.mark.parametrize("layout", ["classic-layer", "domain"]) +def test_empty_aggregator(tmp_path: Path, layout: str) -> None: + package = make_project(tmp_path, nested=False) + aggregator = package / "api/api.py" + aggregator.write_text("", encoding="utf-8") + add_new_route(str(tmp_path), "users", layout=layout) + source = aggregator.read_text() + assert "api_router = APIRouter()" in source + assert source.count("include_router") == 1 + + +@pytest.mark.parametrize("name", ["bad-name", "class"]) +def test_invalid_route_name_preserves_project(tmp_path: Path, name: str) -> None: + make_project(tmp_path) + before = snapshot(tmp_path) + with pytest.raises(BackendExceptions, match="Python identifier"): + add_new_route(str(tmp_path), name) + assert snapshot(tmp_path) == before + + +def test_missing_source_directory(tmp_path: Path) -> None: + with pytest.raises(BackendExceptions, match="Source directory not found"): + DomainRouteGenerator().add_new_route( + str(tmp_path), "users", {"package_dir": str(tmp_path / "absent")} + ) + + +def test_missing_domain_templates( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + make_project(tmp_path) + before = snapshot(tmp_path) + monkeypatch.setattr(settings, "FASTKIT_TEMPLATE_ROOT", str(tmp_path / "absent")) + with pytest.raises(BackendExceptions, match="Missing domain template"): + add_new_route(str(tmp_path), "users") + assert snapshot(tmp_path) == before + + +def test_domain_template_copy_failure(tmp_path: Path) -> None: + make_project(tmp_path) + with patch( + "fastapi_fastkit.backend.route_generators.copy_and_convert_template_file", + return_value=False, + ): + with pytest.raises(BackendExceptions, match="Failed to create domain file"): + add_new_route(str(tmp_path), "users") + + +@pytest.mark.parametrize("name", ["_", "none"]) +def test_unusual_domain_names_compile(tmp_path: Path, name: str) -> None: + package = make_project(tmp_path) + add_new_route(str(tmp_path), name) + for file in (package / "domains" / name).glob("*.py"): + compile(file.read_text(), str(file), "exec") + + +def test_invalid_generator_layout() -> None: + with pytest.raises(BackendExceptions, match="Unknown route layout"): + get_route_generator("invalid") diff --git a/tests/test_backends/test_route_wiring.py b/tests/test_backends/test_route_wiring.py new file mode 100644 index 0000000..4638403 --- /dev/null +++ b/tests/test_backends/test_route_wiring.py @@ -0,0 +1,50 @@ +"""Tests for router registration.""" + +from pathlib import Path +from unittest.mock import patch + +import pytest + +from fastapi_fastkit.backend.route_wiring import register_router, router_alias + + +@pytest.mark.parametrize("keyword", [False, True]) +@pytest.mark.parametrize("custom", [False, True]) +def test_existing_registration(tmp_path: Path, keyword: bool, custom: bool) -> None: + target = tmp_path / "api.py" + argument = "router=users.router" if keyword else "users.router" + prefix = "/custom" if custom else "/users" + source = f'from .routes import users\napi_router.include_router({argument}, prefix="{prefix}", tags=["users"])\n' + target.write_text(source) + with patch("fastapi_fastkit.backend.route_wiring.print_info") as info: + register_router( + {"api_dir": str(tmp_path), "api_router_file": str(target)}, + "from .routes import users", + 'api_router.include_router(users.router, prefix="/users", tags=["users"])', + ) + assert target.read_text() == source + assert info.call_count == int(custom) + + +def test_syntax_error_falls_back_to_text_insertion(tmp_path: Path) -> None: + target = tmp_path / "api.py" + source = "from .routes import users\n# fastkit:imports\napi_router = APIRouter(\n# fastkit:routes\n" + target.write_text(source) + assert router_alias(str(target), "routes", "users", "users") == "users" + register_router( + {"api_dir": str(tmp_path), "api_router_file": str(target)}, + "from .routes import groups", + 'api_router.include_router(groups.router, prefix="/groups")', + ) + result = target.read_text() + assert "api_router = APIRouter(" in result + assert result.count("from .routes import groups") == 1 + assert result.count("include_router(groups.router") == 1 + + +def test_alias_avoids_all_top_level_bindings(tmp_path: Path) -> None: + target = tmp_path / "api.py" + target.write_text( + "import package as users\nfrom package import module as users_2\nusers_3 = None\nusers_4: int = 1\ndef users_5(): pass\nclass users_6: pass\n" + ) + assert router_alias(str(target), "routes", "users", "users") == "users_7"