Duplicate Routes and Class Based Handlers

Part 1, Chapter 4


Recap

To recap, in the first two chapters, you started writing your own Python-based web framework with the following features:

  1. WSGI compatibility
  2. Request Handlers
  3. Routing -- both simple (/books/) and parameterized (/books/{id}/)

Take a few minutes to step back and reflect on what you've learned. Ask yourself the following questions:

  1. What were my objectives?
  2. How far did I get? How much is left?
  3. How was the process? Am I on the right track?

Objectives:

  1. Explain what WSGI is and why it's needed
  2. Build a basic web framework and run it with Gunicorn, a WSGI-compatible server
  3. Develop the core request handlers and routes

Moving right along, in this chapter, you'll add the following features to your framework:

  • Check for duplicate routes
  • Class-based handlers

Ready? Let's get started.

Duplicate routes

Currently, you can add the same route any number of times to your framework without complaint.

For example:

@app.route("/home")
def home(request, response):
    response.text = "Hello from the HOME page"


@app.route("/home")
def home2(request, response):
    response.text = "Hello from the SECOND HOME page"

The framework will not complain, but since a Python dictionary is used to store the routes, only the second route, home2, will work if you navigate to http://localhost:8000/home.

That's not good.

You want to make sure that the framework throws an exception if the user tries to add a duplicate route handler. Fortunately, since we're using a Python dict to store routes, we can check if the given path already exists in the dictionary. If it does, we can throw an exception. If it does not, we add the route per usual.

Before writing any code, take a moment to review the main API class:

# api.py

from parse import parse
from webob import Request, Response


class API:
    def __init__(self):
        self.routes = {}

    def __call__(self, environ, start_response):
        request = Request(environ)

        response = self.handle_request(request)

        return response(environ, start_response)

    def route(self, path):
        def wrapper(handler):
            self.routes[path] = handler
            return handler

        return wrapper

    def default_response(self, response):
        response.status_code = 404
        response.text = "Not found."

    def find_handler(self, request_path):
        for path, handler in self.routes.items():
            parse_result = parse(path, request_path)
            if parse_result is not None:
                return handler, parse_result.named

        return None, None

    def handle_request(self, request):
        response = Response()

        handler, kwargs = self.find_handler(request_path=request.path)

        if handler is not None:
            handler(request, response, **kwargs)
        else:
            self.default_response(response)

        return response

Before moving on to the implementation, think on your own about how and where to handle the duplicate route check.

Implementation

You need to change the route method so that it throws an exception if an existing route is being added again:

# api.py

from parse import parse
from webob import Request, Response


class API:
    ...

    def route(self, path):
        if path in self.routes:
            raise AssertionError("Such route already exists.")

        def wrapper(handler):
            self.routes[path] = handler
            return handler

        return wrapper

    ...

Now, try adding the same route twice in app.py:

# app.py

from api import API


app = API()


@app.route("/home")
def home(request, response):
    response.text = "Hello from the HOME page"


@app.route("/home")
def home2(request, response):
    response.text = "Hello from the SECOND HOME page"

...

Run (or restart) Gunicorn:

(venv)$ gunicorn app:app

You should see the following exception thrown:

AssertionError: Such route already exists.

Next, let's refactor the code to decrease that check to a single line:

def route(self, path):
    assert path not in self.routes, "Such route already exists."

    def wrapper(handler):
        self.routes[path] = handler
        return handler

    return wrapper

You should now have:

# api.py

from parse import parse
from webob import Request, Response


class API:
    def __init__(self):
        self.routes = {}

    def __call__(self, environ, start_response):
        request = Request(environ)

        response = self.handle_request(request)

        return response(environ, start_response)

    def route(self, path):
        assert path not in self.routes, "Such route already exists."

        def wrapper(handler):
            self.routes[path] = handler
            return handler

        return wrapper

    def default_response(self, response):
        response.status_code = 404
        response.text = "Not found."

    def find_handler(self, request_path):
        for path, handler in self.routes.items():
            parse_result = parse(path, request_path)
            if parse_result is not None:
                return handler, parse_result.named

        return None, None

    def handle_request(self, request):
        response = Response()

        handler, kwargs = self.find_handler(request_path=request.path)

        if handler is not None:
            handler(request, response, **kwargs)
        else:
            self.default_response(response)

        return response

Voilà! Onto the next feature.

Class Based Handlers

With the function-based handlers complete, let's add class-based ones. Much like Django's class-based views, these are more suitable for larger, more complicated handlers.

End goal:

@app.route("/book")
class BooksResource:
    def get(self, req, resp):
        resp.text = "Books Page"

    def post(self, req, resp):
        resp.text = "Endpoint to create a book"

Here, the dict that stores routes, self.routes, can contain both classes and functions as values. Thus, when we find a handler in the handle_request() method, we need to check if the handler is a function or if it is a class.

  • If it's a function, it should work as it does now.
  • If it's a class, depending on the request method, you should call the appropriate method of the class. That is, if the request method is GET you should call the get() method of the class... if it is POST you should call the post method... and so on.

Here's how the handle_request() method currently looks:

def handle_request(self, request):
    response = Response()

    handler, kwargs = self.find_handler(request_path=request.path)

    if handler is not None:
        handler(request, response, **kwargs)
    else:
        self.default_response(response)

    return response

To update this to handle classes, the first thing we need to do is check if the found handler is a class. For that, we can use the built-in inspect module like this:

def handle_request(self, request):
    response = Response()

    handler, kwargs = self.find_handler(request_path=request.path)

    if handler is not None:
        if inspect.isclass(handler):
            pass   # class-based handler is being used
        else:
            handler(request, response, **kwargs)
    else:
        self.default_response(response)

    return response

Make sure you add the import:

import inspect

Now, if a class-based handler is being used, we need to find the appropriate method of the class based on the given request method. For that we can use the built-in getattr function:

def handle_request(self, request):
    response = Response()

    handler, kwargs = self.find_handler(request_path=request.path)

    if handler is not None:
        if inspect.isclass(handler):
            handler_function = getattr(handler(), request.method.lower(), None)
            pass
        else:
            handler(request, response, **kwargs)
    else:
        self.default_response(response)

    return response

getattr accepts an object instance as the first param and the attribute name to get as the second. The third argument is the value to return if nothing is found.

So, GET will return get, POST will return post, and some_other_attribute will return None. If the handler_function is None, it means that such function was not implemented in the class and the request method is not allowed:

if inspect.isclass(handler):
    handler_function = getattr(handler(), request.method.lower(), None)
    if handler_function is None:
        raise AttributeError("Method not allowed", request.method)

If the handler_function was actually found, then you call it:

if inspect.isclass(handler):
    handler_function = getattr(handler(), request.method.lower(), None)
    if handler_function is None:
        raise AttributeError("Method not allowed", request.method)
    handler_function(request, response, **kwargs)

Now the whole method looks like this:

def handle_request(self, request):
    response = Response()

    handler, kwargs = self.find_handler(request_path=request.path)

    if handler is not None:
        if inspect.isclass(handler):
            handler_function = getattr(handler(), request.method.lower(), None)
            if handler_function is None:
                raise AttributeError("Method not allowed", request.method)
            handler_function(request, response, **kwargs)
        else:
            handler(request, response, **kwargs)
    else:
        self.default_response(response)

    return response

Rather than having a handler_function and a handler, we can refactor the code to make it more elegant:

def handle_request(self, request):
    response = Response()

    handler, kwargs = self.find_handler(request_path=request.path)

    if handler is not None:
        if inspect.isclass(handler):
            handler = getattr(handler(), request.method.lower(), None)
            if handler is None:
                raise AttributeError("Method not allowed", request.method)

        handler(request, response, **kwargs)
    else:
        self.default_response(response)

    return response

And that's it. You can now test support for class-based handlers.

For reference, api.py should now look like this:

# api.py

import inspect

from parse import parse
from webob import Request, Response


class API:
    def __init__(self):
        self.routes = {}

    def __call__(self, environ, start_response):
        request = Request(environ)

        response = self.handle_request(request)

        return response(environ, start_response)

    def route(self, path):
        assert path not in self.routes, "Such route already exists."

        def wrapper(handler):
            self.routes[path] = handler
            return handler

        return wrapper

    def default_response(self, response):
        response.status_code = 404
        response.text = "Not found."

    def find_handler(self, request_path):
        for path, handler in self.routes.items():
            parse_result = parse(path, request_path)
            if parse_result is not None:
                return handler, parse_result.named

        return None, None

    def handle_request(self, request):
        response = Response()

        handler, kwargs = self.find_handler(request_path=request.path)

        if handler is not None:
            if inspect.isclass(handler):
                handler = getattr(handler(), request.method.lower(), None)
                if handler is None:
                    raise AttributeError("Method not allowed", request.method)

            handler(request, response, **kwargs)
        else:
            self.default_response(response)

        return response

Sanity Check

If you haven't already, add the following handler to app.py:

# app.py

from api import API


app = API()

...

@app.route("/book")
class BooksResource:
    def get(self, req, resp):
        resp.text = "Books Page"

    def post(self, req, resp):
        resp.text = "Endpoint to create a book"

Now, restart Gunicorn and navigate to http://localhost:8000/book. You should see the message Books Page. Test the POST method as well by sending the following request with the help of curl in the console:

$ curl -X POST http://localhost:8000/book

You should see the corresponding message of Endpoint to create a book. And there you go. You have added support for class-based handlers. Play around with them for a bit by implementing other methods such as put and delete. Make sure to test a nonexistent path as well.

See you in the next chapter.




Mark as Completed