From 2ba9a1401377ca9cb7af135d681b40c995edf4a9 Mon Sep 17 00:00:00 2001 From: Shaoyu Date: Thu, 11 Oct 2018 20:59:06 -0500 Subject: [PATCH 1/4] add data relation hateoas to response json --- eve/methods/common.py | 62 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 61 insertions(+), 1 deletion(-) diff --git a/eve/methods/common.py b/eve/methods/common.py index 6c0980458..3ee2b5fc6 100644 --- a/eve/methods/common.py +++ b/eve/methods/common.py @@ -638,6 +638,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) @@ -681,12 +684,69 @@ 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 link. + :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: + # Get the resource endpoint string for the linked relation + related_resource = ( + document[field].collection + if isinstance(document[field], DBRef) + else field_def["data_relation"]["resource"] + ) + + # Get the item endpoint id for the linked relation + related_document_id = document[field] + 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 endpoint + related_version = ( + document[field].get("_version") + if isinstance(document[field], dict) + else None + ) + + related_dict.update( + { + field: document_link( + related_resource, related_document_id, related_version + ) + } + ) + + 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. From 43dd966e44e567d155ff92d8ff8698264ee596e4 Mon Sep 17 00:00:00 2001 From: Shaoyu Date: Thu, 11 Oct 2018 20:59:06 -0500 Subject: [PATCH 2/4] add data relation hateoas to response json --- eve/methods/common.py | 62 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 61 insertions(+), 1 deletion(-) diff --git a/eve/methods/common.py b/eve/methods/common.py index 6c0980458..3ee2b5fc6 100644 --- a/eve/methods/common.py +++ b/eve/methods/common.py @@ -638,6 +638,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) @@ -681,12 +684,69 @@ 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 link. + :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: + # Get the resource endpoint string for the linked relation + related_resource = ( + document[field].collection + if isinstance(document[field], DBRef) + else field_def["data_relation"]["resource"] + ) + + # Get the item endpoint id for the linked relation + related_document_id = document[field] + 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 endpoint + related_version = ( + document[field].get("_version") + if isinstance(document[field], dict) + else None + ) + + related_dict.update( + { + field: document_link( + related_resource, related_document_id, related_version + ) + } + ) + + 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. From cae42b5adff7e4fcaf5e19be688ab1c3e5b7d3f9 Mon Sep 17 00:00:00 2001 From: Shaoyu Date: Fri, 12 Oct 2018 18:56:41 -0500 Subject: [PATCH 3/4] add xml rendering for data relation links --- eve/methods/common.py | 69 +++++++++++++++++++++++++------------------ eve/render.py | 63 +++++++++++++++++++++++++++++++++++---- 2 files changed, 98 insertions(+), 34 deletions(-) diff --git a/eve/methods/common.py b/eve/methods/common.py index 3ee2b5fc6..dea5c5af7 100644 --- a/eve/methods/common.py +++ b/eve/methods/common.py @@ -668,6 +668,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 of list without a schema. See #1204. + .. versionadded 0.5 """ definition = config.DOMAIN[resource] @@ -696,7 +699,7 @@ def field_definition(resource, chained_fields): 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 link. + :param document: the document to include data relation links. :param resource: the resource name. .. versionadded:: 0.8.2 @@ -711,37 +714,47 @@ def resolve_data_relation_links(document, resource): continue if field in document and document[field] is not None: - # Get the resource endpoint string for the linked relation - related_resource = ( - document[field].collection - if isinstance(document[field], DBRef) - else field_def["data_relation"]["resource"] - ) + related_links = [] - # Get the item endpoint id for the linked relation - related_document_id = document[field] - 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 endpoint - related_version = ( - document[field].get("_version") - if isinstance(document[field], dict) - else None - ) + # 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_dict.update( - { - field: document_link( + 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}) diff --git a/eve/render.py b/eve/render.py index 882c21da9..2636e7dae 100644 --- a/eve/render.py +++ b/eve/render.py @@ -418,11 +418,15 @@ def xml_add_meta(cls, data): @classmethod def xml_add_links(cls, data): """ Returns as many 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). @@ -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"])) @@ -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). @@ -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): @@ -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 += "" % k + xml += cls.xml_field_close(k) else: - xml += "<%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, + related_links[field][idx]["href"], + related_links[field][idx]["title"], + ) + else: + return '<%s href="%s" title="%s">' % ( + field, + 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 "" % field From e33dfc8234b3bcb9c42d49802294616fdbc59b9e Mon Sep 17 00:00:00 2001 From: Shaoyu Date: Sun, 14 Oct 2018 18:42:33 -0500 Subject: [PATCH 4/4] add tests for data relation hateoas links --- docs/features.rst | 2 +- eve/methods/common.py | 27 +++++++++++++++--- eve/methods/get.py | 10 +++++-- eve/render.py | 4 +-- eve/tests/__init__.py | 13 +++++++++ eve/tests/methods/get.py | 59 +++++++++++++++++++++++++++++++++++++++- eve/tests/renders.py | 27 ++++++++++++++++++ 7 files changed, 132 insertions(+), 10 deletions(-) diff --git a/docs/features.rst b/docs/features.rst index 4fe7734e8..f1b7a2906 100644 --- a/docs/features.rst +++ b/docs/features.rst @@ -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 diff --git a/eve/methods/common.py b/eve/methods/common.py index dea5c5af7..28a9de58d 100644 --- a/eve/methods/common.py +++ b/eve/methods/common.py @@ -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 @@ -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). @@ -669,7 +673,7 @@ def field_definition(resource, chained_fields): :param chained_fields: query string to retrieve field definition .. versionchanged:: 0.8.2 - fix field definition of list without a schema. See #1204. + fix field definition for list without a schema. See #1204. .. versionadded 0.5 """ @@ -1334,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). @@ -1349,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. @@ -1377,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): diff --git a/eve/methods/get.py b/eve/methods/get.py index a444e10bb..f068e813b 100644 --- a/eve/methods/get.py +++ b/eve/methods/get.py @@ -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 @@ -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 diff --git a/eve/render.py b/eve/render.py index 2636e7dae..9662ebb1e 100644 --- a/eve/render.py +++ b/eve/render.py @@ -548,13 +548,13 @@ def xml_field_open(cls, field, idx, related_links): if isinstance(related_links[field], list): return '<%s href="%s" title="%s">' % ( field, - related_links[field][idx]["href"], + utils.escape(related_links[field][idx]["href"]), related_links[field][idx]["title"], ) else: return '<%s href="%s" title="%s">' % ( field, - related_links[field]["href"], + utils.escape(related_links[field]["href"]), related_links[field]["title"], ) else: diff --git a/eve/tests/__init__.py b/eve/tests/__init__.py index 8f4bb3e59..badc531fd 100644 --- a/eve/tests/__init__.py +++ b/eve/tests/__init__.py @@ -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) diff --git a/eve/tests/methods/get.py b/eve/tests/methods/get.py index cb4a03e9d..96af25355 100644 --- a/eve/tests/methods/get.py +++ b/eve/tests/methods/get.py @@ -4,6 +4,7 @@ import simplejson as json from datetime import datetime, timedelta from bson import ObjectId +from bson.dbref import DBRef from bson.son import SON from werkzeug.datastructures import ImmutableMultiDict from eve.tests import TestBase @@ -1579,7 +1580,7 @@ def assertItemResponse(self, response, status, resource=None): self.assert200(status) self.assertTrue(self.app.config["ETAG"] in response) links = response["_links"] - self.assertEqual(len(links), 3) + self.assertTrue(len(links) == 3 or len(links) == 4) self.assertHomeLink(links) self.assertCollectionLink(links, resource or self.known_resource) self.assertItem(response, resource or self.known_resource) @@ -1818,6 +1819,62 @@ def test_subresource_getitem(self): self.assertEqual(response["person"], str(fake_contact_id)) self.assertEqual(response["_id"], self.invoice_id) + def test_getitem_data_relation_hateoas(self): + # We need to assign a `person` to our test invoice + _db = self.connection[MONGO_DBNAME] + + fake_contact = self.random_contacts(1)[0] + fake_contact_id = _db.contacts.insert_one(fake_contact).inserted_id + url = self.domain[self.known_resource]["url"] + item_title = self.domain[self.known_resource]["item_title"] + invoices = self.domain["invoices"] + + # Test nullable data relation fields + _db.invoices.update_one( + {"_id": ObjectId(self.invoice_id)}, {"$set": {"person": None}} + ) + + response, status = self.get("%s/%s" % (invoices["url"], self.invoice_id)) + self.assertTrue("related" not in response["_links"]) + + # Test object id data relation fields + _db.invoices.update_one( + {"_id": ObjectId(self.invoice_id)}, {"$set": {"person": fake_contact_id}} + ) + + response, status = self.get("%s/%s" % (invoices["url"], self.invoice_id)) + self.assertRelatedLink(response["_links"], "person") + related_links = response["_links"]["related"] + self.assertEqual(related_links["person"]["title"], item_title) + self.assertEqual( + related_links["person"]["href"], "%s/%s" % (url, fake_contact_id) + ) + + # Test DBRef data relation fields + _db.invoices.update_one( + {"_id": ObjectId(self.invoice_id)}, + {"$set": {"persondbref": DBRef("contacts", fake_contact_id)}}, + ) + + response, status = self.get("%s/%s" % (invoices["url"], self.invoice_id)) + self.assertRelatedLink(response["_links"], "persondbref") + related_links = response["_links"]["related"] + self.assertEqual(related_links["persondbref"]["title"], item_title) + self.assertEqual( + related_links["persondbref"]["href"], "%s/%s" % (url, fake_contact_id) + ) + + # Test list of object id data relation fields + _db.invoices.update_one( + {"_id": ObjectId(self.invoice_id)}, + {"$set": {"invoicing_contacts": [fake_contact_id] * 5}}, + ) + + response, status = self.get("%s/%s" % (invoices["url"], self.invoice_id)) + self.assertRelatedLink(response["_links"], "invoicing_contacts") + related_links = response["_links"]["related"] + self.assertEqual(len(related_links["invoicing_contacts"]), 5) + def test_getitem_ifmatch_disabled(self): # when IF_MATCH is disabled no etag is present in payload self.app.config["IF_MATCH"] = False diff --git a/eve/tests/renders.py b/eve/tests/renders.py index 423295645..320de3691 100644 --- a/eve/tests/renders.py +++ b/eve/tests/renders.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- +from bson import ObjectId from eve.tests import TestBase from eve.utils import api_prefix from eve.tests.test_settings import MONGO_DBNAME @@ -63,6 +64,32 @@ def test_xml_ordered_nodes(self): idx3 = data.index(b"parent") self.assertTrue(idx1 < idx2 < idx3) + def test_xml_data_relation_hateoas(self): + # We need to assign a `person` to our test invoice + _db = self.connection[MONGO_DBNAME] + + fake_contact = self.random_contacts(1)[0] + fake_contact_id = _db.contacts.insert_one(fake_contact).inserted_id + url = self.domain[self.known_resource]["url"] + item_title = self.domain[self.known_resource]["item_title"] + invoices = self.domain["invoices"] + + # Test object id data relation fields + _db.invoices.update_one( + {"_id": ObjectId(self.invoice_id)}, {"$set": {"person": fake_contact_id}} + ) + + r = self.test_client.get( + "%s/%s" % (invoices["url"], self.invoice_id), + headers=[("Accept", "application/xml")], + ) + data_relation_opening_tag = '' % ( + url, + fake_contact_id, + item_title, + ) + self.assertTrue(data_relation_opening_tag in r.data.decode()) + def test_unknown_render(self): r = self.test_client.get("/", headers=[("Accept", "application/html")]) self.assertEqual(r.content_type, "application/json")