Skip to content

Commit

Permalink
Add support for specifying the Palace api-version in mimetype header …
Browse files Browse the repository at this point in the history
…when retrieving opds1 and opds2 client feeds.
  • Loading branch information
dbernstein committed Oct 25, 2024
1 parent 2e11018 commit 2573aa0
Show file tree
Hide file tree
Showing 7 changed files with 110 additions and 35 deletions.
18 changes: 12 additions & 6 deletions src/palace/manager/feed/opds.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -21,16 +24,19 @@ 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(
serializers.keys(), default="application/atom+xml"
)
return serializers[match]()
# Default
return OPDS1Serializer()
return OPDS1Version1Serializer()


class BaseOPDSFeed(FeedInterface):
Expand Down Expand Up @@ -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
Expand Down
10 changes: 9 additions & 1 deletion src/palace/manager/feed/serializer/opds.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
9 changes: 8 additions & 1 deletion src/palace/manager/feed/serializer/opds2.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
9 changes: 6 additions & 3 deletions tests/manager/api/controller/test_loan.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
),
],
)

Expand All @@ -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 (
Expand Down
14 changes: 7 additions & 7 deletions tests/manager/feed/test_opds2_serializer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"]
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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")

Expand All @@ -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": [
Expand Down
69 changes: 60 additions & 9 deletions tests/manager/feed/test_opds_base.py
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -13,45 +16,93 @@ 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(
headers=dict(
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(
headers=dict(
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(
headers=dict(
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
)
16 changes: 8 additions & 8 deletions tests/manager/feed/test_opds_serializer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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 <entry> 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"
Expand Down

0 comments on commit 2573aa0

Please sign in to comment.