From 872159fbd1157d1ffc4c3adf2490ec5a7bc087f3 Mon Sep 17 00:00:00 2001 From: Jerome Kelleher Date: Tue, 17 Mar 2015 10:10:43 +0000 Subject: [PATCH] Initial outline of protocol error testing. - Adds a simple script to decode error codes. - Adds tests for bad page sizes, and adds a configuration key for default page size. - Improves request validation error messages --- convert_error_code.py | 33 +++++++++ ga4gh/backend.py | 17 ++++- ga4gh/exceptions.py | 46 ++++++++----- ga4gh/frontend.py | 1 + ga4gh/serverconfig.py | 1 + tests/unit/test_protocol_errors.py | 104 +++++++++++++++++++++++++++++ 6 files changed, 185 insertions(+), 17 deletions(-) create mode 100644 convert_error_code.py create mode 100644 tests/unit/test_protocol_errors.py diff --git a/convert_error_code.py b/convert_error_code.py new file mode 100644 index 000000000..2c422e9e4 --- /dev/null +++ b/convert_error_code.py @@ -0,0 +1,33 @@ +""" +Simple script to decode exception error codes. This translates +the error code received by clients into an exception class. +""" +from __future__ import division +from __future__ import print_function +from __future__ import unicode_literals + +import argparse + +import ga4gh.exceptions as exceptions + + +def parseArgs(): + parser = argparse.ArgumentParser( + description=( + "Converts an error code received by a clients to the " + "corresponding exception class.")) + parser.add_argument( + "errorCode", type=int, + help="The errorCode value in a GAException object.") + args = parser.parse_args() + return args + + +def main(): + args = parseArgs() + exceptionClass = exceptions.getExceptionClass(args.errorCode) + print(args.errorCode, exceptionClass, sep="\t") + + +if __name__ == '__main__': + main() diff --git a/ga4gh/backend.py b/ga4gh/backend.py index ef4e1865c..9fbce68f9 100644 --- a/ga4gh/backend.py +++ b/ga4gh/backend.py @@ -33,6 +33,7 @@ def __init__(self): self._callSetIds = [] self._requestValidation = False self._responseValidation = False + self._defaultPageSize = 100 def getVariantSets(self): """ @@ -85,6 +86,10 @@ def runSearchRequest( raise exceptions.InvalidJsonException(requestStr) self.validateRequest(requestDict, requestClass) request = requestClass.fromJsonDict(requestDict) + if request.pageSize is None: + request.pageSize = self._defaultPageSize + if request.pageSize <= 0: + raise exceptions.BadPageSizeException(request.pageSize) pageList = [] nextPageToken = None for obj, nextPageToken in objectGenerator(request): @@ -279,7 +284,8 @@ def validateRequest(self, jsonDict, requestClass): """ if self._requestValidation: if not requestClass.validate(jsonDict): - raise exceptions.RequestValidationFailureException() + raise exceptions.RequestValidationFailureException( + jsonDict, requestClass) def validateResponse(self, jsonDict, responseClass): """ @@ -288,7 +294,8 @@ def validateResponse(self, jsonDict, responseClass): """ if self._responseValidation: if not responseClass.validate(jsonDict): - raise exceptions.ResponseValidationFailureException() + raise exceptions.ResponseValidationFailureException( + jsonDict, responseClass) def setRequestValidation(self, requestValidation): """ @@ -302,6 +309,12 @@ def setResponseValidation(self, responseValidation): """ self._responseValidation = responseValidation + def setDefaultPageSize(self, defaultPageSize): + """ + Sets the default page size for request to the specified value. + """ + self._defaultPageSize = defaultPageSize + class EmptyBackend(AbstractBackend): """ diff --git a/ga4gh/exceptions.py b/ga4gh/exceptions.py index a1f7c92e1..c6d4be27e 100644 --- a/ga4gh/exceptions.py +++ b/ga4gh/exceptions.py @@ -6,11 +6,25 @@ from __future__ import print_function from __future__ import unicode_literals +import sys import zlib +import inspect import ga4gh.protocol as protocol +def getExceptionClass(errorCode): + """ + Converts the specified error code into the corresponding class object. + Raises a KeyError if the errorCode is not found. + """ + classMap = {} + for name, class_ in inspect.getmembers(sys.modules[__name__]): + if inspect.isclass(class_) and issubclass(class_, Exception): + classMap[class_.getErrorCode()] = class_ + return classMap[errorCode] + + def getServerError(exception): """ Converts the specified exception that is not a subclass of @@ -85,7 +99,8 @@ class BadRequestException(BaseServerException): class BadPageSizeException(BadRequestException): - message = "Request page size invalid" + def __init__(self, pageSize): + self.message = "Request page size '{}' is invalid".format(pageSize) class BadPageTokenException(BadRequestException): @@ -97,6 +112,16 @@ def __init__(self, jsonString): self.message = "Cannot parse JSON: '{}'".format(jsonString) +class RequestValidationFailureException(BadRequestException): + """ + A validation of the request data failed + """ + def __init__(self, jsonDict, requestClass): + self.message = ( + "Request '{}' is not a valid instance of {}".format( + jsonDict, requestClass)) + + class NotFoundException(BaseServerException): """ The superclass of all exceptions in which some resource was not @@ -138,17 +163,6 @@ def __init__(self, message): self.message = message -class RequestValidationFailureException(BaseServerException): - """ - A validation of the request data failed - """ - def getMessage(self): - message = ( - "Malformed request: JSON does not conform to the GA4GH" - "protocol version {}".format(protocol.version)) - return message - - class CallSetNotInVariantSetException(NotFoundException): """ Indicates a request was made for a callSet not in the actual variantSet @@ -180,6 +194,8 @@ class ResponseValidationFailureException(ServerError): """ A validation of the response data failed """ - message = ( - "Validation of the generated response failed. " - "Please file a bug report") + def __init__(self, jsonDict, requestClass): + self.message = ( + "Response '{}' is not a valid instance of {}. " + "Please file a bug report.".format( + jsonDict, requestClass)) diff --git a/ga4gh/frontend.py b/ga4gh/frontend.py index 8b306db85..1cc50fac2 100644 --- a/ga4gh/frontend.py +++ b/ga4gh/frontend.py @@ -172,6 +172,7 @@ def configure(configFile=None, baseConfig="ProductionConfig"): theBackend = backend.FileSystemBackend(dataSource) theBackend.setRequestValidation(app.config["REQUEST_VALIDATION"]) theBackend.setResponseValidation(app.config["RESPONSE_VALIDATION"]) + theBackend.setDefaultPageSize(app.config["DEFAULT_PAGE_SIZE"]) app.backend = theBackend diff --git a/ga4gh/serverconfig.py b/ga4gh/serverconfig.py index eb89c8b74..933f628c2 100644 --- a/ga4gh/serverconfig.py +++ b/ga4gh/serverconfig.py @@ -16,6 +16,7 @@ class BaseConfig(object): MAX_CONTENT_LENGTH = 2 * 1024 * 1024 # 2MB REQUEST_VALIDATION = False RESPONSE_VALIDATION = False + DEFAULT_PAGE_SIZE = 100 DATA_SOURCE = "__EMPTY__" # Options for the simulated backend. diff --git a/tests/unit/test_protocol_errors.py b/tests/unit/test_protocol_errors.py new file mode 100644 index 000000000..fed4a2ade --- /dev/null +++ b/tests/unit/test_protocol_errors.py @@ -0,0 +1,104 @@ +""" +Unit tests for frontend error conditions. +""" +from __future__ import division +from __future__ import print_function +from __future__ import unicode_literals + +import unittest + +import ga4gh.frontend as frontend +import ga4gh.exceptions as exceptions +import ga4gh.protocol as protocol +import tests.utils as utils + +_app = None + + +def setUp(): + """ + Set up the test Flask app. + """ + global _app + frontend.configure(baseConfig="TestConfig") + _app = frontend.app.test_client() + + +def tearDown(): + global _app + _app = None + + +class TestFrontendErrors(unittest.TestCase): + """ + Tests the frontend for various errors that can occur and verify + that the correct exception was raised by the error code sent + back. + """ + def setUp(self): + self.app = _app + # TODO replace this with ALL post methods once the rest of the + # end points have been implemented. This should also add an API + # to protocol.py to simplify and document the process of getting + # the correct API endpoints and classes. That is, we shouldn't + # use protocol.postMethods directly, but instead call a function. + supportedMethods = set([ + protocol.GASearchCallSetsRequest, + protocol.GASearchVariantSetsRequest, + protocol.GASearchVariantsRequest, + ]) + self.endPointMap = {} + for endPoint, requestClass, responseClass in protocol.postMethods: + if requestClass in supportedMethods: + path = utils.applyVersion(endPoint) + self.endPointMap[path] = requestClass + + def _createInstance(self, requestClass): + """ + Returns a valid instance of the specified class. + """ + return utils.InstanceGenerator().generateInstance(requestClass) + + def assertRawRequestRaises(self, exceptionClass, url, requestString): + """ + Verifies that the specified request string returns a protocol + exception corresponding to the specified class when applied to + all POST endpoints. + """ + response = self.app.post( + url, headers={'Content-type': 'application/json'}, + data=requestString) + self.assertEqual(response.status_code, exceptionClass.httpStatus) + error = protocol.GAException.fromJsonString(response.data) + self.assertEqual( + error.errorCode, exceptionClass.getErrorCode()) + self.assertGreater(len(error.message), 0) + + def assertRequestRaises(self, exceptionClass, url, request): + """ + Verifies that the specified request returns a protocol exception + corresponding to the specified exception class. + """ + self.assertRawRequestRaises( + exceptionClass, url, request.toJsonString()) + + def testPageSize(self): + for url, requestClass in self.endPointMap.items(): + for badType in ["", "1", "None", 0.0, 1e3]: + request = self._createInstance(requestClass) + request.pageSize = badType + self.assertRequestRaises( + exceptions.RequestValidationFailureException, url, request) + for badSize in [-100, -1, 0]: + request = self._createInstance(requestClass) + request.pageSize = badSize + self.assertRequestRaises( + exceptions.BadPageSizeException, url, request) + + def testPageToken(self): + for url, requestClass in self.endPointMap.items(): + for badType in [0, 0.0, 1e-3, {}, [], [None]]: + request = self._createInstance(requestClass) + request.pageToken = badType + self.assertRequestRaises( + exceptions.RequestValidationFailureException, url, request)