Skip to main content

The micropath Web Application Framework

Project description

https://travis-ci.org/klmitch/micropath.svg?branch=master

The micropath web application framework is a framework for constructing web applications. It is based on the UNIX philosophy: do one thing and do it well. The micropath framework is ideally suited for creating microservices, but can easily be used to construct much larger applications as well. Users extend the micropath.Controller class to implement their web application, and instances of this class can then be used as WSGI applications. The micropath framework utilizes a tree structure for routing incoming requests to the proper handler method, allowing for a significant speedup over competing systems like routes, which matches an incoming request against a list of regular expressions. The micropath framework also utilizes dependency injection techniques to pass handler methods only the arguments they need to process the request. Finally, requests are represented using the micropath.Request class, which is a subclass of webob.Request; handler methods can return webob.Response objects, or even plain text (which will be wrapped in a “200 OK” response).

The Controller Class

The central functionality of micropath is the Controller class; creating a WSGI web application using micropath is as simple as creating a subclass of micropath.Controller and populating it. The micropath.Controller.__call__() method implements a WSGI web application interface, meaning instances of the Controller subclass are full-fledged WSGI web applications. There is also a micropath.Controller.__init__() method, taking no arguments, that all subclasses should ensure is called (either by not overriding it, or by including super(ClassName, self).__init__() in the implementation). Aside from these two methods, all other methods and attributes have names beginning with micropath_ (for hook and utility methods and data values that developers may override to customize functionality) or _micropath (for internal methods and data values). This document will touch on some of this functionality, but for full details, see the help on micropath.Controller.

The most important part of any web application framework is determining how to route the request to the handler that will act on that request. The micropath framework relies on the special functions micropath.path() and micropath.bind()–both of which return an internal type Element that includes path() and bind() methods as well–to construct the URL path that the web application is expecting. Under the covers, this creates a tree-like object, rooted at the controller, for traversing the URL path of a request, and should be considerably faster than other frameworks, which often iterate over a list of regular expressions. These path elements are then bound to handler methods using the @micropath.route() decorator (for requests at the root of the controller) or @Element.route() decorator (for requests relative to an Element returned by a micropath.path() or micropath.bind()), which takes as arguments the HTTP methods that should trigger the handler. (Note: either form of the @route() decorator must be the outer-most decorator on a helper method–that is, the @route() must be the very first decorator in the source file. This decorator stores the function that is passed to it in the Element tree, so any decorators that occur before it in the source file will not be invoked when handling a request, even though they would be if directly accessing the method through the class instance.)

The following example should help clarify how these components work together. The example is a part of a REST-style web API for a library (the dead-tree kind) for handling subscribers:

class SubscriberController(micropath.Controller):
    @micropath.route('get')
    def index(self):
        # Return a list of subscribers
        ...

    @micropath.route('post')
    def create(self, json_body):
        # Create a new subscriber with details from the JSON body
        ...

    # Bind a subscriber ID to the next path element.  Since no
    # name is passed to ``bind()``, it will default to the
    # variable name "sub_id" (this is done by the metaclass).
    sub_id = micropath.bind()

    @sub_id.route('get')
    def get(self, sub_id):
        # Return the details about a specific subscriber
        ...

    @sub_id.route('put')
    def update(self, sub_id, json_body):
        # Update the subscriber with details from the JSON body
        ...

    @sub_id.route('delete')
    def delete(self, sub_id):
        # Delete the subscriber
        ...

With the above example, an HTTP GET request to “/” would map to the index() method, while an HTTP PUT request to “/1234” would call update() with sub_id="1234" and json_body being the result of calling json.loads() on the request body. It’s also worth pointing out that micropath implements a default handler for the HTTP OPTIONS method; if no handler method has the HTTP OPTIONS method routed to it, micropath will return a “204 No Content” response with the “Allow” header set based on the routed methods. For instance, if we sent the HTTP OPTIONS request to “/1234”, the “Allow” header would contain the string “DELETE,GET,HEAD,PUT,OPTIONS”. (The “HEAD” HTTP method is automatically converted to “GET” by the micropath routing algorithm and so will be present in “Allow” anytime “GET” is; and “OPTIONS” is always added, since there’s a default implementation.)

In our toy web API, we might also wish to know what books a given subscriber has checked out. There are multiple ways this could be handled, but for the purposes of the example, we’ll assume that we want a REST resource off the subscriber–e.g., “/1234/books” would list the books the subscriber has checked out, “/1234/books/5678” would get details on the checked-out book with id “5678”, etc. With micropath, there are two ways of accomplishing this; the first way would be to add the following lines to the SubscriberController class:

# Bind the "books" path element after the subscriber ID
books = sub_id.path()

@books.route('get')
def books_index(self, sub_id):
    # Return a list of checked-out books
    ...

@books.route('post')
def books_create(self, sub_id, json_body):
    # Create (check out) a book under the subscriber from the JSON
    # body
    ...

# Bind a book ID to the next path element
book_id = books.bind()

@book_id.route('get')
def book_get(self, sub_id, book_id):
    # Return the details about a specific book
    ...

@book_id.route('put')
def book_update(self, sub_id, book_id, json_body):
    # Update the book with details from the JSON body
    ...

@book_id.route('delete')
def book_delete(self, sub_id, book_id):
    # Delete (check in) the book from the subscriber
    ...

With a simple API, or a microservice-style API, this scheme is perfectly fine, but for large APIs, the size of the controller class could become problematic very quickly. Thus, micropath provides another way to accomplish this task: create a BookController class providing the functionality for the book resource, then mount it on the SubscriberController like so:

# The path() call is given the name "books" by the metaclass; the
# mount() method configures the path element to delegate requests
# to that path to the BookController class.  The BookController
# class will be instantiated when SubscriberController is,
# assuming that the __init__() method is not overridden, or that
# the superclass method is called.
books = sub_id.path().mount(BookController)

Path Binding Validation and Translation

In the examples above, the values assigned to sub_id and book_id are passed as simple strings to the hook methods. However, bindings can also have validators and formatters: a validator is a method that is passed the value argument (and any other request elements that can be injected, including bindings from earlier in the path). The validator should validate that the value is legal and return whatever object should be passed to handlers. This could be used to, for instance, resolve a subscriber ID into an actual subscriber model object that would subsequently be passed to the handler methods. Validators should not raise just any exception, however; they may raise any of the exceptions contained in webob.exc, which will cause a suitable error to be returned to the user, or they may raise micropath.SkipBinding, which will ultimately result in returning a 404 to the client. Any other exception will result in a 500 error being returned.

In addition to the validator, a binding may have a formatter; this is a function that will be passed the object that was passed to micropath.Request.url_for() for that binding, and must return a string suitable for inclusion in the URL. An application that uses the url_for() method should either provide a formatter or ensure that the object has an implemented __str__() method.

Validators and formatters are set by decorating methods with the @validator and @formatter decorators, respectively. For instance, for the SubscriberController example above, the following would set the validator and formatter functions for sub_id:

@sub_id.validator
def sub_id_validator(self, value):
    ...

@sub_id.formatter
def sub_id_formatter(self, value):
    ...

Requests

Handler methods can request the Request object by listing request among their arguments. The Request class used by micropath is a subclass of webob.Request, which provides two additional properties and an additional function. The injector property contains a dictionary-like class which is used for micropath’s dependency injection system, and base_path contains the value of script_name at the time the request was constructed by the __call__() method of Controller. (The routing algorithm of Controller modifies script_name and path_info as it routes the request, so a handler method always sees script_name as the path to that handler method.) The base_path is thus the path to the root Controller class, and is used by the url_for() method.

The url_for() method allows an application to construct an absolute URL for any other handler method in the application. The first (and only) positional argument that should be passed to Request.url_for() should be the handler method in question, and keyword arguments specify the values for bindings. Note that the method reference must be to an instance method; passing something like SubscriberController.index is an error; use something like self.index. It should also be noted that handler methods can request a reference to the root controller of the WSGI application by listing root_controller among their arguments. Finally, mounted controllers can be referenced using the mount point; in the example above, where a BookController is mounted on a SubscriberController, the index() method of the BookController could be referenced using root_controller.books.index.

Configuration of a Controller Instance

The micropath framework is not opinionated about the implementation of the class __init__() method, other than requiring, for thread safety purposes, that the superclass’s constructor is called. This means that applications can provide configuration information at class construction time. By default, mounted classes are passed only keyword arguments provided to the mount() method (which, typically, must be constants; this mechanism is intended to allow a controller to tailor its behavior depending on where it is mounted); however, mounted class construction can be customized by overriding the micropath_construct() method of the controller class onto which another controller is mounted. This means that configuration information can be propagated to the other controllers quite easily.

Dependency Injection and Wrapping Decorators

Handler methods in micropath are invoked using dependency injection, passing them the arguments that are declared as part of the method signature. However, handler methods are often additionally decorated with wrapping-type decorators; that is, the decorator creates a function, typically taking *args and **kwargs, does some processing, and then invokes the decorated function (or not, depending on what the decorator is intended to do). This affects the function signature seen by the dependency injector, and could cause spurious failures.

To counter this problem, the micropath framework provides a variation of @functools.wraps(). The @micropath.wraps() decorator functions similarly to the @functools.wraps() decorator, but has some additional properties. First, on Python 2.7, it ensures the __wrapped__ attribute is set (this is implemented by Python 3’s @functools.wraps(), but not present in Python 2.7’s version); this makes it easier to get the underlying function, which could be useful for unit testing. Second, the @micropath.wraps() decorator accepts three additional, optional keyword arguments: provides can be a list of keyword arguments that the wrapped function may want that are provided by the decorator; required is a list of keyword arguments that are required by the decorator itself; and optional is a list of keyword arguments that may be provided if they’re available in the injector.

In addition to the @micropath.wraps() decorator, the micropath framework also provides the micropath.call_wrapped() utility function. This function takes as arguments the wrapped function, a tuple of positional arguments, and a dictionary of keyword arguments, and invokes the function using the injector machinery, returning the value of calling the function. A micropath decorator may also wish to know if specific arguments are requested by the function; this may be determined using micropath.wants(), which takes as arguments the wrapped function and the name of a keyword argument, and returns a boolean indicating whether the function wants the specified keyword argument.

Using the @micropath.wraps() decorator and the micropath.call_wrapped() function, function decorators can be created that wrap a handler method without disrupting the dependency injection mechanism that is integral to how micropath calls handler methods.

Customizing Request Handling

The micropath framework provides a number of ways of customizing request handling. First of all, the class used for representing a request can be set by overriding the value of Controller.micropath_request; by default, this value is micropath.Request. (It is highly recommended that custom request classes extend micropath.Request, so that all functionality is available.) Second, the request attributes that are available for dependency injection are stored in the Controller.micropath_request_attrs dictionary; additional attributes can be added by copying Controller.micropath_request_attrs and adding additional entries to it. The keys of this dictionary will be the argument names, and if the value is None they will also name the attribute; otherwise, the value should be the name of the request attribute.

After the request is constructed, the micropath framework invokes the Controller.micropath_prepare_injector() hook method. The default implementation does nothing, but this method can be overridden in the root controller of an application to implement any desired behavior: authentication headers can be verified, additional data can be added to the dependency injector, etc. This is the last step before traversing the element tree and invoking the proper handler method.

Several other hook methods exist in the micropath.Controller class. For instance, if an error occurs while attempting to evaluate a request attribute for injection to a handler method, the Controller.micropath_request_error() hook method is invoked; its default implementation will return a 400 error to the client. If, on the other hand, an exception occurs in a handler method, the Controller.micropath_server_error() hook method is invoked, which will, by default, return a 500 error to the client. If the client’s URL could not be mapped to a controller, the Controller.micropath_not_found() hook method is called to generate a 404 error, and Controller.micropath_not_implemented() is called if the URL exists, but the specified HTTP method is not routed to a handler. Finally, the Controller.micropath_options() provides the default implementation for the HTTP OPTIONS method; by default, it returns a 204 response with the “Allow” header containing a comma-separated list of recognized HTTP methods.

Methods Requesting path_info

By default, the entire URL must be consumed for a handler method to be invoked. However, a handler method may request the path_info attribute of the request; if the handler method represents the longest match for the requested URL, the handler will be invoked with the remaining components of the URL path passed as the path_info parameter of the handler method. This effectively inhibits the usual behavior of returning a 404 response.

Launching a micropath Application

Instances of micropath.Controller subclasses are fully fledged WSGI applications. Many WSGI servers want a module with an application callable present that is the actual WSGI application; this attribute may simply be an instance of the root controller. The exact semantics depend on the WSGI server, so refer to the documentation of the server for more details about how to provide the WSGI application to it.

Instances of micropath.Controller also have a micropath_run() utility method. This method simply uses the Python standard library’s built-in wsgiref package to launch a simple web server. By default, this server will run on the loopback interface (“localhost”, or, more technically, “127.0.0.1”) on port 8000, although that can be controlled using arguments to the method. Note that this is NOT RECOMMENDED for production systems; this simple server does not attempt to handle threading, exceptional error handling, SSL, or a host of other issues that real, production-ready HTTP and WSGI servers handle. This is simply meant to simplify testing an application on a developer’s local laptop.

The HEAD HTTP Method

The HTTP specification specifically states that the HEAD method should act identically to the GET method, except that no body is sent in the response. Given that, the micropath framework treats HEAD as if it were GET. In particular, a method that has HEAD routed to it will never be called unless GET is also routed to that method. Nevertheless, the method attribute of the request will contain the actual HTTP method that was sent. This also means that the default OPTIONS response will include HEAD if GET is routed, without any additional effort on the part of the user.

Controller Inheritance

The element tree built for any given micropath.Controller subclass is unique to that specific class. In particular, this means that a class that inherits from another class does not inherit the URL or method routing from that class, nor does it inherit the mount points. However, the hook methods and other customizing data elements are inherited, as are the actual handler methods and any other methods and data elements. Users may take advantage of this by constructing a base controller class with the needed features, then basing the application controller classes on that base controller class.

Project details


Release history Release notifications

This version
History Node

0.1.0

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Filename, size & hash SHA256 hash help File type Python version Upload date
micropath-0.1.0.tar.gz (46.9 kB) Copy SHA256 hash SHA256 Source None

Supported by

Elastic Elastic Search Pingdom Pingdom Monitoring Google Google BigQuery Sentry Sentry Error logging AWS AWS Cloud computing DataDog DataDog Monitoring Fastly Fastly CDN SignalFx SignalFx Supporter DigiCert DigiCert EV certificate StatusPage StatusPage Status page