Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Enhancement for data relation hateoas #1186 #1204

Closed
wants to merge 5 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/features.rst
Original file line number Diff line number Diff line change
Expand Up @@ -478,7 +478,7 @@ HATEOAS links are always relative to the API entry point, so if your API home
is at ``examples.com/api/v1``, the ``self`` link in the above example would
mean that the *people* endpoint is located at ``examples.com/api/v1/people``.

Please note that ``next``, ``previous`` and ``last`` items will only be
Please note that ``next``, ``previous``, ``last`` and ``related`` items will only be
included when appropriate.

Disabling HATEOAS
Expand Down
100 changes: 96 additions & 4 deletions eve/methods/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
:copyright: (c) 2017 by Nicola Iarocci.
:license: BSD, see LICENSE for more details.
"""
import re
import base64
import time
from copy import copy
Expand Down Expand Up @@ -603,6 +604,9 @@ def build_response_document(document, resource, embedded_fields, latest_doc=None
:param embedded_fields: the list of fields we are allowed to embed.
:param document: the latest version of document.

.. versionchanged:: 0.8.2
Add data relation fields hateoas support (#1204).

.. versionchanged:: 0.5
Only compute ETAG if necessary (#369).
Add version support (#475).
Expand Down Expand Up @@ -638,6 +642,9 @@ def build_response_document(document, resource, embedded_fields, latest_doc=None
elif "self" not in document[config.LINKS]:
document[config.LINKS].update(self_dict)

# add data relation links if hateoas enabled
resolve_data_relation_links(document, resource)

# add version numbers
resolve_document_version(document, resource, "GET", latest_doc)

Expand Down Expand Up @@ -665,6 +672,9 @@ def field_definition(resource, chained_fields):
:param resource: the resource name whose field to be accepted.
:param chained_fields: query string to retrieve field definition

.. versionchanged:: 0.8.2
fix field definition for list without a schema. See #1204.

.. versionadded 0.5
"""
definition = config.DOMAIN[resource]
Expand All @@ -681,12 +691,79 @@ def field_definition(resource, chained_fields):
definition = definition["schema"][field]
field_type = definition.get("type")
if field_type == "list":
definition = definition["schema"]
# the list can be 1) a list of allowed values for string and list types
# 2) a list of references that have schema
# we want to resolve field definition deeper for the second one
definition = definition.get("schema", definition)
elif field_type == "objectid":
pass
return definition


def resolve_data_relation_links(document, resource):
""" Resolves all fields in a document that has data relation to other resources

:param document: the document to include data relation links.
:param resource: the resource name.

.. versionadded:: 0.8.2
"""
resource_def = config.DOMAIN[resource]
related_dict = {}

for field in resource_def.get("schema", {}):

field_def = field_definition(resource, field)
if "data_relation" not in field_def:
continue

if field in document and document[field] is not None:
related_links = []

# Make the code DRY for list of linked relation and single linked relation
for related_document_id in (
document[field]
if isinstance(document[field], list)
else [document[field]]
):
# Get the resource endpoint string for the linked relation
related_resource = (
related_document_id.collection
if isinstance(related_document_id, DBRef)
else field_def["data_relation"]["resource"]
)

# Get the item endpoint id for the linked relation
if isinstance(related_document_id, DBRef):
related_document_id = related_document_id.id
if isinstance(related_document_id, dict):
related_resource_field = field_definition(resource, field)[
"data_relation"
]["field"]
related_document_id = related_document_id[related_resource_field]

# Get the version for the item endpoint id
related_version = (
related_document_id.get("_version")
if isinstance(related_document_id, dict)
else None
)

related_links.append(
document_link(
related_resource, related_document_id, related_version
)
)

if isinstance(document[field], list):
related_dict.update({field: related_links})
else:
related_dict.update({field: related_links[0]})

if related_dict != {}:
document[config.LINKS].update({"related": related_dict})


def resolve_embedded_fields(resource, req):
""" Returns a list of validated embedded fields from the incoming request
or from the resource definition is the request does not specify.
Expand Down Expand Up @@ -1261,6 +1338,9 @@ def document_link(resource, document_id, version=None):
:param document_id: the document unique identifier.
:param version: the document version. Defaults to None.

.. versionchanged:: 0.8.2
Support document link for data relation resources. See #1204.

.. versionchanged:: 0.5
Add version support (#475).

Expand All @@ -1276,17 +1356,23 @@ def document_link(resource, document_id, version=None):
version_part = "?version=%s" % version if version else ""
return {
"title": "%s" % config.DOMAIN[resource]["item_title"],
"href": "%s/%s%s" % (resource_link(), document_id, version_part),
"href": "%s/%s%s" % (resource_link(resource), document_id, version_part),
}


def resource_link():
def resource_link(resource=None):
""" Returns the current resource path relative to the API entry point.
Mostly going to be used by hateoas functions when building
document/resource links. The resource URL stored in the config settings
might contain regexes and custom variable names, all of which are not
needed in the response payload.

:param resource: the resource name if not using the resource from request.path

.. versionchanged:: 0.8.2
Support resource link for data relation resources
which may be different from request.path resource. See #1204.

.. versionchanged:: 0.5
URL is relative to API root.

Expand All @@ -1304,7 +1390,13 @@ def strip_prefix(hit):
path = strip_prefix(config.URL_PREFIX + "/")
if config.API_VERSION:
path = strip_prefix(config.API_VERSION + "/")
return path

# If request path does not match resource URL regex definition
# We are creating a path for data relation resources
if resource and not re.search(config.DOMAIN[resource]["url"], path):
return config.DOMAIN[resource]["url"]
else:
return path


def oplog_push(resource, document, op, id=None):
Expand Down
10 changes: 8 additions & 2 deletions eve/methods/get.py
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,10 @@ def getitem_internal(resource, **lookup):
:param resource: the name of the resource to which the document belongs.
:param **lookup: the lookup query.

.. versionchanged:: 0.8.2
Prevent extra hateoas links from overwriting
already existed data relation hateoas links.

.. versionchanged:: 0.6
Handle soft deleted documents

Expand Down Expand Up @@ -464,8 +468,10 @@ def getitem_internal(resource, **lookup):
if config.DOMAIN[resource]["pagination"]:
response[config.META] = _meta_links(req, count)
else:
response[config.LINKS] = _pagination_links(
resource, req, None, response[resource_def["id_field"]]
response[config.LINKS].update(
_pagination_links(
resource, req, None, response[resource_def["id_field"]]
)
)

# callbacks not supported on version diffs because of partial documents
Expand Down
63 changes: 57 additions & 6 deletions eve/render.py
Original file line number Diff line number Diff line change
Expand Up @@ -418,11 +418,15 @@ def xml_add_meta(cls, data):
@classmethod
def xml_add_links(cls, data):
""" Returns as many <link> nodes as there are in the datastream. The
links are then removed from the datastream to allow for further
added links are then removed from the datastream to allow for further
processing.

:param data: the data stream to be rendered as xml.

.. versionchanged:: 0.8.2
Keep data relation links in the datastream as they will be
processed as node attributes in xml_dict

.. versionchanged:: 0.5
Always return ordered items (#441).

Expand All @@ -436,7 +440,12 @@ def xml_add_links(cls, data):
links = data.pop(config.LINKS, {})
ordered_links = OrderedDict(sorted(links.items()))
for rel, link in ordered_links.items():
if isinstance(link, list):
if rel == "related":
# add data relation links back for
# future processing of hateoas attributes
data.update({config.LINKS: {rel: link}})

elif isinstance(link, list):
xml += "".join(
[
chunk % (rel, utils.escape(d["href"]), utils.escape(d["title"]))
Expand Down Expand Up @@ -491,6 +500,9 @@ def xml_dict(cls, data):

:param data: the data stream to be rendered as xml.

.. versionchanged:: 0.8.2
Renders hateoas attributes on XML nodes. See #1204.

.. versionchanged:: 0.5
Always return ordered items (#441).

Expand All @@ -500,6 +512,7 @@ def xml_dict(cls, data):
.. versionadded:: 0.0.3
"""
xml = ""
related_links = data.pop(config.LINKS, {}).pop("related", {})
ordered_items = OrderedDict(sorted(data.items()))
for k, v in ordered_items.items():
if isinstance(v, datetime.datetime):
Expand All @@ -508,13 +521,51 @@ def xml_dict(cls, data):
v = v.isoformat()
if not isinstance(v, list):
v = [v]
for value in v:
for idx, value in enumerate(v):
if isinstance(value, dict):
links = cls.xml_add_links(value)
xml += "<%s>" % k
xml += cls.xml_field_open(k, idx, related_links)
xml += cls.xml_dict(value)
xml += links
xml += "</%s>" % k
xml += cls.xml_field_close(k)
else:
xml += "<%s>%s</%s>" % (k, utils.escape(value), k)
xml += cls.xml_field_open(k, idx, related_links)
xml += "%s" % utils.escape(value)
xml += cls.xml_field_close(k)
return xml

@classmethod
def xml_field_open(cls, field, idx, related_links):
""" Returns opening tag for XML field element node.

:param field: field name for the element node
:param idx: the index in the data relation links if serializing a list of same field to XML
:param related_links: a dictionary that stores all data relation links

.. versionadded:: 0.8.2
"""
if field in related_links:
if isinstance(related_links[field], list):
return '<%s href="%s" title="%s">' % (
field,
utils.escape(related_links[field][idx]["href"]),
related_links[field][idx]["title"],
)
else:
return '<%s href="%s" title="%s">' % (
field,
utils.escape(related_links[field]["href"]),
related_links[field]["title"],
)
else:
return "<%s>" % field

@classmethod
def xml_field_close(cls, field):
""" Returns closing tag of XML field element node.

:param field: field name for the element node

.. versionadded:: 0.8.2
"""
return "</%s>" % field
13 changes: 13 additions & 0 deletions eve/tests/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,19 @@ def assertLastLink(self, links, page):
else:
self.assertTrue("last" not in links)

def assertRelatedLink(self, links, field):
self.assertTrue("related" in links)
data_relation_links = links["related"]
self.assertTrue(field in data_relation_links)
related_field_links = data_relation_links[field]
for related_field_link in (
related_field_links
if isinstance(related_field_links, list)
else [related_field_links]
):
self.assertTrue("title" in related_field_link)
self.assertTrue("href" in related_field_link)

def assertCustomParams(self, link, params):
self.assertTrue("href" in link)
url_params = parse_qs(urlparse(link["href"]).query)
Expand Down
Loading