diff --git a/src/example/app.py b/src/example/app.py index 201c4dd..1c12356 100644 --- a/src/example/app.py +++ b/src/example/app.py @@ -10,7 +10,23 @@ from .note.exceptions import NoteNotFoundExceptionHandler from .note.handler import make_note_router from .note.repository import InMemoryNoteRepository -from .note.use_case import CreateNoteUseCase, GetNoteUseCase, ListNotesUseCase +from .note.use_case import ( + CreateNoteUseCase, + DeleteNoteUseCase, + GetNoteUseCase, + ListNotesUseCase, + UpdateNoteUseCase, +) +from .tag.exceptions import TagNotFoundExceptionHandler +from .tag.handler import make_tag_router +from .tag.repository import InMemoryTagRepository +from .tag.use_case import ( + CreateTagUseCase, + DeleteTagUseCase, + GetTagUseCase, + ListTagsUseCase, + UpdateTagUseCase, +) def create_app(settings: AppSettings | None = None) -> FastAPI: @@ -26,7 +42,7 @@ def create_app(settings: AppSettings | None = None) -> FastAPI: app.add_middleware( ErrorHandlerMiddleware, debug=cfg.app_debug, - domain_handlers=[NoteNotFoundExceptionHandler()], + domain_handlers=[NoteNotFoundExceptionHandler(), TagNotFoundExceptionHandler()], ) app.add_exception_handler( ValidationException, @@ -40,6 +56,20 @@ def create_app(settings: AppSettings | None = None) -> FastAPI: ListNotesUseCase(note_repo), GetNoteUseCase(note_repo), CreateNoteUseCase(note_repo), + UpdateNoteUseCase(note_repo), + DeleteNoteUseCase(note_repo), + ) + ) + + # Wire tag domain + tag_repo = InMemoryTagRepository() + app.include_router( + make_tag_router( + ListTagsUseCase(tag_repo), + GetTagUseCase(tag_repo), + CreateTagUseCase(tag_repo), + UpdateTagUseCase(tag_repo), + DeleteTagUseCase(tag_repo), ) ) diff --git a/src/example/note/handler.py b/src/example/note/handler.py index 01c7a31..b5e5ea1 100644 --- a/src/example/note/handler.py +++ b/src/example/note/handler.py @@ -19,8 +19,6 @@ UpdateNoteUseCase, ) -router = APIRouter(prefix="/notes", tags=["notes"]) - class CreateNoteBody(BaseModel): title: str @@ -39,6 +37,7 @@ def make_note_router( update_use_case: UpdateNoteUseCase, delete_use_case: DeleteNoteUseCase, ) -> APIRouter: + router = APIRouter(prefix="/notes", tags=["notes"]) @router.get("") async def list_notes(request: Request) -> JSONResponse: pagination = PaginationQueryParser.parse(request) diff --git a/src/example/tag/entity.py b/src/example/tag/entity.py new file mode 100644 index 0000000..69f080f --- /dev/null +++ b/src/example/tag/entity.py @@ -0,0 +1,9 @@ +"""Tag domain entity.""" + +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class Tag: + id: int + name: str diff --git a/src/example/tag/exceptions.py b/src/example/tag/exceptions.py new file mode 100644 index 0000000..31a8b28 --- /dev/null +++ b/src/example/tag/exceptions.py @@ -0,0 +1,19 @@ +"""Tag domain exceptions and their HTTP handlers.""" + +from starlette.responses import Response + +from nene2.http.problem_details import problem_details_response + + +class TagNotFoundException(Exception): + def __init__(self, tag_id: int) -> None: + self.tag_id = tag_id + super().__init__(f"Tag {tag_id} not found.") + + +class TagNotFoundExceptionHandler: + def handles(self, exc: Exception) -> bool: + return isinstance(exc, TagNotFoundException) + + def handle(self, exc: Exception) -> Response: + return problem_details_response("not-found", "Not Found", 404) diff --git a/src/example/tag/handler.py b/src/example/tag/handler.py new file mode 100644 index 0000000..0850edf --- /dev/null +++ b/src/example/tag/handler.py @@ -0,0 +1,83 @@ +"""Tag HTTP handlers — thin layer: parse → use-case → response.""" + +from fastapi import APIRouter, Request +from fastapi.responses import JSONResponse +from pydantic import BaseModel + +from nene2.http import PaginationQueryParser, PaginationResponse +from nene2.validation.exceptions import ValidationError, ValidationException + +from .use_case import ( + CreateTagInput, + CreateTagUseCase, + DeleteTagInput, + DeleteTagUseCase, + GetTagUseCase, + ListTagsInput, + ListTagsUseCase, + UpdateTagInput, + UpdateTagUseCase, +) + + +class CreateTagBody(BaseModel): + name: str + + +class UpdateTagBody(BaseModel): + name: str + + +def make_tag_router( + list_use_case: ListTagsUseCase, + get_use_case: GetTagUseCase, + create_use_case: CreateTagUseCase, + update_use_case: UpdateTagUseCase, + delete_use_case: DeleteTagUseCase, +) -> APIRouter: + router = APIRouter(prefix="/tags", tags=["tags"]) + @router.get("") + async def list_tags(request: Request) -> JSONResponse: + pagination = PaginationQueryParser.parse(request) + output = list_use_case.execute(ListTagsInput(pagination.limit, pagination.offset)) + return JSONResponse( + PaginationResponse( + items=[{"id": t.id, "name": t.name} for t in output.items], + limit=output.limit, + offset=output.offset, + total=output.total, + ).to_dict() + ) + + @router.get("/{tag_id}") + async def get_tag(tag_id: int) -> JSONResponse: + tag = get_use_case.execute(tag_id) + return JSONResponse({"id": tag.id, "name": tag.name}) + + @router.post("", status_code=201) + async def create_tag(body: CreateTagBody) -> JSONResponse: + errors: list[ValidationError] = [] + if not body.name.strip(): + errors.append(ValidationError("name", "Name must not be empty.", "required")) + if errors: + raise ValidationException(errors) + + tag = create_use_case.execute(CreateTagInput(body.name)) + return JSONResponse({"id": tag.id, "name": tag.name}, status_code=201) + + @router.put("/{tag_id}") + async def update_tag(tag_id: int, body: UpdateTagBody) -> JSONResponse: + errors: list[ValidationError] = [] + if not body.name.strip(): + errors.append(ValidationError("name", "Name must not be empty.", "required")) + if errors: + raise ValidationException(errors) + + tag = update_use_case.execute(UpdateTagInput(tag_id, body.name)) + return JSONResponse({"id": tag.id, "name": tag.name}) + + @router.delete("/{tag_id}", status_code=204) + async def delete_tag(tag_id: int) -> None: + delete_use_case.execute(DeleteTagInput(tag_id)) + + return router diff --git a/src/example/tag/repository.py b/src/example/tag/repository.py new file mode 100644 index 0000000..696de1d --- /dev/null +++ b/src/example/tag/repository.py @@ -0,0 +1,62 @@ +"""Tag repository interface and in-memory implementation.""" + +from abc import ABC, abstractmethod + +from .entity import Tag + + +class TagRepositoryInterface(ABC): + @abstractmethod + def find_all(self, limit: int, offset: int) -> list[Tag]: ... + + @abstractmethod + def find_by_id(self, tag_id: int) -> Tag | None: ... + + @abstractmethod + def save(self, name: str) -> Tag: ... + + @abstractmethod + def update(self, tag_id: int, name: str) -> Tag | None: ... + + @abstractmethod + def delete(self, tag_id: int) -> bool: ... + + @abstractmethod + def count(self) -> int: ... + + +class InMemoryTagRepository(TagRepositoryInterface): + """In-memory implementation for development and testing.""" + + def __init__(self) -> None: + self._store: dict[int, Tag] = {} + self._next_id: int = 1 + + def find_all(self, limit: int, offset: int) -> list[Tag]: + tags = sorted(self._store.values(), key=lambda t: t.id) + return tags[offset : offset + limit] + + def find_by_id(self, tag_id: int) -> Tag | None: + return self._store.get(tag_id) + + def save(self, name: str) -> Tag: + tag = Tag(id=self._next_id, name=name) + self._store[self._next_id] = tag + self._next_id += 1 + return tag + + def update(self, tag_id: int, name: str) -> Tag | None: + if tag_id not in self._store: + return None + updated = Tag(id=tag_id, name=name) + self._store[tag_id] = updated + return updated + + def delete(self, tag_id: int) -> bool: + if tag_id not in self._store: + return False + del self._store[tag_id] + return True + + def count(self) -> int: + return len(self._store) diff --git a/src/example/tag/use_case.py b/src/example/tag/use_case.py new file mode 100644 index 0000000..cb0e4a5 --- /dev/null +++ b/src/example/tag/use_case.py @@ -0,0 +1,92 @@ +"""Tag use-cases — business logic, no HTTP or database knowledge.""" + +from dataclasses import dataclass + +from .entity import Tag +from .exceptions import TagNotFoundException +from .repository import TagRepositoryInterface + + +@dataclass(frozen=True) +class ListTagsInput: + limit: int + offset: int + + +@dataclass(frozen=True) +class ListTagsOutput: + items: list[Tag] + limit: int + offset: int + total: int + + +class ListTagsUseCase: + def __init__(self, repository: TagRepositoryInterface) -> None: + self._repository = repository + + def execute(self, input_: ListTagsInput) -> ListTagsOutput: + items = self._repository.find_all(input_.limit, input_.offset) + total = self._repository.count() + return ListTagsOutput( + items=items, + limit=input_.limit, + offset=input_.offset, + total=total, + ) + + +class GetTagUseCase: + def __init__(self, repository: TagRepositoryInterface) -> None: + self._repository = repository + + def execute(self, tag_id: int) -> Tag: + tag = self._repository.find_by_id(tag_id) + if tag is None: + raise TagNotFoundException(tag_id) + return tag + + +@dataclass(frozen=True) +class CreateTagInput: + name: str + + +class CreateTagUseCase: + def __init__(self, repository: TagRepositoryInterface) -> None: + self._repository = repository + + def execute(self, input_: CreateTagInput) -> Tag: + return self._repository.save(input_.name) + + +@dataclass(frozen=True) +class UpdateTagInput: + tag_id: int + name: str + + +class UpdateTagUseCase: + def __init__(self, repository: TagRepositoryInterface) -> None: + self._repository = repository + + def execute(self, input_: UpdateTagInput) -> Tag: + tag = self._repository.update(input_.tag_id, input_.name) + if tag is None: + raise TagNotFoundException(input_.tag_id) + return tag + + +@dataclass(frozen=True) +class DeleteTagInput: + tag_id: int + + +class DeleteTagUseCase: + def __init__(self, repository: TagRepositoryInterface) -> None: + self._repository = repository + + def execute(self, input_: DeleteTagInput) -> None: + deleted = self._repository.delete(input_.tag_id) + if not deleted: + raise TagNotFoundException(input_.tag_id) diff --git a/tests/example/tag/__init__.py b/tests/example/tag/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/example/tag/test_tags.py b/tests/example/tag/test_tags.py new file mode 100644 index 0000000..ea4d2f5 --- /dev/null +++ b/tests/example/tag/test_tags.py @@ -0,0 +1,92 @@ +"""HTTP-level tests for the Tag endpoints.""" + +from fastapi.testclient import TestClient + +from nene2.config import AppSettings +from src.example.app import create_app + + +def _client() -> TestClient: + return TestClient(create_app(AppSettings())) + + +def test_list_tags_empty() -> None: + r = _client().get("/tags") + assert r.status_code == 200 + body = r.json() + assert body["items"] == [] + assert body["total"] == 0 + + +def test_create_and_get_tag() -> None: + client = _client() + r = client.post("/tags", json={"name": "python"}) + assert r.status_code == 201 + tag_id = r.json()["id"] + + r2 = client.get(f"/tags/{tag_id}") + assert r2.status_code == 200 + assert r2.json()["name"] == "python" + + +def test_create_tag_empty_name_returns_422() -> None: + r = _client().post("/tags", json={"name": ""}) + assert r.status_code == 422 + assert r.json()["errors"][0]["field"] == "name" + + +def test_get_nonexistent_tag_returns_404() -> None: + r = _client().get("/tags/9999") + assert r.status_code == 404 + + +def test_update_tag_returns_200() -> None: + client = _client() + r = client.post("/tags", json={"name": "old"}) + tag_id = r.json()["id"] + + r2 = client.put(f"/tags/{tag_id}", json={"name": "new"}) + assert r2.status_code == 200 + assert r2.json()["name"] == "new" + + +def test_update_nonexistent_tag_returns_404() -> None: + r = _client().put("/tags/9999", json={"name": "x"}) + assert r.status_code == 404 + + +def test_update_tag_empty_name_returns_422() -> None: + client = _client() + r = client.post("/tags", json={"name": "t"}) + tag_id = r.json()["id"] + r2 = client.put(f"/tags/{tag_id}", json={"name": ""}) + assert r2.status_code == 422 + + +def test_delete_tag_returns_204() -> None: + client = _client() + r = client.post("/tags", json={"name": "temp"}) + tag_id = r.json()["id"] + + r2 = client.delete(f"/tags/{tag_id}") + assert r2.status_code == 204 + + r3 = client.get(f"/tags/{tag_id}") + assert r3.status_code == 404 + + +def test_delete_nonexistent_tag_returns_404() -> None: + r = _client().delete("/tags/9999") + assert r.status_code == 404 + + +def test_list_tags_pagination() -> None: + client = _client() + for name in ["a", "b", "c"]: + client.post("/tags", json={"name": name}) + + r = client.get("/tags?limit=2&offset=0") + assert r.status_code == 200 + body = r.json() + assert len(body["items"]) == 2 + assert body["total"] == 3