From c8da719ed44e134cd9e61b12eedf7ac0f6701a48 Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Fri, 22 May 2026 17:49:52 -0500 Subject: [PATCH] Extract resource presenter --- backend/app/api/v1/presenters/__init__.py | 13 + backend/app/api/v1/presenters/resource.py | 532 ++++++++++++++++++ backend/app/api/v1/utils.py | 518 ++--------------- .../tests/api/v1/test_resource_presenter.py | 272 +++++++++ 4 files changed, 865 insertions(+), 470 deletions(-) create mode 100644 backend/app/api/v1/presenters/__init__.py create mode 100644 backend/app/api/v1/presenters/resource.py create mode 100644 backend/tests/api/v1/test_resource_presenter.py diff --git a/backend/app/api/v1/presenters/__init__.py b/backend/app/api/v1/presenters/__init__.py new file mode 100644 index 00000000..13542370 --- /dev/null +++ b/backend/app/api/v1/presenters/__init__.py @@ -0,0 +1,13 @@ +"""Presentation helpers for API v1 response objects.""" + +from app.api.v1.presenters.resource import ( + RESOURCE_PRESENTATION_UNSET, + ResourceHydrationContext, + ResourcePresenter, +) + +__all__ = [ + "RESOURCE_PRESENTATION_UNSET", + "ResourceHydrationContext", + "ResourcePresenter", +] diff --git a/backend/app/api/v1/presenters/resource.py b/backend/app/api/v1/presenters/resource.py new file mode 100644 index 00000000..c0a25d30 --- /dev/null +++ b/backend/app/api/v1/presenters/resource.py @@ -0,0 +1,532 @@ +"""Resource presentation for the public JSON:API contract.""" + +from __future__ import annotations + +import json +import logging +from dataclasses import dataclass +from typing import Any + +from app.services.distribution_repository import DistributionContext +from app.services.ogm_field_mapper import OGMFieldMapper + +logger = logging.getLogger(__name__) + +RESOURCE_PRESENTATION_UNSET = object() + + +@dataclass(slots=True) +class ResourceHydrationContext: + """Preloaded data used to render a resource without extra per-item lookups.""" + + distribution_context: DistributionContext | None = None + ui_downloads: list[dict[str, Any]] | None = None + bridge_asset_download_rows: Any = None + licensed_accesses_payload: list[dict[str, Any]] | None | object = RESOURCE_PRESENTATION_UNSET + ui_relationships: dict[str, Any] | None = None + ui_relationship_counts: dict[str, int] | None = None + ui_relationship_browse_links: dict[str, str] | None = None + allmaps_attributes: dict[str, Any] | None = None + data_dictionaries_payload: list[dict[str, Any]] | None | object = RESOURCE_PRESENTATION_UNSET + thumbnail_asset_url: str | None | object = RESOURCE_PRESENTATION_UNSET + + +class ResourcePresenter: + """Build stable resource response objects from raw resource rows.""" + + ui_field_names = { + "ui_thumbnail_url", + "ui_resource_class_icon_url", + "ui_citation", + "ui_citations", + "ui_downloads", + "ui_licensed_accesses", + "ui_links", + "ui_viewer_protocol", + "ui_viewer_endpoint", + "ui_viewer_geometry", + "ui_relationships", + "ui_relationship_counts", + "ui_relationship_browse_links", + "ui_summaries", + "ai_summaries", + "suggest", + } + + def __init__(self, session=None): + self.session = session + + @staticmethod + def serialize_jsonapi_resource(resource_data, request_url=None): + """Create the JSON:API resource object for a resource attribute payload.""" + api_utils = _api_utils() + + ui_fields = {} + core_attributes = {} + + for key, value in resource_data.items(): + if key in ResourcePresenter.ui_field_names: + ui_fields[key] = value + elif value is not None: + core_attributes[key] = value + + core_attributes = api_utils.filter_empty_values(core_attributes) + + resource_id = core_attributes.get("id") or resource_data.get("id", "") + + ogm_fields = {} + b1g_fields = {} + ogm_aardvark_field_set = OGMFieldMapper.get_ogm_aardvark_fields() + + for key, value in core_attributes.items(): + if key in ogm_aardvark_field_set: + ogm_fields[key] = value + else: + b1g_fields[key] = value + + ogm_fields = api_utils.filter_empty_values(ogm_fields) + b1g_fields = api_utils.filter_empty_values(b1g_fields) + + nested_attributes = {} + if ogm_fields: + nested_attributes["ogm"] = ogm_fields + if b1g_fields: + nested_attributes["b1g"] = b1g_fields + + restructured_ui = {} + + if "ui_thumbnail_url" in ui_fields and ui_fields["ui_thumbnail_url"] is not None: + thumbnail_url = ui_fields["ui_thumbnail_url"] + restructured_ui["thumbnail_url"] = thumbnail_url + if "/thumbnails/placeholder" in str(thumbnail_url): + restructured_ui["thumbnail_placeholder"] = True + if ( + "ui_resource_class_icon_url" in ui_fields + and ui_fields["ui_resource_class_icon_url"] is not None + ): + restructured_ui["resource_class_icon_url"] = ui_fields["ui_resource_class_icon_url"] + if "ui_citation" in ui_fields: + restructured_ui["citation"] = ui_fields["ui_citation"] + if "ui_citations" in ui_fields: + restructured_ui["citations"] = ui_fields["ui_citations"] + if "ui_downloads" in ui_fields: + restructured_ui["downloads"] = ui_fields["ui_downloads"] + if "ui_licensed_accesses" in ui_fields: + restructured_ui["licensed_accesses"] = ui_fields["ui_licensed_accesses"] + if "ui_links" in ui_fields: + restructured_ui["links"] = ui_fields["ui_links"] + if "ui_relationships" in ui_fields: + restructured_ui["relationships"] = ui_fields["ui_relationships"] + if "ui_relationship_counts" in ui_fields: + restructured_ui["relationship_counts"] = ui_fields["ui_relationship_counts"] + if "ui_relationship_browse_links" in ui_fields: + restructured_ui["relationship_browse_links"] = ui_fields["ui_relationship_browse_links"] + if "ui_summaries" in ui_fields: + restructured_ui["summaries"] = ui_fields["ui_summaries"] + if "ai_summaries" in ui_fields: + restructured_ui["ai_summaries"] = ui_fields["ai_summaries"] + if "suggest" in ui_fields: + restructured_ui["suggest"] = ui_fields["suggest"] + + viewer_fields = {} + if "ui_viewer_protocol" in ui_fields: + viewer_fields["protocol"] = ui_fields["ui_viewer_protocol"] + if "ui_viewer_endpoint" in ui_fields: + viewer_fields["endpoint"] = ui_fields["ui_viewer_endpoint"] + if "ui_viewer_geometry" in ui_fields: + viewer_fields["geometry"] = ui_fields["ui_viewer_geometry"] + + if viewer_fields: + restructured_ui["viewer"] = viewer_fields + + return { + "type": "resource", + "id": str(resource_id), + "attributes": nested_attributes if nested_attributes else {}, + "meta": { + "@context": "https://gin.btaa.org/ld/contexts/ogm-aardvark-btaa.context.jsonld", + "@type": "BtaaAardvarkRecord", + "ui": restructured_ui, + }, + } + + async def present_full( + self, + resource_dict, + *, + apply_field_mapping: bool = True, + include_similar_items: bool = True, + hot_only_thumbnail_url: bool = False, + hydration: ResourceHydrationContext | None = None, + ): + """Render the full resource profile used by resource detail and list paths.""" + from app.services.citation_service import CitationService + from app.services.download_service import DownloadService + from app.services.link_service import LinkService + from app.services.relationship_service import RelationshipService + from app.services.viewer_service import ViewerService + + api_utils = _api_utils() + hydration = hydration or ResourceHydrationContext() + + if apply_field_mapping: + resource_dict = OGMFieldMapper.map_resource_fields(resource_dict) + + distribution_context = hydration.distribution_context + if distribution_context is None: + distribution_context = await api_utils.fetch_distribution_context( + resource_dict["id"], + session=self.session, + ) + + thumbnail_kwargs: dict[str, Any] = {"distribution_context": distribution_context} + if hot_only_thumbnail_url: + thumbnail_kwargs["hot_only"] = True + resource_dict = api_utils.add_thumbnail_url(resource_dict, **thumbnail_kwargs) + if not resource_dict.get("ui_thumbnail_url"): + resource_dict["ui_resource_class_icon_url"] = api_utils._hot_resource_class_icon_url( + resource_dict + ) + + citation_service = CitationService(resource_dict, distribution_context=distribution_context) + ui_citations = citation_service.get_all_citations() + ui_citation = ui_citations["apa"] + + viewer_service = ViewerService(resource_dict, distribution_context=distribution_context) + viewer_attributes = viewer_service.get_viewer_attributes() + + download_service = DownloadService(resource_dict, distribution_context=distribution_context) + ui_downloads = hydration.ui_downloads + if ui_downloads is None: + ui_downloads = await download_service.get_download_options_with_bridge_asset_downloads( + hydration.bridge_asset_download_rows + ) + + link_service = LinkService(resource_dict, distribution_context=distribution_context) + ui_links = link_service.get_links() + + ui_relationships = hydration.ui_relationships + if ui_relationships is None: + ui_relationships = await RelationshipService.get_resource_relationships( + resource_dict["id"] + ) + + allmaps_attributes = hydration.allmaps_attributes + if allmaps_attributes is None: + allmaps_attributes = await api_utils._fetch_allmaps_attributes_for_resource( + resource_dict, self.session + ) + + attributes = { + **resource_dict, + "ui_citation": ui_citation, + "ui_citations": ui_citations, + "ui_thumbnail_url": resource_dict.get("ui_thumbnail_url"), + "ui_resource_class_icon_url": resource_dict.get("ui_resource_class_icon_url"), + "ui_viewer_endpoint": viewer_attributes.get("ui_viewer_endpoint"), + "ui_viewer_geometry": viewer_attributes.get("ui_viewer_geometry"), + "ui_viewer_protocol": viewer_attributes.get("ui_viewer_protocol"), + "ui_downloads": ui_downloads, + "ui_links": ui_links, + "ui_relationships": ui_relationships, + } + if hydration.ui_relationship_counts: + attributes["ui_relationship_counts"] = hydration.ui_relationship_counts + if hydration.ui_relationship_browse_links: + attributes["ui_relationship_browse_links"] = hydration.ui_relationship_browse_links + + await self._attach_data_dictionaries(attributes, resource_dict, hydration) + await self._attach_licensed_accesses(attributes, resource_dict, hydration) + self._attach_legacy_references(attributes, distribution_context) + self._merge_viewer_attributes(attributes, viewer_attributes) + + resource = self.serialize_jsonapi_resource(attributes) + + await self._apply_thumbnail_asset( + resource, + resource_dict, + distribution_context=distribution_context, + thumbnail_asset_url=hydration.thumbnail_asset_url, + allow_resource_fallback=True, + ) + self._attach_allmaps(resource, allmaps_attributes) + self._attach_static_map(resource, resource_dict) + + if include_similar_items: + resource = await api_utils.add_similar_items_to_resource( + resource, resource_dict, self.session + ) + + return resource + + async def present_homepage( + self, + resource_dict, + *, + apply_field_mapping: bool = True, + hydration: ResourceHydrationContext | None = None, + ): + """Render the lightweight profile used by homepage previews.""" + from app.services.viewer_service import ViewerService + + api_utils = _api_utils() + hydration = hydration or ResourceHydrationContext() + + if apply_field_mapping: + resource_dict = OGMFieldMapper.map_resource_fields(resource_dict) + + distribution_context = hydration.distribution_context + if distribution_context is None: + distribution_context = await api_utils.fetch_distribution_context( + resource_dict["id"], + session=self.session, + ) + + resource_dict = api_utils.add_thumbnail_url( + resource_dict, distribution_context=distribution_context + ) + + viewer_service = ViewerService(resource_dict, distribution_context=distribution_context) + viewer_attributes = viewer_service.get_viewer_attributes() + + allmaps_attributes = hydration.allmaps_attributes + if allmaps_attributes is None: + allmaps_attributes = await api_utils._fetch_allmaps_attributes_for_resource( + resource_dict, self.session + ) + + attributes = { + **resource_dict, + "ui_thumbnail_url": resource_dict.get("ui_thumbnail_url"), + "ui_viewer_endpoint": viewer_attributes.get("ui_viewer_endpoint"), + "ui_viewer_geometry": viewer_attributes.get("ui_viewer_geometry"), + "ui_viewer_protocol": viewer_attributes.get("ui_viewer_protocol"), + } + + self._merge_viewer_attributes(attributes, viewer_attributes) + + resource = self.serialize_jsonapi_resource(attributes) + + await self._apply_thumbnail_asset( + resource, + resource_dict, + distribution_context=distribution_context, + thumbnail_asset_url=hydration.thumbnail_asset_url, + allow_resource_fallback=True, + ) + self._attach_allmaps(resource, allmaps_attributes) + + return resource + + async def present_search_result( + self, + resource_dict, + allmaps_attributes, + *, + apply_field_mapping: bool = True, + hot_only_thumbnail_url: bool = False, + ): + """Render the legacy optimized search-result profile.""" + from app.services.citation_service import CitationService + from app.services.download_service import DownloadService + from app.services.link_service import LinkService + from app.services.relationship_service import RelationshipService + from app.services.viewer_service import ViewerService + + api_utils = _api_utils() + + if apply_field_mapping: + resource_dict = OGMFieldMapper.map_resource_fields(resource_dict) + + distribution_context = await api_utils.fetch_distribution_context(resource_dict["id"]) + + thumbnail_kwargs: dict[str, Any] = {"distribution_context": distribution_context} + if hot_only_thumbnail_url: + thumbnail_kwargs["hot_only"] = True + resource_dict = api_utils.add_thumbnail_url(resource_dict, **thumbnail_kwargs) + if hot_only_thumbnail_url and not resource_dict.get("ui_thumbnail_url"): + resource_dict["ui_resource_class_icon_url"] = api_utils._hot_resource_class_icon_url( + resource_dict + ) + + citation_service = CitationService(resource_dict, distribution_context=distribution_context) + ui_citations = citation_service.get_all_citations() + ui_citation = ui_citations["apa"] + + viewer_service = ViewerService(resource_dict, distribution_context=distribution_context) + viewer_attributes = viewer_service.get_viewer_attributes() + + download_service = DownloadService(resource_dict, distribution_context=distribution_context) + ui_downloads = await download_service.get_download_options_with_bridge_asset_downloads() + + link_service = LinkService(resource_dict, distribution_context=distribution_context) + ui_links = link_service.get_links() + + ui_relationships = await RelationshipService.get_resource_relationships(resource_dict["id"]) + + attributes = { + **resource_dict, + "ui_citation": ui_citation, + "ui_citations": ui_citations, + "ui_thumbnail_url": resource_dict.get("ui_thumbnail_url"), + "ui_resource_class_icon_url": resource_dict.get("ui_resource_class_icon_url"), + "ui_viewer_endpoint": viewer_attributes.get("ui_viewer_endpoint"), + "ui_viewer_geometry": viewer_attributes.get("ui_viewer_geometry"), + "ui_viewer_protocol": viewer_attributes.get("ui_viewer_protocol"), + "ui_downloads": ui_downloads, + "ui_links": ui_links, + "ui_relationships": ui_relationships, + } + + self._attach_legacy_references(attributes, distribution_context) + self._merge_viewer_attributes(attributes, viewer_attributes) + + resource = self.serialize_jsonapi_resource(attributes) + + await self._apply_thumbnail_asset( + resource, + resource_dict, + distribution_context=distribution_context, + thumbnail_asset_url=RESOURCE_PRESENTATION_UNSET, + allow_resource_fallback=not hot_only_thumbnail_url, + ) + self._attach_allmaps(resource, allmaps_attributes) + self._attach_static_map(resource, resource_dict) + + return resource + + async def _attach_data_dictionaries( + self, + attributes: dict[str, Any], + resource_dict: dict[str, Any], + hydration: ResourceHydrationContext, + ) -> None: + api_utils = _api_utils() + data_dictionaries_payload = hydration.data_dictionaries_payload + + if data_dictionaries_payload is RESOURCE_PRESENTATION_UNSET: + try: + data_dictionaries_payload = ( + await api_utils._fetch_data_dictionaries_payload_for_resource( + resource_dict["id"], + self.session, + ) + ) + if data_dictionaries_payload: + attributes["data_dictionaries"] = data_dictionaries_payload + except Exception as e: + logger.warning( + "Failed to load data dictionaries for resource %s: %s", + resource_dict.get("id"), + str(e), + ) + elif data_dictionaries_payload: + attributes["data_dictionaries"] = api_utils.sanitize_for_json(data_dictionaries_payload) + + async def _attach_licensed_accesses( + self, + attributes: dict[str, Any], + resource_dict: dict[str, Any], + hydration: ResourceHydrationContext, + ) -> None: + api_utils = _api_utils() + licensed_accesses_payload = hydration.licensed_accesses_payload + + if licensed_accesses_payload is RESOURCE_PRESENTATION_UNSET: + try: + licensed_accesses_payload = ( + await api_utils._fetch_licensed_accesses_payload_for_resource( + resource_dict["id"], + self.session, + ) + ) + except Exception as e: + logger.warning( + "Failed to load licensed accesses for resource %s: %s", + resource_dict.get("id"), + str(e), + ) + licensed_accesses_payload = None + + if licensed_accesses_payload: + attributes["ui_licensed_accesses"] = api_utils.sanitize_for_json( + licensed_accesses_payload + ) + + def _attach_legacy_references( + self, attributes: dict[str, Any], distribution_context: DistributionContext + ) -> None: + try: + legacy_refs = distribution_context.legacy_reference_payload + if legacy_refs: + attributes["dct_references_s"] = json.dumps(legacy_refs) + except Exception as e: + logger.warning("Failed to serialize legacy references: %s", str(e)) + + def _merge_viewer_attributes( + self, attributes: dict[str, Any], viewer_attributes: dict[str, Any] + ) -> None: + for key, value in viewer_attributes.items(): + if key not in attributes: + attributes[key] = value + + async def _apply_thumbnail_asset( + self, + resource: dict[str, Any], + resource_dict: dict[str, Any], + *, + distribution_context: DistributionContext, + thumbnail_asset_url: str | None | object, + allow_resource_fallback: bool, + ) -> None: + api_utils = _api_utils() + + thumb_asset_url = ( + await api_utils._get_thumbnail_asset_url(resource_dict["id"]) + if thumbnail_asset_url is RESOURCE_PRESENTATION_UNSET + else thumbnail_asset_url + ) + current_thumbnail_url = ((resource.get("meta") or {}).get("ui") or {}).get("thumbnail_url") + if thumb_asset_url and not api_utils._is_immutable_thumbnail_url(current_thumbnail_url): + hot_thumbnail_url = api_utils._hot_thumbnail_url_for_resource( + resource_dict, + distribution_context=distribution_context, + thumbnail_asset_url=thumb_asset_url, + ) + if hot_thumbnail_url or allow_resource_fallback: + resource.setdefault("meta", {}) + resource["meta"].setdefault("ui", {}) + resource["meta"]["ui"]["thumbnail_url"] = ( + hot_thumbnail_url + or api_utils._build_resource_thumbnail_url(resource_dict["id"]) + ) + + def _attach_allmaps( + self, resource: dict[str, Any], allmaps_attributes: dict[str, Any] | None + ) -> None: + if not allmaps_attributes: + return + + resource.setdefault("meta", {}) + resource["meta"].setdefault("ui", {}) + resource["meta"]["ui"]["allmaps"] = allmaps_attributes + + def _attach_static_map(self, resource: dict[str, Any], resource_dict: dict[str, Any]) -> None: + api_utils = _api_utils() + geometry = resource_dict.get("locn_geometry") or resource_dict.get("dcat_bbox") + if not geometry: + return + + static_map_url = api_utils._hot_static_map_url( + resource_dict + ) or api_utils._build_static_map_url(resource_dict["id"]) + + resource.setdefault("meta", {}) + resource["meta"].setdefault("ui", {}) + resource["meta"]["ui"]["static_map"] = static_map_url + + +def _api_utils(): + from app.api.v1 import utils as api_utils + + return api_utils diff --git a/backend/app/api/v1/utils.py b/backend/app/api/v1/utils.py index 0ef9ac89..046add05 100644 --- a/backend/app/api/v1/utils.py +++ b/backend/app/api/v1/utils.py @@ -17,13 +17,12 @@ from app.services.distribution_repository import ( DistributionContext, build_distribution_context, - fetch_distribution_context, + fetch_distribution_context, # noqa: F401 - used by ResourcePresenter via utils shim ) from app.services.licensed_access_repository import ( fetch_resource_licensed_accesses, serialize_resource_licensed_accesses, ) -from app.services.ogm_field_mapper import OGMFieldMapper from db.database import database from db.models import resource_assets @@ -390,131 +389,9 @@ def create_jsonapi_resource(resource_data, request_url=None): Returns: JSON:API compliant resource structure """ - # Extract UI-related fields to move to meta.ui - ui_fields = {} - core_attributes = {} - - # Fields that should go to meta.ui - ui_field_names = [ - "ui_thumbnail_url", - "ui_resource_class_icon_url", - "ui_citation", - "ui_citations", - "ui_downloads", - "ui_licensed_accesses", - "ui_links", - "ui_viewer_protocol", - "ui_viewer_endpoint", - "ui_viewer_geometry", - "ui_relationships", - "ui_relationship_counts", - "ui_relationship_browse_links", - "ui_summaries", - "ai_summaries", - "suggest", - ] - - for key, value in resource_data.items(): - if key in ui_field_names: - ui_fields[key] = value - else: - # Only include non-null values in attributes - if value is not None: - core_attributes[key] = value - - # Filter out empty arrays and empty strings from core_attributes - core_attributes = filter_empty_values(core_attributes) - - # Get resource ID for root level (required by JSON:API spec) - resource_id = core_attributes.get("id") or resource_data.get("id", "") + from app.api.v1.presenters import ResourcePresenter - # Separate OGM Aardvark fields from B1G custom fields - ogm_fields = {} - b1g_fields = {} - ogm_aardvark_field_set = OGMFieldMapper.get_ogm_aardvark_fields() - - # Classify each field (including 'id' which goes into ogm namespace) - for key, value in core_attributes.items(): - if key in ogm_aardvark_field_set: - ogm_fields[key] = value - else: - # All other fields (B1G custom fields and legacy/internal fields) go to b1g - b1g_fields[key] = value - - # Filter empty values from both dictionaries - ogm_fields = filter_empty_values(ogm_fields) - b1g_fields = filter_empty_values(b1g_fields) - - # Build nested attributes structure - nested_attributes = {} - if ogm_fields: - nested_attributes["ogm"] = ogm_fields - if b1g_fields: - nested_attributes["b1g"] = b1g_fields - - # Restructure UI fields to remove prefixes and organize viewer - restructured_ui = {} - - # Simple field mappings (remove ui_ prefix) - if "ui_thumbnail_url" in ui_fields and ui_fields["ui_thumbnail_url"] is not None: - thumbnail_url = ui_fields["ui_thumbnail_url"] - restructured_ui["thumbnail_url"] = thumbnail_url - # Add placeholder flag if it's a placeholder URL - if "/thumbnails/placeholder" in str(thumbnail_url): - restructured_ui["thumbnail_placeholder"] = True - if ( - "ui_resource_class_icon_url" in ui_fields - and ui_fields["ui_resource_class_icon_url"] is not None - ): - restructured_ui["resource_class_icon_url"] = ui_fields["ui_resource_class_icon_url"] - if "ui_citation" in ui_fields: - restructured_ui["citation"] = ui_fields["ui_citation"] - if "ui_citations" in ui_fields: - restructured_ui["citations"] = ui_fields["ui_citations"] - if "ui_downloads" in ui_fields: - restructured_ui["downloads"] = ui_fields["ui_downloads"] - if "ui_licensed_accesses" in ui_fields: - restructured_ui["licensed_accesses"] = ui_fields["ui_licensed_accesses"] - if "ui_links" in ui_fields: - restructured_ui["links"] = ui_fields["ui_links"] - if "ui_relationships" in ui_fields: - restructured_ui["relationships"] = ui_fields["ui_relationships"] - if "ui_relationship_counts" in ui_fields: - restructured_ui["relationship_counts"] = ui_fields["ui_relationship_counts"] - if "ui_relationship_browse_links" in ui_fields: - restructured_ui["relationship_browse_links"] = ui_fields["ui_relationship_browse_links"] - if "ui_summaries" in ui_fields: - restructured_ui["summaries"] = ui_fields["ui_summaries"] - if "ai_summaries" in ui_fields: - restructured_ui["ai_summaries"] = ui_fields["ai_summaries"] - if "suggest" in ui_fields: - restructured_ui["suggest"] = ui_fields["suggest"] - - # Group viewer-related fields into a nested viewer object - viewer_fields = {} - if "ui_viewer_protocol" in ui_fields: - viewer_fields["protocol"] = ui_fields["ui_viewer_protocol"] - if "ui_viewer_endpoint" in ui_fields: - viewer_fields["endpoint"] = ui_fields["ui_viewer_endpoint"] - if "ui_viewer_geometry" in ui_fields: - viewer_fields["geometry"] = ui_fields["ui_viewer_geometry"] - - if viewer_fields: - restructured_ui["viewer"] = viewer_fields - - # Create the resource structure - resource = { - "type": "resource", - "id": str(resource_id), - "attributes": nested_attributes if nested_attributes else {}, - "meta": { - "@context": "https://gin.btaa.org/ld/contexts/ogm-aardvark-btaa.context.jsonld", - "@type": "BtaaAardvarkRecord", - "ui": restructured_ui, - }, - } - - return resource + return ResourcePresenter.serialize_jsonapi_resource(resource_data, request_url=request_url) def strong_params(request, allowed_params): @@ -808,7 +685,7 @@ async def process_resource( ): """ Process a resource to add UI fields and prepare it for JSON:API response. - This function is shared between resources and search endpoints. + Compatibility wrapper around ResourcePresenter. Args: resource_dict: The resource data from the database @@ -818,180 +695,42 @@ async def process_resource( Returns: JSON:API compliant resource object """ - from app.services.citation_service import CitationService - from app.services.download_service import DownloadService - from app.services.link_service import LinkService - from app.services.ogm_field_mapper import OGMFieldMapper - from app.services.relationship_service import RelationshipService - from app.services.viewer_service import ViewerService - - # Map database column names to proper OGM field names (only if requested) - if apply_field_mapping: - resource_dict = OGMFieldMapper.map_resource_fields(resource_dict) - - if distribution_context is None: - distribution_context = await fetch_distribution_context( - resource_dict["id"], - session=session, - ) - - # Keep the default call shape stable for older mocks and callers; only opt into - # the newer hot-cache-only path when explicitly requested. - thumbnail_kwargs: dict[str, Any] = {"distribution_context": distribution_context} - if hot_only_thumbnail_url: - thumbnail_kwargs["hot_only"] = True - resource_dict = add_thumbnail_url(resource_dict, **thumbnail_kwargs) - if not resource_dict.get("ui_thumbnail_url"): - resource_dict["ui_resource_class_icon_url"] = _hot_resource_class_icon_url(resource_dict) - - # Generate citations (APA, MLA, Chicago) - citation_service = CitationService(resource_dict, distribution_context=distribution_context) - ui_citations = citation_service.get_all_citations() - ui_citation = ui_citations["apa"] - - # Use ViewerService to get viewer attributes - viewer_service = ViewerService(resource_dict, distribution_context=distribution_context) - viewer_attributes = viewer_service.get_viewer_attributes() - - # Use DownloadService to get download options - download_service = DownloadService(resource_dict, distribution_context=distribution_context) - if ui_downloads is None: - ui_downloads = await download_service.get_download_options_with_bridge_asset_downloads( - bridge_asset_download_rows - ) - - # Use LinkService to get links - link_service = LinkService(resource_dict, distribution_context=distribution_context) - ui_links = link_service.get_links() - - # Use RelationshipService to get relationships - if ui_relationships is None: - ui_relationships = await RelationshipService.get_resource_relationships(resource_dict["id"]) - - # Get Allmaps attributes - if allmaps_attributes is None: - allmaps_attributes = await _fetch_allmaps_attributes_for_resource(resource_dict, session) - - # Create the attributes dictionary - attributes = { - **resource_dict, - "ui_citation": ui_citation, - "ui_citations": ui_citations, - "ui_thumbnail_url": resource_dict.get("ui_thumbnail_url"), - "ui_resource_class_icon_url": resource_dict.get("ui_resource_class_icon_url"), - "ui_viewer_endpoint": viewer_attributes.get("ui_viewer_endpoint"), - "ui_viewer_geometry": viewer_attributes.get("ui_viewer_geometry"), - "ui_viewer_protocol": viewer_attributes.get("ui_viewer_protocol"), - "ui_downloads": ui_downloads, - "ui_links": ui_links, - "ui_relationships": ui_relationships, - } - if ui_relationship_counts: - attributes["ui_relationship_counts"] = ui_relationship_counts - if ui_relationship_browse_links: - attributes["ui_relationship_browse_links"] = ui_relationship_browse_links - - # Attach read-only resource data dictionaries. - if data_dictionaries_payload is _UNSET: - try: - data_dictionaries_payload = await _fetch_data_dictionaries_payload_for_resource( - resource_dict["id"], - session, - ) - if data_dictionaries_payload: - attributes["data_dictionaries"] = data_dictionaries_payload - except Exception as e: - logger.warning( - "Failed to load data dictionaries for resource %s: %s", - resource_dict.get("id"), - str(e), - ) - elif data_dictionaries_payload: - attributes["data_dictionaries"] = sanitize_for_json(data_dictionaries_payload) - - if licensed_accesses_payload is _UNSET: - try: - licensed_accesses_payload = await _fetch_licensed_accesses_payload_for_resource( - resource_dict["id"], - session, - ) - except Exception as e: - logger.warning( - "Failed to load licensed accesses for resource %s: %s", - resource_dict.get("id"), - str(e), - ) - licensed_accesses_payload = None - - if licensed_accesses_payload: - attributes["ui_licensed_accesses"] = sanitize_for_json(licensed_accesses_payload) - - # Regenerate dct_references_s from resource_distributions for OGM Aardvark compatibility - try: - legacy_refs = distribution_context.legacy_reference_payload - if legacy_refs: - attributes["dct_references_s"] = json.dumps(legacy_refs) - except Exception: - # Do not fail response generation due to references serialization issues - pass - - # Add viewer attributes - for key, value in viewer_attributes.items(): - if key not in attributes: - attributes[key] = value - - # Create JSON:API compliant resource first - resource = create_jsonapi_resource(attributes) - - # If a bridge-synced thumbnail asset exists, expose the stable thumbnail endpoint - # instead of the raw stored object so clients go through the resize/cache pipeline. - thumb_asset_url = ( - await _get_thumbnail_asset_url(resource_dict["id"]) - if thumbnail_asset_url is _UNSET - else thumbnail_asset_url + from app.api.v1.presenters import ( + RESOURCE_PRESENTATION_UNSET, + ResourceHydrationContext, + ResourcePresenter, ) - current_thumbnail_url = ((resource.get("meta") or {}).get("ui") or {}).get("thumbnail_url") - if thumb_asset_url and not _is_immutable_thumbnail_url(current_thumbnail_url): - hot_thumbnail_url = _hot_thumbnail_url_for_resource( - resource_dict, - distribution_context=distribution_context, - thumbnail_asset_url=thumb_asset_url, - ) - resource.setdefault("meta", {}) - resource["meta"].setdefault("ui", {}) - resource["meta"]["ui"]["thumbnail_url"] = ( - hot_thumbnail_url or _build_resource_thumbnail_url(resource_dict["id"]) - ) - # Add Allmaps attributes to meta.ui.allmaps section - if allmaps_attributes: - if "meta" not in resource: - resource["meta"] = {} - if "ui" not in resource["meta"]: - resource["meta"]["ui"] = {} - - # Wrap Allmaps attributes in an allmaps object - resource["meta"]["ui"]["allmaps"] = allmaps_attributes - - # Add static map URL to meta.ui if resource has geometry (locn_geometry or dcat_bbox). - # This points directly at the geometry-overlay asset variant. - geometry = resource_dict.get("locn_geometry") or resource_dict.get("dcat_bbox") - if geometry: - static_map_url = _hot_static_map_url(resource_dict) or _build_static_map_url( - resource_dict["id"] - ) - - if "meta" not in resource: - resource["meta"] = {} - if "ui" not in resource["meta"]: - resource["meta"]["ui"] = {} - - resource["meta"]["ui"]["static_map"] = static_map_url - - if include_similar_items: - resource = await add_similar_items_to_resource(resource, resource_dict, session) + hydration = ResourceHydrationContext( + distribution_context=distribution_context, + ui_downloads=ui_downloads, + bridge_asset_download_rows=bridge_asset_download_rows, + licensed_accesses_payload=( + RESOURCE_PRESENTATION_UNSET + if licensed_accesses_payload is _UNSET + else licensed_accesses_payload + ), + ui_relationships=ui_relationships, + ui_relationship_counts=ui_relationship_counts, + ui_relationship_browse_links=ui_relationship_browse_links, + allmaps_attributes=allmaps_attributes, + data_dictionaries_payload=( + RESOURCE_PRESENTATION_UNSET + if data_dictionaries_payload is _UNSET + else data_dictionaries_payload + ), + thumbnail_asset_url=( + RESOURCE_PRESENTATION_UNSET if thumbnail_asset_url is _UNSET else thumbnail_asset_url + ), + ) - return resource + return await ResourcePresenter(session=session).present_full( + resource_dict, + apply_field_mapping=apply_field_mapping, + include_similar_items=include_similar_items, + hot_only_thumbnail_url=hot_only_thumbnail_url, + hydration=hydration, + ) async def add_licensed_accesses_to_resource( @@ -1024,57 +763,12 @@ async def process_resource_homepage(resource_dict, session=None, apply_field_map so this path intentionally skips downloads, relationships, similar items, and other expensive enrichments used by the full resource view. """ - from app.services.ogm_field_mapper import OGMFieldMapper - from app.services.viewer_service import ViewerService - - if apply_field_mapping: - resource_dict = OGMFieldMapper.map_resource_fields(resource_dict) + from app.api.v1.presenters import ResourcePresenter - distribution_context = await fetch_distribution_context( - resource_dict["id"], - session=session, + return await ResourcePresenter(session=session).present_homepage( + resource_dict, + apply_field_mapping=apply_field_mapping, ) - resource_dict = add_thumbnail_url(resource_dict, distribution_context=distribution_context) - - viewer_service = ViewerService(resource_dict, distribution_context=distribution_context) - viewer_attributes = viewer_service.get_viewer_attributes() - - allmaps_attributes = await _fetch_allmaps_attributes_for_resource(resource_dict, session) - - attributes = { - **resource_dict, - "ui_thumbnail_url": resource_dict.get("ui_thumbnail_url"), - "ui_viewer_endpoint": viewer_attributes.get("ui_viewer_endpoint"), - "ui_viewer_geometry": viewer_attributes.get("ui_viewer_geometry"), - "ui_viewer_protocol": viewer_attributes.get("ui_viewer_protocol"), - } - - for key, value in viewer_attributes.items(): - if key not in attributes: - attributes[key] = value - - resource = create_jsonapi_resource(attributes) - - thumb_asset_url = await _get_thumbnail_asset_url(resource_dict["id"]) - current_thumbnail_url = ((resource.get("meta") or {}).get("ui") or {}).get("thumbnail_url") - if thumb_asset_url and not _is_immutable_thumbnail_url(current_thumbnail_url): - hot_thumbnail_url = _hot_thumbnail_url_for_resource( - resource_dict, - distribution_context=distribution_context, - thumbnail_asset_url=thumb_asset_url, - ) - resource.setdefault("meta", {}) - resource["meta"].setdefault("ui", {}) - resource["meta"]["ui"]["thumbnail_url"] = ( - hot_thumbnail_url or _build_resource_thumbnail_url(resource_dict["id"]) - ) - - if allmaps_attributes: - resource.setdefault("meta", {}) - resource["meta"].setdefault("ui", {}) - resource["meta"]["ui"]["allmaps"] = allmaps_attributes - - return resource async def process_resource_optimized( @@ -1096,127 +790,11 @@ async def process_resource_optimized( Returns: JSON:API compliant resource object """ - from app.services.citation_service import CitationService - from app.services.download_service import DownloadService - from app.services.link_service import LinkService - from app.services.ogm_field_mapper import OGMFieldMapper - from app.services.relationship_service import RelationshipService - from app.services.viewer_service import ViewerService - - # Map database column names to proper OGM field names (only if requested) - if apply_field_mapping: - resource_dict = OGMFieldMapper.map_resource_fields(resource_dict) - - distribution_context = await fetch_distribution_context(resource_dict["id"]) - - # Keep the default call shape stable for older mocks and callers; only opt into - # the newer hot-cache-only path when explicitly requested. - thumbnail_kwargs: dict[str, Any] = {"distribution_context": distribution_context} - if hot_only_thumbnail_url: - thumbnail_kwargs["hot_only"] = True - resource_dict = add_thumbnail_url(resource_dict, **thumbnail_kwargs) - if hot_only_thumbnail_url and not resource_dict.get("ui_thumbnail_url"): - resource_dict["ui_resource_class_icon_url"] = _hot_resource_class_icon_url(resource_dict) - - # Generate citations (APA, MLA, Chicago) - citation_service = CitationService(resource_dict, distribution_context=distribution_context) - ui_citations = citation_service.get_all_citations() - ui_citation = ui_citations["apa"] - - # Use ViewerService to get viewer attributes - viewer_service = ViewerService(resource_dict, distribution_context=distribution_context) - viewer_attributes = viewer_service.get_viewer_attributes() - - # Use DownloadService to get download options - download_service = DownloadService(resource_dict, distribution_context=distribution_context) - ui_downloads = await download_service.get_download_options_with_bridge_asset_downloads() - - # Use LinkService to get links - link_service = LinkService(resource_dict, distribution_context=distribution_context) - ui_links = link_service.get_links() - - # Use RelationshipService to get relationships - ui_relationships = await RelationshipService.get_resource_relationships(resource_dict["id"]) - - # Use pre-fetched Allmaps attributes (no database query needed!) - # allmaps_attributes is passed in as a parameter - - # Create the attributes dictionary - attributes = { - **resource_dict, - "ui_citation": ui_citation, - "ui_citations": ui_citations, - "ui_thumbnail_url": resource_dict.get("ui_thumbnail_url"), - "ui_resource_class_icon_url": resource_dict.get("ui_resource_class_icon_url"), - "ui_viewer_endpoint": viewer_attributes.get("ui_viewer_endpoint"), - "ui_viewer_geometry": viewer_attributes.get("ui_viewer_geometry"), - "ui_viewer_protocol": viewer_attributes.get("ui_viewer_protocol"), - "ui_downloads": ui_downloads, - "ui_links": ui_links, - "ui_relationships": ui_relationships, - } - - # Regenerate dct_references_s from resource_distributions for OGM Aardvark compatibility - try: - legacy_refs = distribution_context.legacy_reference_payload - if legacy_refs: - attributes["dct_references_s"] = json.dumps(legacy_refs) - except Exception: - pass - - # Add viewer attributes - for key, value in viewer_attributes.items(): - if key not in attributes: - attributes[key] = value - - # Create JSON:API compliant resource first - resource = create_jsonapi_resource(attributes) - - # Prefer the stable thumbnail endpoint when a bridge-synced thumbnail asset exists. - thumb_asset_url = await _get_thumbnail_asset_url(resource_dict["id"]) - current_thumbnail_url = ((resource.get("meta") or {}).get("ui") or {}).get("thumbnail_url") - if thumb_asset_url and not _is_immutable_thumbnail_url(current_thumbnail_url): - hot_thumbnail_url = _hot_thumbnail_url_for_resource( - resource_dict, - distribution_context=distribution_context, - thumbnail_asset_url=thumb_asset_url, - ) - resource.setdefault("meta", {}) - resource["meta"].setdefault("ui", {}) - if hot_thumbnail_url: - resource["meta"]["ui"]["thumbnail_url"] = hot_thumbnail_url - elif not hot_only_thumbnail_url: - resource["meta"]["ui"]["thumbnail_url"] = _build_resource_thumbnail_url( - resource_dict["id"] - ) - - # Add pre-fetched Allmaps attributes to meta.ui.allmaps section - if allmaps_attributes: - if "meta" not in resource: - resource["meta"] = {} - if "ui" not in resource["meta"]: - resource["meta"]["ui"] = {} + from app.api.v1.presenters import ResourcePresenter - # Wrap Allmaps attributes in an allmaps object - resource["meta"]["ui"]["allmaps"] = allmaps_attributes - - # Add static map URL to meta.ui if resource has geometry (locn_geometry or dcat_bbox). - # See notes above in process_resource(). - geometry = resource_dict.get("locn_geometry") or resource_dict.get("dcat_bbox") - if geometry: - static_map_url = _hot_static_map_url(resource_dict) or _build_static_map_url( - resource_dict["id"] - ) - - if "meta" not in resource: - resource["meta"] = {} - if "ui" not in resource["meta"]: - resource["meta"]["ui"] = {} - - resource["meta"]["ui"]["static_map"] = static_map_url - - # Note: Similar items are intentionally omitted from process_resource_optimized to avoid - # per-result similarity lookups on search results. Clients should fetch them lazily - # via the `/api/v1/resources/{id}/similar-items` endpoint when needed. - - return resource + return await ResourcePresenter(session=None).present_search_result( + resource_dict, + allmaps_attributes, + apply_field_mapping=apply_field_mapping, + hot_only_thumbnail_url=hot_only_thumbnail_url, + ) diff --git a/backend/tests/api/v1/test_resource_presenter.py b/backend/tests/api/v1/test_resource_presenter.py new file mode 100644 index 00000000..d125c128 --- /dev/null +++ b/backend/tests/api/v1/test_resource_presenter.py @@ -0,0 +1,272 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import pytest + +from app.api.v1.presenters import ResourceHydrationContext, ResourcePresenter + + +def _distribution_context(): + return SimpleNamespace( + legacy_reference_payload={"http://schema.org/url": "https://example.edu/catalog/res-1"}, + by_uri={}, + ) + + +def _thumbnail_url(item, distribution_context=None, hot_only=False): + return {**item, "ui_thumbnail_url": "https://images.example.edu/res-1-thumb.jpg"} + + +@pytest.mark.asyncio +async def test_resource_presenter_full_profile_contract_snapshot(): + presenter = ResourcePresenter(session=None) + immutable_thumbnail_url = f"http://localhost:8000/api/v1/thumbnails/{'a' * 64}" + hydration = ResourceHydrationContext( + distribution_context=_distribution_context(), + ui_downloads=[{"label": "GeoJSON", "url": "https://example.edu/res-1.geojson"}], + licensed_accesses_payload=[ + { + "institution_code": "01", + "institution_name": "Indiana University", + "access_url": "https://example.edu/license", + } + ], + ui_relationships={"member_of": [{"id": "collection-1", "label": "Collection"}]}, + ui_relationship_counts={"member_of": 1}, + ui_relationship_browse_links={"member_of": "/catalog?member_of=collection-1"}, + allmaps_attributes={"manifest_url": "https://example.edu/iiif/manifest.json"}, + data_dictionaries_payload=[ + { + "name": "Attributes", + "entries": [{"field_name": "parcel_id", "label": "Parcel ID"}], + } + ], + thumbnail_asset_url="https://assets.example.edu/res-1-thumb.jpg", + ) + + with ( + patch("app.api.v1.utils.add_thumbnail_url", side_effect=_thumbnail_url), + patch( + "app.api.v1.utils._hot_thumbnail_url_for_resource", + return_value=immutable_thumbnail_url, + ), + patch("app.api.v1.utils._hot_static_map_url", return_value=None), + patch( + "app.api.v1.utils._build_static_map_url", + return_value="http://localhost:8000/api/v1/static-maps/res-1/geometry", + ), + patch("app.api.v1.utils._get_thumbnail_asset_url", new=AsyncMock()) as get_thumb, + patch( + "app.services.citation_service.CitationService.get_all_citations", + return_value={"apa": "APA", "mla": "MLA", "chicago": "Chicago"}, + ), + patch( + "app.services.viewer_service.ViewerService.get_viewer_attributes", + return_value={ + "ui_viewer_endpoint": "https://tiles.example.edu/res-1", + "ui_viewer_geometry": "ENVELOPE(-94,-93,45,44)", + "ui_viewer_protocol": "xyz", + }, + ), + patch("app.services.link_service.LinkService.get_links", return_value={"catalog": []}), + ): + resource = await presenter.present_full( + { + "id": "res-1", + "dct_title_s": "Sample Resource", + "schema_provider_s": "Example University", + "b1g_code_s": "B1G-001", + "locn_geometry": "ENVELOPE(-94,-93,45,44)", + }, + apply_field_mapping=False, + include_similar_items=False, + hydration=hydration, + ) + + get_thumb.assert_not_awaited() + assert resource == { + "type": "resource", + "id": "res-1", + "attributes": { + "ogm": { + "id": "res-1", + "dct_title_s": "Sample Resource", + "dct_references_s": '{"http://schema.org/url": "https://example.edu/catalog/res-1"}', + "locn_geometry": "ENVELOPE(-94,-93,45,44)", + "schema_provider_s": "Example University", + }, + "b1g": { + "b1g_code_s": "B1G-001", + "data_dictionaries": [ + { + "name": "Attributes", + "entries": [{"field_name": "parcel_id", "label": "Parcel ID"}], + } + ], + }, + }, + "meta": { + "@context": "https://gin.btaa.org/ld/contexts/ogm-aardvark-btaa.context.jsonld", + "@type": "BtaaAardvarkRecord", + "ui": { + "thumbnail_url": immutable_thumbnail_url, + "citation": "APA", + "citations": {"apa": "APA", "mla": "MLA", "chicago": "Chicago"}, + "downloads": [{"label": "GeoJSON", "url": "https://example.edu/res-1.geojson"}], + "licensed_accesses": [ + { + "institution_code": "01", + "institution_name": "Indiana University", + "access_url": "https://example.edu/license", + } + ], + "links": {"catalog": []}, + "relationships": {"member_of": [{"id": "collection-1", "label": "Collection"}]}, + "relationship_counts": {"member_of": 1}, + "relationship_browse_links": {"member_of": "/catalog?member_of=collection-1"}, + "viewer": { + "protocol": "xyz", + "endpoint": "https://tiles.example.edu/res-1", + "geometry": "ENVELOPE(-94,-93,45,44)", + }, + "allmaps": {"manifest_url": "https://example.edu/iiif/manifest.json"}, + "static_map": "http://localhost:8000/api/v1/static-maps/res-1/geometry", + }, + }, + } + + +@pytest.mark.asyncio +async def test_resource_presenter_search_profile_contract_snapshot(): + presenter = ResourcePresenter(session=None) + bridge_download_rows = [{"label": "Original", "file_url": "https://example.edu/file.zip"}] + relationship_service = AsyncMock() + + with ( + patch("app.api.v1.utils.add_thumbnail_url", side_effect=_thumbnail_url), + patch("app.api.v1.utils._get_thumbnail_asset_url", new=AsyncMock()) as get_thumb, + patch( + "app.services.citation_service.CitationService.get_all_citations", + return_value={"apa": "APA", "mla": "MLA", "chicago": "Chicago"}, + ), + patch( + "app.services.viewer_service.ViewerService.get_viewer_attributes", + return_value={"ui_viewer_protocol": "iiif"}, + ), + patch( + "app.services.download_service.DownloadService.get_download_options_with_bridge_asset_downloads", + new=AsyncMock( + return_value=[{"label": "Original", "url": "https://example.edu/file.zip"}] + ), + ) as downloads, + patch("app.services.link_service.LinkService.get_links", return_value={}), + patch( + "app.services.relationship_service.RelationshipService.get_resource_relationships", + new=relationship_service, + ), + ): + resource = await presenter.present_full( + { + "id": "res-1", + "dct_title_s": "Search Result", + "schema_provider_s": "Example University", + }, + apply_field_mapping=False, + include_similar_items=False, + hydration=ResourceHydrationContext( + distribution_context=_distribution_context(), + bridge_asset_download_rows=bridge_download_rows, + ui_relationships={"member_of": []}, + allmaps_attributes={}, + data_dictionaries_payload=[], + licensed_accesses_payload=[], + thumbnail_asset_url=None, + ), + ) + + get_thumb.assert_not_awaited() + relationship_service.assert_not_awaited() + downloads.assert_awaited_once_with(bridge_download_rows) + assert resource == { + "type": "resource", + "id": "res-1", + "attributes": { + "ogm": { + "id": "res-1", + "dct_title_s": "Search Result", + "dct_references_s": '{"http://schema.org/url": "https://example.edu/catalog/res-1"}', + "schema_provider_s": "Example University", + }, + }, + "meta": { + "@context": "https://gin.btaa.org/ld/contexts/ogm-aardvark-btaa.context.jsonld", + "@type": "BtaaAardvarkRecord", + "ui": { + "thumbnail_url": "https://images.example.edu/res-1-thumb.jpg", + "citation": "APA", + "citations": {"apa": "APA", "mla": "MLA", "chicago": "Chicago"}, + "downloads": [{"label": "Original", "url": "https://example.edu/file.zip"}], + "links": {}, + "relationships": {"member_of": []}, + "viewer": {"protocol": "iiif", "endpoint": None, "geometry": None}, + }, + }, + } + + +@pytest.mark.asyncio +async def test_resource_presenter_homepage_profile_contract_snapshot(): + presenter = ResourcePresenter(session=None) + + with ( + patch("app.api.v1.utils.add_thumbnail_url", side_effect=_thumbnail_url), + patch("app.api.v1.utils._get_thumbnail_asset_url", new=AsyncMock()) as get_thumb, + patch( + "app.services.viewer_service.ViewerService.get_viewer_attributes", + return_value={ + "ui_viewer_protocol": "external", + "ui_viewer_endpoint": "https://example.edu", + }, + ), + ): + resource = await presenter.present_homepage( + { + "id": "res-1", + "dct_title_s": "Homepage Resource", + "schema_provider_s": "Example University", + "b1g_code_s": "B1G-001", + }, + apply_field_mapping=False, + hydration=ResourceHydrationContext( + distribution_context=_distribution_context(), + allmaps_attributes={"manifest_url": "https://example.edu/manifest.json"}, + thumbnail_asset_url=None, + ), + ) + + get_thumb.assert_not_awaited() + assert resource == { + "type": "resource", + "id": "res-1", + "attributes": { + "ogm": { + "id": "res-1", + "dct_title_s": "Homepage Resource", + "schema_provider_s": "Example University", + }, + "b1g": {"b1g_code_s": "B1G-001"}, + }, + "meta": { + "@context": "https://gin.btaa.org/ld/contexts/ogm-aardvark-btaa.context.jsonld", + "@type": "BtaaAardvarkRecord", + "ui": { + "thumbnail_url": "https://images.example.edu/res-1-thumb.jpg", + "viewer": { + "protocol": "external", + "endpoint": "https://example.edu", + "geometry": None, + }, + "allmaps": {"manifest_url": "https://example.edu/manifest.json"}, + }, + }, + }