From 2573aa062730e390d875075aab2f5c0a8e3f4cb4 Mon Sep 17 00:00:00 2001 From: Daniel Bernstein Date: Fri, 25 Oct 2024 12:02:58 -0700 Subject: [PATCH] Add support for specifying the Palace api-version in mimetype header when retrieving opds1 and opds2 client feeds. --- src/palace/manager/feed/opds.py | 18 ++++-- src/palace/manager/feed/serializer/opds.py | 10 ++- src/palace/manager/feed/serializer/opds2.py | 9 ++- tests/manager/api/controller/test_loan.py | 9 ++- tests/manager/feed/test_opds2_serializer.py | 14 ++--- tests/manager/feed/test_opds_base.py | 69 ++++++++++++++++++--- tests/manager/feed/test_opds_serializer.py | 16 ++--- 7 files changed, 110 insertions(+), 35 deletions(-) diff --git a/src/palace/manager/feed/opds.py b/src/palace/manager/feed/opds.py index b8617b9658..ed16d1a1d3 100644 --- a/src/palace/manager/feed/opds.py +++ b/src/palace/manager/feed/opds.py @@ -8,8 +8,11 @@ from palace.manager.core.exceptions import BasePalaceException from palace.manager.feed.base import FeedInterface from palace.manager.feed.serializer.base import SerializerInterface -from palace.manager.feed.serializer.opds import OPDS1Serializer -from palace.manager.feed.serializer.opds2 import OPDS2Serializer +from palace.manager.feed.serializer.opds import ( + OPDS1Version1Serializer, + OPDS1Version2Serializer, +) +from palace.manager.feed.serializer.opds2 import OPDS2Version1Serializer from palace.manager.feed.types import FeedData, WorkEntry from palace.manager.sqlalchemy.model.lane import FeaturedFacets from palace.manager.util.flask_util import OPDSEntryResponse, OPDSFeedResponse @@ -21,8 +24,11 @@ def get_serializer( ) -> SerializerInterface[Any]: # Ordering matters for poor matches (eg. */*), so we will keep OPDS1 first serializers: dict[str, type[SerializerInterface[Any]]] = { - "application/atom+xml": OPDS1Serializer, - "application/opds+json": OPDS2Serializer, + "application/atom+xml": OPDS1Version1Serializer, + "application/atom+xml; api-version:2": OPDS1Version2Serializer, + "application/opds+json": OPDS2Version1Serializer, + "application/opds+json; api-version:1": OPDS2Version1Serializer, + "application/opds+json; api-version:2": OPDS2SerializerVersion2, } if mime_types: match = mime_types.best_match( @@ -30,7 +36,7 @@ def get_serializer( ) return serializers[match]() # Default - return OPDS1Serializer() + return OPDS1Version1Serializer() class BaseOPDSFeed(FeedInterface): @@ -90,7 +96,7 @@ def entry_as_response( ), **response_kwargs, ) - if isinstance(serializer, OPDS2Serializer): + if isinstance(serializer, OPDS2Version1Serializer): # Only OPDS2 has the same content type for feed and entry response.content_type = serializer.content_type() return response diff --git a/src/palace/manager/feed/serializer/opds.py b/src/palace/manager/feed/serializer/opds.py index 702cb6bce2..b0e54d2534 100644 --- a/src/palace/manager/feed/serializer/opds.py +++ b/src/palace/manager/feed/serializer/opds.py @@ -64,7 +64,7 @@ def is_sort_link(link: Link) -> bool: ) -class OPDS1Serializer(SerializerInterface[etree._Element], OPDSFeed): +class OPDS1Version1Serializer(SerializerInterface[etree._Element], OPDSFeed): """An OPDS 1.2 Atom feed serializer""" def __init__(self) -> None: @@ -415,3 +415,11 @@ def _serialize_sort_link(self, link: Link) -> etree._Element: if link.get("activeFacet", False): sort_link.add_attributes(dict(activeSort="true")) return self._serialize_feed_entry("link", sort_link) + + +class OPDS1Version2Serializer(OPDS1Version1Serializer): + """An OPDS 1.2 Atom feed serializer with Palace specific modifications (version 2) to support + new IOS and Android client features.""" + + def __init__(self) -> None: + pass diff --git a/src/palace/manager/feed/serializer/opds2.py b/src/palace/manager/feed/serializer/opds2.py index 052fbbd503..5b0fecf1a7 100644 --- a/src/palace/manager/feed/serializer/opds2.py +++ b/src/palace/manager/feed/serializer/opds2.py @@ -36,7 +36,7 @@ PALACE_PROPERTIES_ACTIVE_SORT = AtomFeed.PALACE_PROPS_NS + "active-sort" -class OPDS2Serializer(SerializerInterface[dict[str, Any]]): +class OPDS2Version1Serializer(SerializerInterface[dict[str, Any]]): CONTENT_TYPE = "application/opds+json" def __init__(self) -> None: @@ -237,3 +237,10 @@ def content_type(self) -> str: @classmethod def to_string(cls, data: dict[str, Any]) -> str: return json.dumps(data, indent=2) + + +class OPDS2Version2Serializer(OPDS2Version1Serializer): + CONTENT_TYPE = "application/opds+json" + + def __init__(self) -> None: + pass diff --git a/tests/manager/api/controller/test_loan.py b/tests/manager/api/controller/test_loan.py index 4948b44bb1..4fb2de03dd 100644 --- a/tests/manager/api/controller/test_loan.py +++ b/tests/manager/api/controller/test_loan.py @@ -46,7 +46,7 @@ ) from palace.manager.core.opds_import import OPDSAPI from palace.manager.core.problem_details import INTEGRATION_ERROR, INVALID_INPUT -from palace.manager.feed.serializer.opds2 import OPDS2Serializer +from palace.manager.feed.serializer.opds2 import OPDS2Version1Serializer from palace.manager.service.redis.models.patron_activity import PatronActivity from palace.manager.sqlalchemy.constants import MediaTypes from palace.manager.sqlalchemy.model.collection import Collection @@ -131,7 +131,10 @@ class OPDSSerializationTestHelper: (None, OPDSFeed.ENTRY_TYPE), ("default-foo-bar", OPDSFeed.ENTRY_TYPE), (AtomFeed.ATOM_TYPE, OPDSFeed.ENTRY_TYPE), - (OPDS2Serializer.CONTENT_TYPE, OPDS2Serializer.CONTENT_TYPE), + ( + OPDS2Version1Serializer.CONTENT_TYPE, + OPDS2Version1Serializer.CONTENT_TYPE, + ), ], ) @@ -151,7 +154,7 @@ def verify_and_get_single_entry_feed_links(self, response): if self.expected_content_type == OPDSFeed.ENTRY_TYPE: feed = feedparser.parse(response.get_data()) [entry] = feed["entries"] - elif self.expected_content_type == OPDS2Serializer.CONTENT_TYPE: + elif self.expected_content_type == OPDS2Version1Serializer.CONTENT_TYPE: entry = response.get_json() else: assert ( diff --git a/tests/manager/feed/test_opds2_serializer.py b/tests/manager/feed/test_opds2_serializer.py index 09de507509..c344d23655 100644 --- a/tests/manager/feed/test_opds2_serializer.py +++ b/tests/manager/feed/test_opds2_serializer.py @@ -3,7 +3,7 @@ from palace.manager.feed.serializer.opds2 import ( PALACE_PROPERTIES_ACTIVE_SORT, PALACE_REL_SORT, - OPDS2Serializer, + OPDS2Version1Serializer, ) from palace.manager.feed.types import ( Acquisition, @@ -44,7 +44,7 @@ def test_serialize_feed(self): ) ] - serialized = OPDS2Serializer().serialize_feed(feed) + serialized = OPDS2Version1Serializer().serialize_feed(feed) result = json.loads(serialized) assert result["metadata"]["title"] == "Title" @@ -89,7 +89,7 @@ def test_serialize_work_entry(self): duration=10, ) - serializer = OPDS2Serializer() + serializer = OPDS2Version1Serializer() entry = serializer.serialize_work_entry(data) metadata = entry["metadata"] @@ -155,7 +155,7 @@ def test__serialize_acquisition_link(self): {"vendor": "vendor_name", "clientToken": FeedEntryType(text="token_value")} ) - serializer = OPDS2Serializer() + serializer = OPDS2Version1Serializer() acquisition = Acquisition( href="http://acquisition", rel="acquisition", @@ -222,13 +222,13 @@ def test__serialize_contributor(self): sort_name="Author,", link=Link(href="http://author", rel="contributor", title="Delete me!"), ) - result = OPDS2Serializer()._serialize_contributor(author) + result = OPDS2Version1Serializer()._serialize_contributor(author) assert result["name"] == "Author" assert result["sortAs"] == "Author," assert result["links"] == [{"href": "http://author", "rel": "contributor"}] def test_serialize_opds_message(self): - assert OPDS2Serializer().serialize_opds_message( + assert OPDS2Version1Serializer().serialize_opds_message( OPDSMessage("URN", 200, "Description") ) == dict(urn="URN", description="Description") @@ -237,7 +237,7 @@ def test_serialize_sort_links(self): link = Link(href="test", rel="test_rel", title="text1") link.add_attributes(dict(facetGroup="Sort by", activeFacet="true")) feed_data.facet_links.append(link) - links = OPDS2Serializer()._serialize_feed_links(feed=feed_data) + links = OPDS2Version1Serializer()._serialize_feed_links(feed=feed_data) assert links == { "links": [ diff --git a/tests/manager/feed/test_opds_base.py b/tests/manager/feed/test_opds_base.py index 302b1d8119..8fcff21d1a 100644 --- a/tests/manager/feed/test_opds_base.py +++ b/tests/manager/feed/test_opds_base.py @@ -1,8 +1,11 @@ from flask import Request from palace.manager.feed.opds import get_serializer -from palace.manager.feed.serializer.opds import OPDS1Serializer -from palace.manager.feed.serializer.opds2 import OPDS2Serializer +from palace.manager.feed.serializer.opds import ( + OPDS1Version1Serializer, + OPDS1Version2Serializer, +) +from palace.manager.feed.serializer.opds2 import OPDS2Version1Serializer class TestBaseOPDSFeed: @@ -13,7 +16,9 @@ def test_get_serializer(self): Accept="application/atom+xml;q=0.8,application/opds+json;q=0.9" ) ) - assert isinstance(get_serializer(request.accept_mimetypes), OPDS2Serializer) + assert isinstance( + get_serializer(request.accept_mimetypes), OPDS2Version1Serializer + ) # Multiple additional key-value pairs don't matter request = Request.from_values( @@ -21,14 +26,18 @@ def test_get_serializer(self): Accept="application/atom+xml;profile=opds-catalog;kind=acquisition;q=0.08, application/opds+json;q=0.9" ) ) - assert isinstance(get_serializer(request.accept_mimetypes), OPDS2Serializer) + assert isinstance( + get_serializer(request.accept_mimetypes), OPDS2Version1Serializer + ) request = Request.from_values( headers=dict( Accept="application/atom+xml;profile=opds-catalog;kind=acquisition" ) ) - assert isinstance(get_serializer(request.accept_mimetypes), OPDS1Serializer) + assert isinstance( + get_serializer(request.accept_mimetypes), OPDS1Version1Serializer + ) # The default q-value should be 1, but opds2 specificity is higher request = Request.from_values( @@ -36,13 +45,17 @@ def test_get_serializer(self): Accept="application/atom+xml;profile=feed,application/opds+json;q=0.9" ) ) - assert isinstance(get_serializer(request.accept_mimetypes), OPDS2Serializer) + assert isinstance( + get_serializer(request.accept_mimetypes), OPDS2Version1Serializer + ) # The default q-value should sort above 0.9 request = Request.from_values( headers=dict(Accept="application/opds+json;q=0.9,application/atom+xml") ) - assert isinstance(get_serializer(request.accept_mimetypes), OPDS1Serializer) + assert isinstance( + get_serializer(request.accept_mimetypes), OPDS1Version1Serializer + ) # Same q-values respect order of definition in the code request = Request.from_values( @@ -50,8 +63,46 @@ def test_get_serializer(self): Accept="application/opds+json;q=0.9,application/atom+xml;q=0.9" ) ) - assert isinstance(get_serializer(request.accept_mimetypes), OPDS1Serializer) + assert isinstance( + get_serializer(request.accept_mimetypes), OPDS1Version1Serializer + ) + + # test api-version parameter when specified return the appropriate + # version + request = Request.from_values( + headers=dict(Accept="application/atom+xml; api-version:1") + ) + + assert isinstance( + get_serializer(request.accept_mimetypes), OPDS1Version1Serializer + ) + + request = Request.from_values( + headers=dict(Accept="application/atom+xml; api-version:2") + ) + + assert isinstance( + get_serializer(request.accept_mimetypes), OPDS1Version2Serializer + ) + + request = Request.from_values( + headers=dict(Accept="application/opds+json; api-version:1") + ) + + assert isinstance( + get_serializer(request.accept_mimetypes), OPDS2Version1Serializer + ) + + request = Request.from_values( + headers=dict(Accept="application/opds+json; api-version:2") + ) + + assert isinstance( + get_serializer(request.accept_mimetypes), OPDS1Version2Serializer + ) # No valid accept mimetype should default to OPDS1.x request = Request.from_values(headers=dict(Accept="text/html")) - assert isinstance(get_serializer(request.accept_mimetypes), OPDS1Serializer) + assert isinstance( + get_serializer(request.accept_mimetypes), OPDS1Version1Serializer + ) diff --git a/tests/manager/feed/test_opds_serializer.py b/tests/manager/feed/test_opds_serializer.py index 1ccc970409..9a9b3997e5 100644 --- a/tests/manager/feed/test_opds_serializer.py +++ b/tests/manager/feed/test_opds_serializer.py @@ -3,7 +3,7 @@ import pytz from lxml import etree -from palace.manager.feed.serializer.opds import OPDS1Serializer, is_sort_link +from palace.manager.feed.serializer.opds import OPDS1Version1Serializer, is_sort_link from palace.manager.feed.serializer.opds2 import PALACE_REL_SORT from palace.manager.feed.types import ( Acquisition, @@ -22,7 +22,7 @@ def test__serialize_feed_entry(self): child = FeedEntryType.create(text="child", attr="chattr", grandchild=grandchild) parent = FeedEntryType.create(text="parent", attr="pattr", child=child) - serialized = OPDS1Serializer()._serialize_feed_entry("parent", parent) + serialized = OPDS1Version1Serializer()._serialize_feed_entry("parent", parent) assert serialized.tag == "parent" assert serialized.text == "parent" @@ -50,7 +50,7 @@ def test__serialize_author_tag(self): lc="lc", ) - element = OPDS1Serializer()._serialize_author_tag("author", author) + element = OPDS1Version1Serializer()._serialize_author_tag("author", author) assert element.tag == "author" assert element.get(f"{{{OPDSFeed.OPF_NS}}}role") == author.role @@ -102,7 +102,7 @@ def test__serialize_acquistion_link(self): vendor="vendor", clientToken=FeedEntryType(text="token") ), ) - element = OPDS1Serializer()._serialize_acquistion_link(link) + element = OPDS1Version1Serializer()._serialize_acquistion_link(link) assert element.tag == "link" assert dict(element.attrib) == dict(href=link.href) @@ -159,7 +159,7 @@ def test_serialize_work_entry(self): duration=10, ) - element = OPDS1Serializer().serialize_work_entry(data) + element = OPDS1Version1Serializer().serialize_work_entry(data) assert ( element.get(f"{{{OPDSFeed.SCHEMA_NS}}}additionalType") @@ -246,21 +246,21 @@ def test_serialize_work_entry(self): def test_serialize_work_entry_empty(self): # A no-data work entry - element = OPDS1Serializer().serialize_work_entry(WorkEntryData()) + element = OPDS1Version1Serializer().serialize_work_entry(WorkEntryData()) # This will create an empty tag assert element.tag == "entry" assert list(element) == [] def test_serialize_opds_message(self): message = OPDSMessage("URN", 200, "Description") - serializer = OPDS1Serializer() + serializer = OPDS1Version1Serializer() result = serializer.serialize_opds_message(message) assert serializer.to_string(result) == serializer.to_string(message.tag) def test_serialize_sort_link(self): link = Link(href="test", rel="test_rel", title="text1") link.add_attributes(dict(facetGroup="Sort by", activeFacet="true")) - serializer = OPDS1Serializer() + serializer = OPDS1Version1Serializer() assert is_sort_link(link) sort_link = serializer._serialize_sort_link(link) assert sort_link.attrib["title"] == "text1"