From 0e386b165a70c7738d16d08ae94f60511ea2a936 Mon Sep 17 00:00:00 2001 From: jonhealy1 Date: Sun, 14 Dec 2025 00:53:56 +0800 Subject: [PATCH 1/2] dynamic catalog route links --- CHANGELOG.md | 5 +- .../stac_fastapi/core/extensions/catalogs.py | 287 ++++-------------- .../core/stac_fastapi/core/models/links.py | 11 +- .../core/stac_fastapi/core/serializers.py | 90 ++++++ .../tests/extensions/test_catalogs.py | 77 +++-- 5 files changed, 208 insertions(+), 262 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0285bc931..b4769eb0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,15 +10,12 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Added - 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) - - 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) - - 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) - - 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) - - 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) - 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) +- 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. ### Changed diff --git a/stac_fastapi/core/stac_fastapi/core/extensions/catalogs.py b/stac_fastapi/core/stac_fastapi/core/extensions/catalogs.py index 1444a38fe..68eec59ee 100644 --- a/stac_fastapi/core/stac_fastapi/core/extensions/catalogs.py +++ b/stac_fastapi/core/stac_fastapi/core/extensions/catalogs.py @@ -297,8 +297,42 @@ async def get_catalog(self, catalog_id: str, request: Request) -> Catalog: # Convert to STAC format catalog = self.client.catalog_serializer.db_to_stac(db_catalog, request) - return catalog - except Exception: + # DYNAMIC INJECTION: Ensure the 'children' link exists + # This link points to the /children endpoint which dynamically lists all children + base_url = str(request.base_url) + children_link = { + "rel": "children", + "type": "application/json", + "href": f"{base_url}catalogs/{catalog_id}/children", + "title": "Child catalogs and collections", + } + + # Convert to dict if needed to manipulate links + if isinstance(catalog, dict): + catalog_dict = catalog + else: + catalog_dict = ( + catalog.model_dump() + if hasattr(catalog, "model_dump") + else dict(catalog) + ) + + # Ensure catalog has a links array + if "links" not in catalog_dict: + catalog_dict["links"] = [] + + # Add children link if it doesn't already exist + if not any( + link.get("rel") == "children" for link in catalog_dict.get("links", []) + ): + catalog_dict["links"].append(children_link) + + # Return as Catalog object + return Catalog(**catalog_dict) + except HTTPException: + raise + except Exception as e: + logger.error(f"Error retrieving catalog {catalog_id}: {e}") raise HTTPException( status_code=404, detail=f"Catalog {catalog_id} not found" ) @@ -358,18 +392,8 @@ async def delete_catalog( parent_ids.remove(catalog_id) child["parent_ids"] = parent_ids - # Also remove the catalog link from the collection's links - if "links" in child: - child["links"] = [ - link - for link in child.get("links", []) - if not ( - link.get("rel") == "catalog" - and catalog_id in link.get("href", "") - ) - ] - # Update the collection in the database + # Note: Catalog links are now dynamically generated, so no need to remove them await self.client.database.update_collection( collection_id=child_id, collection=child, @@ -458,8 +482,19 @@ async def get_catalog_collections( collections = [] for coll_id in collection_ids: try: - collection = await self.client.get_collection( - coll_id, request=request + # Get the collection from database + collection_db = await self.client.database.find_collection(coll_id) + # Serialize with catalog context (sets parent to catalog, injects catalog link) + collection = ( + self.client.collection_serializer.db_to_stac_in_catalog( + collection_db, + request, + catalog_id=catalog_id, + extensions=[ + type(ext).__name__ + for ext in self.client.database.extensions + ], + ) ) collections.append(collection) except HTTPException as e: @@ -560,11 +595,6 @@ async def create_catalog_collection( ) ) - # Update the catalog to include a link to the collection - await self._add_collection_to_catalog_links( - catalog_id, collection.id, request - ) - return updated_collection except Exception as e: @@ -585,28 +615,10 @@ async def create_catalog_collection( if catalog_id not in collection_dict["parent_ids"]: collection_dict["parent_ids"].append(catalog_id) - # Add a link from the collection back to its parent catalog BEFORE saving to database - base_url = str(request.base_url) - catalog_link = { - "rel": "catalog", - "type": "application/json", - "href": f"{base_url}catalogs/{catalog_id}", - "title": catalog_id, - } - - # Add the catalog link to the collection dict - if "links" not in collection_dict: - collection_dict["links"] = [] - - # Check if the catalog link already exists - catalog_href = catalog_link["href"] - link_exists = any( - link.get("href") == catalog_href and link.get("rel") == "catalog" - for link in collection_dict.get("links", []) - ) - - if not link_exists: - collection_dict["links"].append(catalog_link) + # Note: We do NOT store catalog links in the database. + # Catalog links are injected dynamically by the serializer based on context. + # This allows the same collection to have different catalog links + # depending on which catalog it's accessed from. # Now convert to database format (this will process the links) collection_db = self.client.database.collection_serializer.stac_to_db( @@ -628,11 +640,6 @@ async def create_catalog_collection( ) ) - # Update the catalog to include a link to the new collection - await self._add_collection_to_catalog_links( - catalog_id, collection.id, request - ) - return created_collection except HTTPException as e: @@ -651,92 +658,6 @@ async def create_catalog_collection( detail=f"Failed to create collection in catalog: {str(e)}", ) - async def _add_collection_to_catalog_links( - self, catalog_id: str, collection_id: str, request: Request - ) -> None: - """Add a collection link to a catalog. - - This helper method updates a catalog's links to include a reference - to a collection by reindexing the updated catalog document. - - Args: - catalog_id: The ID of the catalog to update. - collection_id: The ID of the collection to link. - request: Request object for base URL construction. - """ - try: - # Get the current catalog - db_catalog = await self.client.database.find_catalog(catalog_id) - catalog = self.client.catalog_serializer.db_to_stac(db_catalog, request) - - # Create the collection link - base_url = str(request.base_url) - collection_link = { - "rel": "child", - "href": f"{base_url}collections/{collection_id}", - "type": "application/json", - "title": collection_id, - } - - # Add the link to the catalog if it doesn't already exist - catalog_links = ( - catalog.get("links") - if isinstance(catalog, dict) - else getattr(catalog, "links", None) - ) - if not catalog_links: - catalog_links = [] - if isinstance(catalog, dict): - catalog["links"] = catalog_links - else: - catalog.links = catalog_links - - # Check if the collection link already exists - collection_href = collection_link["href"] - link_exists = any( - ( - link.get("href") - if hasattr(link, "get") - else getattr(link, "href", None) - ) - == collection_href - for link in catalog_links - ) - - if not link_exists: - catalog_links.append(collection_link) - - # Update the catalog in the database by reindexing it - # Convert back to database format - updated_db_catalog = self.client.catalog_serializer.stac_to_db( - catalog, request - ) - updated_db_catalog_dict = ( - updated_db_catalog.model_dump() - if hasattr(updated_db_catalog, "model_dump") - else updated_db_catalog - ) - updated_db_catalog_dict["type"] = "Catalog" - - # Use the same approach as create_catalog to update the document - await self.client.database.client.index( - index=COLLECTIONS_INDEX, - id=catalog_id, - body=updated_db_catalog_dict, - refresh=True, - ) - - logger.info( - f"Updated catalog {catalog_id} to include link to collection {collection_id}" - ) - - except Exception as e: - logger.error( - f"Failed to update catalog {catalog_id} links: {e}", exc_info=True - ) - # Don't fail the entire operation if link update fails - # The collection was created successfully, just the catalog link is missing - async def get_catalog_collection( self, catalog_id: str, collection_id: str, request: Request ) -> stac_types.Collection: @@ -776,9 +697,13 @@ async def get_catalog_collection( status_code=404, detail=f"Collection {collection_id} not found" ) - # Return the collection - return await self.client.get_collection( - collection_id=collection_id, request=request + # Return the collection with catalog context + collection_db = await self.client.database.find_collection(collection_id) + return self.client.collection_serializer.db_to_stac_in_catalog( + collection_db, + request, + catalog_id=catalog_id, + extensions=[type(ext).__name__ for ext in self.client.database.extensions], ) async def get_catalog_collection_items( @@ -1040,6 +965,7 @@ async def delete_catalog_collection( collection_db["parent_ids"] = parent_ids # Update the collection in the database + # Note: Catalog links are now dynamically generated, so no need to remove them await self.client.database.update_collection( collection_id=collection_id, collection=collection_db, refresh=True ) @@ -1056,11 +982,6 @@ async def delete_catalog_collection( f"Deleted collection {collection_id} (only parent was catalog {catalog_id})" ) - # Remove the collection link from the catalog - await self._remove_collection_from_catalog_links( - catalog_id, collection_id, request - ) - except HTTPException: raise except Exception as e: @@ -1072,87 +993,3 @@ async def delete_catalog_collection( status_code=500, detail=f"Failed to delete collection from catalog: {str(e)}", ) - - async def _remove_collection_from_catalog_links( - self, catalog_id: str, collection_id: str, request: Request - ) -> None: - """Remove a collection link from a catalog. - - This helper method updates a catalog's links to remove a reference - to a collection by reindexing the updated catalog document. - - Args: - catalog_id: The ID of the catalog to update. - collection_id: The ID of the collection to unlink. - request: Request object for base URL construction. - """ - try: - # Get the current catalog - db_catalog = await self.client.database.find_catalog(catalog_id) - catalog = self.client.catalog_serializer.db_to_stac(db_catalog, request) - - # Get the catalog links - catalog_links = ( - catalog.get("links") - if isinstance(catalog, dict) - else getattr(catalog, "links", None) - ) - - if not catalog_links: - return - - # Find and remove the collection link - collection_href = ( - f"{str(request.base_url).rstrip('/')}/collections/{collection_id}" - ) - links_to_keep = [] - link_removed = False - - for link in catalog_links: - link_href = ( - link.get("href") - if hasattr(link, "get") - else getattr(link, "href", None) - ) - if link_href == collection_href and not link_removed: - # Skip this link (remove it) - link_removed = True - else: - links_to_keep.append(link) - - if link_removed: - # Update the catalog with the modified links - if isinstance(catalog, dict): - catalog["links"] = links_to_keep - else: - catalog.links = links_to_keep - - # Convert back to database format and update - updated_db_catalog = self.client.catalog_serializer.stac_to_db( - catalog, request - ) - updated_db_catalog_dict = ( - updated_db_catalog.model_dump() - if hasattr(updated_db_catalog, "model_dump") - else updated_db_catalog - ) - updated_db_catalog_dict["type"] = "Catalog" - - # Update the document - await self.client.database.client.index( - index=COLLECTIONS_INDEX, - id=catalog_id, - body=updated_db_catalog_dict, - refresh=True, - ) - - logger.info( - f"Removed collection {collection_id} link from catalog {catalog_id}" - ) - - except Exception as e: - logger.error( - f"Failed to remove collection link from catalog {catalog_id}: {e}", - exc_info=True, - ) - # Don't fail the entire operation if link removal fails diff --git a/stac_fastapi/core/stac_fastapi/core/models/links.py b/stac_fastapi/core/stac_fastapi/core/models/links.py index 99e0d4e53..17961befc 100644 --- a/stac_fastapi/core/stac_fastapi/core/models/links.py +++ b/stac_fastapi/core/stac_fastapi/core/models/links.py @@ -113,6 +113,7 @@ class CollectionLinks(BaseLinks): collection_id: str = attr.ib() extensions: List[str] = attr.ib(default=attr.Factory(list)) + parent_url: Optional[str] = attr.ib(default=None, kw_only=True) def link_self(self) -> Dict: """Return the self link.""" @@ -123,8 +124,14 @@ def link_self(self) -> Dict: ) def link_parent(self) -> Dict[str, Any]: - """Create the `parent` link.""" - return dict(rel=Relations.parent, type=MimeTypes.json.value, href=self.base_url) + """Create the `parent` link. + + The parent link represents the structural parent (the path the user is traversing): + - If accessed via /catalogs/{id}/collections/{id}, parent is the catalog + - If accessed via /collections/{id}, parent is the root landing page + """ + parent_href = self.parent_url if self.parent_url else self.base_url + return dict(rel=Relations.parent, type=MimeTypes.json.value, href=parent_href) def link_items(self) -> Dict[str, Any]: """Create the `items` link.""" diff --git a/stac_fastapi/core/stac_fastapi/core/serializers.py b/stac_fastapi/core/stac_fastapi/core/serializers.py index 6cae58afe..0fe152b91 100644 --- a/stac_fastapi/core/stac_fastapi/core/serializers.py +++ b/stac_fastapi/core/stac_fastapi/core/serializers.py @@ -210,6 +210,96 @@ def db_to_stac( original_links = collection.get("links") if original_links: collection_links += resolve_links(original_links, str(request.base_url)) + + collection["links"] = collection_links + + if get_bool_env("STAC_INDEX_ASSETS"): + collection["assets"] = { + a.pop("es_key"): a for a in collection.get("assets", []) + } + collection["item_assets"] = { + i.pop("es_key"): i for i in collection.get("item_assets", []) + } + + else: + collection["assets"] = collection.get("assets", {}) + if item_assets := collection.get("item_assets"): + collection["item_assets"] = item_assets + + # Return the stac_types.Collection object + return stac_types.Collection(**collection) + + @classmethod + def db_to_stac_in_catalog( + cls, + collection: dict, + request: Request, + catalog_id: str, + extensions: Optional[List[str]] = [], + ) -> stac_types.Collection: + """Transform database model to STAC collection within a catalog context. + + This method is used when a collection is accessed via /catalogs/{id}/collections/{id}. + It sets the structural parent to the catalog and injects a catalog link. + + Args: + collection (dict): The collection data in dictionary form, extracted from the database. + request: the API request + catalog_id: The ID of the parent catalog (sets structural parent) + extensions: A list of the extension class names (`ext.__name__`) or all enabled STAC API extensions. + + Returns: + stac_types.Collection: The STAC collection object with catalog context. + """ + # Avoid modifying the input dict in-place + collection = deepcopy(collection) + + # Remove internal fields (not part of STAC spec) + collection.pop("bbox_shape", None) + + # Set defaults + collection_id = collection.get("id") + collection.setdefault("type", "Collection") + collection.setdefault("stac_extensions", []) + collection.setdefault("stac_version", "") + collection.setdefault("title", "") + collection.setdefault("description", "") + collection.setdefault("keywords", []) + collection.setdefault("license", "") + collection.setdefault("providers", []) + collection.setdefault("summaries", {}) + collection.setdefault( + "extent", {"spatial": {"bbox": []}, "temporal": {"interval": []}} + ) + collection.setdefault("assets", {}) + + # Determine the structural parent URL + # When accessed via /catalogs/{id}/collections/{id}, the parent is the catalog + base_url = str(request.base_url) + parent_url = f"{base_url}catalogs/{catalog_id}" + + # Create the collection links using CollectionLinks with catalog as parent + collection_links = CollectionLinks( + collection_id=collection_id, + request=request, + extensions=extensions, + parent_url=parent_url, + ).create_links() + + # Add any additional links from the collection dictionary + original_links = collection.get("links") + if original_links: + collection_links += resolve_links(original_links, str(request.base_url)) + + # Inject catalog link for consistency (same as parent in this context) + catalog_link = { + "rel": "catalog", + "type": "application/json", + "href": parent_url, + "title": catalog_id, + } + collection_links.append(catalog_link) + collection["links"] = collection_links if get_bool_env("STAC_INDEX_ASSETS"): diff --git a/stac_fastapi/tests/extensions/test_catalogs.py b/stac_fastapi/tests/extensions/test_catalogs.py index 950715937..b22cf669e 100644 --- a/stac_fastapi/tests/extensions/test_catalogs.py +++ b/stac_fastapi/tests/extensions/test_catalogs.py @@ -417,12 +417,14 @@ async def test_create_catalog_collection(catalogs_app_client, load_test_data, ct assert created_collection["id"] == test_collection["id"] assert created_collection["type"] == "Collection" - # Verify the collection was created by getting it directly - get_resp = await catalogs_app_client.get(f"/collections/{test_collection['id']}") + # Verify the collection was created by getting it via the catalog endpoint + get_resp = await catalogs_app_client.get( + f"/catalogs/{catalog_id}/collections/{test_collection['id']}" + ) assert get_resp.status_code == 200 assert get_resp.json()["id"] == test_collection["id"] - # Verify the collection has a catalog link to the catalog + # Verify the collection has a catalog link to the catalog (when accessed via catalog context) collection_data = get_resp.json() collection_links = collection_data.get("links", []) catalog_link = None @@ -439,28 +441,24 @@ async def test_create_catalog_collection(catalogs_app_client, load_test_data, ct assert catalog_link["type"] == "application/json" assert catalog_link["href"].endswith(f"/catalogs/{catalog_id}") - # Verify the catalog has a child link to the collection + # Verify the catalog has a children link catalog_resp = await catalogs_app_client.get(f"/catalogs/{catalog_id}") assert catalog_resp.status_code == 200 catalog_data = catalog_resp.json() catalog_links = catalog_data.get("links", []) - collection_child_link = None + children_link = None for link in catalog_links: if link.get( "rel" - ) == "child" and f"/collections/{test_collection['id']}" in link.get( - "href", "" - ): - collection_child_link = link + ) == "children" and f"/catalogs/{catalog_id}/children" in link.get("href", ""): + children_link = link break assert ( - collection_child_link is not None - ), f"Catalog should have child link to collection /collections/{test_collection['id']}" - assert collection_child_link["type"] == "application/json" - assert collection_child_link["href"].endswith( - f"/collections/{test_collection['id']}" - ) + children_link is not None + ), f"Catalog should have children link to /catalogs/{catalog_id}/children" + assert children_link["type"] == "application/json" + assert children_link["href"].endswith(f"/catalogs/{catalog_id}/children") # Verify the catalog now includes the collection in its collections endpoint catalog_resp = await catalogs_app_client.get(f"/catalogs/{catalog_id}/collections") @@ -689,20 +687,24 @@ async def test_create_catalog_collection_adds_parent_id( created_collection = create_resp.json() assert created_collection["id"] == collection_id - # Verify the collection has the catalog in parent_ids by getting it directly - get_resp = await catalogs_app_client.get(f"/collections/{collection_id}") + # Verify the collection has the catalog in parent_ids by getting it via catalog endpoint + get_resp = await catalogs_app_client.get( + f"/catalogs/{catalog_id}/collections/{collection_id}" + ) assert get_resp.status_code == 200 collection_data = get_resp.json() # parent_ids should be in the collection data (from database) - # We can verify it exists by checking the catalog link + # We can verify it exists by checking the catalog link (when accessed via catalog context) catalog_link = None for link in collection_data.get("links", []): if link.get("rel") == "catalog" and catalog_id in link.get("href", ""): catalog_link = link break - assert catalog_link is not None, "Collection should have catalog link" + assert ( + catalog_link is not None + ), "Collection should have catalog link when accessed via catalog endpoint" @pytest.mark.asyncio @@ -968,28 +970,41 @@ async def test_catalog_links_contain_all_collections( assert create_resp.status_code == 201 collection_ids.append(collection_id) - # Get the catalog and verify all 3 collections are in its links + # Get the catalog and verify it has a children link catalog_get_resp = await catalogs_app_client.get(f"/catalogs/{catalog_id}") assert catalog_get_resp.status_code == 200 catalog_data = catalog_get_resp.json() catalog_links = catalog_data.get("links", []) - # Extract all child links (collection links) - child_links = [link for link in catalog_links if link.get("rel") == "child"] + # Extract the children link + children_link = None + for link in catalog_links: + if link.get( + "rel" + ) == "children" and f"/catalogs/{catalog_id}/children" in link.get("href", ""): + children_link = link + break - # Verify we have exactly 3 child links + # Verify we have a children link assert ( - len(child_links) == 3 - ), f"Catalog should have 3 child links, but has {len(child_links)}" + children_link is not None + ), f"Catalog should have a children link to /catalogs/{catalog_id}/children" + + # Verify all 3 collections are accessible via the catalog's collections endpoint + collections_resp = await catalogs_app_client.get( + f"/catalogs/{catalog_id}/collections" + ) + assert collections_resp.status_code == 200 + collections_data = collections_resp.json() + collection_ids_in_catalog = [ + col["id"] for col in collections_data.get("collections", []) + ] - # Verify each collection ID is in the child links - child_hrefs = [link.get("href", "") for link in child_links] for collection_id in collection_ids: - collection_href = f"/collections/{collection_id}" - assert any( - collection_href in href for href in child_hrefs - ), f"Collection {collection_id} missing from catalog links. Found links: {child_hrefs}" + assert ( + collection_id in collection_ids_in_catalog + ), f"Collection {collection_id} missing from catalog collections endpoint" @pytest.mark.asyncio From 8299450e32fe94fc486cf01fb3db51cd9250f151 Mon Sep 17 00:00:00 2001 From: jonhealy1 Date: Mon, 15 Dec 2025 11:50:24 +0800 Subject: [PATCH 2/2] update changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b4769eb0a..64d082c51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - 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) - 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) - 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) -- 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. +- 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. [#559](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/pull/559) ### Changed