FastAPI-fastkit 1.2.0__py3-none-any.whl → 1.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fastapi_fastkit/__init__.py +1 -1
- fastapi_fastkit/backend/inspector.py +153 -32
- fastapi_fastkit/backend/interactive/__init__.py +2 -0
- fastapi_fastkit/backend/interactive/config_builder.py +21 -2
- fastapi_fastkit/backend/interactive/prompts.py +52 -1
- fastapi_fastkit/backend/interactive/selectors.py +10 -0
- fastapi_fastkit/backend/main.py +114 -6
- fastapi_fastkit/backend/package_managers/factory.py +1 -1
- fastapi_fastkit/backend/package_managers/pdm_manager.py +3 -3
- fastapi_fastkit/backend/package_managers/poetry_manager.py +71 -10
- fastapi_fastkit/backend/package_managers/uv_manager.py +3 -3
- fastapi_fastkit/backend/project_builder/__init__.py +3 -0
- fastapi_fastkit/backend/project_builder/config_generator.py +17 -10
- fastapi_fastkit/backend/project_builder/preset_layout.py +203 -0
- fastapi_fastkit/backend/transducer.py +0 -1
- fastapi_fastkit/cli.py +109 -44
- fastapi_fastkit/core/settings.py +17 -1
- fastapi_fastkit/fastapi_project_template/README.md +72 -23
- fastapi_fastkit/fastapi_project_template/fastapi-async-crud/pyproject.toml-tpl +4 -1
- fastapi_fastkit/fastapi_project_template/fastapi-custom-response/pyproject.toml-tpl +4 -1
- fastapi_fastkit/fastapi_project_template/fastapi-default/pyproject.toml-tpl +4 -1
- fastapi_fastkit/fastapi_project_template/fastapi-dockerized/pyproject.toml-tpl +4 -1
- fastapi_fastkit/fastapi_project_template/fastapi-domain-starter/.env-tpl +2 -0
- fastapi_fastkit/fastapi_project_template/fastapi-domain-starter/.gitignore-tpl +31 -0
- fastapi_fastkit/fastapi_project_template/fastapi-domain-starter/README.md-tpl +128 -0
- fastapi_fastkit/fastapi_project_template/fastapi-domain-starter/pyproject.toml-tpl +70 -0
- fastapi_fastkit/fastapi_project_template/fastapi-domain-starter/requirements.txt-tpl +11 -0
- fastapi_fastkit/fastapi_project_template/fastapi-domain-starter/scripts/format.sh-tpl +5 -0
- fastapi_fastkit/fastapi_project_template/fastapi-domain-starter/scripts/lint.sh-tpl +6 -0
- fastapi_fastkit/fastapi_project_template/fastapi-domain-starter/scripts/run-server.sh-tpl +8 -0
- fastapi_fastkit/fastapi_project_template/fastapi-domain-starter/scripts/test.sh-tpl +6 -0
- fastapi_fastkit/fastapi_project_template/fastapi-domain-starter/src/__init__.py-tpl +0 -0
- fastapi_fastkit/fastapi_project_template/fastapi-domain-starter/src/app/__init__.py-tpl +0 -0
- fastapi_fastkit/fastapi_project_template/fastapi-domain-starter/src/app/api/__init__.py-tpl +0 -0
- fastapi_fastkit/fastapi_project_template/fastapi-domain-starter/src/app/api/health.py-tpl +11 -0
- fastapi_fastkit/fastapi_project_template/fastapi-domain-starter/src/app/api/router.py-tpl +12 -0
- fastapi_fastkit/fastapi_project_template/fastapi-domain-starter/src/app/core/__init__.py-tpl +0 -0
- fastapi_fastkit/fastapi_project_template/fastapi-domain-starter/src/app/core/config.py-tpl +49 -0
- fastapi_fastkit/fastapi_project_template/fastapi-domain-starter/src/app/db/__init__.py-tpl +0 -0
- fastapi_fastkit/fastapi_project_template/fastapi-domain-starter/src/app/db/memory.py-tpl +48 -0
- fastapi_fastkit/fastapi_project_template/fastapi-domain-starter/src/app/domains/__init__.py-tpl +0 -0
- fastapi_fastkit/fastapi_project_template/fastapi-domain-starter/src/app/domains/items/__init__.py-tpl +9 -0
- fastapi_fastkit/fastapi_project_template/fastapi-domain-starter/src/app/domains/items/models.py-tpl +17 -0
- fastapi_fastkit/fastapi_project_template/fastapi-domain-starter/src/app/domains/items/repository.py-tpl +48 -0
- fastapi_fastkit/fastapi_project_template/fastapi-domain-starter/src/app/domains/items/router.py-tpl +59 -0
- fastapi_fastkit/fastapi_project_template/fastapi-domain-starter/src/app/domains/items/schemas.py-tpl +23 -0
- fastapi_fastkit/fastapi_project_template/fastapi-domain-starter/src/app/domains/items/service.py-tpl +52 -0
- fastapi_fastkit/fastapi_project_template/fastapi-domain-starter/src/app/main.py-tpl +25 -0
- fastapi_fastkit/fastapi_project_template/fastapi-domain-starter/tests/__init__.py-tpl +0 -0
- fastapi_fastkit/fastapi_project_template/fastapi-domain-starter/tests/conftest.py-tpl +24 -0
- fastapi_fastkit/fastapi_project_template/fastapi-domain-starter/tests/test_health.py-tpl +11 -0
- fastapi_fastkit/fastapi_project_template/fastapi-domain-starter/tests/test_items.py-tpl +77 -0
- fastapi_fastkit/fastapi_project_template/fastapi-empty/README.md-tpl +2 -2
- fastapi_fastkit/fastapi_project_template/fastapi-empty/pyproject.toml-tpl +4 -1
- fastapi_fastkit/fastapi_project_template/fastapi-mcp/pyproject.toml-tpl +4 -1
- fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/pyproject.toml-tpl +4 -1
- fastapi_fastkit/fastapi_project_template/fastapi-single-module/pyproject.toml-tpl +4 -1
- fastapi_fastkit/utils/main.py +73 -6
- {fastapi_fastkit-1.2.0.dist-info → fastapi_fastkit-1.3.0.dist-info}/METADATA +33 -3
- {fastapi_fastkit-1.2.0.dist-info → fastapi_fastkit-1.3.0.dist-info}/RECORD +63 -32
- {fastapi_fastkit-1.2.0.dist-info → fastapi_fastkit-1.3.0.dist-info}/WHEEL +1 -1
- {fastapi_fastkit-1.2.0.dist-info → fastapi_fastkit-1.3.0.dist-info}/entry_points.txt +0 -0
- {fastapi_fastkit-1.2.0.dist-info → fastapi_fastkit-1.3.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "<project_name>"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "[FastAPI-fastkit templated] <description>"
|
|
5
|
+
authors = [
|
|
6
|
+
{name = "<author>", email = "<author_email>"},
|
|
7
|
+
]
|
|
8
|
+
readme = "README.md"
|
|
9
|
+
license = "MIT"
|
|
10
|
+
requires-python = ">=3.12"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"fastapi>=0.115.8",
|
|
13
|
+
"uvicorn[standard]>=0.34.0",
|
|
14
|
+
"pydantic>=2.10.6",
|
|
15
|
+
"pydantic-settings>=2.7.1",
|
|
16
|
+
"python-dotenv>=1.0.1",
|
|
17
|
+
"fastapi-fastkit>=1.1.5",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[project.optional-dependencies]
|
|
21
|
+
dev = [
|
|
22
|
+
"pytest>=8.3.4",
|
|
23
|
+
"httpx>=0.28.1",
|
|
24
|
+
"black>=25.1.0",
|
|
25
|
+
"isort>=6.0.0",
|
|
26
|
+
"mypy>=1.15.0",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[dependency-groups]
|
|
30
|
+
dev = [
|
|
31
|
+
"pytest>=8.3.4",
|
|
32
|
+
"httpx>=0.28.1",
|
|
33
|
+
"black>=25.1.0",
|
|
34
|
+
"isort>=6.0.0",
|
|
35
|
+
"mypy>=1.15.0",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
[tool.fastapi-fastkit]
|
|
39
|
+
managed = true
|
|
40
|
+
template = "fastapi-domain-starter"
|
|
41
|
+
|
|
42
|
+
[build-system]
|
|
43
|
+
requires = ["hatchling"]
|
|
44
|
+
build-backend = "hatchling.build"
|
|
45
|
+
|
|
46
|
+
[tool.hatch.build.targets.wheel]
|
|
47
|
+
packages = ["src"]
|
|
48
|
+
|
|
49
|
+
[tool.black]
|
|
50
|
+
line-length = 88
|
|
51
|
+
target-version = ["py312"]
|
|
52
|
+
|
|
53
|
+
[tool.isort]
|
|
54
|
+
profile = "black"
|
|
55
|
+
line_length = 88
|
|
56
|
+
known_first_party = ["src"]
|
|
57
|
+
|
|
58
|
+
[tool.mypy]
|
|
59
|
+
python_version = "3.12"
|
|
60
|
+
strict = true
|
|
61
|
+
warn_return_any = true
|
|
62
|
+
warn_unused_configs = true
|
|
63
|
+
disallow_untyped_defs = true
|
|
64
|
+
|
|
65
|
+
[tool.pytest.ini_options]
|
|
66
|
+
testpaths = ["tests"]
|
|
67
|
+
python_files = ["test_*.py"]
|
|
68
|
+
python_classes = ["Test*"]
|
|
69
|
+
python_functions = ["test_*"]
|
|
70
|
+
addopts = "-v --tb=short"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# --------------------------------------------------------------------------
|
|
2
|
+
# Health endpoint — small, side-effect-free probe for liveness checks.
|
|
3
|
+
# --------------------------------------------------------------------------
|
|
4
|
+
from fastapi import APIRouter
|
|
5
|
+
|
|
6
|
+
router = APIRouter(tags=["health"])
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@router.get("/health")
|
|
10
|
+
def health() -> dict[str, str]:
|
|
11
|
+
return {"status": "ok"}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# --------------------------------------------------------------------------
|
|
2
|
+
# Top-level API router — aggregates the health probe with every domain
|
|
3
|
+
# module under ``src/app/domains/``.
|
|
4
|
+
# --------------------------------------------------------------------------
|
|
5
|
+
from fastapi import APIRouter
|
|
6
|
+
|
|
7
|
+
from src.app.api import health
|
|
8
|
+
from src.app.domains.items import router as items_router
|
|
9
|
+
|
|
10
|
+
api_router = APIRouter()
|
|
11
|
+
api_router.include_router(health.router)
|
|
12
|
+
api_router.include_router(items_router.router)
|
|
File without changes
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# --------------------------------------------------------------------------
|
|
2
|
+
# Application settings (pydantic-settings).
|
|
3
|
+
# --------------------------------------------------------------------------
|
|
4
|
+
import secrets
|
|
5
|
+
from typing import Annotated, Any, List, Literal, Union
|
|
6
|
+
|
|
7
|
+
from pydantic import AnyUrl, BeforeValidator, computed_field
|
|
8
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _parse_cors(v: Any) -> Union[List[str], str]:
|
|
12
|
+
if isinstance(v, str) and not v.startswith("["):
|
|
13
|
+
return [item.strip() for item in v.split(",") if item.strip()]
|
|
14
|
+
if isinstance(v, (list, str)):
|
|
15
|
+
return v
|
|
16
|
+
raise ValueError(v)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Settings(BaseSettings):
|
|
20
|
+
"""Centralized configuration for the application.
|
|
21
|
+
|
|
22
|
+
Values are populated from environment variables (or a local ``.env``
|
|
23
|
+
file) so the same image / build can run in development, staging, and
|
|
24
|
+
production without code changes.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
model_config = SettingsConfigDict(
|
|
28
|
+
env_file=".env",
|
|
29
|
+
env_ignore_empty=True,
|
|
30
|
+
extra="ignore",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
PROJECT_NAME: str = "<project_name>"
|
|
34
|
+
ENVIRONMENT: Literal["development", "staging", "production"] = "development"
|
|
35
|
+
SECRET_KEY: str = secrets.token_urlsafe(32)
|
|
36
|
+
|
|
37
|
+
API_V1_PREFIX: str = "/api/v1"
|
|
38
|
+
|
|
39
|
+
BACKEND_CORS_ORIGINS: Annotated[
|
|
40
|
+
Union[List[AnyUrl], str], BeforeValidator(_parse_cors)
|
|
41
|
+
] = []
|
|
42
|
+
|
|
43
|
+
@computed_field # type: ignore[prop-decorator]
|
|
44
|
+
@property
|
|
45
|
+
def all_cors_origins(self) -> List[str]:
|
|
46
|
+
return [str(origin).rstrip("/") for origin in self.BACKEND_CORS_ORIGINS]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
settings = Settings() # type: ignore[call-arg]
|
|
File without changes
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# --------------------------------------------------------------------------
|
|
2
|
+
# Process-local in-memory store used as a placeholder for a real database.
|
|
3
|
+
#
|
|
4
|
+
# Domain repositories sit on top of this store, so swapping the store for
|
|
5
|
+
# SQLAlchemy / async drivers later is a contained change — only the
|
|
6
|
+
# repositories need to be rewritten.
|
|
7
|
+
# --------------------------------------------------------------------------
|
|
8
|
+
from typing import Dict, Generic, Iterable, Optional, TypeVar
|
|
9
|
+
|
|
10
|
+
T = TypeVar("T")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class InMemoryStore(Generic[T]):
|
|
14
|
+
"""Tiny ID-keyed collection backed by a dict.
|
|
15
|
+
|
|
16
|
+
Not thread-safe and not suitable for production — but lets the rest of
|
|
17
|
+
the application code stay realistic (repository / service / router) while
|
|
18
|
+
keeping the starter self-contained.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self) -> None:
|
|
22
|
+
self._data: Dict[int, T] = {}
|
|
23
|
+
self._next_id: int = 1
|
|
24
|
+
|
|
25
|
+
def list(self) -> Iterable[T]:
|
|
26
|
+
return list(self._data.values())
|
|
27
|
+
|
|
28
|
+
def get(self, id_: int) -> Optional[T]:
|
|
29
|
+
return self._data.get(id_)
|
|
30
|
+
|
|
31
|
+
def add(self, item: T) -> int:
|
|
32
|
+
new_id = self._next_id
|
|
33
|
+
self._data[new_id] = item
|
|
34
|
+
self._next_id += 1
|
|
35
|
+
return new_id
|
|
36
|
+
|
|
37
|
+
def replace(self, id_: int, item: T) -> bool:
|
|
38
|
+
if id_ not in self._data:
|
|
39
|
+
return False
|
|
40
|
+
self._data[id_] = item
|
|
41
|
+
return True
|
|
42
|
+
|
|
43
|
+
def delete(self, id_: int) -> bool:
|
|
44
|
+
return self._data.pop(id_, None) is not None
|
|
45
|
+
|
|
46
|
+
def clear(self) -> None:
|
|
47
|
+
self._data.clear()
|
|
48
|
+
self._next_id = 1
|
fastapi_fastkit/fastapi_project_template/fastapi-domain-starter/src/app/domains/__init__.py-tpl
ADDED
|
File without changes
|
fastapi_fastkit/fastapi_project_template/fastapi-domain-starter/src/app/domains/items/models.py-tpl
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# --------------------------------------------------------------------------
|
|
2
|
+
# Items domain — internal entity model.
|
|
3
|
+
#
|
|
4
|
+
# Kept separate from the API schemas in ``schemas.py`` so the wire format
|
|
5
|
+
# can evolve without touching domain logic, and vice versa.
|
|
6
|
+
# --------------------------------------------------------------------------
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class Item:
|
|
12
|
+
"""Domain entity: an item tracked by the application."""
|
|
13
|
+
|
|
14
|
+
id: int
|
|
15
|
+
name: str
|
|
16
|
+
price: float
|
|
17
|
+
in_stock: bool = True
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# --------------------------------------------------------------------------
|
|
2
|
+
# Items domain — repository (data access boundary).
|
|
3
|
+
#
|
|
4
|
+
# Wraps the in-memory store with item-typed methods so the service layer
|
|
5
|
+
# stays decoupled from how items are persisted.
|
|
6
|
+
# --------------------------------------------------------------------------
|
|
7
|
+
from typing import List, Optional
|
|
8
|
+
|
|
9
|
+
from src.app.db.memory import InMemoryStore
|
|
10
|
+
from src.app.domains.items.models import Item
|
|
11
|
+
|
|
12
|
+
_store: InMemoryStore[Item] = InMemoryStore()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ItemRepository:
|
|
16
|
+
"""Data access for ``Item`` entities."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, store: Optional[InMemoryStore[Item]] = None) -> None:
|
|
19
|
+
self._store = store if store is not None else _store
|
|
20
|
+
|
|
21
|
+
def list_all(self) -> List[Item]:
|
|
22
|
+
return list(self._store.list())
|
|
23
|
+
|
|
24
|
+
def get(self, item_id: int) -> Optional[Item]:
|
|
25
|
+
return self._store.get(item_id)
|
|
26
|
+
|
|
27
|
+
def add(self, name: str, price: float, in_stock: bool = True) -> Item:
|
|
28
|
+
# Reserve the next id from the store, then back-fill it on the
|
|
29
|
+
# entity so the repository is the single source of identity.
|
|
30
|
+
item = Item(id=0, name=name, price=price, in_stock=in_stock)
|
|
31
|
+
new_id = self._store.add(item)
|
|
32
|
+
item.id = new_id
|
|
33
|
+
return item
|
|
34
|
+
|
|
35
|
+
def replace(self, item_id: int, name: str, price: float, in_stock: bool) -> Optional[Item]:
|
|
36
|
+
existing = self._store.get(item_id)
|
|
37
|
+
if existing is None:
|
|
38
|
+
return None
|
|
39
|
+
updated = Item(id=item_id, name=name, price=price, in_stock=in_stock)
|
|
40
|
+
self._store.replace(item_id, updated)
|
|
41
|
+
return updated
|
|
42
|
+
|
|
43
|
+
def delete(self, item_id: int) -> bool:
|
|
44
|
+
return self._store.delete(item_id)
|
|
45
|
+
|
|
46
|
+
def reset(self) -> None:
|
|
47
|
+
# Convenience for tests / local development; not exposed via the API.
|
|
48
|
+
self._store.clear()
|
fastapi_fastkit/fastapi_project_template/fastapi-domain-starter/src/app/domains/items/router.py-tpl
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# --------------------------------------------------------------------------
|
|
2
|
+
# Items domain — HTTP transport (FastAPI router).
|
|
3
|
+
# --------------------------------------------------------------------------
|
|
4
|
+
from typing import List
|
|
5
|
+
|
|
6
|
+
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
|
7
|
+
|
|
8
|
+
from src.app.domains.items.schemas import ItemCreate, ItemRead
|
|
9
|
+
from src.app.domains.items.service import ItemNotFoundError, ItemService
|
|
10
|
+
|
|
11
|
+
router = APIRouter(prefix="/items", tags=["items"])
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def get_item_service() -> ItemService:
|
|
15
|
+
"""Default dependency — overridable in tests."""
|
|
16
|
+
return ItemService()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@router.get("", response_model=List[ItemRead])
|
|
20
|
+
def list_items(service: ItemService = Depends(get_item_service)) -> List[ItemRead]:
|
|
21
|
+
return [ItemRead.model_validate(item) for item in service.list_items()]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@router.get("/{item_id}", response_model=ItemRead)
|
|
25
|
+
def get_item(item_id: int, service: ItemService = Depends(get_item_service)) -> ItemRead:
|
|
26
|
+
try:
|
|
27
|
+
return ItemRead.model_validate(service.get_item(item_id))
|
|
28
|
+
except ItemNotFoundError as exc:
|
|
29
|
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@router.post("", response_model=ItemRead, status_code=status.HTTP_201_CREATED)
|
|
33
|
+
def create_item(
|
|
34
|
+
payload: ItemCreate, service: ItemService = Depends(get_item_service)
|
|
35
|
+
) -> ItemRead:
|
|
36
|
+
return ItemRead.model_validate(service.create_item(payload))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@router.put("/{item_id}", response_model=ItemRead)
|
|
40
|
+
def replace_item(
|
|
41
|
+
item_id: int,
|
|
42
|
+
payload: ItemCreate,
|
|
43
|
+
service: ItemService = Depends(get_item_service),
|
|
44
|
+
) -> ItemRead:
|
|
45
|
+
try:
|
|
46
|
+
return ItemRead.model_validate(service.replace_item(item_id, payload))
|
|
47
|
+
except ItemNotFoundError as exc:
|
|
48
|
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@router.delete("/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
52
|
+
def delete_item(
|
|
53
|
+
item_id: int, service: ItemService = Depends(get_item_service)
|
|
54
|
+
) -> Response:
|
|
55
|
+
try:
|
|
56
|
+
service.delete_item(item_id)
|
|
57
|
+
except ItemNotFoundError as exc:
|
|
58
|
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
|
|
59
|
+
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
fastapi_fastkit/fastapi_project_template/fastapi-domain-starter/src/app/domains/items/schemas.py-tpl
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# --------------------------------------------------------------------------
|
|
2
|
+
# Items domain — API I/O schemas (pydantic).
|
|
3
|
+
# --------------------------------------------------------------------------
|
|
4
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ItemCreate(BaseModel):
|
|
8
|
+
"""Payload accepted on POST /items."""
|
|
9
|
+
|
|
10
|
+
name: str = Field(min_length=1, max_length=120)
|
|
11
|
+
price: float = Field(ge=0)
|
|
12
|
+
in_stock: bool = True
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ItemRead(BaseModel):
|
|
16
|
+
"""Outgoing representation of an item."""
|
|
17
|
+
|
|
18
|
+
id: int
|
|
19
|
+
name: str
|
|
20
|
+
price: float
|
|
21
|
+
in_stock: bool
|
|
22
|
+
|
|
23
|
+
model_config = ConfigDict(from_attributes=True)
|
fastapi_fastkit/fastapi_project_template/fastapi-domain-starter/src/app/domains/items/service.py-tpl
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# --------------------------------------------------------------------------
|
|
2
|
+
# Items domain — service layer (business logic).
|
|
3
|
+
#
|
|
4
|
+
# Routers should call into the service rather than the repository directly,
|
|
5
|
+
# so policy and validation rules can grow here without touching transport
|
|
6
|
+
# code or persistence.
|
|
7
|
+
# --------------------------------------------------------------------------
|
|
8
|
+
from typing import List, Optional
|
|
9
|
+
|
|
10
|
+
from src.app.domains.items.models import Item
|
|
11
|
+
from src.app.domains.items.repository import ItemRepository
|
|
12
|
+
from src.app.domains.items.schemas import ItemCreate
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ItemNotFoundError(Exception):
|
|
16
|
+
"""Raised when an item lookup misses."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ItemService:
|
|
20
|
+
def __init__(self, repository: Optional[ItemRepository] = None) -> None:
|
|
21
|
+
self._repository = repository if repository is not None else ItemRepository()
|
|
22
|
+
|
|
23
|
+
def list_items(self) -> List[Item]:
|
|
24
|
+
return self._repository.list_all()
|
|
25
|
+
|
|
26
|
+
def get_item(self, item_id: int) -> Item:
|
|
27
|
+
item = self._repository.get(item_id)
|
|
28
|
+
if item is None:
|
|
29
|
+
raise ItemNotFoundError(f"Item {item_id} does not exist")
|
|
30
|
+
return item
|
|
31
|
+
|
|
32
|
+
def create_item(self, payload: ItemCreate) -> Item:
|
|
33
|
+
return self._repository.add(
|
|
34
|
+
name=payload.name,
|
|
35
|
+
price=payload.price,
|
|
36
|
+
in_stock=payload.in_stock,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
def replace_item(self, item_id: int, payload: ItemCreate) -> Item:
|
|
40
|
+
updated = self._repository.replace(
|
|
41
|
+
item_id,
|
|
42
|
+
name=payload.name,
|
|
43
|
+
price=payload.price,
|
|
44
|
+
in_stock=payload.in_stock,
|
|
45
|
+
)
|
|
46
|
+
if updated is None:
|
|
47
|
+
raise ItemNotFoundError(f"Item {item_id} does not exist")
|
|
48
|
+
return updated
|
|
49
|
+
|
|
50
|
+
def delete_item(self, item_id: int) -> None:
|
|
51
|
+
if not self._repository.delete(item_id):
|
|
52
|
+
raise ItemNotFoundError(f"Item {item_id} does not exist")
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# --------------------------------------------------------------------------
|
|
2
|
+
# FastAPI application entry point for <project_name>.
|
|
3
|
+
# --------------------------------------------------------------------------
|
|
4
|
+
from fastapi import FastAPI
|
|
5
|
+
from starlette.middleware.cors import CORSMiddleware
|
|
6
|
+
|
|
7
|
+
from src.app.api.router import api_router
|
|
8
|
+
from src.app.core.config import settings
|
|
9
|
+
|
|
10
|
+
app = FastAPI(
|
|
11
|
+
title=settings.PROJECT_NAME,
|
|
12
|
+
description="<description>",
|
|
13
|
+
version="0.1.0",
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
if settings.all_cors_origins:
|
|
17
|
+
app.add_middleware(
|
|
18
|
+
CORSMiddleware,
|
|
19
|
+
allow_origins=settings.all_cors_origins,
|
|
20
|
+
allow_credentials=True,
|
|
21
|
+
allow_methods=["*"],
|
|
22
|
+
allow_headers=["*"],
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
app.include_router(api_router, prefix=settings.API_V1_PREFIX)
|
|
File without changes
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# --------------------------------------------------------------------------
|
|
2
|
+
# pytest fixtures for <project_name>.
|
|
3
|
+
# --------------------------------------------------------------------------
|
|
4
|
+
from collections.abc import Generator
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
from fastapi.testclient import TestClient
|
|
8
|
+
|
|
9
|
+
from src.app.domains.items.repository import ItemRepository
|
|
10
|
+
from src.app.main import app
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@pytest.fixture(autouse=True)
|
|
14
|
+
def reset_items_store() -> Generator[None, None, None]:
|
|
15
|
+
"""Each test starts with an empty items store."""
|
|
16
|
+
ItemRepository().reset()
|
|
17
|
+
yield
|
|
18
|
+
ItemRepository().reset()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@pytest.fixture
|
|
22
|
+
def client() -> Generator[TestClient, None, None]:
|
|
23
|
+
with TestClient(app) as c:
|
|
24
|
+
yield c
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# --------------------------------------------------------------------------
|
|
2
|
+
# Health endpoint smoke test.
|
|
3
|
+
# --------------------------------------------------------------------------
|
|
4
|
+
from src.app.core.config import settings
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_health_endpoint_returns_ok(client):
|
|
8
|
+
response = client.get(f"{settings.API_V1_PREFIX}/health")
|
|
9
|
+
|
|
10
|
+
assert response.status_code == 200
|
|
11
|
+
assert response.json() == {"status": "ok"}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# --------------------------------------------------------------------------
|
|
2
|
+
# Items domain — end-to-end coverage of the CRUD endpoints.
|
|
3
|
+
# --------------------------------------------------------------------------
|
|
4
|
+
from src.app.core.config import settings
|
|
5
|
+
|
|
6
|
+
ITEMS_URL = f"{settings.API_V1_PREFIX}/items"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_list_items_starts_empty(client):
|
|
10
|
+
response = client.get(ITEMS_URL)
|
|
11
|
+
|
|
12
|
+
assert response.status_code == 200
|
|
13
|
+
assert response.json() == []
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_create_then_read_item(client):
|
|
17
|
+
create_response = client.post(
|
|
18
|
+
ITEMS_URL,
|
|
19
|
+
json={"name": "Sticker", "price": 2.5, "in_stock": True},
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
assert create_response.status_code == 201
|
|
23
|
+
created = create_response.json()
|
|
24
|
+
assert created["id"] == 1
|
|
25
|
+
assert created["name"] == "Sticker"
|
|
26
|
+
|
|
27
|
+
read_response = client.get(f"{ITEMS_URL}/{created['id']}")
|
|
28
|
+
|
|
29
|
+
assert read_response.status_code == 200
|
|
30
|
+
assert read_response.json() == created
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_get_unknown_item_returns_404(client):
|
|
34
|
+
response = client.get(f"{ITEMS_URL}/999")
|
|
35
|
+
|
|
36
|
+
assert response.status_code == 404
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_replace_item(client):
|
|
40
|
+
created = client.post(
|
|
41
|
+
ITEMS_URL,
|
|
42
|
+
json={"name": "Mug", "price": 9.0, "in_stock": True},
|
|
43
|
+
).json()
|
|
44
|
+
|
|
45
|
+
response = client.put(
|
|
46
|
+
f"{ITEMS_URL}/{created['id']}",
|
|
47
|
+
json={"name": "Mug v2", "price": 11.0, "in_stock": False},
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
assert response.status_code == 200
|
|
51
|
+
body = response.json()
|
|
52
|
+
assert body["id"] == created["id"]
|
|
53
|
+
assert body["name"] == "Mug v2"
|
|
54
|
+
assert body["price"] == 11.0
|
|
55
|
+
assert body["in_stock"] is False
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_delete_item(client):
|
|
59
|
+
created = client.post(
|
|
60
|
+
ITEMS_URL,
|
|
61
|
+
json={"name": "Keychain", "price": 4.0, "in_stock": True},
|
|
62
|
+
).json()
|
|
63
|
+
|
|
64
|
+
delete_response = client.delete(f"{ITEMS_URL}/{created['id']}")
|
|
65
|
+
assert delete_response.status_code == 204
|
|
66
|
+
|
|
67
|
+
follow_up = client.get(f"{ITEMS_URL}/{created['id']}")
|
|
68
|
+
assert follow_up.status_code == 404
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_create_item_validates_payload(client):
|
|
72
|
+
response = client.post(
|
|
73
|
+
ITEMS_URL,
|
|
74
|
+
json={"name": "", "price": -1.0},
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
assert response.status_code == 422
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Minimal FastAPI Template
|
|
2
2
|
|
|
3
|
-
>
|
|
3
|
+
> Generated project: **<project_name>** — minimal FastAPI scaffold for rapid prototyping
|
|
4
4
|
|
|
5
5
|
A minimal FastAPI template that provides the basic structure for building FastAPI applications. Perfect for getting started quickly or as a foundation for more complex projects.
|
|
6
6
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "<project_name>"
|
|
3
3
|
version = "0.1.0"
|
|
4
|
-
description = "<description>"
|
|
4
|
+
description = "[FastAPI-fastkit templated] <description>"
|
|
5
5
|
authors = [
|
|
6
6
|
{name = "<author>", email = "<author_email>"},
|
|
7
7
|
]
|
|
@@ -34,6 +34,9 @@ dev = [
|
|
|
34
34
|
"mypy>=1.15.0",
|
|
35
35
|
]
|
|
36
36
|
|
|
37
|
+
[tool.fastapi-fastkit]
|
|
38
|
+
managed = true
|
|
39
|
+
|
|
37
40
|
[build-system]
|
|
38
41
|
requires = ["hatchling"]
|
|
39
42
|
build-backend = "hatchling.build"
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "<project_name>"
|
|
3
3
|
version = "0.1.0"
|
|
4
|
-
description = "<description>"
|
|
4
|
+
description = "[FastAPI-fastkit templated] <description>"
|
|
5
5
|
authors = [
|
|
6
6
|
{name = "<author>", email = "<author_email>"},
|
|
7
7
|
]
|
|
@@ -45,6 +45,9 @@ dev = [
|
|
|
45
45
|
"PyYAML>=6.0.2",
|
|
46
46
|
]
|
|
47
47
|
|
|
48
|
+
[tool.fastapi-fastkit]
|
|
49
|
+
managed = true
|
|
50
|
+
|
|
48
51
|
[build-system]
|
|
49
52
|
requires = ["hatchling"]
|
|
50
53
|
build-backend = "hatchling.build"
|