Part 3: Controllers & Routes¶
Generate a controller for each model:
This created app/Controllers/project_controller.py and
app/Controllers/task_controller.py, each a Controller subclass with a
placeholder index method. Replace app/Controllers/project_controller.py:
from starlette.responses import JSONResponse, Response
from zeython import Controller, NotFoundException
from app.Models.project import Project
class ProjectController(Controller):
async def index(self, request):
projects = await Project.all()
return JSONResponse([project.to_dict() for project in projects])
async def show(self, request):
project = await Project.find(int(request.path_params["id"]))
if project is None:
raise NotFoundException("Project not found")
return JSONResponse(project.to_dict())
async def store(self, request):
data = await request.json()
project = await Project.create(name=data.get("name"))
return JSONResponse(project.to_dict(), status_code=201)
async def update(self, request):
project = await Project.find(int(request.path_params["id"]))
if project is None:
raise NotFoundException("Project not found")
data = await request.json()
await project.update(name=data.get("name", project.name))
return JSONResponse(project.to_dict())
async def destroy(self, request):
project = await Project.find(int(request.path_params["id"]))
if project is None:
raise NotFoundException("Project not found")
await project.delete()
return Response(status_code=204)
Notice update() pulls name out of the request body by key rather than
spreading the whole thing (project.update(**data)) — the moment a model
gains a column that shouldn't be caller-settable, blindly spreading a
request body into update()/create() lets a request set that too. See
Mass-assignment protection.
Nothing here is Zeython-specific magic — it's plain async Python calling
the Active Record API from Part 2. NotFoundException
(and its siblings — ValidationException, UnauthorizedException,
ForbiddenException) are the framework's way of turning "this went
wrong" into the right HTTP status and JSON shape without you writing that
translation by hand every time; raise one, the framework handles the
response.
Do the same for app/Controllers/task_controller.py, swapping Project
for Task and name for title (and pass done through on create if
you want to set it explicitly — it defaults to False from the model
either way):
from starlette.responses import JSONResponse, Response
from zeython import Controller, NotFoundException
from app.Models.task import Task
class TaskController(Controller):
async def index(self, request):
tasks = await Task.all()
return JSONResponse([task.to_dict() for task in tasks])
async def show(self, request):
task = await Task.find(int(request.path_params["id"]))
if task is None:
raise NotFoundException("Task not found")
return JSONResponse(task.to_dict())
async def store(self, request):
data = await request.json()
task = await Task.create(title=data.get("title"))
return JSONResponse(task.to_dict(), status_code=201)
async def update(self, request):
task = await Task.find(int(request.path_params["id"]))
if task is None:
raise NotFoundException("Task not found")
data = await request.json()
await task.update(title=data.get("title", task.title), done=data.get("done", task.done))
return JSONResponse(task.to_dict())
async def destroy(self, request):
task = await Task.find(int(request.path_params["id"]))
if task is None:
raise NotFoundException("Task not found")
await task.delete()
return Response(status_code=204)
Wire up the routes¶
Open routes/web.py, import both controllers, and register a full REST
resource for each:
from app.Controllers.project_controller import ProjectController
from app.Controllers.task_controller import TaskController
app.router.resource("/projects", ProjectController)
app.router.resource("/tasks", TaskController)
resource() maps one controller onto the standard five CRUD routes in
one line:
| Method | Path | Controller method |
|---|---|---|
GET |
/projects |
index |
POST |
/projects |
store |
GET |
/projects/{id} |
show |
PUT/PATCH |
/projects/{id} |
update |
DELETE |
/projects/{id} |
destroy |
Pass only=("index", "show") if you only want a subset — the generated
UserController/PostController in routes/web.py already do this for
routes that shouldn't exist yet (see the finished file).
Try it¶
zeython serve picked up the changes automatically. Create a project,
then a task -- every unsafe request here still needs -H "X-CSRF-Token:
$CSRF" (cookies.txt/$CSRF from Part 1),
even though none of these routes require login yet:
curl -sS -b cookies.txt -H "X-CSRF-Token: $CSRF" -X POST http://127.0.0.1:8000/projects \
-H 'Content-Type: application/json' \
-d '{"name": "Website Redesign"}'
{"name":"Website Redesign","id":1,"created_at":"...","updated_at":"...","is_deleted":false,"deleted_at":null}
curl -sS -b cookies.txt -H "X-CSRF-Token: $CSRF" -X POST http://127.0.0.1:8000/tasks \
-H 'Content-Type: application/json' \
-d '{"title": "Design the new homepage"}'
curl -sS http://127.0.0.1:8000/tasks
curl -sS http://127.0.0.1:8000/tasks/1
curl -sS -b cookies.txt -H "X-CSRF-Token: $CSRF" -X PUT http://127.0.0.1:8000/tasks/1 \
-H 'Content-Type: application/json' \
-d '{"done": true}'
curl -sS -b cookies.txt -H "X-CSRF-Token: $CSRF" -X DELETE http://127.0.0.1:8000/tasks/1 -o /dev/null -w '%{http_code}\n'
The last command prints 204 — a successful delete with no body.
You now have full CRUD for two models. What's missing: a task has no idea which project it belongs to. Continue to Part 4 — Relationships.