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:
- WSGI compatibility
- Request Handlers
- 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:
- What were my objectives?
- How far did I get? How much is left?
- How was the process? Am I on the right track?
Objectives:
- Explain what WSGI is and why it's needed
- Build a basic web framework and run it with Gunicorn, a WSGI-compatible server
- 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 theget()
method of the class... if it isPOST
you should call thepost
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