Book Image

Python Microservices Development – 2nd edition - Second Edition

By : Simon Fraser, Tarek Ziadé
Book Image

Python Microservices Development – 2nd edition - Second Edition

By: Simon Fraser, Tarek Ziadé

Overview of this book

The small scope and self-contained nature of microservices make them faster, cleaner, and more scalable than code-heavy monolithic applications. However, building microservices architecture that is efficient as well as lightweight into your applications can be challenging due to the complexity of all the interacting pieces. Python Microservices Development, Second Edition will teach you how to overcome these issues and craft applications that are built as small standard units using proven best practices and avoiding common pitfalls. Through hands-on examples, this book will help you to build efficient microservices using Quart, SQLAlchemy, and other modern Python tools In this updated edition, you will learn how to secure connections between services and how to script Nginx using Lua to build web application firewall features such as rate limiting. Python Microservices Development, Second Edition describes how to use containers and AWS to deploy your services. By the end of the book, you’ll have created a complete Python application based on microservices.
Table of Contents (14 chapters)
12
Other Books You May Enjoy
13
Index

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 the QuartMap object, and we can see here what it knows about endpoints and the associated views.
  • Request: Quart creates a Request object and my_microservice is showing us that it is a GET request to /api.
  • dir() shows us which methods and variables are in a class, such as get_data() to retrieve any data that was sent with the request.
  • Response: A Response object to be sent back to the client; in this case, curl. It has an HTTP response code of 200, 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:

  1. 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).
  2. The more complex rules come first, so the second argument is the negative length of the number of weights.
  3. 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 a Response 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 using jsonify().
  • 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 a response 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 the response 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:

  1. When the application starts, any function decorated with @app.route() is registered as a view and stored in app.url_map.
  2. A call is dispatched to the right view depending on its endpoint and method.
  3. A Request object is created in a local, isolated execution context.
  4. 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.