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:
Design REST endpoints as a separate layer over business logic,
Implement Tornado HTTP server and service endpoint handlers,
Use Tornado hooks to control behavior and assist debugging, and
Write unit and integration tests using Tornado testing infra.
Application REST Endpoints
As an example, we will build a CRUD microservice for an address-book using Tornado:
Create an address: POST /addresses
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.
Read an address: GET /addresses/{id}
Returns 404 if the id doesn’t exist, else returns 200. The response body contains the address in JSON format.
Update an address: PUT /addresses/{id}
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 204 upon deleting the address, 404 is id doesn’t exist.
List all addresses: GET /addresses
Returns 200, and the response body with all addresses in the address book.
In case of an error (i.e. when the return status code is 4xx or 5xx), the response body has JSON describing the error.
# addrservice/service.pyfromtypingimportDictimportuuidclassAddressBookService:def__init__(self,config:Dict)->None:self.addrs:Dict[str,Dict]={}defstart(self):self.addrs={}defstop(self):passasyncdefcreate_address(self,value:Dict)->str:key=uuid.uuid4().hexself.addrs[key]=valuereturnkeyasyncdefget_address(self,key:str)->Dict:returnself.addrs[key]asyncdefupdate_address(self,key:str,value:Dict)->None:self.addrs[key]# will cause exception if key doesn't existself.addrs[key]=valueasyncdefdelete_address(self,key:str)->None:self.addrs[key]# will cause exception if key doesn't existdelself.addrs[key]asyncdefget_all_addresses(self)->Dict[str,Dict]:returnself.addrs
In the AddressBookService class uses an in-memory dictionary to store the addresses. In reality, it will a lot more complicated, and using some databases. Nonetheless, it is functioning. It is enough for implementing and testing the Web Framework layer.
Both of these inherit from BaseRequestHandler that has common functionalities. For example, Tornado returns HTTP response by default, but the address-book service must return JSON.
# addrservice/tornado/app.pyclassBaseRequestHandler(tornado.web.RequestHandler):definitialize(self,service:AddressBookService,config:Dict)->None:self.service=serviceself.config=configdefwrite_error(self,status_code:int,**kwargs:Any)->None:self.set_header('Content-Type','application/json; charset=UTF-8')body={'method':self.request.method,'uri':self.request.path,'code':status_code,'message':self._reason}ifself.settings.get("serve_traceback") \
and"exc_info"inkwargs:# in debug mode, send a tracebacktrace='\n'.join(traceback.format_exception(*kwargs['exc_info']))body['trace']=traceself.finish(body)
The BaseRequestHandler utilizes the following Tornado hooks:
write_error method: to send a JSON error message instead of HTTP,
serve_traceback setting: to send exception traceback in debug mode,
initialize method: to get the needed objects (like the underlying AddressBookService that has the business logic).
You will see how initialize and serve_traceback are tied to the handlers in the next section.
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.
All request handlers need to be tied into a tornado.web.Application. That requires the following:
RegEx-handler mapping: A list of a tuple (regex, handler class, parameters to handler’s initialize method) that is how the service object is passed to all handlers,
The make_addrservice_app function creates an AddressBookService object, uses it to make tornado.web.Application, and then returns both the service and the app.
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 the following steps:
# addrservice/tornado/server.pydefrun_server(app:tornado.web.Application,service:AddressBookService,config:Dict,port:int,debug:bool,):name=config['service']['name']loop=asyncio.get_event_loop()service.start()# Start AddressBook service (business logic)# Bind http server to porthttp_server_args={'decompress_request':True}http_server=app.listen(port,'',**http_server_args)try:loop.run_forever()# Start asyncio IO event loopexceptKeyboardInterrupt:# signal.SIGINTpassfinally:loop.stop()# Stop event loophttp_server.stop()# stop accepting new http reqsloop.run_until_complete(# Complete all pending coroutinesloop.shutdown_asyncgens())service.stop()# stop serviceloop.close()# close the loopdefmain(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,)
The proof of the pudding
Let’s run the server and try some requests.
Run the server
1
2
3
$ python3 addrservice/tornado/server.py --port 8080 --config ./configs/addressbook-local.yaml --debug
Starting Address Book on port 8080 ...
Manual testing is tedious and error-prone. Tornado provides testing infrastructure. It starts the HTTP server and runs the tests. It does necessary plumbing to route the HTTP requests to the server it started.
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. Code duplication can be kept at a minimum by reusing make_addrservice_app function in get_app.
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.
For address book service, except the default handler, all handlers use the service (business logic) module. That module has only simple stubs in this blog post, but in reality, it will be way more complex. So only the default handler is independent and qualifies for the unit tests. All other handlers should be covered in the integration tests (next section).
The whole lifecycle of an address entry tested manually earlier can be automated as integration tests. It will be a lot easier and faster to run all those tests in seconds every time you make a code change.
# tests/integration/tornado_app_addreservice_handlers_test.pyADDRESSBOOK_ENTRY_URI_FORMAT_STR=r'/addresses/{id}'classTestAddressServiceApp(AddressServiceTornadoAppTestSetup):deftest_address_book_endpoints(self):# Get all addresses in the address book, must be ZEROr=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 addressr=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']# POST: error casesr=self.fetch(ADDRESSBOOK_ENTRY_URI_FORMAT_STR.format(id=''),method='POST',headers=self.headers,body='it is not json',)self.assertEqual(r.code,400)self.assertEqual(r.reason,'Invalid JSON body')# Get the added addressr=self.fetch(addr_uri,method='GET',headers=None,)self.assertEqual(r.code,200)self.assertEqual(self.addr0,json.loads(r.body.decode('utf-8')))# GET: error casesr=self.fetch(ADDRESSBOOK_ENTRY_URI_FORMAT_STR.format(id='no-such-id'),method='GET',headers=None,)self.assertEqual(r.code,404)# Update that addressr=self.fetch(addr_uri,method='PUT',headers=self.headers,body=json.dumps(self.addr1),)self.assertEqual(r.code,204)r=self.fetch(addr_uri,method='GET',headers=None,)self.assertEqual(r.code,200)self.assertEqual(self.addr1,json.loads(r.body.decode('utf-8')))# PUT: error casesr=self.fetch(addr_uri,method='PUT',headers=self.headers,body='it is not json',)self.assertEqual(r.code,400)self.assertEqual(r.reason,'Invalid JSON body')r=self.fetch(ADDRESSBOOK_ENTRY_URI_FORMAT_STR.format(id='1234'),method='PUT',headers=self.headers,body=json.dumps(self.addr1),)self.assertEqual(r.code,404)# Delete that addressr=self.fetch(addr_uri,method='DELETE',headers=None,)self.assertEqual(r.code,204)r=self.fetch(addr_uri,method='GET',headers=None,)self.assertEqual(r.code,404)# DELETE: error casesr=self.fetch(addr_uri,method='DELETE',headers=None,)self.assertEqual(r.code,404)# Get all addresses in the address book, must be ZEROr=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)
Code Coverage
Let’s run these tests:
1
2
3
4
5
6
7
8
# All tests$ ./run.py test# Only unit tests$ ./run.py test --suite unit
# Only integration tests$ ./run.py test --suite integration
Let’s check code coverage:
1
2
3
4
5
6
7
8
9
10
11
12
$ coverage run --source=addrservice \
--omit="addrservice/tornado/server.py"\
--branch ./run.py test$ coverage report
Name Stmts Miss Branch BrPart Cover
-------------------------------------------------------------------
addrservice/__init__.py 2000 100%
addrservice/service.py 23100 96%
addrservice/tornado/__init__.py 0000 100%
addrservice/tornado/app.py 83483 92%
-------------------------------------------------------------------
TOTAL 108583 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.
1
2
3
4
5
6
7
8
9
10
11
$ coverage run --source=addrservice --branch ./run.py test$ coverage report
Name Stmts Miss Branch BrPart Cover
-------------------------------------------------------------------
addrservice/__init__.py 2000 100%
addrservice/service.py 23100 96%
addrservice/tornado/__init__.py 0000 100%
addrservice/tornado/app.py 83483 92%
addrservice/tornado/server.py 414120 0%
-------------------------------------------------------------------
TOTAL 14946103 68%
Summary
In this article, you learned about how to put together a microservice and tests using Tornado:
Layered design: Isolate endpoint code in the 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.