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:
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_messageorerror_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...")
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