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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -73,12 +73,13 @@ jobs:
run: uv run ruff check . --select "I,E,F,Q,UP,FAST" --line-length 120

- name: Run type checker
run: uv run ty check || true
run: uv run ty check

- name: Run pytest
run: |
# Check if tests directory or test files exist
if [ -d "tests" ] || find . -name "test_*.py" -o -name "*_test.py" | grep -q .; then
if [ -d "tests" ] || \
find . -not -path "./.venv/*" \( -name "test_*.py" -o -name "*_test.py" \) | grep -q .; then
uv run pytest -v
else
echo "No tests found in ${{ matrix.project }}"
Expand Down
5 changes: 2 additions & 3 deletions Chapter07/sitters-catalog/config.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
from enum import StrEnum

from pydantic import ConfigDict
from pydantic_settings import BaseSettings
from pydantic_settings import BaseSettings, SettingsConfigDict


class RepositoryType(StrEnum):
Expand All @@ -20,7 +19,7 @@ class Settings(BaseSettings):
repository_type: RepositoryType = RepositoryType.TINYDB_FILE
tinydb_path: str = "data/babysitters_db.json"

model_config = ConfigDict(env_file=".env")
model_config = SettingsConfigDict(env_file=".env")


settings = Settings()
9 changes: 5 additions & 4 deletions Chapter07/sitters-catalog/features/create_babysitter/route.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
from typing import Annotated
from typing import Annotated, Any

from fastapi import APIRouter, Depends, status

from shared.dependencies import (
get_repository,
)
from shared.dto import BabysitterResponseDTO
from shared.infrastructure import BaseRepository

from .dto import CreateBabysitterDTO
from .handler import create_babysitter
Expand All @@ -15,11 +16,11 @@
)


@router.post("/", status_code=status.HTTP_201_CREATED)
@router.post("/", status_code=status.HTTP_201_CREATED, response_model=BabysitterResponseDTO)
async def create_babysitter_endpoint(
dto: CreateBabysitterDTO,
repo: Annotated[object, Depends(get_repository)],
) -> BabysitterResponseDTO:
repo: Annotated[BaseRepository, Depends(get_repository)],
) -> Any:
"""Register a new babysitter in the catalog."""
babysitter = await create_babysitter(dto, repo)
return babysitter
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
from typing import Annotated
from typing import Annotated, Any

from fastapi import APIRouter, Depends, Path

from shared.dependencies import get_repository
from shared.dto import BabysitterResponseDTO
from shared.infrastructure import BaseRepository

from .handler import deactivate_babysitter

Expand All @@ -17,10 +18,10 @@
]


@router.post("/{id}/deactivate")
@router.post("/{id}/deactivate", response_model=BabysitterResponseDTO)
async def deactivate_babysitter_endpoint(
id: BabysitterIdDep,
repo: Annotated[object, Depends(get_repository)],
) -> BabysitterResponseDTO:
repo: Annotated[BaseRepository, Depends(get_repository)],
) -> Any:
"""Soft-delete: set is_active=False, keep the record."""
return await deactivate_babysitter(id, repo)
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from fastapi import APIRouter, Depends, Path, status

from shared.dependencies import get_repository
from shared.infrastructure import BaseRepository

from .handler import delete_babysitter

Expand All @@ -19,7 +20,7 @@
@router.delete("/{id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_babysitter_endpoint(
id: BabysitterIdDep,
repo: Annotated[object, Depends(get_repository)],
repo: Annotated[BaseRepository, Depends(get_repository)],
) -> None:
"""Permanently remove. Use /deactivate for soft-delete."""
await delete_babysitter(id, repo)
9 changes: 5 additions & 4 deletions Chapter07/sitters-catalog/features/get_babysitter/route.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
from typing import Annotated
from typing import Annotated, Any

from fastapi import APIRouter, Depends, Path

from shared.dependencies import get_repository
from shared.dto import BabysitterResponseDTO
from shared.infrastructure import BaseRepository

from .handler import get_babysitter_by_id

Expand All @@ -17,10 +18,10 @@
]


@router.get("/{id}")
@router.get("/{id}", response_model=BabysitterResponseDTO)
async def get_babysitter_endpoint(
id: BabysitterIdDep,
repo: Annotated[object, Depends(get_repository)],
) -> BabysitterResponseDTO:
repo: Annotated[BaseRepository, Depends(get_repository)],
) -> Any:
"""Fetch a single babysitter by ID."""
return await get_babysitter_by_id(id, repo)
9 changes: 5 additions & 4 deletions Chapter07/sitters-catalog/features/get_featured/route.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
from typing import Annotated
from typing import Annotated, Any

from fastapi import APIRouter, Depends

from shared.dependencies import get_repository
from shared.dto import BabysitterResponseDTO
from shared.infrastructure import BaseRepository

from .handler import get_featured_babysitters

Expand All @@ -12,9 +13,9 @@
)


@router.get("/featured")
@router.get("/featured", response_model=list[BabysitterResponseDTO])
async def get_featured_endpoint(
repo: Annotated[object, Depends(get_repository)],
) -> list[BabysitterResponseDTO]:
repo: Annotated[BaseRepository, Depends(get_repository)],
) -> Any:
"""Return the top 5 most experienced active babysitters."""
return await get_featured_babysitters(repo)
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from shared.dto import BabysitterResponseDTO
from shared.domain.entities import Babysitter
from shared.infrastructure.base_repository import BaseRepository

from .dto import BabysitterSearchFilters
Expand All @@ -9,7 +9,7 @@ async def list_babysitters(
skip: int,
limit: int,
repo: BaseRepository,
) -> list[BabysitterResponseDTO]:
) -> list[Babysitter]:
"""Search and list babysitters with optional filters."""
raw: dict = {}
if filters.city is not None:
Expand Down
9 changes: 5 additions & 4 deletions Chapter07/sitters-catalog/features/list_babysitters/route.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
from typing import Annotated
from typing import Annotated, Any

from fastapi import APIRouter, Depends, Query

from shared.dependencies import get_repository
from shared.dto import BabysitterResponseDTO
from shared.infrastructure.base_repository import BaseRepository

from .dto import BabysitterSearchFilters
from .handler import list_babysitters
Expand All @@ -13,9 +14,9 @@
)


@router.get("/")
@router.get("/", response_model=list[BabysitterResponseDTO])
async def list_babysitters_endpoint(
repo: Annotated[object, Depends(get_repository)],
repo: Annotated[BaseRepository, Depends(get_repository)],
city: Annotated[
str | None, Query(description="Filter by city")
] = None,
Expand Down Expand Up @@ -44,7 +45,7 @@ async def list_babysitters_endpoint(
int,
Query(ge=1, le=100, description="Page size (max 100)"),
] = 20,
) -> list[BabysitterResponseDTO]:
) -> Any:
"""Search and list babysitters with optional filters."""
filters = BabysitterSearchFilters(
city=city,
Expand Down
10 changes: 5 additions & 5 deletions Chapter07/sitters-catalog/features/update_babysitter/route.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import Annotated
from typing import Annotated, Any

from fastapi import APIRouter, Depends, Path

Expand All @@ -19,21 +19,21 @@
]


@router.put("/{id}")
@router.put("/{id}", response_model=BabysitterResponseDTO)
async def update_babysitter_endpoint(
id: BabysitterIdDep,
dto: UpdateBabysitterDTO,
repo: Annotated[BaseRepository, Depends(get_repository)],
) -> BabysitterResponseDTO:
) -> Any:
"""Full update — replace all provided fields."""
return await update_babysitter(id, dto, repo)


@router.patch("/{id}")
@router.patch("/{id}", response_model=BabysitterResponseDTO)
async def partial_update_babysitter_endpoint(
id: BabysitterIdDep,
dto: UpdateBabysitterDTO,
repo: Annotated[BaseRepository, Depends(get_repository)],
) -> BabysitterResponseDTO:
) -> Any:
"""Partial update — only sent fields are changed."""
return await update_babysitter(id, dto, repo)
22 changes: 11 additions & 11 deletions Chapter07/sitters-catalog/shared/infrastructure/mongo_repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,25 +15,25 @@ def __init__(self, mongo_url: str) -> None:
db = self.client.sitter_calatog_app
self._collection = db.babysitters

async def save_sitter(self, entity: Babysitter) -> Babysitter:
async def save_sitter(self, sitter: Babysitter) -> Babysitter:
"""Persist an entity. Creates new or updates existing."""
entity.refresh_updated_at()
data = entity.model_dump(exclude={"id"})
sitter.refresh_updated_at()
data = sitter.model_dump(exclude={"id"})

if entity.id:
if sitter.id:
# Update existing document
await self._collection.replace_one(
{"_id": ObjectId(entity.id)},
{"_id": ObjectId(sitter.id)},
data,
)
else:
# Insert new document
entity.created_at = datetime.now(UTC)
data["created_at"] = entity.created_at
sitter.created_at = datetime.now(UTC)
data["created_at"] = sitter.created_at
result = await self._collection.insert_one(data)
entity.id = str(result.inserted_id)
sitter.id = str(result.inserted_id)

return entity
return sitter

async def find_sitter_by_id(
self, id: str
Expand All @@ -44,7 +44,7 @@ async def find_sitter_by_id(
{"_id": ObjectId(id)}
)
if doc:
return self._to_entity(doc)
return Babysitter.model_validate(doc)
except Exception:
pass
return None
Expand Down Expand Up @@ -80,7 +80,7 @@ async def find_featured_sitters(
.sort("years_of_experience", -1)
.limit(limit)
)
return [self._to_entity(doc) async for doc in cursor]
return [Babysitter.model_validate(doc) async for doc in cursor]

def _build_query(self, filters: dict) -> dict:
"""Build MongoDB query from filter parameters."""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,25 +38,25 @@ def __init__(

self._collection = self._db.table("babysitters")

async def save_sitter(self, entity: Babysitter) -> Babysitter:
async def save_sitter(self, sitter: Babysitter) -> Babysitter:
"""Persist an entity. Creates new or updates existing."""
if entity.id is None:
entity.id = str(uuid.uuid4())
entity.created_at = datetime.now(UTC)
if sitter.id is None:
sitter.id = str(uuid.uuid4())
sitter.created_at = datetime.now(UTC)

entity.refresh_updated_at()
sitter.refresh_updated_at()

q = Query()
existing = self._collection.search(q.id == entity.id)
existing = self._collection.search(q.id == sitter.id)

data = entity.model_dump(mode="json")
data = sitter.model_dump(mode="json")

if existing:
self._collection.update(data, q.id == entity.id)
self._collection.update(data, q.id == sitter.id)
else:
self._collection.insert(data)

return entity
return sitter

async def find_sitter_by_id(
self, id: str
Expand Down
1 change: 1 addition & 0 deletions Chapter08/secured-api/.python-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
3.13
Empty file added Chapter08/secured-api/README.md
Empty file.
45 changes: 45 additions & 0 deletions Chapter08/secured-api/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from typing import Annotated, TypedDict

from fastapi import Depends, FastAPI

from security.authenticators import (
BaseAuthenticator,
UnsafeAuthenticator,
)
from security.commons import UserInfo
from security.dependencies import GetUserWithRole, get_user
from security.router import router as security_router

# to get a string like this run:
# openssl rand -hex 32


class State(TypedDict):
authenticator: BaseAuthenticator


@asynccontextmanager
async def lifespan(_app: FastAPI) -> AsyncIterator[State]:
yield {"authenticator": UnsafeAuthenticator()}


app = FastAPI(lifespan=lifespan)
app.include_router(security_router)


@app.get("/users/me/")
async def read_users_me(
current_user: Annotated[UserInfo, Depends(get_user)],
) -> UserInfo:
return current_user


@app.get("/users/me/premium")
async def read_own_items(
current_user: Annotated[
UserInfo, Depends(GetUserWithRole("premium"))
],
):
return current_user
24 changes: 24 additions & 0 deletions Chapter08/secured-api/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
[project]
name = "secured-api"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"fastapi[standard]>=0.135.3",
"pwdlib[argon2]>=0.3.0",
"pyjwt>=2.12.1",
]

[dependency-groups]
dev = [
"pytest>=9.0.3",
"ruff>=0.15.10",
"ty>=0.0.29",
]

[tool.ruff]
line-length = 61

[tool.ruff.lint]
select = ["I", "E", "F", "Q", "UP", "FAST", "ARG"]
Empty file.
Loading
Loading