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
GETyou should call theget()method of the class... if it isPOSTyou should call thepostmethod... 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