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
34 changes: 32 additions & 2 deletions src/example/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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,
Expand All @@ -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),
)
)

Expand Down
3 changes: 1 addition & 2 deletions src/example/note/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,6 @@
UpdateNoteUseCase,
)

router = APIRouter(prefix="/notes", tags=["notes"])


class CreateNoteBody(BaseModel):
title: str
Expand All @@ -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)
Expand Down
9 changes: 9 additions & 0 deletions src/example/tag/entity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
"""Tag domain entity."""

from dataclasses import dataclass


@dataclass(frozen=True, slots=True)
class Tag:
id: int
name: str
19 changes: 19 additions & 0 deletions src/example/tag/exceptions.py
Original file line number Diff line number Diff line change
@@ -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)
83 changes: 83 additions & 0 deletions src/example/tag/handler.py
Original file line number Diff line number Diff line change
@@ -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
62 changes: 62 additions & 0 deletions src/example/tag/repository.py
Original file line number Diff line number Diff line change
@@ -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)
92 changes: 92 additions & 0 deletions src/example/tag/use_case.py
Original file line number Diff line number Diff line change
@@ -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)
Empty file added tests/example/tag/__init__.py
Empty file.
Loading
Loading