mirror of
https://github.com/kennethreitz/responder.git
synced 2026-07-21 18:39:29 +00:00
327 lines
9.5 KiB
Python
327 lines
9.5 KiB
Python
"""A practical Todo API example for Responder.
|
|
|
|
Run it:
|
|
|
|
responder run examples/todo.py
|
|
|
|
Try it with:
|
|
|
|
curl http://127.0.0.1:5042/todos
|
|
curl -H "Authorization: Bearer demo-token" \
|
|
-H "Content-Type: application/json" \
|
|
-d '{"title": "Write the release notes", "tags": ["release"]}' \
|
|
http://127.0.0.1:5042/todos
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field
|
|
from datetime import UTC, date, datetime
|
|
from typing import Literal
|
|
|
|
from pydantic import BaseModel, Field, field_validator
|
|
|
|
import responder
|
|
from responder.ext.auth import BearerAuth
|
|
|
|
TodoPriority = Literal["low", "normal", "high"]
|
|
|
|
|
|
def _now() -> datetime:
|
|
return datetime.now(UTC).replace(microsecond=0)
|
|
|
|
|
|
def _clean_tags(tags: list[str]) -> list[str]:
|
|
normalized: list[str] = []
|
|
for tag in tags:
|
|
cleaned = tag.strip().lower()
|
|
if cleaned and cleaned not in normalized:
|
|
normalized.append(cleaned)
|
|
return normalized
|
|
|
|
|
|
class TodoCreate(BaseModel):
|
|
title: str = Field(min_length=1, examples=["Write the release notes"])
|
|
notes: str = Field(default="", examples=["Link to the changelog."])
|
|
due: date | None = Field(default=None, examples=["2026-07-01"])
|
|
priority: TodoPriority = Field(default="normal", examples=["high"])
|
|
tags: list[str] = Field(default_factory=list, examples=[["release", "docs"]])
|
|
|
|
@field_validator("tags")
|
|
@classmethod
|
|
def normalize_tags(cls, tags: list[str]) -> list[str]:
|
|
return _clean_tags(tags)
|
|
|
|
|
|
class TodoPatch(BaseModel):
|
|
title: str | None = Field(default=None, min_length=1)
|
|
notes: str | None = None
|
|
due: date | None = None
|
|
priority: TodoPriority | None = None
|
|
tags: list[str] | None = None
|
|
completed: bool | None = None
|
|
|
|
@field_validator("tags")
|
|
@classmethod
|
|
def normalize_tags(cls, tags: list[str] | None) -> list[str] | None:
|
|
return None if tags is None else _clean_tags(tags)
|
|
|
|
|
|
class TodoOut(TodoCreate):
|
|
id: int
|
|
completed: bool
|
|
created_at: datetime
|
|
completed_at: datetime | None
|
|
owner: str
|
|
|
|
|
|
class User(BaseModel):
|
|
name: str
|
|
scopes: list[str]
|
|
|
|
|
|
TODO_EXAMPLE = {
|
|
"id": 1,
|
|
"title": "Write the release notes",
|
|
"notes": "Link to the changelog.",
|
|
"due": "2026-07-01",
|
|
"priority": "high",
|
|
"tags": ["release", "docs"],
|
|
"completed": False,
|
|
"created_at": "2026-06-30T12:00:00Z",
|
|
"completed_at": None,
|
|
"owner": "Ada",
|
|
}
|
|
|
|
|
|
@dataclass
|
|
class TodoStore:
|
|
todos: dict[int, TodoOut] = field(default_factory=dict)
|
|
next_id: int = 1
|
|
|
|
@classmethod
|
|
def seeded(cls) -> TodoStore:
|
|
store = cls()
|
|
store.create(
|
|
TodoCreate(
|
|
title="Sketch the release post",
|
|
notes="Keep it short, useful, and link the changelog.",
|
|
due=date(2026, 7, 1),
|
|
priority="high",
|
|
tags=["release", "writing"],
|
|
),
|
|
owner="Ada",
|
|
)
|
|
done = store.create(
|
|
TodoCreate(
|
|
title="Review generated client ergonomics",
|
|
notes="Check the golden contract app before shipping.",
|
|
priority="normal",
|
|
tags=["clientgen", "quality"],
|
|
),
|
|
owner="Grace",
|
|
)
|
|
store.update(done.id, TodoPatch(completed=True))
|
|
return store
|
|
|
|
def all(
|
|
self,
|
|
*,
|
|
completed: bool | None = None,
|
|
tag: str | None = None,
|
|
) -> list[TodoOut]:
|
|
rows = sorted(self.todos.values(), key=lambda item: item.id)
|
|
if completed is not None:
|
|
rows = [item for item in rows if item.completed is completed]
|
|
if tag:
|
|
wanted = tag.strip().lower()
|
|
rows = [item for item in rows if wanted in item.tags]
|
|
return [item.model_copy(deep=True) for item in rows]
|
|
|
|
def get(self, todo_id: int) -> TodoOut | None:
|
|
todo = self.todos.get(todo_id)
|
|
return None if todo is None else todo.model_copy(deep=True)
|
|
|
|
def create(self, todo: TodoCreate, *, owner: str) -> TodoOut:
|
|
todo_id = self.next_id
|
|
self.next_id += 1
|
|
record = TodoOut(
|
|
id=todo_id,
|
|
completed=False,
|
|
created_at=_now(),
|
|
completed_at=None,
|
|
owner=owner,
|
|
**todo.model_dump(),
|
|
)
|
|
self.todos[todo_id] = record
|
|
return record.model_copy(deep=True)
|
|
|
|
def update(self, todo_id: int, patch: TodoPatch) -> TodoOut | None:
|
|
todo = self.todos.get(todo_id)
|
|
if todo is None:
|
|
return None
|
|
|
|
updates = patch.model_dump(exclude_unset=True)
|
|
updates = {
|
|
key: value
|
|
for key, value in updates.items()
|
|
if value is not None or key == "due"
|
|
}
|
|
|
|
if "completed" in updates:
|
|
completed = updates["completed"]
|
|
updates["completed_at"] = (
|
|
todo.completed_at or _now()
|
|
) if completed else None
|
|
|
|
updated = todo.model_copy(update=updates)
|
|
self.todos[todo_id] = updated
|
|
return updated.model_copy(deep=True)
|
|
|
|
def complete(self, todo_id: int) -> TodoOut | None:
|
|
return self.update(todo_id, TodoPatch(completed=True))
|
|
|
|
def delete(self, todo_id: int) -> bool:
|
|
return self.todos.pop(todo_id, None) is not None
|
|
|
|
|
|
def create_api(*, store: TodoStore | None = None) -> responder.API:
|
|
store = store or TodoStore.seeded()
|
|
users = {
|
|
"demo-token": User(name="Ada", scopes=["todos:write"]),
|
|
"ops-token": User(name="Grace", scopes=["todos:write"]),
|
|
}
|
|
|
|
bearer = BearerAuth(
|
|
verify=lambda token: users.get(token),
|
|
bearer_format="opaque",
|
|
realm="todos",
|
|
)
|
|
|
|
api = responder.API(
|
|
title="Todo API",
|
|
version="1.0",
|
|
openapi="3.1.0",
|
|
docs_route="/docs",
|
|
sessions=False,
|
|
request_id=True,
|
|
)
|
|
|
|
writer = api.policy("writer", bearer.requires("todos:write"))
|
|
|
|
@api.get("/", include_in_schema=False)
|
|
def index(req, resp):
|
|
resp.media = {"name": "Todo API", "todos": "/todos", "docs": "/docs"}
|
|
|
|
@api.get(
|
|
"/todos",
|
|
operation_id="list_todos",
|
|
tags=["todos"],
|
|
summary="List todos",
|
|
description="Return every todo, optionally filtered by completion or tag.",
|
|
examples={"seed": {"summary": "Seeded todos", "value": [TODO_EXAMPLE]}},
|
|
)
|
|
def list_todos(
|
|
req,
|
|
resp,
|
|
*,
|
|
completed: bool | None = responder.Query(
|
|
None,
|
|
description="Only return active or completed todos.",
|
|
),
|
|
tag: str | None = responder.Query(
|
|
None,
|
|
description="Only return todos with this tag.",
|
|
),
|
|
) -> list[TodoOut]:
|
|
return store.all(completed=completed, tag=tag)
|
|
|
|
@api.post(
|
|
"/todos",
|
|
auth=writer,
|
|
operation_id="create_todo",
|
|
tags=["todos"],
|
|
summary="Create a todo",
|
|
status_code=201,
|
|
responses={201: {"description": "Todo created"}},
|
|
response_examples={201: {"created": {"value": TODO_EXAMPLE}}},
|
|
)
|
|
def create_todo(req, resp, *, user: User, todo: TodoCreate) -> TodoOut:
|
|
created = store.create(todo, owner=user.name)
|
|
resp.headers["Location"] = f"/todos/{created.id}"
|
|
return created
|
|
|
|
@api.get(
|
|
"/todos/{todo_id:int}",
|
|
operation_id="get_todo",
|
|
tags=["todos"],
|
|
summary="Fetch a todo",
|
|
response_model=TodoOut,
|
|
responses={404: "Todo not found"},
|
|
examples={"release_notes": {"value": TODO_EXAMPLE}},
|
|
)
|
|
def get_todo(req, resp, *, todo_id: int):
|
|
todo = store.get(todo_id)
|
|
if todo is None:
|
|
resp.problem(404, f"Todo {todo_id} does not exist.", todo_id=todo_id)
|
|
return
|
|
resp.media = todo
|
|
|
|
@api.patch(
|
|
"/todos/{todo_id:int}",
|
|
auth=writer,
|
|
operation_id="update_todo",
|
|
tags=["todos"],
|
|
summary="Update a todo",
|
|
response_model=TodoOut,
|
|
responses={400: "No changes supplied", 404: "Todo not found"},
|
|
)
|
|
def update_todo(req, resp, *, todo_id: int, user: User, patch: TodoPatch):
|
|
if not patch.model_fields_set:
|
|
resp.problem(400, "Send at least one field to update.", todo_id=todo_id)
|
|
return
|
|
todo = store.update(todo_id, patch)
|
|
if todo is None:
|
|
resp.problem(404, f"Todo {todo_id} does not exist.", todo_id=todo_id)
|
|
return
|
|
resp.media = todo
|
|
|
|
@api.post(
|
|
"/todos/{todo_id:int}/complete",
|
|
auth=writer,
|
|
operation_id="complete_todo",
|
|
tags=["todos"],
|
|
summary="Complete a todo",
|
|
response_model=TodoOut,
|
|
responses={404: "Todo not found"},
|
|
)
|
|
def complete_todo(req, resp, *, todo_id: int, user: User):
|
|
todo = store.complete(todo_id)
|
|
if todo is None:
|
|
resp.problem(404, f"Todo {todo_id} does not exist.", todo_id=todo_id)
|
|
return
|
|
resp.media = todo
|
|
|
|
@api.delete(
|
|
"/todos/{todo_id:int}",
|
|
auth=writer,
|
|
operation_id="delete_todo",
|
|
tags=["todos"],
|
|
summary="Delete a todo",
|
|
responses={204: "Todo deleted", 404: "Todo not found"},
|
|
)
|
|
def delete_todo(req, resp, *, todo_id: int, user: User):
|
|
if not store.delete(todo_id):
|
|
resp.problem(404, f"Todo {todo_id} does not exist.", todo_id=todo_id)
|
|
return
|
|
resp.no_content(headers={"X-Deleted-Todo": str(todo_id)})
|
|
|
|
return api
|
|
|
|
|
|
api = create_api()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
api.run()
|