diff --git a/api/controller.py b/api/controller.py index 688eeec50f..465f070e6b 100644 --- a/api/controller.py +++ b/api/controller.py @@ -21,11 +21,12 @@ from sqlalchemy import select from sqlalchemy.orm import eagerload from sqlalchemy.orm.exc import NoResultFound +from werkzeug.datastructures import MIMEAccept from api.authentication.access_token import AccessTokenProvider from api.model.patron_auth import PatronAuthAccessToken from api.model.time_tracking import PlaytimeEntriesPost, PlaytimeEntriesPostResponse -from api.opds2 import OPDS2NavigationsAnnotator, OPDS2PublicationsAnnotator +from api.opds2 import OPDS2NavigationsAnnotator from api.saml.controller import SAMLController from core.analytics import Analytics from core.app_server import ApplicationVersionController @@ -961,6 +962,7 @@ def feed(self, lane_identifier, feed_class=OPDSAcquisitionFeed): ) return feed.as_response( max_age=int(max_age) if max_age else None, + mime_types=flask.request.accept_mimetypes, ) def navigation(self, lane_identifier): @@ -1161,7 +1163,7 @@ def search(self, lane_identifier, feed_class=OPDSAcquisitionFeed): # Run a search. annotator = self.manager.annotator(lane, facets) info = OpenSearchDocument.search_info(lane) - return feed_class.search( + response = feed_class.search( _db=self._db, title=info["name"], url=make_url(), @@ -1172,6 +1174,9 @@ def search(self, lane_identifier, feed_class=OPDSAcquisitionFeed): pagination=pagination, facets=facets, ) + if isinstance(response, ProblemDetail): + return response + return response.as_response(mime_types=flask.request.accept_mimetypes) def _qa_feed( self, feed_factory, feed_title, controller_name, facet_class, worklist_factory @@ -1305,23 +1310,22 @@ def publications(self): params: FeedRequestParameters = self._parse_feed_request() if params.problem: return params.problem - annotator = OPDS2PublicationsAnnotator( - flask.request.url, params.facets, params.pagination, params.library - ) lane = self.load_lane(None) + annotator = self.manager.annotator(lane, params.facets) max_age = flask.request.args.get("max_age") - feed = AcquisitonFeedOPDS2.publications( + feed = OPDSAcquisitionFeed.page( self._db, + lane.display_name, + flask.request.url, lane, + annotator, params.facets, params.pagination, self.search_engine, - annotator, - max_age=int(max_age) if max_age is not None else None, ) - - return Response( - str(feed), status=200, headers={"Content-Type": annotator.OPDS2_TYPE} + return feed.as_response( + mime_types=MIMEAccept([("application/opds+json", 1)]), # Force the type + max_age=int(max_age) if max_age is not None else None, ) def navigation(self): @@ -1464,7 +1468,17 @@ def sync(self): ) # Then make the feed. - return OPDSAcquisitionFeed.active_loans_for(self.circulation, patron) + feed = OPDSAcquisitionFeed.active_loans_for(self.circulation, patron) + response = feed.as_response( + max_age=0, + private=True, + mime_types=flask.request.accept_mimetypes, + ) + + last_modified = patron.last_loan_activity_sync + if last_modified: + response.last_modified = last_modified + return response def borrow(self, identifier_type, identifier, mechanism_id=None): """Create a new loan or hold for a book. diff --git a/core/feed_protocol/acquisition.py b/core/feed_protocol/acquisition.py index 7cc5c7630a..65ab077acf 100644 --- a/core/feed_protocol/acquisition.py +++ b/core/feed_protocol/acquisition.py @@ -475,7 +475,7 @@ def active_loans_for( patron: Patron, annotator: Optional[LibraryAnnotator] = None, **response_kwargs: Any, - ) -> OPDSFeedResponse: + ) -> OPDSAcquisitionFeed: """A patron specific feed that only contains the loans and holds of a patron""" db = Session.object_session(patron) active_loans_by_work = {} @@ -516,12 +516,7 @@ def active_loans_for( feed = OPDSAcquisitionFeed("Active loans and holds", url, works, annotator) feed.generate_feed() - response = feed.as_response(max_age=0, private=True) - - last_modified = patron.last_loan_activity_sync - if last_modified: - response.last_modified = last_modified - return response + return feed @classmethod def single_entry_loans_feed( @@ -592,9 +587,10 @@ def single_entry_loans_feed( entry = cls.single_entry(work, annotator, even_if_no_license_pool=True) - # TODO: max_age and private response kwargs if isinstance(entry, WorkEntry) and entry.computed: return cls.entry_as_response(entry, **response_kwargs) + elif isinstance(entry, OPDSMessage): + return cls.entry_as_response(entry, max_age=0) return None @@ -769,7 +765,7 @@ def search( pagination: Optional[Pagination] = None, facets: Optional[FacetsWithEntryPoint] = None, **response_kwargs: Any, - ) -> OPDSFeedResponse | ProblemDetail: + ) -> OPDSAcquisitionFeed | ProblemDetail: """Run a search against the given search engine and return the results as a Flask Response. @@ -833,8 +829,7 @@ def make_link(ep: Type[EntryPoint]) -> str: # technically searching the this lane; you are searching the # library's entire collection, using _some_ of the constraints # imposed by this lane (notably language and audience). - - return OPDSFeedResponse(response=feed.serialize(), **response_kwargs) + return feed @classmethod def from_query( diff --git a/core/feed_protocol/annotator/base.py b/core/feed_protocol/annotator/base.py index 1cab1aafb4..3eeab3544c 100644 --- a/core/feed_protocol/annotator/base.py +++ b/core/feed_protocol/annotator/base.py @@ -204,7 +204,7 @@ def categories(cls, work: Work) -> Dict[str, List[Dict[str, str]]]: # Add the audience as a category of schema # http://schema.org/audience if work.audience: - audience_uri = "audience" + audience_uri = "http://schema.org/audience" categories[audience_uri] = [dict(term=work.audience, label=work.audience)] # Any book can have a target age, but the target age @@ -294,6 +294,8 @@ def annotate_work_entry( if edition.subtitle: computed.subtitle = FeedEntryType(text=edition.subtitle) + if edition.sort_title: + computed.sort_title = FeedEntryType(text=edition.sort_title) author_entries = self.authors(edition) computed.contributors = author_entries.get("contributors", []) diff --git a/core/feed_protocol/annotator/circulation.py b/core/feed_protocol/annotator/circulation.py index d9233e3c3d..74d8678877 100644 --- a/core/feed_protocol/annotator/circulation.py +++ b/core/feed_protocol/annotator/circulation.py @@ -652,7 +652,12 @@ def acquisition_link( else: initial_type = None indirect_types = [] - link = Acquisition(href=href, rel=rel, type=initial_type) + link = Acquisition( + href=href, + rel=rel, + type=initial_type, + is_loan=True if active_loan else False, + ) indirect = cls.indirect_acquisition(indirect_types) if indirect is not None: @@ -1342,7 +1347,12 @@ def borrow_link( _external=True, ) rel = OPDSFeed.BORROW_REL - borrow_link = Acquisition(rel=rel, href=borrow_url, type=OPDSFeed.ENTRY_TYPE) + borrow_link = Acquisition( + rel=rel, + href=borrow_url, + type=OPDSFeed.ENTRY_TYPE, + is_hold=True if active_hold else False, + ) indirect_acquisitions: List[IndirectAcquisition] = [] for lpdm in fulfillment_mechanisms: diff --git a/core/feed_protocol/navigation.py b/core/feed_protocol/navigation.py index eeb611cd4a..74142dc633 100644 --- a/core/feed_protocol/navigation.py +++ b/core/feed_protocol/navigation.py @@ -1,7 +1,10 @@ +from __future__ import annotations + from typing import Any, Optional from sqlalchemy.orm import Session from typing_extensions import Self +from werkzeug.datastructures import MIMEAccept from core.feed_protocol.annotator.circulation import CirculationManagerAnnotator from core.feed_protocol.opds import BaseOPDSFeed @@ -79,7 +82,11 @@ def add_entry( entry.links.append(Link(rel="subsection", href=url, type=type)) self._feed.data_entries.append(entry) - def as_response(self, **kwargs: Any) -> OPDSFeedResponse: - response = super().as_response(**kwargs) + def as_response( + self, + mime_types: Optional[MIMEAccept] = None, + **kwargs: Any, + ) -> OPDSFeedResponse: + response = super().as_response(mime_types=mime_types, **kwargs) response.content_type = OPDSFeed.NAVIGATION_FEED_TYPE return response diff --git a/core/feed_protocol/opds.py b/core/feed_protocol/opds.py index b8d3781d87..c73d2cf9d4 100644 --- a/core/feed_protocol/opds.py +++ b/core/feed_protocol/opds.py @@ -1,48 +1,97 @@ from __future__ import annotations import logging -from typing import Any, List, Optional +from typing import Any, Dict, List, Optional, Type + +from werkzeug.datastructures import MIMEAccept from core.feed_protocol.base import FeedInterface +from core.feed_protocol.serializer.base import SerializerInterface from core.feed_protocol.serializer.opds import OPDS1Serializer +from core.feed_protocol.serializer.opds2 import OPDS2Serializer from core.feed_protocol.types import FeedData, WorkEntry from core.util.flask_util import OPDSEntryResponse, OPDSFeedResponse +from core.util.opds_writer import OPDSMessage + + +def get_serializer( + mime_types: Optional[MIMEAccept], +) -> SerializerInterface[Any]: + # Loop through and return whichever mimetype is encountered first + # Sort values by q-value first + serializers: Dict[str, Type[SerializerInterface[Any]]] = { + "application/opds+json": OPDS2Serializer, + "application/atom+xml": OPDS1Serializer, + } + if mime_types: + match = mime_types.best_match( + serializers.keys(), default="application/atom+xml" + ) + return serializers[match]() + # Default + return OPDS1Serializer() class BaseOPDSFeed(FeedInterface): def __init__( - self, title: str, url: str, precomposed_entries: Optional[List[Any]] = None + self, + title: str, + url: str, + precomposed_entries: Optional[List[OPDSMessage]] = None, ) -> None: self.url = url self.title = title self._precomposed_entries = precomposed_entries or [] self._feed = FeedData() - self._serializer = OPDS1Serializer() self.log = logging.getLogger(self.__class__.__name__) - def serialize(self) -> bytes: - return self._serializer.serialize_feed(self._feed) + def serialize(self, mime_types: Optional[MIMEAccept] = None) -> bytes: + serializer = get_serializer(mime_types) + return serializer.serialize_feed(self._feed) def add_link(self, href: str, rel: Optional[str] = None, **kwargs: Any) -> None: self._feed.add_link(href, rel=rel, **kwargs) - def as_response(self, **kwargs: Any) -> OPDSFeedResponse: + def as_response( + self, + mime_types: Optional[MIMEAccept] = None, + **kwargs: Any, + ) -> OPDSFeedResponse: """Serialize the feed using the serializer protocol""" + serializer = get_serializer(mime_types) return OPDSFeedResponse( - self._serializer.serialize_feed( + serializer.serialize_feed( self._feed, precomposed_entries=self._precomposed_entries ), + content_type=serializer.content_type(), **kwargs, ) @classmethod def entry_as_response( - cls, entry: WorkEntry, **response_kwargs: Any + cls, + entry: WorkEntry | OPDSMessage, + mime_types: Optional[MIMEAccept] = None, + **response_kwargs: Any, ) -> OPDSEntryResponse: + serializer = get_serializer(mime_types) + if isinstance(entry, OPDSMessage): + return OPDSEntryResponse( + response=serializer.to_string(serializer.serialize_opds_message(entry)), + status=entry.status_code, + content_type=serializer.content_type(), + **response_kwargs, + ) + + # A WorkEntry if not entry.computed: logging.getLogger().error(f"Entry data has not been generated for {entry}") raise ValueError(f"Entry data has not been generated") - serializer = OPDS1Serializer() - return OPDSEntryResponse( - response=serializer.serialize_work_entry(entry.computed), **response_kwargs + response = OPDSEntryResponse( + response=serializer.serialize_work_entry(entry.computed), + **response_kwargs, ) + if isinstance(serializer, OPDS2Serializer): + # Only OPDS2 has the same content type for feed and entry + response.content_type = serializer.content_type() + return response diff --git a/core/feed_protocol/serializer/base.py b/core/feed_protocol/serializer/base.py new file mode 100644 index 0000000000..424b4c87e5 --- /dev/null +++ b/core/feed_protocol/serializer/base.py @@ -0,0 +1,32 @@ +from abc import ABC, abstractmethod +from typing import Generic, List, Optional, TypeVar + +from core.feed_protocol.types import FeedData, WorkEntryData +from core.util.opds_writer import OPDSMessage + +T = TypeVar("T") + + +class SerializerInterface(ABC, Generic[T]): + @classmethod + @abstractmethod + def to_string(cls, data: T) -> bytes: + ... + + @abstractmethod + def serialize_feed( + self, feed: FeedData, precomposed_entries: Optional[List[OPDSMessage]] = None + ) -> bytes: + ... + + @abstractmethod + def serialize_work_entry(self, entry: WorkEntryData) -> T: + ... + + @abstractmethod + def serialize_opds_message(self, message: OPDSMessage) -> T: + ... + + @abstractmethod + def content_type(self) -> str: + ... diff --git a/core/feed_protocol/serializer/opds.py b/core/feed_protocol/serializer/opds.py index 7380f12b92..a21e91eacb 100644 --- a/core/feed_protocol/serializer/opds.py +++ b/core/feed_protocol/serializer/opds.py @@ -6,6 +6,7 @@ from lxml import etree +from core.feed_protocol.serializer.base import SerializerInterface from core.feed_protocol.types import ( Acquisition, Author, @@ -48,7 +49,7 @@ } -class OPDS1Serializer(OPDSFeed): +class OPDS1Serializer(SerializerInterface[etree._Element], OPDSFeed): """An OPDS 1.2 Atom feed serializer""" def __init__(self) -> None: @@ -93,7 +94,7 @@ def serialize_feed( if precomposed_entries: for precomposed in precomposed_entries: if isinstance(precomposed, OPDSMessage): - serialized.append(precomposed.tag) + serialized.append(self.serialize_opds_message(precomposed)) for link in feed.links: serialized.append(self._serialize_feed_entry("link", link)) @@ -236,6 +237,9 @@ def serialize_work_entry(self, feed_entry: WorkEntryData) -> etree._Element: return entry + def serialize_opds_message(self, entry: OPDSMessage) -> etree._Element: + return entry.tag + def _serialize_series_entry(self, series: FeedEntryType) -> etree._Element: entry = self._tag("series") if name := getattr(series, "name", None): @@ -354,3 +358,6 @@ def _serialize_data_entry(self, entry: DataEntry) -> etree._Element: @classmethod def to_string(cls, element: etree._Element) -> bytes: return cast(bytes, etree.tostring(element)) + + def content_type(self) -> str: + return OPDSFeed.ACQUISITION_FEED_TYPE diff --git a/core/feed_protocol/serializer/opds2.py b/core/feed_protocol/serializer/opds2.py index 9701dec120..8e95a931c6 100644 --- a/core/feed_protocol/serializer/opds2.py +++ b/core/feed_protocol/serializer/opds2.py @@ -1,27 +1,77 @@ import json -from typing import Any, Dict +from collections import defaultdict +from typing import Any, Dict, List, Optional -from core.feed_protocol.types import FeedData, Link, WorkEntryData +from core.feed_protocol.serializer.base import SerializerInterface +from core.feed_protocol.types import ( + Acquisition, + Author, + FeedData, + IndirectAcquisition, + Link, + WorkEntryData, +) +from core.model import Contributor +from core.util.opds_writer import OPDSMessage +ALLOWED_ROLES = [ + "translator", + "editor", + "artist", + "illustrator", + "letterer", + "penciler", + "colorist", + "inker", + "narrator", +] +MARC_CODE_TO_ROLES = { + code: name.lower() + for name, code in Contributor.MARC_ROLE_CODES.items() + if name.lower() in ALLOWED_ROLES +} -class OPDS2Serializer: + +class OPDS2Serializer(SerializerInterface[Dict[str, Any]]): def __init__(self) -> None: pass - def serialize_feed(self, feed: FeedData) -> bytes: + def serialize_feed( + self, feed: FeedData, precomposed_entries: Optional[List[Any]] = None + ) -> bytes: serialized: Dict[str, Any] = {"publications": []} + serialized["metadata"] = self._serialize_metadata(feed) + for entry in feed.entries: if entry.computed: - publication = self._serialize_work_entry(entry.computed) + publication = self.serialize_work_entry(entry.computed) serialized["publications"].append(publication) - return json.dumps(serialized, indent=2).encode() + serialized.update(self._serialize_feed_links(feed)) + + return self.to_string(serialized) + + def _serialize_metadata(self, feed: FeedData) -> Dict[str, Any]: + fmeta = feed.metadata + metadata: Dict[str, Any] = {} + if title := fmeta.get("title"): + metadata["title"] = title.text + if item_count := fmeta.get("items_per_page"): + metadata["itemsPerPage"] = int(item_count.text or 0) + return metadata + + def serialize_opds_message(self, entry: OPDSMessage) -> Dict[str, Any]: + return dict(urn=entry.urn, description=entry.message) - def _serialize_work_entry(self, data: WorkEntryData) -> Dict[str, Any]: + def serialize_work_entry(self, data: WorkEntryData) -> Dict[str, Any]: metadata: Dict[str, Any] = {} + if data.additionalType: + metadata["@type"] = data.additionalType + if data.title: metadata["title"] = data.title.text - metadata["sortAs"] = data.title.text # TODO: Change this! + if data.sort_title: + metadata["sortAs"] = data.sort_title.text if data.subtitle: metadata["subtitle"] = data.subtitle.text @@ -48,16 +98,28 @@ def _serialize_work_entry(self, data: WorkEntryData) -> Dict[str, Any]: { "scheme": subject.scheme, # type: ignore[attr-defined] "name": subject.label, # type: ignore[attr-defined] - "sortAs": subject.label, # type: ignore[attr-defined] # TODO: Change this! + "sortAs": subject.label, # type: ignore[attr-defined] # Same as above, don't think we have an alternate } ) metadata["subject"] = subjects + if data.series: + name = getattr(data.series, "name", None) + position = int(getattr(data.series, "position", 1)) + if name: + metadata["belongsTo"] = dict(name=name, position=position) + + if len(data.authors): + metadata["author"] = self._serialize_contributor(data.authors[0]) + for contributor in data.contributors: + if role := MARC_CODE_TO_ROLES.get(contributor.role or "", None): + metadata[role] = self._serialize_contributor(contributor) + images = [self._serialize_link(link) for link in data.image_links] - links = [ - self._serialize_link(link) - for link in data.acquisition_links + data.other_links - ] + links = [self._serialize_link(link) for link in data.other_links] + + for acquisition in data.acquisition_links: + links.append(self._serialize_acquisition_link(acquisition)) publication = {"metadata": metadata, "links": links, "images": images} return publication @@ -66,4 +128,86 @@ def _serialize_link(self, link: Link) -> Dict[str, Any]: serialized = {"href": link.href, "rel": link.rel} if link.type: serialized["type"] = link.type + if link.title: + serialized["title"] = link.title return serialized + + def _serialize_acquisition_link(self, link: Acquisition) -> Dict[str, Any]: + item = self._serialize_link(link) + + def _indirect(indirect: IndirectAcquisition) -> Dict[str, Any]: + result: Dict[str, Any] = dict(type=indirect.type) + if indirect.children: + result["child"] = [] + for child in indirect.children: + result["child"].append(_indirect(child)) + return result + + props: Dict[str, Any] = {} + if link.availability_status: + state = link.availability_status + if link.is_loan: + state = "ready" + elif link.is_hold: + state = "reserved" + # This only exists in the serializer because there is no case where cancellable is false, + # that logic should be in the annotator if it ever occurs + props["actions"] = dict(cancellable=True) + props["availability"] = dict(state=state) + if link.availability_since: + props["availability"]["since"] = link.availability_since + if link.availability_until: + props["availability"]["until"] = link.availability_until + + if link.indirect_acquisitions: + props["indirectAcquisition"] = [] + for indirect in link.indirect_acquisitions: + props["indirectAcquisition"].append(_indirect(indirect)) + + if link.lcp_hashed_passphrase: + props["lcp_hashed_passphrase"] = link.lcp_hashed_passphrase + + if link.drm_licensor: + props["licensor"] = { + "clientToken": getattr(link.drm_licensor, "clientToken"), + "vendor": getattr(link.drm_licensor, "vendor"), + } + + if props: + item["properties"] = props + + return item + + def _serialize_feed_links(self, feed: FeedData) -> Dict[str, Any]: + link_data: Dict[str, List[Dict[str, Any]]] = {"links": [], "facets": []} + for link in feed.links: + link_data["links"].append(self._serialize_link(link)) + + facet_links: Dict[str, Any] = defaultdict(lambda: {"metadata": {}, "links": []}) + for link in feed.facet_links: + group = getattr(link, "facetGroup", None) + if group: + facet_links[group]["links"].append(self._serialize_link(link)) + facet_links[group]["metadata"]["title"] = group + for _, facets in facet_links.items(): + link_data["facets"].append(facets) + + return link_data + + def _serialize_contributor(self, author: Author) -> Dict[str, Any]: + result: Dict[str, Any] = {"name": author.name} + if author.sort_name: + result["sortAs"] = author.sort_name + if author.link: + link = self._serialize_link(author.link) + # OPDS2 does not need "title" in the link + link.pop("title", None) + result["links"] = [link] + return result + + def content_type(self) -> str: + return "application/opds+json" + + @classmethod + def to_string(cls, data: Dict[str, Any]) -> bytes: + return json.dumps(data, indent=2).encode() diff --git a/core/feed_protocol/types.py b/core/feed_protocol/types.py index 704fd01fa0..1b6d1d4000 100644 --- a/core/feed_protocol/types.py +++ b/core/feed_protocol/types.py @@ -125,6 +125,10 @@ class Acquisition(Link): indirect_acquisitions: List[IndirectAcquisition] = field(default_factory=list) + # Signal if the acquisition is for a loan or a hold for the patron + is_loan: bool = False + is_hold: bool = False + @dataclass class Author(FeedEntryType): @@ -153,6 +157,7 @@ class WorkEntryData(BaseModel): published: Optional[FeedEntryType] = None updated: Optional[FeedEntryType] = None title: Optional[FeedEntryType] = None + sort_title: Optional[FeedEntryType] = None subtitle: Optional[FeedEntryType] = None series: Optional[FeedEntryType] = None imprint: Optional[FeedEntryType] = None diff --git a/core/opds_schema.py b/core/opds_schema.py index 23f24a0b87..05717c1811 100644 --- a/core/opds_schema.py +++ b/core/opds_schema.py @@ -36,6 +36,8 @@ def validate_schema(self, schema_path: str, feed: dict): class OPDS2SchemaValidation(OPDS2ImportMonitor, OPDS2SchemaValidationMixin): def import_one_feed(self, feed): + if type(feed) in (str, bytes): + feed = json.loads(feed) self.validate_schema("core/resources/opds2_schema/feed.schema.json", feed) return [], [] diff --git a/tests/api/feed_protocol/equivalence/test_feed_equivalence.py b/tests/api/feed_protocol/equivalence/test_feed_equivalence.py index 15403c857c..2656782203 100644 --- a/tests/api/feed_protocol/equivalence/test_feed_equivalence.py +++ b/tests/api/feed_protocol/equivalence/test_feed_equivalence.py @@ -116,7 +116,7 @@ def test_page_feed_with_loan_annotator( work1.active_license_pool(library).loan_to(patron) with app.test_request_context("/"): - new_feed = OPDSAcquisitionFeed.active_loans_for(None, patron) + new_feed = OPDSAcquisitionFeed.active_loans_for(None, patron).as_response() old_feed = OldLibraryLoanAndHoldAnnotator.active_loans_for(None, patron) assert_equal_xmls(str(old_feed), str(new_feed)) @@ -180,7 +180,7 @@ def test_search_feed(self, annotator_fixture: LibraryAnnotatorFixture): with app.test_request_context("/"): new_annotator = LibraryAnnotator(None, lane, library) - new_feed = OPDSAcquisitionFeed.search( + new_feed = OPDSAcquisitionFeed.search( # type: ignore[union-attr] db.session, "Search", "http://search/", @@ -190,7 +190,7 @@ def test_search_feed(self, annotator_fixture: LibraryAnnotatorFixture): new_annotator, Pagination.default(), Facets.default(library), - ) + ).as_response() old_annotator = OldLibraryAnnotator(None, lane, library) old_feed = AcquisitionFeed.search( diff --git a/tests/api/feed_protocol/test_library_annotator.py b/tests/api/feed_protocol/test_library_annotator.py index 5ae9640906..9001ea5621 100644 --- a/tests/api/feed_protocol/test_library_annotator.py +++ b/tests/api/feed_protocol/test_library_annotator.py @@ -914,25 +914,12 @@ def test_active_loan_feed( patron=patron, ) - response = OPDSAcquisitionFeed.active_loans_for(None, patron, annotator) + response = OPDSAcquisitionFeed.active_loans_for( + None, patron, annotator + ).as_response() # The feed is private and should not be cached. assert isinstance(response, OPDSFeedResponse) - assert 0 == response.max_age - assert True == response.private - - # Instead, the Last-Modified header is set to the last time - # we successfully brought the patron's bookshelf in sync with - # the vendor APIs. - # - # (The timestamps aren't exactly the same because - # last_loan_activity_sync is tracked at the millisecond level - # and Last-Modified is tracked at the second level.) - - assert response.last_modified is not None - assert ( - patron.last_loan_activity_sync - response.last_modified - ).total_seconds() < 1 # No entries in the feed... raw = str(response) @@ -1016,7 +1003,7 @@ def test_active_loan_feed( annotator_fixture.db.default_library(), patron=patron, ), - ) + ).as_response() raw = str(feed_obj) feed = feedparser.parse(raw) @@ -1060,7 +1047,7 @@ def test_loan_feed_includes_patron( LibraryLoanAndHoldAnnotator( None, None, annotator_fixture.db.default_library(), patron ), - ) + ).as_response() raw = str(feed_obj) feed_details = feedparser.parse(raw)["feed"] @@ -1078,7 +1065,7 @@ def test_loans_feed_includes_annotations_link( self, annotator_fixture: LibraryAnnotatorFixture ): patron = annotator_fixture.db.patron() - feed_obj = OPDSAcquisitionFeed.active_loans_for(None, patron) + feed_obj = OPDSAcquisitionFeed.active_loans_for(None, patron).as_response() raw = str(feed_obj) feed = feedparser.parse(raw)["feed"] links = feed["links"] @@ -1107,7 +1094,7 @@ def test_active_loan_feed_ignores_inconsistent_local_data( hold.license_pool = None # We can still get a feed... - feed_obj = OPDSAcquisitionFeed.active_loans_for(None, patron) + feed_obj = OPDSAcquisitionFeed.active_loans_for(None, patron).as_response() # ...but it's empty. assert "" not in str(feed_obj) @@ -1171,7 +1158,7 @@ def test_loans_feed_includes_fulfill_links( feed_obj = OPDSAcquisitionFeed.active_loans_for( None, patron, - ) + ).as_response() raw = str(feed_obj) entries = feedparser.parse(raw)["entries"] @@ -1196,7 +1183,7 @@ def test_loans_feed_includes_fulfill_links( library = annotator_fixture.db.default_library() settings = library_fixture.settings(library) settings.hidden_content_types = [mech1.delivery_mechanism.content_type] - OPDSAcquisitionFeed.active_loans_for(None, patron) + OPDSAcquisitionFeed.active_loans_for(None, patron).as_response() assert { mech2.delivery_mechanism.drm_scheme_media_type, OPDSFeed.ENTRY_TYPE, @@ -1207,7 +1194,7 @@ def test_loans_feed_includes_fulfill_links( # and the streaming mechanism. loan.fulfillment = mech1 - feed_obj = OPDSAcquisitionFeed.active_loans_for(None, patron) + feed_obj = OPDSAcquisitionFeed.active_loans_for(None, patron).as_response() raw = str(feed_obj) entries = feedparser.parse(raw)["entries"] diff --git a/tests/api/feed_protocol/test_opds2_serializer.py b/tests/api/feed_protocol/test_opds2_serializer.py new file mode 100644 index 0000000000..8145035a8c --- /dev/null +++ b/tests/api/feed_protocol/test_opds2_serializer.py @@ -0,0 +1,215 @@ +import json + +from core.feed_protocol.serializer.opds2 import OPDS2Serializer +from core.feed_protocol.types import ( + Acquisition, + Author, + FeedData, + FeedEntryType, + IndirectAcquisition, + Link, + WorkEntry, + WorkEntryData, +) +from core.model.edition import Edition +from core.model.identifier import Identifier +from core.model.work import Work +from core.util.opds_writer import OPDSMessage + + +class TestOPDS2Serializer: + def test_serialize_feed(self): + feed = FeedData( + metadata=dict( + items_per_page=FeedEntryType(text="20"), + title=FeedEntryType(text="Title"), + ) + ) + w = WorkEntry( + work=Work(), + edition=Edition(), + identifier=Identifier(), + ) + w.computed = WorkEntryData(identifier="identifier", pwid="permanent-id") + feed.entries = [w] + feed.links = [Link(href="http://link", rel="link-rel")] + feed.facet_links = [ + Link.create( + href="http://facet-link", rel="facet-rel", facetGroup="FacetGroup" + ) + ] + + serialized = OPDS2Serializer().serialize_feed(feed) + result = json.loads(serialized) + + assert result["metadata"]["title"] == "Title" + assert result["metadata"]["itemsPerPage"] == 20 + + assert len(result["publications"]) == 1 + assert result["publications"][0] == dict( + metadata={"identifier": "identifier"}, images=[], links=[] + ) + + assert len(result["links"]) == 1 + assert result["links"][0] == dict(href="http://link", rel="link-rel") + + assert len(result["facets"]) == 1 + assert result["facets"][0] == dict( + metadata={"title": "FacetGroup"}, + links=[{"href": "http://facet-link", "rel": "facet-rel"}], + ) + + def test_serialize_work_entry(self): + data = WorkEntryData( + additionalType="type", + title=FeedEntryType(text="The Title"), + sort_title=FeedEntryType(text="Title, The"), + subtitle=FeedEntryType(text="Sub Title"), + identifier="urn:id", + language=FeedEntryType(text="de"), + updated=FeedEntryType(text="2022-02-02"), + published=FeedEntryType(text="2020-02-02"), + summary=FeedEntryType(text="Summary"), + publisher=FeedEntryType(text="Publisher"), + imprint=FeedEntryType(text="Imprint"), + categories=[ + FeedEntryType.create(scheme="scheme", label="label"), + ], + series=FeedEntryType.create(name="Series", position="3"), + image_links=[Link(href="http://image", rel="image-rel")], + acquisition_links=[ + Acquisition(href="http://acquisition", rel="acquisition-rel") + ], + other_links=[Link(href="http://link", rel="rel")], + ) + + serializer = OPDS2Serializer() + + entry = serializer.serialize_work_entry(data) + metadata = entry["metadata"] + + assert metadata["@type"] == data.additionalType + assert metadata["title"] == data.title.text + assert metadata["sortAs"] == data.sort_title.text + assert metadata["subtitle"] == data.subtitle.text + assert metadata["identifier"] == data.identifier + assert metadata["language"] == data.language.text + assert metadata["modified"] == data.updated.text + assert metadata["published"] == data.published.text + assert metadata["description"] == data.summary.text + assert metadata["publisher"] == dict(name=data.publisher.text) + assert metadata["imprint"] == dict(name=data.imprint.text) + assert metadata["subject"] == [ + dict(scheme="scheme", name="label", sortAs="label") + ] + assert metadata["belongsTo"] == dict(name="Series", position=3) + + assert entry["links"] == [ + dict(href="http://link", rel="rel"), + dict(href="http://acquisition", rel="acquisition-rel"), + ] + assert entry["images"] == [dict(href="http://image", rel="image-rel")] + + # Test the different author types + data = WorkEntryData( + authors=[Author(name="author1"), Author(name="author2")], + contributors=[ + Author(name="translator", role="trl"), + Author(name="editor", role="edt"), + Author(name="artist", role="art"), + Author(name="illustrator", role="ill"), + Author(name="letterer", role="ctb"), + Author(name="penciller", role="ctb"), + Author(name="colorist", role="clr"), + Author(name="inker", role="ctb"), + Author(name="narrator", role="nrt"), + Author(name="narrator2", role="nrt"), + ], + ) + + entry = serializer.serialize_work_entry(data) + metadata = entry["metadata"] + # Only the first author is considered + assert metadata["author"] == dict(name="author1") + # Of the allowed roles + assert metadata["translator"] == dict(name="translator") + assert metadata["editor"] == dict(name="editor") + assert metadata["artist"] == dict(name="artist") + assert metadata["illustrator"] == dict(name="illustrator") + assert metadata["colorist"] == dict(name="colorist") + # Of letterer, penciller, and inker, only inker is used, since the marc roles overlap + assert metadata["inker"] == dict(name="inker") + # Of repeated roles, only the last entry is picked + assert metadata["narrator"] == dict(name="narrator2") + + def test__serialize_acquisition_link(self): + serializer = OPDS2Serializer() + acquisition = Acquisition( + href="http://acquisition", + rel="acquisition", + availability_status="available", + availability_since="2022-02-02", + availability_until="2222-02-02", + indirect_acquisitions=[ + IndirectAcquisition( + type="indirect1", + children=[ + IndirectAcquisition(type="indirect1-1"), + IndirectAcquisition(type="indirect1-2"), + ], + ), + ], + ) + + result = serializer._serialize_acquisition_link(acquisition) + + assert result["href"] == acquisition.href + assert result["rel"] == acquisition.rel + assert result["properties"] == dict( + availability={ + "since": "2022-02-02", + "until": "2222-02-02", + "state": "available", + }, + indirectAcquisition=[ + { + "type": "indirect1", + "child": [{"type": "indirect1-1"}, {"type": "indirect1-2"}], + } + ], + ) + + # Test availability states + acquisition = Acquisition( + href="http://hold", + rel="hold", + is_hold=True, + availability_status="available", + ) + result = serializer._serialize_acquisition_link(acquisition) + assert result["properties"]["availability"]["state"] == "reserved" + + acquisition = Acquisition( + href="http://loan", + rel="loan", + is_loan=True, + availability_status="available", + ) + result = serializer._serialize_acquisition_link(acquisition) + assert result["properties"]["availability"]["state"] == "ready" + + def test__serialize_contributor(self): + author = Author( + name="Author", + sort_name="Author,", + link=Link(href="http://author", rel="contributor", title="Delete me!"), + ) + result = OPDS2Serializer()._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( + OPDSMessage("URN", 200, "Description") + ) == dict(urn="URN", description="Description") diff --git a/tests/api/feed_protocol/test_opds_acquisition_feed.py b/tests/api/feed_protocol/test_opds_acquisition_feed.py index ec9d5082c7..ade74edddb 100644 --- a/tests/api/feed_protocol/test_opds_acquisition_feed.py +++ b/tests/api/feed_protocol/test_opds_acquisition_feed.py @@ -2,9 +2,11 @@ import logging from collections import defaultdict from typing import Any, Callable, Generator, List, Type +from unittest.mock import MagicMock, patch import pytest from sqlalchemy.orm import Session +from werkzeug.datastructures import MIMEAccept from core.entrypoint import ( AudiobooksEntryPoint, @@ -20,21 +22,71 @@ from core.feed_protocol.annotator.circulation import ( AcquisitionHelper, CirculationManagerAnnotator, + LibraryAnnotator, ) +from core.feed_protocol.annotator.loan_and_hold import LibraryLoanAndHoldAnnotator from core.feed_protocol.annotator.verbose import VerboseAnnotator from core.feed_protocol.navigation import NavigationFeed -from core.feed_protocol.types import FeedData, Link, WorkEntry +from core.feed_protocol.opds import BaseOPDSFeed +from core.feed_protocol.types import FeedData, Link, WorkEntry, WorkEntryData from core.lane import Facets, FeaturedFacets, Lane, Pagination, SearchFacets, WorkList from core.model import DeliveryMechanism, Representation from core.model.constants import LinkRelations from core.opds import MockUnfulfillableAnnotator from core.util.datetime_helpers import utc_now -from core.util.flask_util import OPDSFeedResponse +from core.util.flask_util import OPDSEntryResponse, OPDSFeedResponse from core.util.opds_writer import OPDSFeed, OPDSMessage +from tests.api.feed_protocol.fixtures import PatchedUrlFor, patch_url_for # noqa from tests.fixtures.database import DatabaseTransactionFixture from tests.fixtures.search import ExternalSearchPatchFixture +class TestOPDSFeedProtocol: + def test_entry_as_response(self, db: DatabaseTransactionFixture): + work = db.work() + entry = WorkEntry( + work=work, + edition=work.presentation_edition, + identifier=work.presentation_edition.primary_identifier, + ) + + with pytest.raises(ValueError) as raised: + BaseOPDSFeed.entry_as_response(entry) + assert str(raised.value) == "Entry data has not been generated" + + entry.computed = WorkEntryData() + + response = BaseOPDSFeed.entry_as_response(entry) + assert isinstance(response, OPDSEntryResponse) + # default content type is XML + assert response.content_type == OPDSEntryResponse().content_type + + # Specifically asking for a json type + response = BaseOPDSFeed.entry_as_response( + entry, mime_types=MIMEAccept([("application/opds+json", 0.9)]) + ) + assert isinstance(response, OPDSEntryResponse) + assert response.content_type == "application/opds+json" + + response = BaseOPDSFeed.entry_as_response( + OPDSMessage("URN", 204, "Test OPDS Message") + ) + assert isinstance(response, OPDSEntryResponse) + assert response.status_code == 204 + assert ( + b"Test OPDS Message" + in response.data + ) + + response = BaseOPDSFeed.entry_as_response( + OPDSMessage("URN", 204, "Test OPDS Message"), + mime_types=MIMEAccept([("application/opds+json", 1)]), + ) + assert isinstance(response, OPDSEntryResponse) + assert response.status_code == 204 + assert dict(description="Test OPDS Message", urn="URN") == response.json + + class MockAnnotator(CirculationManagerAnnotator): def __init__(self): self.lanes_by_work = defaultdict(list) @@ -893,6 +945,75 @@ def facet_link(cls, url, facet_title, group_title, selected): assert Facets.GROUP_DISPLAY_TITLES[Facets.COLLECTION_FACET_GROUP_NAME] == group assert True == selected + def test_active_loans_for_with_holds( + self, db: DatabaseTransactionFixture, patch_url_for: PatchedUrlFor + ): + patron = db.patron() + work = db.work(with_license_pool=True) + hold, _ = work.active_license_pool().on_hold_to(patron) + + feed = OPDSAcquisitionFeed.active_loans_for( + None, patron, LibraryAnnotator(None, None, db.default_library()) + ) + assert feed.annotator.active_holds_by_work == {work: hold} + + def test_single_entry_loans_feed_errors(self, db: DatabaseTransactionFixture): + with pytest.raises(ValueError) as raised: + # Mandatory loans item was missing + OPDSAcquisitionFeed.single_entry_loans_feed(None, None) # type: ignore[arg-type] + assert str(raised.value) == "Argument 'item' must be non-empty" + + with pytest.raises(ValueError) as raised: + # Mandatory loans item was incorrect + OPDSAcquisitionFeed.single_entry_loans_feed(None, object()) # type: ignore[arg-type] + assert "Argument 'item' must be an instance of" in str(raised.value) + + # A work and pool that has no edition, will not have an entry + work = db.work(with_open_access_download=True) + pool = work.active_license_pool() + work.presentation_edition = None + pool.presentation_edition = None + response = OPDSAcquisitionFeed.single_entry_loans_feed(MagicMock(), pool) + assert isinstance(response, OPDSEntryResponse) + assert response.status_code == 403 + + def test_single_entry_loans_feed_default_annotator( + self, db: DatabaseTransactionFixture + ): + patron = db.patron() + work = db.work(with_license_pool=True) + pool = work.active_license_pool() + assert pool is not None + loan, _ = pool.loan_to(patron) + + with patch.object(OPDSAcquisitionFeed, "single_entry") as mock: + mock.return_value = None + response = OPDSAcquisitionFeed.single_entry_loans_feed(None, loan) + + assert response == None + assert mock.call_count == 1 + _work, annotator = mock.call_args[0] + assert isinstance(annotator, LibraryLoanAndHoldAnnotator) + assert _work == work + assert annotator.library == db.default_library() + + def test_single_entry_with_edition(self, db: DatabaseTransactionFixture): + work = db.work(with_license_pool=True) + annotator = object() + + with patch.object(OPDSAcquisitionFeed, "_create_entry") as mock: + OPDSAcquisitionFeed.single_entry( + work.presentation_edition, annotator, even_if_no_license_pool=True # type: ignore[arg-type] + ) + + assert mock.call_count == 1 + _work, _pool, _edition, _identifier, _annotator = mock.call_args[0] + assert _work == work + assert _pool == None + assert _edition == work.presentation_edition + assert _identifier == work.presentation_edition.primary_identifier + assert _annotator == annotator + class TestEntrypointLinkInsertionFixture: db: DatabaseTransactionFixture diff --git a/tests/api/feed_protocol/test_opds_base.py b/tests/api/feed_protocol/test_opds_base.py new file mode 100644 index 0000000000..5900b7dc5b --- /dev/null +++ b/tests/api/feed_protocol/test_opds_base.py @@ -0,0 +1,57 @@ +from flask import Request + +from core.feed_protocol.opds import get_serializer +from core.feed_protocol.serializer.opds import OPDS1Serializer +from core.feed_protocol.serializer.opds2 import OPDS2Serializer + + +class TestBaseOPDSFeed: + def test_get_serializer(self): + # The q-value should take priority + request = Request.from_values( + headers=dict( + Accept="application/atom+xml;q=0.8,application/opds+json;q=0.9" + ) + ) + assert isinstance(get_serializer(request.accept_mimetypes), OPDS2Serializer) + + # 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) + + request = Request.from_values( + headers=dict( + Accept="application/atom+xml;profile=opds-catalog;kind=acquisition" + ) + ) + assert isinstance(get_serializer(request.accept_mimetypes), OPDS1Serializer) + + # 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) + + # 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) + + # Same q-values respect order fo arrival + 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), OPDS2Serializer) + + # 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) diff --git a/tests/api/feed_protocol/test_opds_serializer.py b/tests/api/feed_protocol/test_opds_serializer.py index 7169092fff..5d18ebb0b0 100644 --- a/tests/api/feed_protocol/test_opds_serializer.py +++ b/tests/api/feed_protocol/test_opds_serializer.py @@ -12,7 +12,7 @@ Link, WorkEntryData, ) -from core.util.opds_writer import OPDSFeed +from core.util.opds_writer import OPDSFeed, OPDSMessage class TestOPDSSerializer: @@ -224,3 +224,9 @@ def test_serialize_work_entry_empty(self): # 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() + result = serializer.serialize_opds_message(message) + assert serializer.to_string(result) == serializer.to_string(message.tag) diff --git a/tests/api/test_controller_opdsfeed.py b/tests/api/test_controller_opdsfeed.py index c05579c723..c1476f78eb 100644 --- a/tests/api/test_controller_opdsfeed.py +++ b/tests/api/test_controller_opdsfeed.py @@ -505,7 +505,9 @@ class Mock: @classmethod def search(cls, **kwargs): self.called_with = kwargs - return "An OPDS feed" + resp = MagicMock() + resp.as_response.return_value = "An OPDS feed" + return resp with circulation_fixture.request_context_with_library( "/?q=t&size=99&after=22&media=Music"