Python Microservices - Tornado REST and Unit Tests - Slanglabs
Python Microservices - Tornado REST and Unit Tests - Slanglabs
Python Microservices - Tornado REST and Unit Tests - Slanglabs
At Slang Labs, we are building a platform for programmers to easily and quickly add
multilingual, multimodal Voice Augmented eXperiences (VAX) to their mobile and web
apps. Think of an assistant like Alexa or Siri, but running inside your app and tailored for
your app.
The platform is powered by a collection of microservices. For implementing these
services, we chose Tornado because it has AsyncIO APIs. It is not heavyweight. Yet, it is
mature and has a number of configurations, hooks, and a nice testing framework.
This blog post covers some of the best practices we learned while building these
services; how to:
. . .
Returns HTTP status 201 upon adding successfully, and 400 if request body payload
is malformed. The request body should have the new address entry in JSON format.
The id the newly created address is sent back in the Location attribute of the
header of the HTTP response.
Returns 404 if the id doesn’t exist, else returns 200. The response body contains the
address in JSON format.
Returns 204 upon updating successfully, 404 if the request body is malformed, and
404 if the id doesn’t exist. The request body should have the new value of the
address.
Delete an address: DELETE /addresses/{id}
Returns 200, and the response body with all addresses in the address book.
In case of an error (i.e. when return status code is 4xx or 5xx), the response body has
JSON describing the error.
You may want to refer to the list of HTTP status codes, and best practices for REST API
design.
By the end of this blog post, you will know how to implement and test these endpoints.
. . .
$ cd tutorial-python-microservice-tornado
$ git checkout -b <branch> tag-02-microservice
$ tree .
.
├── LICENSE
├── README.md
├── addrservice
│ ├── service.py
│ └── tornado
│ ├── app.py
│ └── server.py
├── configs
│ └── addressbook-local.yaml
├── data
│ └── addresses
│ ├── namo.json
│ └── raga.json
├── requirements.txt
├── run.py
└
└── tests
├── integration
│ └── tornado_app_addreservice_handlers_test.py
└── unit
└── tornado_app_handlers_test.py
The service endpoints and tests are implemented in the highlighted files in the listing
above.
Setup a virtual environment, and install the dependencies from requirements.txt . Run
$ ./run.py test
. . .
Layered Design
The address service will be implemented in two layers:
Service Layer contains all the business logic and knows nothing about REST and
HTTP.
Web Framework Layer contains REST service endpoints over HTTP protocol and
knows nothing about business logic.
Service and Web Framework (Tornado) layers.
The Service Layer exposes the function APIs for various CRUD operations to be used by
the Web Framework layer.
Since the focus of this article is on the Web Framework layer, the Service layer is
implemented as simple stubs.
# addrservice/service.py
class AddressBookService:
def __init__(self, config: Dict) -> None:
self.addrs: Dict[str, Dict] = {}
def start(self):
self.addrs = {}
def stop(self):
pass
. . .
HTTP Server that listens on a given port and routes requests to the application.
Request Handlers
A request handler is needed for every endpoint regex. For address-book service, there
are two handlers needed:
# addrservice/tornado/app.py
class AddressBookRequestHandler(BaseRequestHandler):
async def get(self):
all_addrs = await self.service.get_all_addresses()
self.set_status(200)
self.finish(all_addrs)
class AddressBookEntryRequestHandler(BaseRequestHandler):
async def get(self, id):
try:
addr = await self.service.get_address(id)
self.set_status(200)
self.finish(addr)
except KeyError as e:
raise tornado.web.HTTPError(404, reason=str(e))
# addrservice/tornado/app.py
class BaseRequestHandler(tornado.web.RequestHandler):
def initialize(
self,
service: AddressBookService,
config: Dict
) -> None:
self.service = service
self.config = config
if self.settings.get("serve_traceback") \
and "exc_info" in kwargs:
self.finish(body)
These handlers define a set of valid endpoint URLs. A default handler can be defined to
handle all invalid URLs. The prepare method is called for all HTTP methods.
# addrservice/tornado/app.py
class DefaultRequestHandler(BaseRequestHandler):
def initialize(self, status_code, message):
self.set_status(status_code, reason=message)
# addrservice/tornado/app.py
ADDRESSBOOK_REGEX = r'/addresses/?'
ADDRESSBOOK_ENTRY_REGEX = r'/addresses/(?P<id>[a-zA-Z0-9-]+)/?'
def make_addrservice_app(
config: Dict,
debug: bool
) -> Tuple[AddressBookService, tornado.web.Application]:
service = AddressBookService(config)
app = tornado.web.Application(
[
(ADDRESSBOOK_REGEX,
AddressBookRequestHandler,
dict(service=service, config=config)),
(ADDRESSBOOK_ENTRY_REGEX,
AddressBookEntryRequestHandler,
dict(service=service, config=config))
],
compress_response=True,
serve_traceback=debug,
default_handler_class=DefaultRequestHandler,
default_handler_args={
'status_code': 404,
'message': 'Unknown Endpoint'
}
)
In the debug mode, serve_traceback is set True . When an exception happens, the error
returned to the client also has the exception string. We have found this very useful in
debugging. Without requiring to scan through server logs and to attach a debugger to
the server, the exception string at the client offers good pointers to the cause.
HTTP Server
The application (that has routes to various request handlers) is started as an HTTP
server with following steps:
When the server is stopped, the server is stopped and all pending requests are
completed:
# addrservice/tornado/server.py
def run_server(
app: tornado.web.Application,
service: AddressBookService,
config: Dict,
port: int,
debug: bool,
):
name = config['service']['name']
loop = asyncio.get_event_loop()
try:
loop.run_forever() # Start asyncio IO event loop
except KeyboardInterrupt:
# signal.SIGINT
pass
finally:
loop.stop() # Stop event loop
http_server.stop() # stop accepting new http reqs
loop.run_until_complete( # Complete all pending coroutines
loop.shutdown_asyncgens()
)
service.stop() # stop service
loop.close() # close the loop
def main(args=parse_args()):
config = yaml.load(args.config.read(), Loader=yaml.SafeLoader)
addr_service, addr_app = make_addrservice_app(
config, args.debug
)
run_server(
app=addr_app,
service=addr_service,
config=config,
port=args.port,
debug=args.debug,
)
. . .
$ curl -i http://localhost:8080/xyz
HTTP/1.1 404 Unknown Endpoint
Server: TornadoServer/6.0.3
Content-Type: application/json; charset=UTF-8
Date: Tue, 10 Mar 2020 14:31:27 GMT
Content-Length: 518
Vary: Accept-Encoding
$ curl -i -X GET
http://localhost:8080/addresses/66fdbb78e79846849608b2cfe244a858
HTTP/1.1 200 OK
Server: TornadoServer/6.0.3
Content-Type: application/json; charset=UTF-8
Date: Tue, 10 Mar 2020 14:44:26 GMT
Etag: "5496aee01a83cf2386641b2c43540fc5919d621e"
Content-Length: 22
Vary: Accept-Encoding
$ curl -i -X PUT
http://localhost:8080/addresses/66fdbb78e79846849608b2cfe244a858 -d
'{"full_name": "William Henry Gates III"}'
HTTP/1.1 200 OK
Server: TornadoServer/6.0.3
Content-Type: application/json; charset=UTF-8
Date: Tue, 10 Mar 2020 14:49:10 GMT
Etag: "5601e676f3fa4447feaa8d2dd960be163af7570a"
Content-Length: 73
Vary: Accept-Encoding
$ curl -i -X DELETE
http://localhost:8080/addresses/66fdbb78e79846849608b2cfe244a858
HTTP/1.1 200 OK
Server: TornadoServer/6.0.3
Content-Type: application/json; charset=UTF-8
Date: Tue, 10 Mar 2020 14:52:01 GMT
Etag: "bf21a9e8fbc5a3846fb05b4fa0859e0917b2202f"
Content-Length: 2
Vary: Accept-Encoding
{}
$ curl -i -X GET
http://localhost:8080/addresses/66fdbb78e79846849608b2cfe244a858
. . .
Test classes should inherit from AsyncHTTPTestCase , and implement a get_app method,
which returns the tornado.web.Application . It is similar to what is done in server.py .
Tornado creates a new IOLoop for each test. When it is not appropriate to use a new
loop, you should override get_new_ioloop method.
# tests/unit/tornado_app_handlers_test.py
class AddressServiceTornadoAppTestSetup(
tornado.testing.AsyncHTTPTestCase
):
def get_app(self) -> tornado.web.Application:
addr_service, app = make_addrservice_app(
config=TEST_CONFIG,
debug=True
)
addr_service.start()
atexit.register(lambda: addr_service.stop())
return app
def get_new_ioloop(self):
return IOLoop.current()
# tests/unit/tornado_app_handlers_test.py
class AddressServiceTornadoAppUnitTests(
AddressServiceTornadoAppTestSetup
):
def test_default_handler(self):
r = self.fetch(
'/does-not-exist',
method='GET',
headers=None,
)
info = json.loads(r.body.decode('utf-8'))
# tests/integration/tornado_app_addreservice_handlers_test.py
ADDRESSBOOK_ENTRY_URI_FORMAT_STR = r'/addresses/{id}'
class TestAddressServiceApp(AddressServiceTornadoAppTestSetup):
def test_address_book_endpoints(self):
# Get all addresses in the address book, must be ZERO
r = self.fetch(
ADDRESSBOOK_ENTRY_URI_FORMAT_STR.format(id=''),
method='GET',
headers=None,
)
all_addrs = json.loads(r.body.decode('utf-8'))
self.assertEqual(r.code, 200, all_addrs)
self.assertEqual(len(all_addrs), 0, all_addrs)
# Add an address
r = self.fetch(
ADDRESSBOOK_ENTRY_URI_FORMAT_STR.format(id=''),
method='POST',
headers=self.headers,
body=json.dumps(self.addr0),
)
self.assertEqual(r.code, 201)
addr_uri = r.headers['Location']
Code Coverage
Let’s run these tests:
# All tests
$ ./run.py test
$ coverage report
Name Stmts Miss Branch BrPart Cover
-------------------------------------------------------------------
addrservice/__init__.py 2 0 0 0 100%
addrservice/service.py 23 1 0 0 96%
addrservice/tornado/__init__.py 0 0 0 0 100%
addrservice/tornado/app.py 83 4 8 3 92%
-------------------------------------------------------------------
TOTAL 108 5 8 3 93%
As you can see, it is pretty good coverage.
Notice that addrservice/tornado/server.py was omitted from code coverage. It has the
code that runs the HTTP server, but Tornado test infra has its own mechanism of
running the HTTP server. This is the only file that can not be covered by unit and
integration tests. Including it will skew the overall coverage metrics.
$ coverage report
Name Stmts Miss Branch BrPart Cover
-------------------------------------------------------------------
addrservice/__init__.py 2 0 0 0 100%
addrservice/service.py 23 1 0 0 96%
addrservice/tornado/__init__.py 0 0 0 0 100%
addrservice/tornado/app.py 83 4 8 3 92%
addrservice/tornado/server.py 41 41 2 0 0%
-------------------------------------------------------------------
TOTAL 149 46 10 3 68%
. . .
Summary
In this article, you learned about how to put together a microservice and tests using
Tornado:
Layered design: Isolate endpoint code in Web Framework Layer, and implement
business logic in Service Layer.
Tornado: Implement the web framework layer with Tornado request handlers, app
endpoint routing, and HTTP server.
Tests: Write unit and integration tests for the web framework layer using Tornado
testing infrastructure.
Tooling: Use lint, test, code coverage for measuring the health of the code. Integrate
early, write stub code if necessary to make it run end-to-end.
. . .