Skip to content
This repository has been archived by the owner on Oct 14, 2021. It is now read-only.

Commit

Permalink
added support for HATEOAS
Browse files Browse the repository at this point in the history
  • Loading branch information
amitnabarro committed Oct 24, 2017
1 parent 77ce05e commit 454bfd9
Show file tree
Hide file tree
Showing 6 changed files with 104 additions and 16 deletions.
25 changes: 25 additions & 0 deletions docs/source/resources.rst
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,31 @@ TBone provides an authentication mechanism which is wired into the resource's fl
By default, all resources are associated with a ``NoAuthentication`` class, which does not check for any authentication whatsoever. Developers need to subclass ``NoAuthentication`` to add their own authentication mechanism. Authentication classes implement a single method ``is_authenticated`` which has the request object passed. Normally, developers would use the request headers to check for authentication and return ``True`` or ``False`` based on the content of the request.


HATEOAS
-------
HATEOAS (Hypermedia as the Engine of Application State) is part of the REST specification.
TBone supports basic HATEOAS directives and allows for extending this support in resource subclasses.
By default, all TBone resources include a ``_links`` key in their serialized form, which contains a unique ``href`` to the resource itself, like so::

{
"first_name': 'Ron",
"last_name': 'Burgundy",
"_links" : {
"self" : {
"href" : "/api/person/1/"
}
}
}

Disabling HATEOAS support is done per resource, by setting the ``hypermedia`` flag in the ``ResourceOptions`` class to ``False``, like so::

class NoHypermediaPersonResource(Resource):
class Meta:
hypermedia = False
...

Adding additional links to the resource is done by overriding ``add_hypermedia`` on the resource subclass.



Nested Resources
Expand Down
2 changes: 1 addition & 1 deletion examples/chatrooms/backend/resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ class Meta:
object_class = Entry
authentication = UserAuthentication()
sort = [('_id', -1)] # revert order by creation, we want to display by order of entry decending
add_resource_uri = False
hypermedia = False

async def create(self, **kwargs):
''' Override the create method to add the user's id to the request data '''
Expand Down
4 changes: 2 additions & 2 deletions tbone/resources/mongo.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,8 @@ async def post_save(cls, sender, db, instance, created):
async def _emit(event_name):
resource = cls()
obj = await instance.serialize()
if cls._meta.add_resource_uri is True:
obj['resource_uri'] = '{}{}/'.format(resource.get_resource_uri(), instance.pk)
if cls._meta.hypermedia is True:
self.add_hypermedia(obj)
await resource.emit(db, event_name, obj)

if created is True and 'created' in cls._meta.outgoing_detail:
Expand Down
27 changes: 19 additions & 8 deletions tbone/resources/resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@ class ResourceOptions(object):
:param sort:
Define a sort directive which the resource will apply to GET requests without a unique identifier. Used in ``MongoResource`` to declare default sorting for collection.
:param add_resource_uri:
Specify if the a ``Resource`` should format data and include the unique uri of the resource.
:param hypermedia:
Specify if the a ``Resource`` should format data and include HATEOAS directives, specifically link to itself.
Defaults to ``True``
:param fts_operator:
Expand Down Expand Up @@ -75,7 +75,7 @@ class ResourceOptions(object):
object_class = None
query = None
sort = None
add_resource_uri = True
hypermedia = True
channel_class = Channel
fts_operator = 'q'
incoming_list = ['get', 'post', 'put', 'patch', 'delete']
Expand Down Expand Up @@ -348,6 +348,17 @@ def parse_detail(self, body):
return self._meta.formatter.parse(body)
return {}

def add_hypermedia(self, obj):
'''
Adds HATEOAS links to the resource. Adds href link to self.
Override in subclasses to include additional functionality
'''
obj['_links'] = {
'self': {
'href': '{}{}/'.format(self.get_resource_uri(), obj[self.pk])
}
}

def format(self, method, endpoint, data):
''' Calls format on list or detail '''
if data is None and method == 'GET':
Expand All @@ -363,19 +374,19 @@ def format(self, method, endpoint, data):
def format_list(self, data):
if data is None:
return ''

if self._meta.add_resource_uri is True:
if self._meta.hypermedia is True:
# add resource uri
for item in data['objects']:
item['resource_uri'] = '{}{}/'.format(self.get_resource_uri(), item[self.pk])
self.add_hypermedia(item)

return self._meta.formatter.format(data)

def format_detail(self, data):
if data is None:
return ''
if self._meta.add_resource_uri is True:
data['resource_uri'] = '{}{}/'.format(self.get_resource_uri(), data[self.pk])
if self._meta.hypermedia is True:
self.add_hypermedia(data)

return self._meta.formatter.format(self.get_resource_data(data))

def get_resource_data(self, data):
Expand Down
8 changes: 4 additions & 4 deletions tests/resources/test_mongo_resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ async def test_mongo_resource_crud(json_fixture, db):
response = await client.put(url + data['isbn'] + '/', body=data)
assert response.status == ACCEPTED
update_obj = client.parse_response_data(response)
assert update_obj['resource_uri'] == data['resource_uri']
assert update_obj['_links'] == data['_links']
assert len(update_obj['reviews']) == 1

# create new review by performing PATCH
Expand All @@ -133,14 +133,14 @@ async def test_mongo_resource_crud(json_fixture, db):
response = await client.patch(url + data['isbn'] + '/', body={'reviews': reviews})
assert response.status == OK
update_obj = client.parse_response_data(response)
assert update_obj['resource_uri'] == data['resource_uri']
assert update_obj['_links'] == data['_links']
assert len(update_obj['reviews']) == 2

# get detail
response = await client.get(url + data['isbn'] + '/')
assert response.status == OK
update_obj = client.parse_response_data(response)
assert update_obj['resource_uri'] == data['resource_uri']
assert update_obj['_links'] == data['_links']
assert len(update_obj['reviews']) == 2
# verify internal document fields were not serialized
assert 'impressions' not in update_obj
Expand Down Expand Up @@ -299,7 +299,7 @@ async def test_mongo_collection_custom_indices(load_book_collection):
data = client.parse_response_data(response)
for obj in data['objects']:
# verify that the unique isbn is part of the resource uri
assert obj['isbn'] in obj['resource_uri']
assert obj['isbn'] in obj['_links']['self']['href']

# fail to insert a new book with existing isbn
new_book = {
Expand Down
54 changes: 53 additions & 1 deletion tests/resources/test_resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ async def test_resource_get_detail(event_loop, json_fixture):
# check that id matches
assert obj['id'] == id
# check all expected keys are in
assert set(('id', 'first_name', 'last_name', 'resource_uri')) == set(obj.keys())
assert set(('id', 'first_name', 'last_name', '_links')) == set(obj.keys())


@pytest.mark.asyncio
Expand Down Expand Up @@ -100,3 +100,55 @@ async def test_resource_delete(event_loop, json_fixture):
# load datafixture for this test
app = App(db=json_fixture('persons.json'))
# set variables

@pytest.mark.asyncio
async def test_resource_hateoas(event_loop, json_fixture):
def _check(obj):
assert '_links' in obj
assert 'self' in obj['_links']
assert 'href' in obj['_links']['self']
assert url in obj['_links']['self']['href']

# load datafixture for this test
app = App(db=json_fixture('persons.json'))
# set variables
url = '/api/{}/'.format(PersonResource.__name__)
client = ResourceTestClient(app, PersonResource)

# get collection of persons
response = await client.get(url=url)
assert isinstance(response, Response)
assert response.status == OK
# parse response and retrieve data
data = client.parse_response_data(response)
for resource in data['objects']:
_check(resource)

# test on single resource
href = data['objects'][0]['_links']['self']['href']
response = await client.get(url=href)
assert isinstance(response, Response)
assert response.status == OK
resource = client.parse_response_data(response)
_check(resource)

@pytest.mark.asyncio
async def test_resource_without_hateoas(event_loop, json_fixture):
# load datafixture for this test
app = App(db=json_fixture('persons.json'))
# turn off hypermedia
class NoHPersonResource(PersonResource):
class Meta:
hypermedia = False

url = '/api/{}/'.format(NoHPersonResource.__name__)
client = ResourceTestClient(app, NoHPersonResource)
# get collection of persons
response = await client.get(url=url)
assert isinstance(response, Response)
assert response.status == OK
# parse response and retrieve data
data = client.parse_response_data(response)
for resource in data['objects']:
assert '_links' not in resource

0 comments on commit 454bfd9

Please sign in to comment.