Skip to content

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:

  1. ValidationError carries a dictionary that maps each bad field to a message. This lets one exception report many problems.
  2. validate_note checks each field and records errors in errors. It does not stop at the first problem.
  3. raise ValidationError(errors) runs only if something failed. Otherwise the cleaned data is returned.
  4. error_middleware takes the handler and returns wrapped. Inside wrapped, the try runs the handler, and the except turns the error into a 422 response.
  5. build_app wraps the handler with each middleware. Here the result is error_middleware(create_note).
  6. 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

  1. Forgetting to return the response. A middleware that calls next_handler(request) but does not return its result passes None back. The next line that reads it fails with:

    TypeError: 'NoneType' object is not subscriptable
    

    Always return what next_handler(request) gives back.

  2. Reading a missing key. data["title"] on a body without title gives:

    KeyError: 'title'
    

    Use data.get("title"), and then check the value, as validate_note does.

  3. Converting user text without a check. int("abc") fails with:

    ValueError: invalid literal for int() with base 10: 'abc'
    

    Catch ValueError and record it as a validation error.

  4. Accepting True as a number. isinstance(True, int) is True, because bool is a kind of int. A field that must be a number should also reject bool.

  5. Hiding every error. An except Exception that returns 500 and says nothing hides the bug. Log the exception first.

Practice

  1. Assert that validate_note({"title": "Hi"}) returns a body of "".
  2. Assert that a title of 101 "a" characters raises ValidationError, and that the error is stored under "title".
  3. Write validate_priority(value) that accepts integers 1 to 5 and rejects bool. Assert it for 3, for True, and for 6.
  4. Write require_json_middleware that returns {"status": 400, "body": {"error": "json required"}} when "json" is missing from the request, and otherwise calls the handler. Assert it for both cases.
  5. Stretch: write counter_middleware that adds an increasing "request_id" to each response dictionary, using a nonlocal counter. Assert that two calls give ids 1 and 2.
Solution
  1. python assert validate_note({"title": "Hi"})["body"] == ""

  2. python try: validate_note({"title": "a" * 101}) assert False, "a long title must raise" except ValidationError as error: assert "title" in error.errors

  3. ```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 ```

  1. ```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 ```

  1. ```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.

Sources