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

Quart's built-in features

The previous section gave us a good understanding of how Quart processes a request, and that's good enough to get you started. There are more helpers that will prove useful. We'll discover the following main ones in this section:

  • The session object: Cookie-based data
  • Globals: Storing data in the request context
  • Signals: Sending and intercepting events
  • Extensions and middleware: Adding features
  • Templates: Building text-based content
  • Configuring: Grouping your running options in a config file
  • Blueprints: Organizing your code in namespaces
  • Error handling and debugging: Dealing with errors in your app

The session object

Like the request object, Quart creates a session object, which is unique to the request context. It's a dict-like object, which Quart serializes into a cookie on the user side. The data contained in the session mapping is dumped into a JSON mapping, then compressed using zlib to make it smaller, and finally encoded in base64.

When the session gets serialized, the itsdangerous (https://pythonhosted.org/itsdangerous/) library signs the content using a secret_key value defined in the application. The signing uses HMAC (https://en.wikipedia.org/wiki/Hash-based_message_authentication_code) and SHA1.

This signature, which is added to the data as a suffix, ensures that the client cannot tamper with the data that is stored in a cookie unless they know the secret key to sign the session value. Note that the data itself is not encrypted. Quart will let you customize the signing algorithm to use, but HMAC + SHA1 is good enough when you need to store data in cookies.

However, when you're building microservices that are not producing HTML, you rarely rely on cookies as they are specific to web browsers. However, the idea of keeping a volatile key-value storage for each user can be extremely useful for speeding up some of the server-side work. For instance, if you need to perform some database look-ups to get some information pertaining to a user every time they connect, caching this information in a session-like object on the server side and retrieving the values based on their authentication details makes a lot of sense.

Globals

As discussed earlier in this chapter, Quart provides a mechanism for storing global variables that are unique to a particular request context. That is used for request and session, but is also available to store any custom object.

The quart.g variable contains all globals, and you can set whatever attributes you want on it. In Quart, the @app.before_request decorator can be used to point to a function that the app will call every time a request is made, just before it dispatches the request to a view.

It's a typical pattern in Quart to use before_request to set values in the globals. That way, all the functions that are called within the request context can interact with the special global variable called g and get the data. In the following example, we copy the username provided when the client performs an HTTP Basic Authentication in the user attribute:

# globals.py
from quart import Quart, g, request
app = Quart(__name__)
@app.before_request
def authenticate():
    if request.authorization:
        g.user = request.authorization["username"]
    else:
        g.user = "Anonymous"
@app.route("/api")
def my_microservice():
    return {"Hello": g.user}
if __name__ == "__main__":
    app.run()

When a client requests the /api view, the authenticate function will set g.user depending on the headers provided:

$ curl http://localhost:5000/api 
{ 
  "Hello": "Anonymous" 
} 
$ curl http://localhost:5000/api --user alice:password 
{ 
  "Hello": "alice" 
} 

Any data you may think of that's specific to a request context, and that would be usefully shared throughout your code, can be added to quart.g.

Signals

Sometimes in an application, we want to send a message from one place to another, when components are not directly connected. One way in which we can send such messages is to use signals. Quart integrates with Blinker (https://pythonhosted.org/blinker/), which is a signal library that lets you subscribe a function to an event.

Events are instances of the AsyncNamedSignal class, which is based on the blinker.base.NamedSignal class. It is created with a unique label, and Quart instantiates 10 of them in version 0.13. Quart triggers signals at critical moments during the processing of a request. Since Quart and Flask use the same system, we can refer to the following full list: http://flask.pocoo.org/docs/latest/api/#core-signals-list.

Registering to a particular event is done by calling the signal's connect method. Signals are triggered when some code calls the signal's send method. The send method accepts extra arguments to pass data to all the registered functions.

In the following example, we register the finished function to the request_finished signal. That function will receive the response object:

# signals.py
from quart import Quart, g, request_finished
from quart.signals import signals_available
app = Quart(__name__)
def finished(sender, response, **extra):
    print("About to send a Response")
    print(response)
request_finished.connect(finished)
@app.route("/api")
async def my_microservice():
    return {"Hello": "World"}
if __name__ == "__main__":
    app.run()

The signal feature is provided by Blinker, which is installed by default as a dependency when you install Quart.

Some signals implemented in Quart are not useful in microservices, such as the ones occurring when the framework renders a template. However, there are some interesting signals that Quart triggers throughout the request life, which can be used to log what's going on. For instance, the got_request_exception signal is triggered when an exception occurs before the framework does something with it. That's how Sentry's (https://sentry.io) Python client hooks itself in to log exceptions.

It can also be interesting to implement custom signals in your apps when you want to trigger some of your features with events and decouple the code. For example, if your microservice produces PDF reports, and you want to have the reports cryptographically signed, you could trigger a report_ready signal, and have a signer register to that event.

One important aspect of the signals implementation is that the registered functions are not called in any particular order, and so if there are dependencies between the functions that get called, this may cause trouble. If you need to do more complex or time-consuming work, then consider using a queue such as RabbitMQ (https://www.rabbitmq.com/) or one provided by a cloud platform such as Amazon Simple Queue Service or Google PubSub to send a message to another service. These message queues offer far more options than a basic signal and allow two components to communicate easily without even necessarily being on the same computer. We will cover an example of message queues in Chapter 6, Interacting with Other Services.

Extensions and middleware

Quart extensions are simply Python projects that, once installed, provide a package or a module named quart_something. They can be useful for avoiding having to reinvent anything when wanting to do things such as authentication or sending an email.

Because Quart can support some of the extensions available to Flask, you can often find something to help in Flask's list of extensions: Search for Framework::Flask in the Python package index at https://pypi.org/. To use Flask extensions, you must first import a patch module to ensure that it will work. For example, to import Flask's login extension, use the following commands:

import quart.flask_patch
import flask_login

The most up-to-date list of Flask extensions that are known to work with Quart will be at the address below. This is a good place to start looking when searching for extra features that your microservice needs: http://pgjones.gitlab.io/quart/how_to_guides/flask_extensions.html.

The other mechanism for extending Quart is to use ASGI or WSGI middleware. These extend the application by wrapping themselves around an endpoint and changing the data that goes in and comes out again.

In the example that follows, the middleware fakes an X-Forwarded-For header, so the Quart application thinks it's behind a proxy such as nginx. This is useful in a testing environment when you want to make sure your application behaves properly when it tries to get the remote IP address, since the remote_addr attribute will get the IP of the proxy, and not the real client. In this example, we have to create a new Headers object, as the existing one is immutable:

# middleware.py
from quart import Quart, request
from werkzeug.datastructures import Headers
class XFFMiddleware:
    def __init__(self, app, real_ip="10.1.1.1"):
        self.app = app
        self.real_ip = real_ip
    async def __call__(self, scope, receive, send):
        if "headers" in scope and "HTTP_X_FORWARDED_FOR" not in scope["headers"]:
            new_headers = scope["headers"].raw_items() + [
                (
                    b"X-Forwarded-For",
                    f"{self.real_ip}, 10.3.4.5, 127.0.0.1".encode(),
                )
            ]
            scope["headers"] = Headers(new_headers)
        return await self.app(scope, receive, send)
app = Quart(__name__)
app.asgi_app = XFFMiddleware(app.asgi_app)
@app.route("/api")
def my_microservice():
    if "X-Forwarded-For" in request.headers:
        ips = [ip.strip() for ip in request.headers["X-Forwarded-For"].split(",")]
        ip = ips[0]
    else:
        ip = request.remote_addr
    return {"Hello": ip}
if __name__ == "__main__":
    app.run()

Notice that we use app.asgi_app here to wrap the ASGI application. app.asgi_app is where the application is stored to let people wrap it in this way. The send and receive parameters are channels through which we can communicate. It's worth remembering that if the middleware returns a response to the client, then the rest of the Quart app will never see the request!

In most situations, we won't have to write our own middleware, and it will be enough to include an extension to add a feature that someone else has produced.

Templates

Sending back JSON or YAML documents is easy enough, as we have seen in the examples so far. It's also true that most microservices produce machine-readable data and if a human needs to read it, the frontend must format it properly, using, for example, JavaScript on a web page. In some cases, though, we might need to create documents with some layout, whether it's an HTML page, a PDF report, or an email.

For anything that's text-based, Quart integrates a template engine called Jinja (https://jinja.palletsprojects.com/). You will often find examples showing Jinja being used to create HTML documents, but it works with any text-based document. Configuration management tools such as Ansible use Jinja to create configuration files from a template so that a computer's settings can be kept up to date automatically.

Most of the time, Quart will use Jinja to produce HTML documents, email messages, or some other piece of communication meant for a human—such as an SMS message or a bot that talks to people on tools such as Slack or Discord. Quart provides helpers such as render_template, which generate responses by picking a Jinja template, and provides the output given some data.

For example, if your microservice sends emails instead of relying on the standard library's email package to produce the email content, which can be cumbersome, you could use Jinja. The following example email template should be saved as email_template.j2 in order for the later code examples to work:

Date: {{date}} 
From: {{from}} 
Subject: {{subject}} 
To: {{to}} 
Content-Type: text/plain 
 
Hello {{name}}, 
 
We have received your payment! 
 
Below is the list of items we will deliver for lunch: 
 
{% for item in items %}- {{item['name']}} ({{item['price']}} Euros) 
{% endfor %} 
 
Thank you for your business! 
 
-- 
My Fictional Burger Place

Jinja uses double brackets for marking variables that will be replaced by a value. Variables can be anything that is passed to Jinja at execution time. You can also use Python's if and for blocks directly in your templates with the {% for x in y % }... {% endfor %} and {% if x %}...{% endif %} notations.

The following is a Python script that uses the email template to produce an entirely valid RFC 822 message, which you can send via SMTP:

# email_render.py
from datetime import datetime
from jinja2 import Template
from email.utils import format_datetime
def render_email(**data):
    with open("email_template.j2") as f:
        template = Template(f.read())
    return template.render(**data)
data = {
    "date": format_datetime(datetime.now()),
    "to": "[email protected]",
    "from": "[email protected]",
    "subject": "Your Burger order",
    "name": "Bob",
    "items": [
        {"name": "Cheeseburger", "price": 4.5},
        {"name": "Fries", "price": 2.0},
        {"name": "Root Beer", "price": 3.0},
    ],
}
print(render_email(**data))

The render_email function uses the Template class to generate the email using the data provided.

Jinja is a powerful tool and comes with many features that would take too much space to describe here. If you need to do some templating work in your microservices, it is a good choice, also being present in Quart. Check out the following for full documentation on Jinja's features: https://jinja.palletsprojects.com/.

Configuration

When building applications, you will need to expose options to run them, such as the information needed to connect to a database, the contact email address to use, or any other variable that is specific to a deployment.

Quart uses a mechanism similar to Django in its configuration approach. The Quart object comes with an object called config, which contains some built-in variables, and which can be updated when you start your Quart app via your configuration objects. For example, you can define a Config class in a Python-format file as follows:

# prod_settings.py
class Config:
    DEBUG = False
    SQLURI = "postgres://username:xxx@localhost/db"

It can then be loaded from your app object using app.config.from_object:

>>> from quart import Quart
>>> import pprint
>>> pp = pprint.PrettyPrinter(indent=4)
>>> app = Quart(__name__) 
>>> app.config.from_object('prod_settings.Config') 
>>> pp.pprint(app.config) 
{   'APPLICATION_ROOT': None,
    'BODY_TIMEOUT': 60,
    'DEBUG': False,
    'ENV': 'production',
    'JSONIFY_MIMETYPE': 'application/json',
    'JSONIFY_PRETTYPRINT_REGULAR': False,
    'JSON_AS_ASCII': True,
    'JSON_SORT_KEYS': True,
    'MAX_CONTENT_LENGTH': 16777216,
    'PERMANENT_SESSION_LIFETIME': datetime.timedelta(days=31),
    'PREFER_SECURE_URLS': False,
    'PROPAGATE_EXCEPTIONS': None,
    'RESPONSE_TIMEOUT': 60,
    'SECRET_KEY': None,
    'SEND_FILE_MAX_AGE_DEFAULT': datetime.timedelta(seconds=43200),
    'SERVER_NAME': None,
    'SESSION_COOKIE_DOMAIN': None,
    'SESSION_COOKIE_HTTPONLY': True,
    'SESSION_COOKIE_NAME': 'session',
    'SESSION_COOKIE_PATH': None,
    'SESSION_COOKIE_SAMESITE': None,
    'SESSION_COOKIE_SECURE': False,
    'SESSION_REFRESH_EACH_REQUEST': True,
    'SQLURI': 'postgres://username:xxx@localhost/db',
    'TEMPLATES_AUTO_RELOAD': None,
    'TESTING': False,
    'TRAP_HTTP_EXCEPTIONS': False}

However, there are two significant drawbacks when using Python modules as configuration files. Firstly, since these configuration modules are Python files, it can be tempting to add code to them as well as simple values. By doing so, you will have to treat those modules like the rest of the application code; this can be a complicated way to ensure that it always produces the right value, especially if the configuration is produced with a template! Usually, when an application is deployed, the configuration is managed separately from the code.

Secondly, if another team is in charge of managing the configuration file of your application, they will need to edit the Python code to do so. While this is usually fine, it makes it increase the chance that some problems will be introduced, as it assumes that the other people are familiar with Python and how your application is structured. It is often good practice to make sure that someone who just needs to change the configuration doesn't also need to know how the code works.

Since Quart exposes its configuration via app.config, it is quite simple to load additional options from a JSON, YAML, or other popular text-based configuration formats. All of the following examples are equivalent:

>>> from quart import Quart
>>> import yaml
>>> from pathlib import Path 
>>> app = Quart(__name__)
>>> print(Path("prod_settings.json").read_text())
{
    "DEBUG": false,
    "SQLURI":"postgres://username:xxx@localhost/db"
} 
>>> app.config.from_json("prod_settings.json")
>>> app.config["SQLURI"]
'postgres://username:xxx@localhost/db'
>>> print(Path("prod_settings.yml").read_text())
---
DEBUG: False
SQLURI: "postgres://username:xxx@localhost/db"
>>> app.config.from_file("prod_settings.yml", yaml.safe_load)

You can give from_file a function to use to understand the data, such as yaml.safe_load, toml.load, and json.load. If you prefer the INI format with [sections] along with name = value, then many extensions exist to help, and the standard library's ConfigParser is also straightforward.

Blueprints

When you write microservices that have more than a single endpoint, you will end up with a number of different decorated functions—remember those are functions with a decorator above, such as @app.route. The first logical step to organize your code is to have one module per endpoint, and when you create your app instance, make sure they get imported so that Quart registers the views.

For example, if your microservice manages a company's employees database, you could have one endpoint to interact with all employees, and one with teams. You could organize your application into these three modules:

  • app.py: To contain the Quart app object, and to run the app
  • employees.py: To provide all the views related to employees
  • teams.py: To provide all the views related to teams

From there, employees and teams can be seen as a subset of the app, and might have a few specific utilities and configurations. This is a standard way of structuring any Python application.

Blueprints take this logic a step further by providing a way to group your views into namespaces, making the structure used in separate files and giving it some special framework assistance. You can create a Blueprint object that looks like a Quart app object, and then use it to arrange some views. The initialization process can then register blueprints with app.register_blueprint to make sure that all the views defined in the blueprint are part of the app. A possible implementation of the employee's blueprint could be as follows:

# blueprints.py
from quart import Blueprint
teams = Blueprint("teams", __name__)
_DEVS = ["Alice", "Bob"]
_OPS = ["Charles"]
_TEAMS = {1: _DEVS, 2: _OPS}
@teams.route("/teams")
def get_all():
    return _TEAMS
@teams.route("/teams/<int:team_id>")
def get_team(team_id):
    return _TEAMS[team_id]

The main module (app.py) can then import this file, and register its blueprint with app.register_blueprint(teams). This mechanism is also interesting when you want to reuse a generic set of views in another application or several times in the same application—it's easy to imagine a situation where, for example, both the inventory management area and a sales area might want to have the same ability to look at current stock levels.

Error handling

When something goes wrong in your application, it is important to be able to control what responses the clients will receive. In HTML web apps, you usually get specific HTML pages when you encounter a 404 (Resource not found) or 5xx (Server error), and that's how Quart works out of the box. But when building microservices, you need to have more control of what should be sent back to the client—that's where custom error handlers are useful.

The other important feature is the ability to debug your code when an unexpected error occurs; Quart comes with a built-in debugger, which can be activated when your app runs in debug mode.

Custom error handler

When your code does not handle an exception, Quart returns an HTTP 500 response without providing any specific information, like the traceback. Producing a generic error is a safe default behavior to avoid leaking any private information to users in the body of the error. The default 500 response is a simple HTML page along with the right status code:

$ curl http://localhost:5000/api 
<!doctype html>
<title>500 Internal Server Error</title>
<h1>Internal Server Error</h1>
Server got itself in trouble

When implementing microservices using JSON, it is good practice to make sure that every response sent to clients, including any exception, is JSON-formatted. Consumers of your microservice will expect every response to be machine-parseable. It's far better to tell a client that you had an error and have it set up to process that message and show it to a human than to give a client something it doesn't understand and have it raise its own errors.

Quart lets you customize the app error handling via a couple of functions. The first one is the @app.errorhandler decorator, which works like @app.route. But instead of providing an endpoint, the decorator links a function to a specific error code.

In the following example, we use it to connect a function that will return a JSON-formatted error when Quart returns a 500 server response (any code exception):

# error_handler.py
from quart import Quart
app = Quart(__name__)
@app.errorhandler(500)
def error_handling(error):
    return {"Error": str(error)}, 500
@app.route("/api")
def my_microservice():
    raise TypeError("Some Exception")
if __name__ == "__main__":
    app.run()

Quart will call this error view no matter what exception the code raises. However, in case your application issues an HTTP 404 or any other 4xx or 5xx response, you will be back to the default HTML responses that Quart sends. To make sure your app sends JSON for every 4xx and 5xx response, we need to register that function to each error code.

One place where you can find the list of errors is in the abort.mapping dict. In the following code snippet, we register the error_handling function to every error using app.register_error_handler, which is similar to the @app.errorhandler decorator:

# catch_all_errors.py
from quart import Quart, jsonify, abort
from werkzeug.exceptions import HTTPException, default_exceptions
def jsonify_errors(app):
    def error_handling(error):
        if isinstance(error, HTTPException):
            result = {
                "code": error.code,
                "description": error.description,
                "message": str(error),
            }
        else:
            description = abort.mapping[ error.code].description
            result = {"code":  error.code, "description": description, "message": str(error)}
        resp = jsonify(result)
        resp.status_code = result["code"]
        return resp
    for code in default_exceptions.keys():
        app.register_error_handler(code, error_handling)
    return app
app = Quart(__name__)
app = jsonify_errors(app)
@app.route("/api")
def my_microservice():
   raise TypeError("Some Exception")
if __name__ == "__main__":
    app.run()

The jsonify_errors function modifies a Quart app instance and sets up the custom JSON error handler for every 4xx and 5xx error that might occur.