Skip to content

Commit 0e386b1

Browse files
committed
dynamic catalog route links
1 parent 4e30406 commit 0e386b1

File tree

5 files changed

+208
-262
lines changed

5 files changed

+208
-262
lines changed

CHANGELOG.md

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,12 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
1010
### Added
1111

1212
- Environment variable `VALIDATE_QUERYABLES` to enable/disable validation of queryables in search/filter requests. When set to `true`, search requests will be validated against the defined queryables, returning an error for any unsupported fields. Defaults to `false` for backward compatibility.[#532](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/pull/532)
13-
1413
- Environment variable `QUERYABLES_CACHE_TTL` to configure the TTL (in seconds) for caching queryables. Default is `1800` seconds (30 minutes) to balance performance and freshness of queryables data. [#532](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/pull/532)
15-
1614
- Added optional `/catalogs` route support to enable federated hierarchical catalog browsing and navigation. [#547](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/pull/547)
17-
1815
- Added DELETE `/catalogs/{catalog_id}/collections/{collection_id}` endpoint to support removing collections from catalogs. When a collection belongs to multiple catalogs, it removes only the specified catalog from the collection's parent_ids. When a collection belongs to only one catalog, the collection is deleted entirely. [#554](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/pull/554)
19-
2016
- Added `parent_ids` internal field to collections to support multi-catalog hierarchies. Collections can now belong to multiple catalogs, with parent catalog IDs stored in this field for efficient querying and management. [#554](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/pull/554)
2117
- Added GET `/catalogs/{catalog_id}/children` endpoint implementing the STAC Children extension for efficient hierarchical catalog browsing. Supports type filtering (?type=Catalog|Collection), pagination, and returns numberReturned/numberMatched counts at the top level. [#558](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/pull/558)
18+
- Implemented context-aware dynamic linking: catalogs use dynamic `rel="children"` links pointing to the `/catalogs/{id}/children` endpoint, and collections have context-dependent `rel="parent"` links (pointing to catalog when accessed via `/catalogs/{id}/collections/{id}`, or root when accessed via `/collections/{id}`). Catalog links are only injected in catalog context. This eliminates race conditions and ensures consistency with parent_ids relationships.
2219

2320
### Changed
2421

stac_fastapi/core/stac_fastapi/core/extensions/catalogs.py

Lines changed: 62 additions & 225 deletions
Original file line numberDiff line numberDiff line change
@@ -297,8 +297,42 @@ async def get_catalog(self, catalog_id: str, request: Request) -> Catalog:
297297
# Convert to STAC format
298298
catalog = self.client.catalog_serializer.db_to_stac(db_catalog, request)
299299

300-
return catalog
301-
except Exception:
300+
# DYNAMIC INJECTION: Ensure the 'children' link exists
301+
# This link points to the /children endpoint which dynamically lists all children
302+
base_url = str(request.base_url)
303+
children_link = {
304+
"rel": "children",
305+
"type": "application/json",
306+
"href": f"{base_url}catalogs/{catalog_id}/children",
307+
"title": "Child catalogs and collections",
308+
}
309+
310+
# Convert to dict if needed to manipulate links
311+
if isinstance(catalog, dict):
312+
catalog_dict = catalog
313+
else:
314+
catalog_dict = (
315+
catalog.model_dump()
316+
if hasattr(catalog, "model_dump")
317+
else dict(catalog)
318+
)
319+
320+
# Ensure catalog has a links array
321+
if "links" not in catalog_dict:
322+
catalog_dict["links"] = []
323+
324+
# Add children link if it doesn't already exist
325+
if not any(
326+
link.get("rel") == "children" for link in catalog_dict.get("links", [])
327+
):
328+
catalog_dict["links"].append(children_link)
329+
330+
# Return as Catalog object
331+
return Catalog(**catalog_dict)
332+
except HTTPException:
333+
raise
334+
except Exception as e:
335+
logger.error(f"Error retrieving catalog {catalog_id}: {e}")
302336
raise HTTPException(
303337
status_code=404, detail=f"Catalog {catalog_id} not found"
304338
)
@@ -358,18 +392,8 @@ async def delete_catalog(
358392
parent_ids.remove(catalog_id)
359393
child["parent_ids"] = parent_ids
360394

361-
# Also remove the catalog link from the collection's links
362-
if "links" in child:
363-
child["links"] = [
364-
link
365-
for link in child.get("links", [])
366-
if not (
367-
link.get("rel") == "catalog"
368-
and catalog_id in link.get("href", "")
369-
)
370-
]
371-
372395
# Update the collection in the database
396+
# Note: Catalog links are now dynamically generated, so no need to remove them
373397
await self.client.database.update_collection(
374398
collection_id=child_id,
375399
collection=child,
@@ -458,8 +482,19 @@ async def get_catalog_collections(
458482
collections = []
459483
for coll_id in collection_ids:
460484
try:
461-
collection = await self.client.get_collection(
462-
coll_id, request=request
485+
# Get the collection from database
486+
collection_db = await self.client.database.find_collection(coll_id)
487+
# Serialize with catalog context (sets parent to catalog, injects catalog link)
488+
collection = (
489+
self.client.collection_serializer.db_to_stac_in_catalog(
490+
collection_db,
491+
request,
492+
catalog_id=catalog_id,
493+
extensions=[
494+
type(ext).__name__
495+
for ext in self.client.database.extensions
496+
],
497+
)
463498
)
464499
collections.append(collection)
465500
except HTTPException as e:
@@ -560,11 +595,6 @@ async def create_catalog_collection(
560595
)
561596
)
562597

563-
# Update the catalog to include a link to the collection
564-
await self._add_collection_to_catalog_links(
565-
catalog_id, collection.id, request
566-
)
567-
568598
return updated_collection
569599

570600
except Exception as e:
@@ -585,28 +615,10 @@ async def create_catalog_collection(
585615
if catalog_id not in collection_dict["parent_ids"]:
586616
collection_dict["parent_ids"].append(catalog_id)
587617

588-
# Add a link from the collection back to its parent catalog BEFORE saving to database
589-
base_url = str(request.base_url)
590-
catalog_link = {
591-
"rel": "catalog",
592-
"type": "application/json",
593-
"href": f"{base_url}catalogs/{catalog_id}",
594-
"title": catalog_id,
595-
}
596-
597-
# Add the catalog link to the collection dict
598-
if "links" not in collection_dict:
599-
collection_dict["links"] = []
600-
601-
# Check if the catalog link already exists
602-
catalog_href = catalog_link["href"]
603-
link_exists = any(
604-
link.get("href") == catalog_href and link.get("rel") == "catalog"
605-
for link in collection_dict.get("links", [])
606-
)
607-
608-
if not link_exists:
609-
collection_dict["links"].append(catalog_link)
618+
# Note: We do NOT store catalog links in the database.
619+
# Catalog links are injected dynamically by the serializer based on context.
620+
# This allows the same collection to have different catalog links
621+
# depending on which catalog it's accessed from.
610622

611623
# Now convert to database format (this will process the links)
612624
collection_db = self.client.database.collection_serializer.stac_to_db(
@@ -628,11 +640,6 @@ async def create_catalog_collection(
628640
)
629641
)
630642

631-
# Update the catalog to include a link to the new collection
632-
await self._add_collection_to_catalog_links(
633-
catalog_id, collection.id, request
634-
)
635-
636643
return created_collection
637644

638645
except HTTPException as e:
@@ -651,92 +658,6 @@ async def create_catalog_collection(
651658
detail=f"Failed to create collection in catalog: {str(e)}",
652659
)
653660

654-
async def _add_collection_to_catalog_links(
655-
self, catalog_id: str, collection_id: str, request: Request
656-
) -> None:
657-
"""Add a collection link to a catalog.
658-
659-
This helper method updates a catalog's links to include a reference
660-
to a collection by reindexing the updated catalog document.
661-
662-
Args:
663-
catalog_id: The ID of the catalog to update.
664-
collection_id: The ID of the collection to link.
665-
request: Request object for base URL construction.
666-
"""
667-
try:
668-
# Get the current catalog
669-
db_catalog = await self.client.database.find_catalog(catalog_id)
670-
catalog = self.client.catalog_serializer.db_to_stac(db_catalog, request)
671-
672-
# Create the collection link
673-
base_url = str(request.base_url)
674-
collection_link = {
675-
"rel": "child",
676-
"href": f"{base_url}collections/{collection_id}",
677-
"type": "application/json",
678-
"title": collection_id,
679-
}
680-
681-
# Add the link to the catalog if it doesn't already exist
682-
catalog_links = (
683-
catalog.get("links")
684-
if isinstance(catalog, dict)
685-
else getattr(catalog, "links", None)
686-
)
687-
if not catalog_links:
688-
catalog_links = []
689-
if isinstance(catalog, dict):
690-
catalog["links"] = catalog_links
691-
else:
692-
catalog.links = catalog_links
693-
694-
# Check if the collection link already exists
695-
collection_href = collection_link["href"]
696-
link_exists = any(
697-
(
698-
link.get("href")
699-
if hasattr(link, "get")
700-
else getattr(link, "href", None)
701-
)
702-
== collection_href
703-
for link in catalog_links
704-
)
705-
706-
if not link_exists:
707-
catalog_links.append(collection_link)
708-
709-
# Update the catalog in the database by reindexing it
710-
# Convert back to database format
711-
updated_db_catalog = self.client.catalog_serializer.stac_to_db(
712-
catalog, request
713-
)
714-
updated_db_catalog_dict = (
715-
updated_db_catalog.model_dump()
716-
if hasattr(updated_db_catalog, "model_dump")
717-
else updated_db_catalog
718-
)
719-
updated_db_catalog_dict["type"] = "Catalog"
720-
721-
# Use the same approach as create_catalog to update the document
722-
await self.client.database.client.index(
723-
index=COLLECTIONS_INDEX,
724-
id=catalog_id,
725-
body=updated_db_catalog_dict,
726-
refresh=True,
727-
)
728-
729-
logger.info(
730-
f"Updated catalog {catalog_id} to include link to collection {collection_id}"
731-
)
732-
733-
except Exception as e:
734-
logger.error(
735-
f"Failed to update catalog {catalog_id} links: {e}", exc_info=True
736-
)
737-
# Don't fail the entire operation if link update fails
738-
# The collection was created successfully, just the catalog link is missing
739-
740661
async def get_catalog_collection(
741662
self, catalog_id: str, collection_id: str, request: Request
742663
) -> stac_types.Collection:
@@ -776,9 +697,13 @@ async def get_catalog_collection(
776697
status_code=404, detail=f"Collection {collection_id} not found"
777698
)
778699

779-
# Return the collection
780-
return await self.client.get_collection(
781-
collection_id=collection_id, request=request
700+
# Return the collection with catalog context
701+
collection_db = await self.client.database.find_collection(collection_id)
702+
return self.client.collection_serializer.db_to_stac_in_catalog(
703+
collection_db,
704+
request,
705+
catalog_id=catalog_id,
706+
extensions=[type(ext).__name__ for ext in self.client.database.extensions],
782707
)
783708

784709
async def get_catalog_collection_items(
@@ -1040,6 +965,7 @@ async def delete_catalog_collection(
1040965
collection_db["parent_ids"] = parent_ids
1041966

1042967
# Update the collection in the database
968+
# Note: Catalog links are now dynamically generated, so no need to remove them
1043969
await self.client.database.update_collection(
1044970
collection_id=collection_id, collection=collection_db, refresh=True
1045971
)
@@ -1056,11 +982,6 @@ async def delete_catalog_collection(
1056982
f"Deleted collection {collection_id} (only parent was catalog {catalog_id})"
1057983
)
1058984

1059-
# Remove the collection link from the catalog
1060-
await self._remove_collection_from_catalog_links(
1061-
catalog_id, collection_id, request
1062-
)
1063-
1064985
except HTTPException:
1065986
raise
1066987
except Exception as e:
@@ -1072,87 +993,3 @@ async def delete_catalog_collection(
1072993
status_code=500,
1073994
detail=f"Failed to delete collection from catalog: {str(e)}",
1074995
)
1075-
1076-
async def _remove_collection_from_catalog_links(
1077-
self, catalog_id: str, collection_id: str, request: Request
1078-
) -> None:
1079-
"""Remove a collection link from a catalog.
1080-
1081-
This helper method updates a catalog's links to remove a reference
1082-
to a collection by reindexing the updated catalog document.
1083-
1084-
Args:
1085-
catalog_id: The ID of the catalog to update.
1086-
collection_id: The ID of the collection to unlink.
1087-
request: Request object for base URL construction.
1088-
"""
1089-
try:
1090-
# Get the current catalog
1091-
db_catalog = await self.client.database.find_catalog(catalog_id)
1092-
catalog = self.client.catalog_serializer.db_to_stac(db_catalog, request)
1093-
1094-
# Get the catalog links
1095-
catalog_links = (
1096-
catalog.get("links")
1097-
if isinstance(catalog, dict)
1098-
else getattr(catalog, "links", None)
1099-
)
1100-
1101-
if not catalog_links:
1102-
return
1103-
1104-
# Find and remove the collection link
1105-
collection_href = (
1106-
f"{str(request.base_url).rstrip('/')}/collections/{collection_id}"
1107-
)
1108-
links_to_keep = []
1109-
link_removed = False
1110-
1111-
for link in catalog_links:
1112-
link_href = (
1113-
link.get("href")
1114-
if hasattr(link, "get")
1115-
else getattr(link, "href", None)
1116-
)
1117-
if link_href == collection_href and not link_removed:
1118-
# Skip this link (remove it)
1119-
link_removed = True
1120-
else:
1121-
links_to_keep.append(link)
1122-
1123-
if link_removed:
1124-
# Update the catalog with the modified links
1125-
if isinstance(catalog, dict):
1126-
catalog["links"] = links_to_keep
1127-
else:
1128-
catalog.links = links_to_keep
1129-
1130-
# Convert back to database format and update
1131-
updated_db_catalog = self.client.catalog_serializer.stac_to_db(
1132-
catalog, request
1133-
)
1134-
updated_db_catalog_dict = (
1135-
updated_db_catalog.model_dump()
1136-
if hasattr(updated_db_catalog, "model_dump")
1137-
else updated_db_catalog
1138-
)
1139-
updated_db_catalog_dict["type"] = "Catalog"
1140-
1141-
# Update the document
1142-
await self.client.database.client.index(
1143-
index=COLLECTIONS_INDEX,
1144-
id=catalog_id,
1145-
body=updated_db_catalog_dict,
1146-
refresh=True,
1147-
)
1148-
1149-
logger.info(
1150-
f"Removed collection {collection_id} link from catalog {catalog_id}"
1151-
)
1152-
1153-
except Exception as e:
1154-
logger.error(
1155-
f"Failed to remove collection link from catalog {catalog_id}: {e}",
1156-
exc_info=True,
1157-
)
1158-
# Don't fail the entire operation if link removal fails

0 commit comments

Comments
 (0)