Skip to content

Commit

Permalink
Merge pull request #64 from xsnippet/explicit-default-version
Browse files Browse the repository at this point in the history
Use explicitly set API version when not passed
  • Loading branch information
malor committed Jan 21, 2018
2 parents bf36a5f + bec9e8a commit cd1c24c
Show file tree
Hide file tree
Showing 3 changed files with 61 additions and 131 deletions.
144 changes: 56 additions & 88 deletions tests/test_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,95 +14,63 @@
from xsnippet.api import router


class TestVersionRouter:

@pytest.fixture(scope='function')
async def testapp(self, test_client):
app = web.Application()

class _TestResource1(web.View):
async def get(self):
return web.Response(text='I am the night!')

class _TestResource2(web.View):
async def get(self):
return web.Response(text='I am Batman!')

router_v1 = web.UrlDispatcher()
router_v1.post_init(app)
router_v1.add_route('*', '/test', _TestResource1)

router_v2 = web.UrlDispatcher()
router_v2.post_init(app)
router_v2.add_route('*', '/test', _TestResource2)

# Since aiohttp 1.1, UrlDispatcher has one mandatory attribute -
# application instance, that's used internally only in subapps
# feature. Unfortunately, there's no legal way to pass application
# when router is created or vice versa. Either way we need to
# access internal variable in order to do so.
#
# See https://github.com/KeepSafe/aiohttp/issues/1373 for details.
app._router = router.VersionRouter(
@pytest.fixture(scope='function')
async def testapp(test_client):
class _TestResource1(web.View):
async def get(self):
return web.Response(text='I am the night!')

class _TestResource2(web.View):
async def get(self):
return web.Response(text='I am Batman!')

router_v1 = web.UrlDispatcher()
router_v1.add_route('*', '/test', _TestResource1)

router_v2 = web.UrlDispatcher()
router_v2.add_route('*', '/test', _TestResource2)

app = web.Application(
router=router.VersionRouter(
{
'1': router_v1,
'2': router_v2,
}
},
default='2',
)
return await test_client(app)

async def test_version_1(self, testapp):
resp = await testapp.get('/test', headers={
'Api-Version': '1',
})

assert resp.status == 200
assert await resp.text() == 'I am the night!'

async def test_version_2(self, testapp):
resp = await testapp.get('/test', headers={
'Api-Version': '2',
})

assert resp.status == 200
assert await resp.text() == 'I am Batman!'

async def test_version_is_not_passed(self, testapp):
resp = await testapp.get('/test')

assert resp.status == 200
assert await resp.text() == 'I am Batman!'

async def test_version_is_incorrect(self, testapp):
resp = await testapp.get('/test', headers={
'Api-Version': '42',
})

async with resp:
assert resp.status == 406


class TestGetLatestVersion:

@pytest.mark.parametrize('versions, expected', [
(['1', '2', '3', '4'], '4'),
(['4', '1', '3', '2'], '4'),
(['1.1', '1.4', '1.10', '1.5'], '1.10'),
(['1.1', '2.0', '1.10', '1.5'], '2.0'),
])
def test_general_case(self, versions, expected):
assert router._get_latest_version(versions) == expected

@pytest.mark.parametrize('versions, expected', [
(['1', '1-alpha', '1-beta2', '1-dev13'], '1'),
(['1', '2-alpha', '2-beta2', '2-dev13'], '1'),
])
def test_pre_releases_are_ignored(self, versions, expected):
assert router._get_latest_version(versions) == expected

@pytest.mark.parametrize('versions, expected', [
(['1', '1-alpha', '1-beta2', '1-dev13'], '1'),
(['1', '2-alpha', '2-beta2', '2-dev13'], '2-beta2'),
])
def test_pre_releases_are_counted(self, versions, expected):
assert router._get_latest_version(versions, False) == expected
)
return await test_client(app)


async def test_version_1(testapp):
resp = await testapp.get('/test', headers={
'Api-Version': '1',
})

assert resp.status == 200
assert await resp.text() == 'I am the night!'


async def test_version_2(testapp):
resp = await testapp.get('/test', headers={
'Api-Version': '2',
})

assert resp.status == 200
assert await resp.text() == 'I am Batman!'


async def test_version_is_not_passed(testapp):
resp = await testapp.get('/test')

assert resp.status == 200
assert await resp.text() == 'I am Batman!'


async def test_version_is_incorrect(testapp):
resp = await testapp.get('/test', headers={
'Api-Version': '42',
})

async with resp:
assert resp.status == 406
2 changes: 1 addition & 1 deletion xsnippet/api/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ def create_app(conf):
middlewares=[
middlewares.auth.auth(conf['auth']),
],
router=router.VersionRouter({'1.0': v1}))
router=router.VersionRouter({'1.0': v1}, default='1.0'))
app.on_startup.append(middlewares.auth.setup)

# Attach settings to the application instance in order to make them
Expand Down
46 changes: 4 additions & 42 deletions xsnippet/api/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,48 +10,9 @@
:license: MIT, see LICENSE for details
"""

import pkg_resources

from aiohttp import abc, web, web_urldispatcher


def _get_latest_version(versions, stable=True):
"""Search ``versions`` array and return the latest one.
The function requires a contract that an input array indeed contains at
least one stable version in case of ``stable=True`` or at least one
version otherwise. Examples::
>>> get_latest_version(['1.0', '2.0', '2.1-alpha'])
'2.0'
>>> get_latest_version(['1.0', '2.0', '2.1-alpha'], stable=False)
'2.1-alpha'
:param versions: an array of string-based versions
:param stable: search only among stable versions if ``True``
:return: the latest version as string
"""
# Unfortunately, `packaging.version.parse` looses original version
# representation in favor of PEP-440. Despite the fact I doubt
# anyone would use complex API versions (with alpha/beta notation),
# it still better to implement things correctly and return original
# representation. That's why we save version pairs here.
versions = ((v, pkg_resources.parse_version(v)) for v in versions)

# Both PEP-440 and SemVer define so called "pre-release" version such
# as alpha or beta. Most of the time, we aren't interested in them
# since they shouldn't be considered as stable, and hence as default
# one. So we need to filter them out from list and don't take into
# account in sorting below.
if stable:
versions = filter(lambda item: not item[1].is_prerelease, versions)

# Sort using PEP's rules and return an original version value.
versions = sorted(versions, key=lambda item: item[1])
return versions[-1][0]


class VersionRouter(abc.AbstractRouter):
"""A proxy router to forward requests based on passed API version.
Expand All @@ -64,17 +25,18 @@ class VersionRouter(abc.AbstractRouter):
is passed, ``412 Precondition Failed`` response is returned.
:param routers: a 'API version' -> 'router' map
:param default: an API version to be used if 'Api-Version' was omitted
"""

def __init__(self, routers):
def __init__(self, routers, *, default):
self._routers = routers
self._latest = _get_latest_version(self._routers.keys())
self._default = default

async def resolve(self, request):
version = request.headers.get('Api-Version')

if version is None:
version = self._latest
version = self._default

if version not in self._routers:
return web_urldispatcher.MatchInfoError(web.HTTPNotAcceptable())
Expand Down

0 comments on commit cd1c24c

Please sign in to comment.