How Quart handles requests
The framework entry point is the Quart
class in the quart.app
module. Running a Quart application means running one single instance of this class, which will take care of handling incoming Asynchronous Server Gateway Interface (ASGI) and Web Server Gateway Interface (WSGI) requests, dispatch them to the right code, and then return a response. Remember that in Chapter 1, Understanding Microservices, we discussed ASGI and WSGI, and how they define the interface between a web server and a Python application.
The Quart class offers a route
method, which can decorate your functions. When you decorate a function this way, it becomes a view and is registered in the routing system.
When a request arrives, it will be to a specific endpoint—usually a web address (such as https://duckduckgo.com/?q=quart) or part of an address, such as /api
. The routing system is how Quart connects an endpoint to the view—the bit of code that will run to process the request.
Here's a very basic example of a fully functional Quart application:
# quart_basic.py
from quart import Quart
app = Quart(__name__)
@app.route("/api")
def my_microservice():
return {"Hello": "World!"}
if __name__ == "__main__":
app.run()
All the code samples are available on GitHub at https://github.com/PacktPublishing/Python-Microservices-Development-2nd-Edition/tree/main/CodeSamples.
We see that our function returns a dictionary, and Quart knows that this should be encoded as a JSON object to be transferred. However, only querying the /api
endpoint returns the value. Every other endpoint would return a 404 Error, indicating that it can't find the resource you requested because we haven't told it about any!
The __name__
variable, whose value will be __main__
when you run that single Python module, is the name of the application package. It's used by Quart to create a new logger with that name to format all the log messages, and to find where the file is located on the disk. Quart will use the directory as the root for helpers, such as the configuration that is associated with your app, and to determine default locations for the static
and templates
directories, which we will discuss later.
If you run that module in a terminal, the Quart
app will run its own development web server, and start listening to incoming connections on port 5000
. Here, we assume that you are still in the virtual environment created earlier and that the code above is in a file called quart_basic.py
:
$ python quart_basic.py
* Serving Quart app 'quart_basic'
* Environment: production
* Please use an ASGI server (e.g. Hypercorn) directly in production
* Debug mode: False
* Running on http://localhost:5000 (CTRL + C to quit)
[2020-12-10 14:05:18,948] Running on http://localhost:5000 (CTRL + C to quit)
Visiting http://localhost:5000/api
in your browser or with the curl
command will return a valid JSON response with the right headers:
$ curl -v http://localhost:5000/api
* Trying localhost...
...
< HTTP/1.1 200
< content-type: application/json
< content-length: 18
< date: Wed, 02 Dec 2020 20:29:19 GMT
< server: hypercorn-h11
<
* Connection #0 to host localhost left intact
{"Hello":"World!"}* Closing connection 0
The curl
command is going to be used a lot in this book. If you are under Linux or macOS, it should be pre-installed; refer to https://curl.haxx.se/.
If you are not developing your application on the same computer as the one that you are testing it on, you may need to adjust some of the settings, such as which IP addresses it should use to listen for connections. When we discuss deploying a microservice, we will cover some of the better ways of changing its configuration, but for now, the app.run
line can be changed to use a different host
and port
:
app.run(host="0.0.0.0", port=8000)
While many web frameworks explicitly pass a request
object to your code, Quart provides a global request
variable, which points to the current request
object it built for the incoming HTTP request.
This design decision makes the code for the simpler views very concise. As in our example, if you don't have to look at the request content to reply, there is no need to have it around. As long as your view returns what the client should get and Quart can serialize it, everything happens as you would hope. For other views, they can just import that variable and use it.
The request
variable is global, but it is unique to each incoming request and is thread-safe. Let's add some print
method calls here and there so that we can see what's happening under the hood. We will also explicitly make a Response
object using jsonify
, instead of letting Quart do that for us, so that we can examine it:
# quart_details.py
from quart import Quart, request, jsonify
app = Quart(__name__)
@app.route("/api", provide_automatic_options=False)
async def my_microservice():
print(dir(request))
response = jsonify({"Hello": "World!"})
print(response)
print(await response.get_data())
return response
if __name__ == "__main__":
print(app.url_map)
app.run()
Running that new version in conjunction with the curl
command in another terminal, you get a lot of details, including the following:
$ python quart_details.py
QuartMap([<QuartRule '/api' (HEAD, GET, OPTIONS) -> my_microservice>,
<QuartRule '/static/<filename>' (HEAD, GET, OPTIONS) -> static>])
Running on http://localhost:5000 (CTRL + C to quit)
[… '_load_field_storage', '_load_form_data', '_load_json_data', '_send_push_promise', 'accept_charsets', 'accept_encodings', 'accept_languages', 'accept_mimetypes', 'access_control_request_headers', 'access_control_request_method', 'access_route', 'args', 'authorization', 'base_url', 'blueprint', 'body', 'body_class', 'body_timeout', 'cache_control', 'charset', 'content_encoding', 'content_length', 'content_md5', 'content_type', 'cookies', 'data', 'date', 'dict_storage_class', 'encoding_errors', 'endpoint', 'files', 'form', 'full_path', 'get_data', 'get_json', 'headers', 'host', 'host_url', 'http_version', 'if_match', 'if_modified_since', 'if_none_match', 'if_range', 'if_unmodified_since', 'is_json', 'is_secure', 'json', 'list_storage_class', 'max_forwards', 'method', 'mimetype', 'mimetype_params', 'on_json_loading_failed', 'origin', 'parameter_storage_class', 'path', 'pragma', 'query_string', 'range', 'referrer', 'remote_addr', 'root_path', 'routing_exception', 'scheme', 'scope', 'send_push_promise', 'url', 'url_charset', 'url_root', 'url_rule', 'values', 'view_args']
Response(200)
b'{"Hello":"World!"}'
Let's explore what's happening here:
Routing
: When the service starts, Quart creates theQuartMap
object, and we can see here what it knows about endpoints and the associated views.Request
: Quart creates aRequest
object andmy_microservice
is showing us that it is aGET
request to/api
.dir()
shows us which methods and variables are in a class, such asget_data()
to retrieve any data that was sent with the request.Response
: AResponse
object to be sent back to the client; in this case,curl
. It has an HTTP response code of200
, indicating that everything is fine, and its data is the 'Hello world' dictionary we told it to send.
Routing
Routing happens in app.url_map
, which is an instance of the QuartMap
class that uses a library called Werkzeug
. That class uses regular expressions to determine whether a function decorated by @app.route
matches the incoming request. The routing only looks at the path you provided in the route call to see whether it matches the client's request.
By default, the mapper will only accept GET
, OPTIONS
, and HEAD
methods on a declared route. Sending an HTTP request to a valid endpoint with an unsupported method will return a 405 Method Not Allowed
response together with a list of supported methods in the allow
header:
$ curl -v -XDELETE http://localhost:5000/api
** Trying 127.0.0.1...
* TCP_NODELAY set
* Connected to localhost (127.0.0.1) port 5000 (#0)
> DELETE /api HTTP/1.1
> Host: localhost:5000
> User-Agent: curl/7.64.1
> Accept: */*
>
< HTTP/1.1 405
< content-type: text/html
< allow: GET, OPTIONS, HEAD
< content-length: 137
< date: Wed, 02 Dec 2020 21:14:36 GMT
< server: hypercorn-h11
<
<!doctype html>
<title>405 Method Not Allowed</title>
<h1>Method Not Allowed</h1>
Specified method is invalid for this resource
* Connection #0 to host 127.0.0.1 left intact
* Closing connection 0
If you want to support specific methods allowing you to POST
to an endpoint or DELETE
some data, you can pass them to the route
decorator with the methods
argument, as follows:
@app.route('/api', methods=['POST', 'DELETE', 'GET'])
def my_microservice():
return {'Hello': 'World!'}
Note that the OPTIONS
and HEAD
methods are implicitly added in all rules since it is automatically managed by the request handler. You can deactivate this behavior by giving the provide_automatic_options=False
argument to the route
function. This can be useful when you want to add custom headers to the response when OPTIONS
is called, such as when dealing with Cross-Origin Resource Sharing (CORS), in which you need to add several Access-Control-Allow-*
headers.
For more information regarding HTTP
request methods, a good resource is the Mozilla Developer Network: https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods.
Variables and converters
A common requirement for an API is the ability to specify exactly which data we want to request. For example, if you have a system where each person has a unique number to identify them, you might want to create a function that handles all requests sent to the /person/N
endpoint, so that /person/3
only deals with ID number 3
, and /person/412
only affects the person with ID 412
.
You can do this with variables in the route
, using the <VARIABLE_NAME>
syntax. This notation is pretty standard (Bottle
also uses it), and allows you to describe endpoints with dynamic values. If we create a route
such as /person/<person_id>
, then, when Quart calls your function, it converts the value it finds in the URL to a function argument with the same name:
@app.route('/person/<person_id>')
def person(person_id):
return {'Hello': person_id}
$ curl localhost:5000/person/3
{"Hello": "3"}
If you have several routes that match the same URL, the mapper uses a particular set of rules to determine which one it calls. Quart
and Flask
both use Werkzeug
to organize their routing; this is the implementation description taken from Werkzeug's routing module:
- Rules without any arguments come first for performance. This is because we expect them to match faster and some common rules usually don't have any arguments (index pages, and so on).
- The more complex rules come first, so the second argument is the negative length of the number of weights.
- Lastly, we order by the actual weights.
Werkzeug's rules have, therefore, weights that are used to sort them, and this is not used or made visible in Quart. So, it boils down to picking views with more variables first, and then the others, in order of appearance, when Python imports the different modules. The rule of thumb is to make sure that every declared route in your app is unique, otherwise tracking which one gets picked will give you a headache.
This also means that our new route will not respond to queries sent to /person
, or /person/3/help
, or any other variation—only to /person/
followed by some set of characters. Characters include letters and punctuation, though, and we have already decided that /api/apiperson_id
is a number! This is where converters are useful.
We can tell the route
that a variable has a specific type. Since /api/apiperson_id
is an integer, we can use <int:person_id>
, as in the previous example, so that our code only responds when we give a number, and not when we give a name. You can also see that instead of the string "3"
, person_id
is a number, with no quotes:
@app.route('/person/<int:person_id>')
def person(person_id):
return {'Hello': person_id}
$ curl localhost:5000/person/3
{
"Hello": 3
}
$ curl localhost:5000/person/simon
<!doctype html>
<title>404 Not Found</title>
<h1>Not Found</h1>
Nothing matches the given URI
If we had two routes, one for /person/<int:person_id>
and one for /person/<person_id>
(with different function names!), then the more specific one, which needs an integer, would get all the requests that had a number in the right place, and the other function would get the remaining requests.
Built-in converters are string
(the default is a Unicode string), int
, float
, path
, any
, and uuid
.
The path converter is like the default converter, but includes forward slashes, so that a request to a URL, /api/some/path/like/this
, would match the route /api/<path:my_path>
, and the function would get an argument called my_path
containing some/path/like/this
. If you are familiar with regular expressions, it's similar to matching [^/].*?
.
int
and float
are for integers and floating-point—decimal—numbers. The any
converter allows you to combine several values. It can be a bit confusing to use at first, but it might be useful if you need to route several specific strings to the same place. A route of /<any(about, help, contact):page_name>
will match requests to /about
, /help
, or /contact
, and which one was chosen will be in the page_name
variable passed to the function.
The uuid
converter matches the UUID strings, such as those that you get from Python's uuid
module, providing unique identifiers. Examples of all these converters in action are also in the code samples for this chapter on GitHub.
It's quite easy to create your custom converter. For example, if you want to match user IDs with usernames, you could create a converter that looks up a database and converts the integer into a username. To do this, you need to create a class derived from the BaseConverter
class, which implements two methods: the to_python()
method to convert the value to a Python object for the view, and the to_url()
method to go the other way (used by url_for()
, which is described in the next section):
# quart_converter.py
from quart import Quart, request
from werkzeug.routing import BaseConverter, ValidationError
_USERS = {"1": "Alice", "2": "Bob"}
_IDS = {val: user_id for user_id, val in _USERS.items()}
class RegisteredUser(BaseConverter):
def to_python(self, value):
if value in _USERS:
return _USERS[value]
raise ValidationError()
def to_url(self, value):
return _IDS[value]
app = Quart(__name__)
app.url_map.converters["registered"] = RegisteredUser
@app.route("/api/person/<registered:name>")
def person(name):
return {"Hello": name}
if __name__ == "__main__":
app.run()
The ValidationError
method is raised in case the conversion fails, and the mapper will consider that the route
simply does not match that request. Let's try a few calls to see how that works in practice:
$ curl localhost:5000/api/person/1
{
"Hello hey": "Alice"
}
$ curl localhost:5000/api/person/2
{
"Hello hey": "Bob"
}
$ curl localhost:5000/api/person/3
<!doctype html>
<title>404 Not Found</title>
<h1>Not Found</h1>
Nothing matches the given URI
Be aware that the above is just an example of demonstrating the power of converters—an API that handles personal information in this way could give a lot of information away to malicious people. It can also be painful to change all the routes when the code evolves, so it is best to only use this sort of technique when necessary.
The best practice for routing is to keep it as static and straightforward as possible. This is especially true as moving all the endpoints requires changing all of the software that connects to them! It is often a good idea to include a version in the URL for an endpoint so that it is immediately clear that the behavior will be different between, for example, /v1/person
and /v2/person
.
The url_for function
The last interesting feature of Quart's routing system is the url_for()
function. Given any view, it will return its actual URL. Here's an example of using Python interactively:
>>> from quart_converter import app
>>> from quart import url_for
>>> import asyncio
>>> async def run_url_for():
... async with app.test_request_context("/", method="GET"):
... print(url_for('person', name='Alice'))
...
>>> loop = asyncio.get_event_loop()
>>> loop.run_until_complete(run_url_for())
/api/person/1
The previous example uses the Read-Eval-Print Loop (REPL), which you can get by running the Python executable directly. There is also some extra code there to set up an asynchronous program because here, Quart is not doing that for us.
The url_for
feature is quite useful in templates when you want to display the URLs of some views—depending on the execution context. Instead of hardcoding some links, you can just point the function name to url_for
to get it.
Request
When a request comes in, Quart calls the view and uses a Request Context to make sure that each request has an isolated environment, specific to that request. We saw an example of that in the code above, where we were testing things using the helper method, test_request_context()
. In other words, when you access the global request object in your view, you are guaranteed that it is unique to the handling of your specific request.
As we saw earlier when calling dir(request)
, the Request
object contains a lot of methods when it comes to getting information about what is happening, such as the address of the computer making the request, what sort of request it is, and other information such as authorization headers. Feel free to experiment with some of these request methods using the example code as a starting point.
In the following example, an HTTP Basic Authentication request that is sent by the client is always converted to a base64 form when sent to the server. Quart will detect the Basic prefix and will parse it into username
and password
fields in the request.authorization
attribute:
# quart_auth.py
from quart import Quart, request
app = Quart(__name__)
@app.route("/")
def auth():
print("Quart's Authorization information")
print(request.authorization)
return ""
if __name__ == "__main__":
app.run()
$ python quart_auth.py
* Running on http://localhost:5000/ (Press CTRL+C to quit)
Quart's Authorization information
{'username': 'alice', 'password': 'password'}
[2020-12-03 18:34:50,387] 127.0.0.1:55615 GET / 1.1 200 0 3066
$ curl http://localhost:5000/ --user alice:password
This behavior makes it easy to implement a pluggable authentication system on top of the request
object. Other common request elements, such as cookies and files, are all accessible via other attributes, as we will discover throughout this book.
Response
In many of the previous examples, we have simply returned a Python dictionary and left Quart to produce a response for us that the client will understand. Sometimes, we have called jsonify()
to ensure that the result is a JSON object.
There are other ways to make a response for our web application, along with some other values that are automatically converted to the proper object for us. We could return any of the following, and Quart would do the right thing:
Response()
: Creates aResponse
object manually.str
: A string will be encoded as a text/html object in the response. This is especially useful for HTML pages.dict
: A dictionary will be encoded as application/json usingjsonify()
.- A generator or asynchronous generator object can be returned so that data can be streamed to the client.
- A
(response, status)
tuple: The response will be converted to aresponse
object if it matches one of the preceding data types, and the status will be the HTTP response code used. - A
(response, status, headers)
tuple: The response will be converted, and theresponse
object will use a dictionary provided as headers that should be added to the response.
In most cases, a microservice will be returning data that some other software will interpret and choose how to display, and so we will be returning Python dictionaries or using jsonify()
if we want to return a list or other object that can be serialized as JSON.
Here's an example with YAML, another popular way of representing data: the yamlify()
function will return a (response, status, headers)
tuple, which will be converted by Quart into a proper Response
object:
# yamlify.py
from quart import Quart
import yaml # requires PyYAML
app = Quart(__name__)
def yamlify(data, status=200, headers=None):
_headers = {"Content-Type": "application/x-yaml"}
if headers is not None:
_headers.update(headers)
return yaml.safe_dump(data), status, _headers
@app.route("/api")
def my_microservice():
return yamlify(["Hello", "YAML", "World!"])
if __name__ == "__main__":
app.run()
The way Quart handles requests can be summarized as follows:
- When the application starts, any function decorated with
@app.route()
is registered as a view and stored inapp.url_map
. - A call is dispatched to the right view depending on its endpoint and method.
- A
Request
object is created in a local, isolated execution context. - A
Response
object wraps the content to send back.
These four steps are roughly all you need to know to start building apps using Quart. The next section will summarize the most important built-in features that Quart offers, alongside this request-response mechanism.