{"action":"opened","issue":{"url":"https://api.github.com/repos/box/ClusterRunner/issues/380","repository_url":"https://api.github.com/repos/box/ClusterRunner","labels_url":"https://api.github.com/repos/box/ClusterRunner/issues/380/labels{/name}","comments_url":"https://api.github.com/repos/box/ClusterRunner/issues/380/comments","events_url":"https://api.github.com/repos/box/ClusterRunner/issues/380/events","html_url":"https://github.com/box/ClusterRunner/issues/380","id":240790727,"number":380,"title":"POC for handling different versions in API","user":{"login":"nickzuber","id":10540865,"avatar_url":"https://avatars2.githubusercontent.com/u/10540865?v=3","gravatar_id":"","url":"https://api.github.com/users/nickzuber","html_url":"https://github.com/nickzuber","followers_url":"https://api.github.com/users/nickzuber/followers","following_url":"https://api.github.com/users/nickzuber/following{/other_user}","gists_url":"https://api.github.com/users/nickzuber/gists{/gist_id}","starred_url":"https://api.github.com/users/nickzuber/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/nickzuber/subscriptions","organizations_url":"https://api.github.com/users/nickzuber/orgs","repos_url":"https://api.github.com/users/nickzuber/repos","events_url":"https://api.github.com/users/nickzuber/events{/privacy}","received_events_url":"https://api.github.com/users/nickzuber/received_events","type":"User","site_admin":false},"labels":[],"state":"open","locked":false,"assignee":null,"assignees":[],"milestone":null,"comments":0,"created_at":"2017-07-05T22:10:32Z","updated_at":"2017-07-05T22:10:32Z","closed_at":null,"body":"This is a proof of concept for an idea I had for how we want to adjust our current api to allow for different versions. My main goal here is to make adding new http methods that align with different api versions to be easy and clean, and to have it so we need to change how we write http methods as little as necessary.\r\n\r\nDesign\r\n-------\r\n\r\nThe idea is that we have a few decorators that assign the appropriate supported versions to their respective http method. \r\n\r\n### RequestHandler HTTP Methods Decorator\r\n\r\nThere is another decorator `versioned_method` which wraps a `RequestHandler` http method. This decorator takes three arguments: `method`, which describes the http method that the decorator is wrapping, `start` which specifies the earliest version of the API that this method supports (defaults to the first version), and `end` which specifies the last version of the API that this method supports (defaults to the latest version).\r\n\r\nFor each http method wrapper, we set two attributes to the function: a range of versions that this particular method supports that will be used if the api is using one of those versions, and a tag describing which type of http method this particular function is used for (`post`, `get`, `put`, etc.).\r\n\r\n```python\r\ndef versioned_method(method, start=FIRST_API_VERSION, end=LATEST_API_VERSION):\r\n    \"\"\"\r\n    Decorate HTTP method with a range of API versions that this method is supported in.\r\n    :param method: Type of HTTP method that this function is describing.\r\n    :param start: First supported version in API for this method (inclusive)\r\n    :param end: Last supported version in API for this method (inclusive)\r\n    \"\"\"\r\n    def versioning_decorator(func):\r\n        def wrapper(self, *args, **kwargs):\r\n            print('\\tCalling wrapped function from `versioned_method`')\r\n            return func(self, *args, **kwargs)\r\n\r\n        versions = [v for v in API_VERSIONS if v >= start and v <= end]\r\n        setattr(wrapper, SUPPORTED_API_VERSIONS_METHOD_KEY, versions)\r\n        setattr(wrapper, SUPPORTED_API_VERSIONS_HTTP_TYPE_KEY, HTTP_METHODS[method])\r\n        return wrapper\r\n\r\n    return versioning_decorator\r\n```\r\n\r\n### RequestHandler Class Decorator\r\n\r\nThere is a decorator `versioned_handler` which wraps a `RequestHandler`. Its job is to figure out which http method to use based on the api version whenever a request is made. We identify the api version that is requested from the `RequestHander` and also identify all the methods in the `RequestHandler` that are marked as versioned from the `versioned_method` decorator.\r\n\r\nThe nice thing about finding the methods that are decorated is that it gives us the flexibility to name our different versioned http methods whatever we want and just mark them as `get` or `post` etc. Normally, we'd just override the `RequestHandler`'s `get` method for example, but since we have multiple `get` methods depending on the api version, we can't just name them all `get`. So since these methods need to have unique names, it's nice that we can name them whatever we want rather than say having methods called `get_1`, `get_2`, etc and having this wrapper decorator look for those specific names.\r\n\r\n```python\r\ndef versioned_handler(cls):\r\n    \"\"\"\r\n    Wrap a RequestHandler with a class that's able to figure out which `get`, \r\n    `post`, etc methods to use depending on the API version that was requested.\r\n    \"\"\"\r\n    class Wrapper(object):\r\n        def __init__(self, *args, **kwargs):\r\n            self.wrapped = cls(*args, **kwargs)\r\n            self.api_version = self.wrapped.api_version\r\n            self.versioned_methods = inspect.getmembers(self.wrapped, predicate=self._is_versioned_method)\r\n\r\n        def get(self):\r\n            print('Calling `Wrapper.get`')\r\n            print('\\tVersion assigned to RequestHandler: {}'.format(self.api_version))\r\n            self._call_http_method(HTTP_METHODS['GET'], self.wrapped.get)\r\n\r\n        def post(self):\r\n            print('Calling `Wrapper.post`')\r\n            print('\\tVersion assigned to RequestHandler: {}'.format(self.api_version))\r\n            self._call_http_method(HTTP_METHODS['POST'], self.wrapped.post)\r\n\r\n        def put(self):\r\n            print('Calling `Wrapper.put`')\r\n            print('\\tVersion assigned to RequestHandler: {}'.format(self.api_version))\r\n            self._call_http_method(HTTP_METHODS['PUT'], self.wrapped.put)\r\n\r\n        # ... rest of HTTP methods\r\n\r\n        def _call_http_method(self, http_type, default):\r\n            for method in self.versioned_methods:\r\n                method_assigned_versions = getattr(method[1], SUPPORTED_API_VERSIONS_METHOD_KEY)\r\n                method_assigned_http_type = getattr(method[1], SUPPORTED_API_VERSIONS_HTTP_TYPE_KEY)\r\n                if method_assigned_http_type == http_type and self.api_version in method_assigned_versions:\r\n                    return method[1]()\r\n\r\n            # Execute the default HTTP method from the RequestHandler if no \r\n            # HTTP methods specifically are assigned to a particular API version\r\n            return default()\r\n\r\n        @staticmethod\r\n        def _is_versioned_method(obj):\r\n            has_assigned_api_version = SUPPORTED_API_VERSIONS_METHOD_KEY in dir(obj)\r\n            return inspect.ismethod(obj) and has_assigned_api_version\r\n\r\n\r\n    return Wrapper\r\n```\r\n\r\nWhat it looks like\r\n-----------------\r\n\r\nImplementing a `RequestHandler` object wouldn't change too much, it would just require adding some decorators where ever they're needed to remove ambiguity between different versions of http methods.\r\n\r\nThis is a simple example of what things would look like:\r\n\r\n```python\r\nclass BaseObject(object):\r\n    def __init__(self, *args, **kwargs):\r\n        self.api_version = 3\r\n        super().__init__(*args, **kwargs)\r\n\r\n    def get(self):\r\n        print('Calling base `get`')\r\n\r\n    def post(self):\r\n        print('Calling base `post`')\r\n\r\n    def put(self):\r\n        print('Calling base `post`')\r\n\r\n\r\n# ...\r\n\r\n\r\n@versioned_handler\r\nclass _BuildsHandler(BaseObject):\r\n    @versioned_method(\r\n        method=HTTP_METHODS['GET'], \r\n        start=1, \r\n        end=1,\r\n    )\r\n    def get(self):\r\n        print('Calling `_BuildsHandler.get` for VERSION 1 -> 1')\r\n\r\n    @versioned_method(\r\n        method=HTTP_METHODS['GET'], \r\n        start=2,\r\n    )\r\n    def another_get_with_some_arbitrary_name(self):\r\n        print('Calling `_BuildsHandler.another_get_with_some_arbitrary_name` for VERSION 2 -> LATEST')\r\n\r\n    def post(self):\r\n        print('Calling `_BuildsHandler.post` for VERSION FIRST -> LATEST')\r\n\r\nhandler = _BuildsHandler()\r\nhandler.get()\r\nhandler.post()\r\n```\r\n```\r\nCalling `Wrapper.get`\r\n        Version assigned to RequestHandler: 3\r\n        Calling wrapped function from `versioned_method`\r\nCalling `_BuildsHandler.another_get_with_some_arbitrary_name` for VERSION 2 -> LATEST\r\nCalling `Wrapper.post`\r\n        Version assigned to RequestHandler: 3\r\nCalling `_BuildsHandler.post` for VERSION FIRST -> LATEST\r\n```\r\n\r\nAnd if we change `self.api_version = 3` to `self.api_version = 1` we get\r\n\r\n```\r\nCalling `Wrapper.get`\r\n        Version assigned to RequestHandler: 1\r\n        Calling wrapped function from `versioned_method`\r\nCalling `_BuildsHandler.get` for VERSION 1 -> 1\r\nCalling `Wrapper.post`\r\n        Version assigned to RequestHandler: 1\r\nCalling `_BuildsHandler.post` for VERSION FIRST -> LATEST\r\n```\r\n\r\nAnd how things wouldn't change if we don't need to specify api versions:\r\n\r\n```python\r\n@versioned_handler # This decorator is optional since we don't version any http methods\r\nclass _BuildHandler(BaseObject):\r\n    def get(self):\r\n        print('Calling `_BuildsHandler.get`')\r\n\r\nhandler = _BuildHandler()\r\nhandler.get()\r\n```\r\n```\r\nCalling `Wrapper.get`\r\n        Version assigned to RequestHandler: 3\r\nCalling `_BuildsHandler.get`\r\n```\r\n\r\n\r\n---\r\n\r\nThe actual names and specific implementations of the decorators and functions are tentative just for the example and can be adjusted.\r\n"}}
{"actor":{"display_login":"nickzuber"}}