10.7. Calling Services with the “wrapped” API

This document explains how to define a service provider and client which uses the “wrapped” LN client API. The purpose of that API is to reduce boilerplate code, in situations where a lot of services need to be defined and used. It is a slightly adapted version of the python service code example in the Quickstart section.

Note that, while the code is shorter, it is not necessarily simpler, because it uses generated code.

10.7.1. Defining a service provider in Python, using the “wrapped” API

We show Python code which implements the service provider, giving it the name quickstart/python/services_wrapped/provider_wrapped.py. Here is how it looks:

  1#!/usr/bin/env python3
  2
  3import links_and_nodes as ln
  4import math
  5from math import pi
  6from area_api_constants import ErrorCodes as ec
  7
  8
  9class area_provider(ln.service_provider):
 10    def __init__(self):
 11        self.clnt = ln.client("test_provider")
 12        ln.service_provider.__init__(self, self.clnt, "area_service")
 13
 14        self.wrap_service_provider("compute_circle_area", "area_service/circle_area")
 15        self.wrap_service_provider(
 16            "compute_triangle_area", "area_service/triangle_area"
 17        )
 18        self.wrap_service_provider("compute_ellipse_area", "area_service/ellipse_area")
 19
 20    def compute_circle_area(self, radius):
 21        """This service handler function demonstrates error
 22        checking and returning error values, using the
 23        wrapped API.
 24        """
 25
 26        if radius < 0:
 27            print("we return an error and NaN as value for a negative radius")
 28            area_val = math.nan
 29            error_message = "negative radius input value"
 30            error_code = ec.NEGATIVE_INPUT
 31
 32        else:
 33            if radius == 0:
 34                error_message = "zero in radius input value"
 35                error_code = ec.ZERO_INPUT
 36            else:
 37                error_message = ""
 38                error_code = ec.NO_ERROR
 39
 40            area_val = pi * radius**2
 41            print("circle with radius %5.2f has area %5.2f" % (radius, area_val))
 42
 43        # multiple fields are returned in a dict
 44        return {
 45            "area": area_val,
 46            "error_code": error_code,
 47            "error_message": error_message,
 48        }
 49
 50    def compute_triangle_area(self, base_length, height):
 51        if base_length < 0 or height < 0:
 52            print("we return an error and NaN as value for a negative input lengths")
 53            area_val = math.nan
 54            error_message = "negative input length value"
 55            error_code = ec.NEGATIVE_INPUT
 56
 57        else:
 58            if base_length == 0 or height == 0:
 59                error_message = "zero in input length value"
 60                error_code = ec.ZERO_INPUT
 61            else:
 62                error_message = ""
 63                error_code = ec.NO_ERROR
 64
 65            area_val = (base_length * height) / 2.0
 66            print(
 67                "triangle with base length = %5.2f  and height = %5.2f has area  %f\n"
 68                % (base_length, height, area_val)
 69            )
 70
 71        # multiple fields are returned in a dict
 72        return {
 73            "area": area_val,
 74            "error_code": error_code,
 75            "error_message": error_message,
 76        }
 77
 78    def compute_ellipse_area(self, a, b):
 79        if a < 0 or b < 0:
 80            print("we return an error and NaN as value for a negative radius")
 81            area_val = math.nan
 82            error_message = "negative axis length input value"
 83            error_code = ec.NEGATIVE_INPUT
 84
 85        else:
 86            if a == 0 or b == 0:
 87                error_message = "zero in axis length input value"
 88                error_code = ec.ZERO_INPUT
 89            else:
 90                error_message = ""
 91                error_code = ec.NO_ERROR
 92
 93            area_val = pi * a * b
 94            print(
 95                "ellipse with semi-major axis a %5.2f  and semi-minor axis b %5.2f"
 96                " has area  %5.2f\n" % (a, b, area_val)
 97            )
 98
 99        # multiple fields are returned in a dict
100        return {
101            "area": area_val,
102            "error_code": error_code,
103            "error_message": error_message,
104        }
105
106    def run(self):
107        self.handle_service_group_requests()
108
109
110if __name__ == "__main__":
111    p = area_provider()
112    p.run()

To explain it very briefly: In line 9, we define a class which is derived from links_and_nodes.service_provider, and register some method names along with message definitions which are used to call these methods. The input parameters in the message definitions become the input parameters of these methods, and the result parameter becomes the return value.

The method call links_and_nodes.service_provider.wrap_service_provider() has the method name as the first parameter, and the used message definition as the second parameter. The name of the service itself is passed in links_and_nodes.service_provider..__init__() which is the inherited constructor of the parent class.

The calls to the function links_and_nodes.service_provider.do_register() perform a registration of the service provider with the LN manager, and also assign the name of a service group to the service handler. Here, it is set to a default name, which will be sufficient in almost all cases.

Note that names of services and message definitions, which happen to be almost identical in our example, can be different. (For further reading, the section on Message Definitions and Topics in the tutorial gives a more in-depth explanation of the relationships between message definitions, topics, and their names.)

The function which actually handles service requests has the name links_and_nodes.client.wait_and_handle_service_group_requests(). Here, the optional first parameter is a string with the name of the group of service handlers which will be run to serve a request. In our example, the service handlers will in the main thread of the program, which keeps things nicely simple.

10.7.2. Calling services from Python, using the wrapped API

In addition, we need, of course, again code which calls the computation. For this, we create a service client program examples/quickstart/python/services_wrapped_api/client_wrapped.py like this:

 1#!/usr/bin/env python3
 2
 3import random
 4import links_and_nodes as ln
 5
 6
 7class area_service_client(ln.services_wrapper):
 8    def __init__(self):
 9        self.clnt = ln.client("area_service_client")
10        ln.services_wrapper.__init__(self, self.clnt, "area_service")
11
12        self.wrap_service("compute_circle_area", "area_service/circle_area")
13        self.wrap_service("compute_triangle_area", "area_service/triangle_area")
14        self.wrap_service("compute_ellipse_area", "area_service/ellipse_area")
15
16
17if __name__ == "__main__":
18    c = area_service_client()
19
20    for n in range(10):
21        r = random.uniform(-5, 20)
22        try:
23            print("computing circle area with radius r = {:.2f}".format(r))
24            Result = c.compute_circle_area(r)
25            # look up result from a call that returns multiple fields in a dict
26            print("circle result: %5.2f" % Result["area"])
27        # responses with a non-empty error message are considered
28        # as errors, and are turned into exceptions
29        except Exception as e:
30            print("Error: ", str(e))
31            print("continuing...")
32
33    for n in range(10):
34        a = random.uniform(-2, 10)
35        b = random.uniform(0, 10)
36        try:
37            Result = c.compute_triangle_area(a, b)
38        except Exception as e:
39            print("Error: ", str(e))
40            print("continuing...")
41
42        print("triangle result: %5.2f" % Result["area"])
43
44    for n in range(10):
45        a = random.uniform(-2, 10)
46        b = random.uniform(0, 10)
47        try:
48            Result = c.compute_ellipse_area(a, b)
49        except Exception as e:
50            print("Error: ", str(e))
51            print("continuing...")
52
53        print("ellipse result: %5.2f" % Result["area"])

Here, the method call services_wrapper.wrap_service() has again two parameters, the first is the name of the service method that is provided (which here is equal to the the function name at the provider side), and the second is the used service message definition.

With this, the call area_service_client.compute_circle_area(), for example, becomes a service call which is forwarded to the service provider and implemented by area_provider.compute_circle_area(), and the result of the call is returned by the wrapped method.

10.7.2.1. Error Responses

The “wrapped” API shown here has one important difference to the “direct” API that is shown in the introductory quickstart section: When a service call returns which has any non-empty string assigned to the field resp.error_message, automatically a Python exception of type ln.ServiceErrorResponse will be created with that field as its only argument [1] . Such exceptions need to be caught by the service client, as shown for example in lines 22 and 29 to 31.

10.7.2.2. Configuring the Response for Error Returns

In the “wrapped” API as of links and nodes up to version 2.0.x, there was no straight-forward way to transfer additional data alongside with an error message if a service call should return an error. This could make it harder to return extra information for more specific error handling.

Beginning from LN 2.1.0, it is also possible to configure the service call to return further response data from the exception object, filter error responses with a post-processing function, or disable the automatic generation of exceptions.

The resulting service client and service provider can be configured to be executed in the LN manager exactly as explained in the Python quickstart tutorial in Calling and handling Services from Python. This can be done in two ways:

  1. By defining extra response fields which indicate an error:

    If we change the initialization of the service handle as follows:

    self.wrap_service("request", "elevator/request/elevator_call",
    throw_on_these_error_indicators=["error_message", "error_code"]
    )
    

    Then any response which has either a truthy field error_message or error_code, which means a non-empty error string, or a non-zero numerical code, will result in an exception being raised. The returned exception will have a “response” member which is a dictionary that contains both "error_message" and "error_code" as strings. So, we could re-write the above sample as follows:

    self.wrap_service("request", "elevator/request/elevator_call",
                      throw_on_these_error_indicators=["error_message", "error_code"]
    )
    
    #  ...
    
    r = random.uniform(-5, 20)
    try:
        print("computing circle area with radius r = {:.2f}".format(r))
        Result = c.compute_circle_area(r)
        # look up result from a call that returns multiple fields in a dict
        print("circle result: %5.2f" % Result["area"])
    # responses with a non-empty error message are considered
    # as errors, and are turned into exceptions
    except Exception as e:
        print("Error, with message = "{}" and code = {} ".format(
                  e.response["error_message"],
                  e.response["errror_code"]
        ))
        print("continuing...")
    
  2. In addition, the service call wrapper can configure a post-processor callback method which can inspect the returned message, like so:

    self.wrap_service("request", "elevator/request/elevator_call",
                      postprocessor=self.check_elevator_call_response
    )
    #  ...
    
    def check_elevator_call_response(self, response):
        if response["error_code"] != 0:
            raise MyElevatorException(...)
        return response # return all fields to caller, or
        # drop all error-indicator-fields, as they should be of no interrest for the caller:
        response.pop("error_code")
        response.pop("error_message")
        return response
    

Further response processing functions can be defined with the preprocessors keyword argument, which specifies a dict with the argument name of each message field as a key, and a pre-processing function as the value, and postprocessors (note the plural), which defines a corresponding dict of fields and functions for each response field.

Note that while the API that is used is different with the goal to reduce boilerplate code, the transmitted service messages ath the level of LN services are exactly the same as with the direct API. This means that “wrapped” and “direct” clients and providers can be intermixed without any restrictions.

Footnotes