Houdini 20.5 hwebserver

hwebserver.apiFunction function

Decorator for functions that can be called through an API endpoint on Houdini’s web server, returning JSON or binary responses.

On this page

apiFunction(namespace=None, return_binary=False, arg_types=None, ports=[])

When you decorate a function with this decorator, it registers the function as one that can be called through the /api endpoint.

Note

The decorator uses the function name as the API function name.

namespace

If you specify this string, it acts as a prefix to the function name. For example, if your function name is vector_length, and you pass namespace="geo", then the namespaced function name would be "geo.vector_length". You are encouraged to organize your API functions into namespaces.

return_binary

If this is True, the function’s return value will always be treated as binary data and served with the MIME type application/octet-stream.

When this is False, you can still return binary data by returning a bytes (Python 3 only) or bytearray object.

arg_types

A dictionary mapping argument names to Python types. For example, {"x": int, "y": int, "z": int}.

ports

Optionally specify the ports the api function should bind to. If the ports are not specified the main port for the server is used. If you need to bind to the main port then '0' must be used. Binding to the actual port number will remove the api function from the registry.

The server will call the given types on the arguments from the client before calling the function with them. This is useful when the client supplies the arguments as strings (see the first way to call the API under Calling below).

import hou
import hwebserver


@hwebserver.apiFunction(namespace="geo")
def vector_length(request, x, y, z):
    return hou.Vector3(x, y, z).length()

Note

There is no guarantee which thread the server will call the API function from, so you cannot rely on thread-specific results such as hou.frame().

NEW

API functions support asyncio (Python 3 builds only). Since there is a performance hit to using asyncio as the web server is C++ first. It is recommended to use coroutine API functions only when an API function is likely to wait for IO while handling the request. The decorator will automatically detect the function type and handle the function based on its type.

Calling

There are two ways to call the API from a client:

  • The way first is to POST to /api, where the cmd_function parameter contains the function name, and all other parameters contain arguments. For example, cmd_function=vector_length&x=1&y=2&z=3.

    Python client code using the requests library

    import requests
    
    resp = requests.post("http://127.0.0.1:8008/api", {
        "cmd_function": "geo.vector_length",
        "x": "1", "y": "2", "z": "3"    
    })
    print(resp.text)
    

    This approach is simpler to implement a client for, but the handler receives all argument values as strings so you must either add extra type checking/conversion code in your handler or specify an arg_types dict in the function decorator (see above).

    Note

    Without extra type conversions, this example will not work. See Default values and argument types below for an example of providing type conversions.

  • The second and recommended way is POST to /api, where the json parameter contains a string encoding of a JSON object containing the function name string, a list of positional arguments, and the an object containing keyword arguments. For example, ["geo.vector_length", (1, 2, 3), {}].

    This approach implicitly encodes type information and allows for more complex argument types.

    Any additional multipart/form-data arguments are interpreted to contain additional keyword arguments. Use these arguments to pass binary data instead of having to base64-encode it. (You cannot have a binary data argument named json.) Python clients using webapiclient.py do not need to be aware of this detail.

    Python minimal client code using the requests library

    import json
    import requests
    
    def call_api(endpoint_url, function_name, *args, **kwargs):
        return requests.post(endpoint_url,
            data={"json": json.dumps([function_name, args, kwargs])}).json()
    })
    

    The webapiclient.py module that ships with Houdini implements a complete Python client that can call API functions. It does not depend on Houdini and can by copied into your own projects. With it you can call API functions using a more natural syntax.

    service = webapiclient.Service("http://127.0.0.1:8008/api")
    print(service.geo.vector_length(1, 2, 3))
    

    JavaScript minimal client code

    function call_api(function_name, kwargs)
    {
        var formdata = new FormData();
        formdata.append("json", JSON.stringify(
            [function_name, [], kwargs]));
    
        return fetch("/api", {
            method: "POST",
            body: formdata
        });
    }
    

    A full JavaScript client that handles binary data is provided below.

Default values and argument types

  • API functions may have default values, and the caller can use a mix of positional and keyword arguments when using the JSON-encoded [name, args, kwargs] calling approach.

    Server and Python client example using default values.

    # Server code:
    @hwebserver.apiFunction("test")
    def foo(a, b=2, c=3):
        return (a, b, c)
    
    
    # Client code:
    service = webapiclient.Service("http://127.0.0.1:8008/api")
    
    # These calls are all equivalent and return [1, 2, 3]:
    service.test(1, 2, 3)
    service.test(1, 2, c=3)
    service.test(a=1, c=3, b=2)
    
  • Using the url-encoded calling approach will pass all arguments as strings, but it is possible to specify types when declaring the API function.

    Specifying argument types in Python 2 and 3

    @hwebserver.apiFunction("test", arg_types={"value": int})
    def double_int(value):
        return value * 2
    
    # Calling with a POST body of "value=3" will convert the string "3"
    # to the integer 3.
    

    In Python 3, argument types can also be specified with type hints.

    @hwebserver.apiFunction("test")
    def double_int(value: int):
        return value * 2
    

Binary data

  • API functions can return binary data by returning a bytearray object (or bytes in Python 3), returning a response with a Content-Type of application/octet-stream, using a hwebserver.fileResponse to stream the contents of a file using application/octet-stream, or marking the function as returning binary and returning bytes.

  • When webapiclient.py receives a binary data return value, it will return a file-like object to give the client the opportunity to stream it instead of having to load it all into memory. The client should use the with statement on the result to ensure the request is closed in case the client does not read all the data.

    Server returning binary data and Python client receiving it

    # The server can return binary data as follows:
    
    @hwebserver.apiFunction("test")
    def binary_data(request):
        return bytearray([0, 0, 65, 66, 0])
    
    
    # The client can call the function like any other and receives a
    # `bytes` object instead of unpacked JSON:
    
    with service.test.binary_data() as result_stream:
        binary_data = result_stream.read()
    

    Server marking an API function as returning binary

    @hwebserver.apiFunction("test", return_binary=True)
    def binary_data(request):
        return b'\x00\x00AB\x00'
    
  • Using webapiclient.py, the client can pass binary data in arguments using bytearray objects or stream it from a file on disk using webapiclient.File objects. Note that binary data arguments must be passed using keyword arguments and cannot be passed positionally.

    Python client sending binary data and server that receives it

    # The client can send binary data as follows:
    service.test.use_binary_data(
        small_data=bytearray([0, 0, 65, 66, 0]),
        big_data=webapiclient.File("/path/to/binary/file"))
    
    
    # The server receives the binary data as UploadedFile
    # objects that it can read from:
    import shutil
    import hwebserver
    
    @hwebserver.apiFunction("test")
    def use_binary_data(request, small_data, big_data):
        small_data_as_bytes = small_data.read()
        with open("uploaded_file.bin", "wb") as open_file:
            shutil.copyfileobj(big_data, open_file)
    

Errors

  • Raise a hwebserver.APIError from an API function to return a 422 status.

  • When using webapiclient.py, any responses with a non-200 status code will raise a webapiclient.APIError exception to the client.

  • If your API function generates an exception other than an APIError, the server will return a 500 status code. If the server is in debug mode the response contains a stack trace.

    Server and Python client code illustrating errors

    # The server:
    
    @hwebserver.apiFunction("test")
    def illustrate_errors(request, value):
        if value == 0:
            return ["successful", "result"]
        if value == 1:
            raise hwebserver.APIError("an error occurred")
        if value == 2:
            raise hwebserver.APIError({"any": "json-encodable"})
        if value == 3:
            raise hwebserver.OperationFailed("an unhandled exception")
    
    
    # The client:
    
    print(service.test.illustrate_errors(0)) # prints ["successful", "result"]
    
    try:
        service.test.illustrate_errors(1)
    except webapiclient.APIError as e:
        print(str(e)) # prints "an error ocurred"
        print(e.status_code) # prints 422
    
    try:
        service.test.illustrate_errors(2)
    except webapiclient.APIError as e:
        print(e.args[0]["any"]) # prints "json-encodable"
        print(e.status_code) # prints 422
    
    try:
        service.test.illustrate_errors(3)
    except webapiclient.APIError as e:
        print(e.status_code) # prints 500
    

Examples

Serving relative files

Sometimes you want to serve files using a path relative to the Python script file containing the handler. Usually the __file__ variable contains the path of the currently running file, so you can get the directory containing it using os.path.dirname(__file__).

However, sometimes __file__ is not defined, such as when you launch Houdini from the command line with a Python script. To compensate for this, you can use the following function:

webutils.py

import os
import inspect


def script_file_dir():
    try:
        file_path = __file__
    except NameError:
        file_path = inspect.getfile(inspect.currentframe())

    if not os.path.isabs(file_path):
        file_path = os.path.normpath(os.path.join(os.getcwd(), file_path))

    return os.path.dirname(file_path)

USD processing service

This example shows how you can use Houdini to build a web service that receives USD files, processes them with a SOP HDA stored on the server, and returns the result.

The example provides two API functions. The first assumes that the client and server can both access the same file server for input and output USD data files, and the client passes in the locations of the input and desired output file. The second API function passes around the actual contents of the files, streaming the input file from the client so it doesn’t fill the client’s memory. The server receives it as a hwebserver.UploadedFile so it doesn’t fill its memory, saves the contents to disk if not already there so Houdini can read it in, processes the USD file as in the first function, streams back the temporary file output. The client then streams the received results to disk.

usdprocessor.py

import os
import tempfile

import hou
import hwebserver

import webutils


@hwebserver.apiFunction("usdprocessor")
def process_file(request, input_file, hda_name, output_file):
    '''
    Given a path to an input USD file that is accessible from the server,
    load it in, process it using the name of a SOP HDA stored on the server,
    and write the USD output to a file location that is accessible from the
    client.
    '''
    # Load in the SOP asset file they're requesting.
    hda_file = os.path.join(
        webutils.script_file_dir(), "assets", hda_name + ".hda")
    try:
        sop_node_type_name = get_sop_node_type_from_hda(hda_file)
    except hou.OperationFailed as e:
        raise hwebserver.APIError(e.instanceMessage())

    # Build a lop network to load in the input USD file, process it using
    # the sop, and save out a new file.
    stage = hou.node("/stage")
    file_node = stage.createNode("sublayer")
    file_node.parm("filepath1").set(input_file)

    sopmodify = stage.createNode("sopmodify")
    sopmodify.parm("primpattern").set("*")
    sopmodify.parm("unpacktopolygons").set(True)
    sopmodify.setFirstInput(file_node)

    subnet = sopmodify.node("modify/modify")
    sop = subnet.createNode(sop_node_type_name)
    sop.setFirstInput(subnet.item("1"))
    subnet.displayNode().setFirstInput(sop)
    subnet.layoutChildren()

    rop = stage.createNode("usd_rop")
    rop.parm("lopoutput").set(output_file)
    rop.parm("savestyle").set("flattenstage")
    rop.setFirstInput(sopmodify)
    stage.layoutChildren((file_node, sopmodify, rop))

    # Compute the results and return an error if the nodes had errors.
    rop.render()
    errors = "\n".join(sopmodify.errors() + rop.errors())
    if errors:
        raise hwebserver.APIError(errors)


def get_sop_node_type_from_hda(hda_file):
    hou.hda.installFile(hda_file)
    definition = hou.hda.definitionsInFile(hda_file)[0]
    if definition.nodeTypeCategory() != hou.sopNodeTypeCategory():
        raise hwebserver.OperationFailed("This asset is not a SOP")
    return definition.nodeTypeName()


@hwebserver.apiFunction("usdprocessor", return_binary=True)
def process_data(request, usd_data, hda_name):
    '''
    Process USD data that was streamed to the server and stream back
    USD data as a response.
    '''
    # `usd_data` is an UploadedFile file-like object that was streamed to the
    # server and may be on disk if it is large.  Force it to be saved in a
    # temporary file on disk, process the data to a temporary file, and stream
    # it back.
    usd_data.saveToDisk()

    # Choose a name for the temporary output file.
    with tempfile.NamedTemporaryFile(
            suffix=".usd", delete=False) as open_output_file:
        temp_output_file = open_output_file.name

    try:
        process_file(
            request, usd_data.temporaryFilePath(), hda_name,
            temp_output_file)
    except:
        if os.path.exists(temp_output_file):
            os.unlink(temp_output_file)
        raise

    # We ask the server to delete the temporary file when it is finished
    # streaming it.
    return hwebserver.fileResponse(
        temp_output_file, content_type="application/octet-stream",
        delete_file=True)


if __name__ == "__main__":
    hwebserver.run(8008, debug=True)

The client could call the server as follows:

client.py

import shutil

import webapiclient


if __name__ == "__main__":
    service = webapiclient.Service("http://127.0.0.1:8008/api")
    service.usdprocessor.process_file(
        "input.usd", "surfacepoints", "output.usd")

    with service.usdprocessor.process_data(
            usd_data=webapiclient.File("input.usd"), hda_name="surfacepoints",
            ) as response_stream:
        with open("output_streamed.usd", "wb") as output_file:
            shutil.copyfileobj(response_stream, output_file)

Full JavaScript client

Using the call_api JavaScript function above you could write, for example, call_api("test.func1", {a: 1, b: "two"}). The following JavaScript code lets you call it a little more naturally by by writing service.test.func1({a: 1, b: "two"}). This client also supports binary responses by writing, for example, service.test.binary_func({}, "arraybuffer") to get an ArrayBuffer object.

webapiclient.js

let _proxy_handler = {
    get: (obj, prop) => {
        let function_name = obj.function_name;
        if (function_name.length)
            function_name += ".";
        function_name += prop;

        new_obj = function (kwargs, response_type) {
            return call_api(function_name, kwargs, response_type);
        };
        new_obj.function_name = function_name;
        return new Proxy(new_obj, _proxy_handler);
    },
};

function call_api(function_name, kwargs, response_type)
{
    // response_type is optional.  The caller can set it to "arraybuffer" or
    // "blob" when calling an API function that returns binary data.
    if (kwargs === undefined)
        kwargs = {};

    var request = promisify_xhr(new XMLHttpRequest());
    request.open("POST", "/api", /*async=*/true);
    request.setRequestHeader(
        "Content-Type", "application/x-www-form-urlencoded");
    request.responseType =
        response_type !== undefined ? response_type : "json";
    return request.send("json=" + encodeURIComponent(
        JSON.stringify([function_name, [], kwargs])));
}

function promisify_xhr(xhr) {
    const actual_send = xhr.send;
    xhr.send = function() {
        const xhr_arguments = arguments;
        return new Promise(function (resolve, reject) {
            xhr.onload = function () {
                if (xhr.status != 200) {
                    reject({request: xhr});
                } else {
                    resolve(
                        xhr.responseType != "arraybuffer" &&
                            xhr.responseType != "blob"
                        ? xhr.response : JSON.parse(xhr.responseText));
                }
            };
            xhr.onerror = function () {
                // Pass an object with the request as a property.  This
                // makes it easy for catch blocks to distinguish errors
                // arising here from errors arising elsewhere.
                reject({request: xhr});
            };
            actual_send.apply(xhr, xhr_arguments);
        });
    };
    return xhr;
}

service = new Proxy({function_name: ""}, _proxy_handler);

Protocol Specifications

The following information is helpful when implementing a client in a new language. See webapiclient.py as a reference.

  • Suppose you're calling the function func1 in the namespace test which takes arguments a and b. You're passing 1 for the value of a by position and “two” for the value of b by keyword argument.

    • JSON-encode ["test1.func", [1], {"b": "two"}] to get '["test.func1", [1], {"b": "two"}]'

    • URL-encode the JSON-encoded value with the name json: json=%5B%22test.func1%22%2C+%5B1%5D%2C+%7B%22b%22%3A+%22two%22%7D%5D

    • POST this to the server’s /api URL path.

  • POST is the recommended request method, though anything is technically supported. Don’t use GET for functions that have side effects or rely on data other than arguments, since HTTP defines the results as cacheable.

  • URI-encoding and multipart/form-data encodings are both supported.

  • To send binary arguments, or otherwise stream large arguments, do not include them in the JSON-encoded data. Instead, encode them as multipart/form-data them in the body, using the argument name, and use a Content-Type of application/octet-stream. Binary arguments named json are not supported.

  • Send an Accept header of "application/json, */*" to have the server return error messages as JSON.

  • If the server returns a Content-Type of application/octet-stream then the response back from the API function is binary data and not JSON.

  • The server will respond with 200 on success, 422 if the server raised an APIError exception, and 500 if the server had an unhandled exception.

  • API functions have full access to the request so they can look at headers. This ability is helpful when implementing authentication.

  • You can return any response from an API function, with any HTTP status code, and content type, and any additional headers. You are encouraged to confirm to this protocol, though, and not add custom behavior other than authentication.

See also

hwebserver

Classes

Starting and Stopping

Handling Web Requests and Returning Responses

WebSocket

  • WebSocket

    Base class for WebSocket support with the embedded server.

  • hwebserver.webSocket

    Decorator for registering WebSocket classes with Houdini’s web server.

API Calls

  • hwebserver.apiFunction

    Decorator for functions that can be called through an API endpoint on Houdini’s web server, returning JSON or binary responses.

  • hwebserver.APIError

    Raise this exception in apiFunction handlers to indicate an error.