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.
-
The most common use for the web server is to build an API that can be used to connect various services.
-
This decorator marks functions as being callable using a remote procedure call system. The client-side code can call the server using function call syntax. It does not generate REST-style APIs. See calling below.
Note
The decorator uses the function name as the API function name.
-
Your function must accept a hwebserver.Request object as the first argument.
You can use the
Request
object to get information about the client request such as headers. -
Your function should return one of the following:
-
A JSON-encodable value (
dict
,list
,str
,int
,float
,bool
, orNone
). -
A
bytearray
(orbytes
in Python 3) object for binary data. -
A hwebserver.Response object. (This means you can use hwebserver.fileResponse to stream the contents of a file as binary data.)
-
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 thecmd_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
libraryimport 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 thejson
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 usingwebapiclient.py
do not need to be aware of this detail.Python minimal client code using the
requests
libraryimport 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 (orbytes
in Python 3), returning a response with aContent-Type
ofapplication/octet-stream
, using a hwebserver.fileResponse to stream the contents of a file usingapplication/octet-stream
, or marking the function as returning binary and returningbytes
. -
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 thewith
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 usingbytearray
objects or stream it from a file on disk usingwebapiclient.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 awebapiclient.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 namespacetest
which takes argumentsa
andb
. You're passing 1 for the value ofa
by position and “two” for the value ofb
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 useGET
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 namedjson
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 |