Checking input and wrapping handlers in middleware¶
~25 min ยท prerequisite: Hashing passwords and signing tokens
What you will be able to do¶
- Check a request body for missing fields, wrong types, and out-of-range values, and collect every error.
- Write a middleware function that wraps a handler and runs code before and after it.
- Build a pipeline that turns a raised error into a status code such as 422 or 500.
The idea¶
Input from a client cannot be trusted. A body may omit a field, send a number where you expect text, or send an enormous title. Validation checks each field before your code uses it. A good validator reports every problem at once, so the client fixes them in one round trip.
A handler does the real work for one route. Work shared by many routes, such as logging or error handling, belongs in middleware: a function that takes a handler and returns a new, wrapped handler. The wrapper can act before the handler runs, after it, or stop the request.
Think of airport checkpoints. Each one passes you on or stops you with a reason. Middleware layers work the same way, and each one calls the next.
In this lesson a request is a dictionary with a method, a path, and a json key holding the parsed body. Real frameworks pass richer objects, but the pattern is the same. You will not need a server for these examples.
Worked example¶
class ValidationError(Exception):
def __init__(self, errors):
super().__init__("invalid input")
self.errors = errors
def validate_note(data):
errors = {}
title = data.get("title")
if not isinstance(title, str) or not title.strip():
errors["title"] = "must be a non-empty string"
elif len(title) > 100:
errors["title"] = "must be at most 100 characters"
body = data.get("body", "")
if not isinstance(body, str):
errors["body"] = "must be a string"
elif len(body) > 1000:
errors["body"] = "must be at most 1000 characters"
if errors:
raise ValidationError(errors)
return {"title": title.strip(), "body": body}
def error_middleware(next_handler):
def wrapped(request):
try:
return next_handler(request)
except ValidationError as error:
return {"status": 422, "body": {"errors": error.errors}}
return wrapped
def create_note(request):
note = validate_note(request["json"])
return {"status": 201, "body": {"id": 1, **note}}
def build_app(handler, middlewares):
for middleware in reversed(middlewares):
handler = middleware(handler)
return handler
app = build_app(create_note, [error_middleware])
print(app({"method": "POST", "path": "/notes", "json": {"title": "Milk"}}))
print(app({"method": "POST", "path": "/notes", "json": {"title": ""}}))
Line by line, this is what Python does:
ValidationErrorcarries a dictionary that maps each bad field to a message. This lets one exception report many problems.validate_notechecks each field and records errors inerrors. It does not stop at the first problem.raise ValidationError(errors)runs only if something failed. Otherwise the cleaned data is returned.error_middlewaretakes the handler and returnswrapped. Insidewrapped, thetryruns the handler, and theexceptturns the error into a 422 response.build_appwraps the handler with each middleware. Here the result iserror_middleware(create_note).- The first call prints a 201 response, and the second prints a 422 response.
Try it¶
The first block tests the validator. The second tests a middleware pipeline.
class ValidationError(Exception):
def __init__(self, errors):
super().__init__("invalid input")
self.errors = errors
def validate_note(data):
errors = {}
title = data.get("title")
if not isinstance(title, str) or not title.strip():
errors["title"] = "must be a non-empty string"
elif len(title) > 100:
errors["title"] = "must be at most 100 characters"
body = data.get("body", "")
if not isinstance(body, str):
errors["body"] = "must be a string"
if errors:
raise ValidationError(errors)
return {"title": title.strip(), "body": body}
assert validate_note({"title": " Milk "}) == {"title": "Milk", "body": ""}
try:
validate_note({"title": "", "body": 5})
assert False, "bad input must raise"
except ValidationError as error:
assert set(error.errors) == {"title", "body"} # both problems reported
calls = []
def logging_middleware(next_handler):
def wrapped(request):
calls.append("before " + request["path"])
response = next_handler(request)
calls.append("after " + request["path"])
return response
return wrapped
def handler(request):
calls.append("handler")
return {"status": 200, "body": {}}
app = logging_middleware(handler)
assert app({"path": "/notes"})["status"] == 200
assert calls == ["before /notes", "handler", "after /notes"]
Common mistakes¶
-
Forgetting to return the response. A middleware that calls
next_handler(request)but does not return its result passesNoneback. The next line that reads it fails with:Always return what
next_handler(request)gives back. -
Reading a missing key.
data["title"]on a body withouttitlegives:Use
data.get("title"), and then check the value, asvalidate_notedoes. -
Converting user text without a check.
int("abc")fails with:Catch
ValueErrorand record it as a validation error. -
Accepting
Trueas a number.isinstance(True, int)isTrue, becauseboolis a kind ofint. A field that must be a number should also rejectbool. -
Hiding every error. An
except Exceptionthat returns 500 and says nothing hides the bug. Log the exception first.
Practice¶
- Assert that
validate_note({"title": "Hi"})returns abodyof"". - Assert that a title of 101
"a"characters raisesValidationError, and that the error is stored under"title". - Write
validate_priority(value)that accepts integers 1 to 5 and rejectsbool. Assert it for 3, forTrue, and for 6. - Write
require_json_middlewarethat returns{"status": 400, "body": {"error": "json required"}}when"json"is missing from the request, and otherwise calls the handler. Assert it for both cases. - Stretch: write
counter_middlewarethat adds an increasing"request_id"to each response dictionary, using anonlocalcounter. Assert that two calls give ids 1 and 2.
Solution
-
python assert validate_note({"title": "Hi"})["body"] == "" -
python try: validate_note({"title": "a" * 101}) assert False, "a long title must raise" except ValidationError as error: assert "title" in error.errors -
```python def validate_priority(value): if isinstance(value, bool) or not isinstance(value, int): raise ValidationError({"priority": "must be an integer"}) if not 1 <= value <= 5: raise ValidationError({"priority": "must be from 1 to 5"}) return value
assert validate_priority(3) == 3 for bad in (True, 6): try: validate_priority(bad) assert False, "must raise" except ValidationError: pass ```
- ```python def require_json_middleware(next_handler): def wrapped(request): if "json" not in request: return {"status": 400, "body": {"error": "json required"}} return next_handler(request) return wrapped
guarded = require_json_middleware(lambda request: {"status": 200, "body": {}}) assert guarded({"path": "/notes"})["status"] == 400 assert guarded({"path": "/notes", "json": {}})["status"] == 200 ```
- ```python def counter_middleware(next_handler): count = 0 def wrapped(request): nonlocal count count += 1 response = next_handler(request) response["request_id"] = count return response return wrapped
numbered = counter_middleware(lambda request: {"status": 200}) assert numbered({})["request_id"] == 1 assert numbered({})["request_id"] == 2 ```
Recap¶
- Validation collects every error, and the handler stores data only after the check passes.
- A middleware is a function that takes a handler and returns a wrapped handler. Each one must return a response.
- An error middleware turns exceptions into status codes, and it should log what it hides.