Tangelo Web Services

Tangelo’s special power lies in its ability to run user-created web services as part of a larger web application. Essentially, each Python file in Tangelo’s web space is associated to a URL; requesting this URL (e.g., by visiting it in a browser) will cause Tangelo to load the file as a Python module, run a particular function found within it, and return the output as the content for the URL.

In other words, Tangelo web services mean that Python code can become web resources. Python is a flexible and powerful programming language with a comprehensive standard library and a galaxy of third-party modules providing access to all kinds of APIs and software libraries.

General Services

Here is a simple example of a web service. Suppose /home/riker/tangelo_html/calc.py reads as follows:

import tangelo

allowed = ["add", "subtract", "multiply", "divide"]

@tangelo.types(a=float, b=float)
def run(operation, a=None, b=None):
    if a is None:
        return "Parameter 'a' is missing!"
    elif b is None:
        return "Parameter 'b' is missing!"

    try:
        if operation == "add":
            return a + b
        elif operation == "subtract":
            return a - b
        elif operation == "multiply":
            return a * b
        elif operation == "divide":
            return a / b
        else:
            return "Unsupported operation: %s\nAllowed operations are: %s" % (operation, ", ".join(allowed))
    except ValueError:
        return "Could not %s '%s' and '%s'" % (operation, a, b)
    except ZeroDivisionError:
        return "Can't divide by zero!"

This is a Python module named calc, implementing a very rudimentary four-function calculator in the run() function. Tangelo will respond to a request for the URL http://localhost:8080/examples/calculator/calc/add?a=33&b=14 (without the trailing .py) by loading calc.py as a Python module, executing its run() function, and returning the result - in this case, the string 47 - as the contents of the URL.

The run() function takes three arguments: a positional argument named operation, and two keyword arguments named a and b. Tangelo maps the positional arguments to any “path elements” found after the name of the script in the URL (in this case, add), while keyword arguments are mapped to query parameters (33 and 14 in this case). In other words, the example URL is the equivalent of running the following short Python script:

import calc
print calc.run("add", "33", "14")

Note that all arguments are passed as strings. This is due to the way URLs and associated web technologies work - the URL itself is simply a string, so it is chunked up into tokens which are then sent to the server. These arguments must therefore be cast to appropriate types at run time. The tangelo.types() decorator offers a convenient way to perform this type casting automatically, but of course you can do it manually within the service itself if it is necessary.

Generally speaking, the web endpoints exposed by Tangelo for each Python file are not meant to be visited directly in a web browser; instead, they provide data to a web application using Ajax calls to retrieve the data. Suppose we wish to use calc.py in a web calculator application, which includes an HTML file with two fields for the user to type inputs into, and four buttons, one for each arithmetic operation. An associated JavaScript file might have code like the following:

function do_arithmetic(op) {
    var a_val = $("#input-a").val();
    var b_val = $("#input-b").val();

    $.ajax({
        url: "calc/" + op,
        data: {
            a: a_val,
            b: b_val
        },
        dataType: "text",
        success: function (response) {
            $("#result").text(response);
        },
        error: function (jqxhr, textStatus, reason) {
            $("#result").html(reason);
        }
    });
}

$("#plus").click(function () {
    do_arithmetic("add");
});

$("#minus").click(function () {
    do_arithmetic("subtract");
});

$("#times").click(function () {
    do_arithmetic("multiply");
});

$("#divide").click(function () {
    do_arithmetic("divide");
});

The do_arithmetic() function is called whenever the operation buttons are clicked; it contains a call to the JQuery ajax() function, which prepares a URL with query parameters then retrieves data from it. The success callback then takes the response from the URL and places it on the webpage so the user can see the result. In this way, your web application front end can connect to the Python back end via Ajax.

Return Types

The type of the value returned from the run() function determines how Tangelo creates content for the associated web endpoint. Since web server communication occurs via textual data, all values returned by web services must eventually be converted to strings. By default, Tangelo accomplishes this by considering all such values to be JSON-encoded. For example, in the calculator example, the run() function returns Python int value 47; Tangelo takes this value and applies the standard function json.dump() to it, resulting in the string "47", which is delivered to the client for further processing. Similarly, a service that returns a Python dict value will be converted to a general JSON-object, making it easy to return structured information from any given web service.

This means that, in the most general case, you can create your own types, equipped with methods for JSON encoding them, and use those are direct return values (see the Python documentation for information on custom JSON encoding). Attempting to return a type that is not JSON-serializable results in a 400 error.

The only exception to the default conversion behavior is that if the service returns a string directly, this value will not be JSON encoded (which entails surrounding it with double-quotes), but simply passed along unchanged. This “escape hatch” enables a service to return any kind of data by encoding it as a string. The tangelo.content_type() utility function can be used to specify the intended type of the returned data. For instance, tangelo.content_type("text/plain") followed by return "hello, world" will result in a text result being sent to the client. More complex types are also possible; e.g., a service might compute a PNG image, then send the PNG data back as a string after calling tangelo.content_type("application/png").

Specifying a Custom Return Type Converter

Similarly to the tangelo.types() decorator mentioned above, services can specify a custom return type via the tangelo.return_type() decorator. It takes a single argument, a function to convert the object returned from the service function to a string or JSON-serializable value (see Return Types):

import tangelo

def excited(s):
    return s + "!!!"

@tangelo.return_type(excited)
def run(name):
    return "hello %s" % (name)

Given Data as an input, this service will return the string Hello Data!!! to the client.

A more likely use case for this decorator is special-purpose JSON converters, such as Pymongo’s bson.json_util.dumps() function, which can handle certain non-standard objects such as Python datetime objects when converting to JSON text.

HTTP Status Codes

When something goes wrong during execution of a web service, you may wish to signal to the client what happened. The tangelo.http_status() function can be used to set the status code to indicate the class of problem. For instance, if the service invocation does not include the proper required arguments, the service might signal the error by the following:

tangelo.http_status(400, "Required Argument Missing")

Many HTTP status codes have standard meanings, including default titles (e.g., the default title for 400 is “Bad Request”); invoking tangelo.http_status() with only a numerical code will use such a default title. Otherwise, you may include a second string argument to provide a more specific description.

Errors are generally signaled with 4xx and 5xx codes. In these cases, the response body may be useful for providing specific information about the error to the client. Such information can be provided as JSON, plain text, HTML, or any other feasible format. Just make sure to call tangelo.content_type() to specify the MIME type of the response before using return to prepare and send the response.

RESTful Services

Tangelo also supports the creation of REST services. Instead of placing functionality in a run() function, such a service has one function per desired REST verb. For example, a rudimentary service to manage a collection of databases might look like the following:

import tangelo
import lcarsdb

@tangelo.restful
def get(dbname, query):
    db = lcarsdb.connect("enterprise.starfleet.mil", dbname)
    if not db:
        return None
    else:
        return db.find(query)

@tangelo.restful
def put(dbname):
    connection = lcarsdb.connect("enterprise.starfleet.mil")
    if not connection:
        return "FAIL"
    else:
        success = connection.createDB(dbname)
        if success:
            return "OK"
        else:
            return "FAIL"

The tangelo.restful() decorator is used to explicitly mark the functions that are part of the RESTful interface so as to avoid (1) restricting REST verbs to just the set of commonly used ones and (2) exposing every function in the service as part of a REST interface (since some of those could simply be helper functions).

Bear in mind that a function named run() will always take precedence over any functions marked with @tangelo.restful. This is because run() is meant to be agnostic to the HTTP method that was used to invoke it, and as such, has higher precedence when Tangelo is looking for a function to invoke.

Configuring Web Services

You can optionally include a configuration file alongside the service itself. For instance, suppose the following service is implemented in autodestruct.py:

import tangelo
import starship

def run(officer=None, code=None, countdown=20*60):
    config = tangelo.config()

    if officer is None or code is None:
        return {"status": "failed",
                "reason": "missing officer or code argument"}

    if officer != config["officer"]:
        return {"status": "failed",
                "reason": "unauthorized"}
    elif code != config["code"]:
        return {"status": "failed",
                "reason": "incorrect code"}

    starship.autodestruct(countdown)

    return {"status": "complete",
            "message": "Auto destruct in %d seconds!" % (countdown)}

Via the tangelo.config() function, this service attempts to match the input data against credentials stored in the module level configuration, which is stored in autodestruct.yaml a YAML file containing an associative array (i.e., a key-value store) at its top level:

officer: picard
code: echo november golf alpha golf echo four seven enable

The two files must have the same base name (autodestruct in this case) and be in the same location. Any time the module for a service is loaded, the configuration file will be parsed and loaded as well. The tangelo.config() function returns a copy of the configuration dictionary, to prevent an errant service from updating the configuration in a persistent way. For this reason, it is advisable to only call this function once, capturing the result in a variable, and retrieving values from it as needed.

If the watch plugin is enabled (easily done by specifying --watch when running Tangelo), changing either file will cause the module to be reloaded the next time it is invoked.

Persistent Storage for Web Services

In contrast to the read-only service configuration, each service also has access to a persistent data store that remembers changes made to it from invocation to invocation. This may be accessed by invoking tangelo.store() within a service function. Like tangelo.config(), the store is a Python dictionary, but anything stored in it will be accessible from a subsequent invocation of the service.

A very simple example would increment tangelo.store()["count"] on each invocation, allowing the service to “know” how many times it has been invoked before.