diff --git a/CHANGELOG.md b/CHANGELOG.md index be298dc9..e016fa98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,13 +17,23 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Updated +## [v6.8.1] - 2025-12-15 + +### Changed + +- Implemented a safety-first deletion policy for the catalogs endpoint to prevent accidental data loss. Collections are now never deleted through the catalogs route; they are only unlinked and automatically adopted by the root catalog if they become orphans. Collection data can only be permanently deleted via the explicit `/collections/{collection_id}` DELETE endpoint. This ensures a clear separation between container (catalog) deletion and content (collection/item) deletion, with data always being preserved through the catalogs API. + +### Removed + +- Removed `cascade` parameter from `DELETE /catalogs/{catalog_id}` endpoint. Collections are no longer deleted when a catalog is deleted; they are unlinked and adopted by root if orphaned. + ## [v6.8.0] - 2025-12-15 ### 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 optional `/catalogs` route support to enable 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) @@ -689,7 +699,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Use genexp in execute_search and get_all_collections to return results. - Added db_to_stac serializer to item_collection method in core.py. -[Unreleased]: https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/compare/v6.8.0...main +[Unreleased]: https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/compare/v6.8.1...main +[v6.8.1]: https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/compare/v6.8.0...v6.8.1 [v6.8.0]: https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/compare/v6.7.6...v6.8.0 [v6.7.6]: https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/compare/v6.7.5...v6.7.6 [v6.7.5]: https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/compare/v6.7.4...v6.7.5 @@ -730,3 +741,4 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. [v0.3.0]: https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/compare/v0.2.0...v0.3.0 [v0.2.0]: https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/compare/v0.1.0...v0.2.0 [v0.1.0]: https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/compare/v0.1.0 + diff --git a/README.md b/README.md index 67aeef5a..b050691b 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ The following organizations have contributed time and/or funding to support the ## Latest News -- **12/09/2025:** Feature Merge: **Federated Catalogs**. The [`Catalogs Endpoint`](https://github.com/Healy-Hyperspatial/stac-api-extensions-catalogs-endpoint) extension is now in main! This enables a registry of catalogs and supports **poly-hierarchy** (collections belonging to multiple catalogs simultaneously). Enable it via `ENABLE_CATALOGS_EXTENSION`. _Coming next: Support for nested sub-catalogs._ +- **12/09/2025:** Feature Merge: **Catalogs Endpoint**. The [`Catalogs Endpoint`](https://github.com/Healy-Hyperspatial/stac-api-extensions-catalogs-endpoint) extension is now in main! This enables a registry of catalogs and supports **poly-hierarchy** (collections belonging to multiple catalogs simultaneously). Enable it via `ENABLE_CATALOGS_EXTENSION`. _Coming next: Support for nested sub-catalogs._ - **11/07/2025:** 🌍 The SFEOS STAC Viewer is now available at: https://healy-hyperspatial.github.io/sfeos-web. Use this site to examine your data and test your STAC API! - **10/24/2025:** Added `previous_token` pagination using Redis for efficient navigation. This feature allows users to navigate backwards through large result sets by storing pagination state in Redis. To use this feature, ensure Redis is configured (see [Redis for navigation](#redis-for-navigation)) and set `REDIS_ENABLE=true` in your environment. - **10/23/2025:** The `EXCLUDED_FROM_QUERYABLES` environment variable was added to exclude fields from the `queryables` endpoint. See [docs](#excluding-fields-from-queryables). @@ -233,29 +233,46 @@ These extensions make it easier to build user interfaces that display and naviga ## Catalogs Route -SFEOS supports federated hierarchical catalog browsing through the `/catalogs` endpoint, enabling users to navigate through STAC catalog structures in a tree-like fashion. This extension allows for organized discovery and browsing of collections and sub-catalogs. +SFEOS supports a **Catalog Registry** through the `/catalogs` endpoint. This allows for organized discovery by grouping collections into specific logical catalogs. -This implementation follows the [STAC API Catalogs Extension](https://github.com/Healy-Hyperspatial/stac-api-extensions-catalogs) specification, which enables a Federated STAC API architecture with a "Hub and Spoke" structure. +This implementation follows the [STAC API Catalogs Extension](https://github.com/Healy-Hyperspatial/stac-api-extensions-catalogs) specification, which enables a multi-catalog STAC API architecture. Currently, SFEOS supports a single level of catalogs (Root -> Catalogs -> Collections). ### Features -- **Hierarchical Navigation**: Browse catalogs and sub-catalogs in a parent-child relationship structure +- **Catalog Registry**: Discover and browse a list of available catalogs - **Multi-Catalog Collections**: Collections can belong to multiple catalogs simultaneously, enabling flexible organizational hierarchies - **Collection Discovery**: Access collections within specific catalog contexts - **STAC API Compliance**: Follows STAC specification for catalog objects and linking - **Flexible Querying**: Support for standard STAC API query parameters when browsing collections within catalogs +- **Safety-First Data Protection**: Collection data is never deleted through the catalogs route; only containers (catalogs) can be destroyed + +### Safety Architecture + +The catalogs extension implements a **safety-first design** that protects collection data: + +| Operation | Route | Behavior | Data Safety | +|-----------|-------|----------|-------------| +| Delete Catalog | `DELETE /catalogs/{id}` | Removes the catalog container; all links between catalog and collections are severed; collections are adopted by root if orphaned | 🟢 Safe (structure only) | +| Unlink Collection | `DELETE /catalogs/{id}/collections/{id}` | Severs the link between collection and this catalog; collection survives at root if it has no other parents | 🟢 Safe (zero data loss) | +| Destroy Collection | `DELETE /collections/{id}` | Permanently deletes collection and all items (intentional, outside catalogs route) | 🔴 Destructive | + +**Key Principle**: The catalogs route is write-safe for creation but read-only for deletion of collections. You can create collections via the catalogs route, but deleting collections is only allowed through the explicit `/collections` endpoint. This prevents accidental data loss while allowing full organizational flexibility. + +**Link Removal**: When you delete a catalog or unlink a collection, the relationship links are permanently severed from the database. However, the collection data itself remains intact and is automatically adopted by the root catalog if it becomes an orphan. + +**Collection Deletion**: Collections CAN be permanently deleted, but only via the `/collections/{collection_id}` endpoint (outside the catalogs route). This ensures intentional, explicit deletion of collection data and prevents accidental data loss through the catalogs API. ### Endpoints - **GET `/catalogs`**: Retrieve the root catalog and its child catalogs - **POST `/catalogs`**: Create a new catalog (requires appropriate permissions) - **GET `/catalogs/{catalog_id}`**: Retrieve a specific catalog and its children -- **DELETE `/catalogs/{catalog_id}`**: Delete a catalog (optionally cascade delete all collections) +- **DELETE `/catalogs/{catalog_id}`**: Delete a catalog (collections are unlinked and adopted by root if orphaned) - **GET `/catalogs/{catalog_id}/children`**: Retrieve all children (Catalogs and Collections) of this catalog with optional type filtering - **GET `/catalogs/{catalog_id}/collections`**: Retrieve collections within a specific catalog - **POST `/catalogs/{catalog_id}/collections`**: Create a new collection within a specific catalog - **GET `/catalogs/{catalog_id}/collections/{collection_id}`**: Retrieve a specific collection within a catalog -- **DELETE `/catalogs/{catalog_id}/collections/{collection_id}`**: Delete a collection from a catalog (removes parent_id if multiple parents exist, deletes collection if it's the only parent) +- **DELETE `/catalogs/{catalog_id}/collections/{collection_id}`**: Unlink a collection from a catalog (collection survives at root if orphaned) - **GET `/catalogs/{catalog_id}/collections/{collection_id}/items`**: Retrieve items within a collection in a catalog context - **GET `/catalogs/{catalog_id}/collections/{collection_id}/items/{item_id}`**: Retrieve a specific item within a catalog context @@ -305,25 +322,38 @@ curl "http://localhost:8081/catalogs/earth-observation/collections/sentinel-2/it # Get specific item within a catalog curl "http://localhost:8081/catalogs/earth-observation/collections/sentinel-2/items/S2A_20231015_123456" -# Delete a collection from a catalog -# If the collection has multiple parent catalogs, only removes this catalog from parent_ids -# If this is the only parent catalog, deletes the collection entirely +# Unlink a collection from a catalog +# The collection is removed from this catalog but survives in the database +# If it has no other parent catalogs, it is automatically adopted by root curl -X DELETE "http://localhost:8081/catalogs/earth-observation/collections/sentinel-2" -# Delete a catalog (collections remain intact) +# Delete a catalog +# All collections are unlinked and adopted by root if they become orphans +# Collection data is NEVER deleted curl -X DELETE "http://localhost:8081/catalogs/earth-observation" -# Delete a catalog and all its collections (cascade delete) -curl -X DELETE "http://localhost:8081/catalogs/earth-observation?cascade=true" +# To permanently delete a collection and all its items, use the /collections endpoint +curl -X DELETE "http://localhost:8081/collections/sentinel-2" ``` -### Delete Catalog Parameters +### Delete Behavior + +The catalogs extension implements a **safety-first deletion policy**: + +- **`DELETE /catalogs/{id}`**: Removes the catalog container and severs all links between the catalog and its collections. Collections are automatically adopted by the root catalog if they become orphans. **Collection data is never deleted.** +- **`DELETE /catalogs/{id}/collections/{id}`**: Severs the link between a collection and this catalog. If the collection has other parent catalogs, it remains linked to them. If it becomes an orphan, it is automatically adopted by root. **Collection data is never deleted.** +- **`DELETE /collections/{id}`**: Permanently deletes a collection and all its items. This is the only way to destroy collection data and must be done explicitly outside the catalogs route. + +**What Gets Removed**: +- Catalog documents (when deleting a catalog) +- Relationship links between catalogs and collections (when unlinking) +- Collection documents and items (only via `/collections` endpoint) -The DELETE endpoint supports the following query parameter: +**What Is Always Preserved**: +- Collection data (never deleted through catalogs routes) +- Item data (never deleted through catalogs routes) -- **`cascade`** (boolean, default: `false`): - - If `false`: Only deletes the catalog. Collections linked to the catalog remain in the database but lose their catalog link. - - If `true`: Deletes the catalog AND all collections linked to it. Use with caution as this is a destructive operation. +> **Note**: The `cascade` parameter has been removed. Collections are never deleted through the catalogs route. If you need to delete collections, use the `/collections` endpoint explicitly. ### Response Structure @@ -466,12 +496,12 @@ You can customize additional settings in your `.env` file: | `ELASTICSEARCH_VERSION` | Version of Elasticsearch to use. | `8.11.0` | Optional | | `OPENSEARCH_VERSION` | OpenSearch version | `2.11.1` | Optional | | `ENABLE_DIRECT_RESPONSE` | Enable direct response for maximum performance (disables all FastAPI dependencies, including authentication, custom status codes, and validation) | `false` | Optional | -| `RAISE_ON_BULK_ERROR` | Controls whether bulk insert operations raise exceptions on errors. If set to `true`, the operation will stop and raise an exception when an error occurs. If set to `false`, errors will be logged, and the operation will continue. **Note:** STAC Item and ItemCollection validation errors will always raise, regardless of this flag. | `false` | Optional | -| `DATABASE_REFRESH` | Controls whether database operations refresh the index immediately after changes. If set to `true`, changes will be immediately searchable. If set to `false`, changes may not be immediately visible but can improve performance for bulk operations. If set to `wait_for`, changes will wait for the next refresh cycle to become visible. | `false` | Optional | +| `RAISE_ON_BULK_ERROR`| Controls whether bulk insert operations raise exceptions on errors. If set to `true`, the operation will stop and raise an exception when an error occurs. If set to `false`, errors will be logged, and the operation will continue. **Note:** STAC Item and ItemCollection validation errors will always raise, regardless of this flag. | `false` | Optional | +| `DATABASE_REFRESH` | Controls whether database operations refresh the index immediately after changes. If set to `true`, changes will be immediately searchable. If set to `false`, changes may not be immediately visible but can improve performance for bulk operations. If set to `wait_for`, changes will wait for the next refresh cycle to become visible. | `false` | Optional | | `ENABLE_COLLECTIONS_SEARCH` | Enable collection search extensions (sort, fields, free text search, structured filtering, and datetime filtering) on the core `/collections` endpoint. | `true` | Optional | | `ENABLE_COLLECTIONS_SEARCH_ROUTE` | Enable the custom `/collections-search` endpoint (both GET and POST methods). When disabled, the custom endpoint will not be available, but collection search extensions will still be available on the core `/collections` endpoint if `ENABLE_COLLECTIONS_SEARCH` is true. | `false` | Optional | | `ENABLE_TRANSACTIONS_EXTENSIONS` | Enables or disables the Transactions and Bulk Transactions API extensions. This is useful for deployments where mutating the catalog via the API should be prevented. If set to `true`, the POST `/collections` route for search will be unavailable in the API. | `true` | Optional | -| `ENABLE_CATALOGS_ROUTE` | Enable the `/catalogs` endpoint for federated hierarchical catalog browsing and navigation. When enabled, provides access to federated STAC API architecture with hub-and-spoke pattern. | `false` | Optional | +| `ENABLE_CATALOGS_ROUTE`| Enable the **/catalogs** endpoint for hierarchical catalog browsing and navigation. | `false` | Optional | | `STAC_GLOBAL_COLLECTION_MAX_LIMIT` | Configures the maximum number of STAC collections that can be returned in a single search request. | N/A | Optional | | `STAC_DEFAULT_COLLECTION_LIMIT` | Configures the default number of STAC collections returned when no limit parameter is specified in the request. | `300` | Optional | | `STAC_GLOBAL_ITEM_MAX_LIMIT` | Configures the maximum number of STAC items that can be returned in a single search request. | N/A | Optional | diff --git a/stac_fastapi/core/stac_fastapi/core/extensions/catalogs.py b/stac_fastapi/core/stac_fastapi/core/extensions/catalogs.py index 68eec59e..116cfc9e 100644 --- a/stac_fastapi/core/stac_fastapi/core/extensions/catalogs.py +++ b/stac_fastapi/core/stac_fastapi/core/extensions/catalogs.py @@ -126,7 +126,7 @@ def register(self, app: FastAPI, settings=None) -> None: response_class=self.response_class, status_code=204, summary="Delete Catalog", - description="Delete a catalog. Optionally cascade delete all collections in the catalog.", + description="Delete a catalog. All linked collections are unlinked and adopted by root if orphaned.", tags=["Catalogs"], ) @@ -337,22 +337,21 @@ async def get_catalog(self, catalog_id: str, request: Request) -> Catalog: status_code=404, detail=f"Catalog {catalog_id} not found" ) - async def delete_catalog( - self, - catalog_id: str, - request: Request, - cascade: bool = Query( - False, - description="If true, delete all collections linked to this catalog. If false, only delete the catalog.", - ), - ) -> None: - """Delete a catalog. + async def delete_catalog(self, catalog_id: str, request: Request) -> None: + """Delete a catalog (The Container). + + Deletes the Catalog document itself. All linked Collections are unlinked + and adopted by Root if they become orphans. Collection data is NEVER deleted. + + Logic: + 1. Finds all Collections linked to this Catalog. + 2. Unlinks them (removes catalog_id from their parent_ids). + 3. If a Collection becomes an orphan, it is adopted by Root. + 4. PERMANENTLY DELETES the Catalog document itself. Args: catalog_id: The ID of the catalog to delete. request: Request object. - cascade: If true, delete all collections linked to this catalog. - If false, only delete the catalog. Returns: None (204 No Content) @@ -361,58 +360,42 @@ async def delete_catalog( HTTPException: If the catalog is not found. """ try: - # Get the catalog to verify it exists + # Verify the catalog exists await self.client.database.find_catalog(catalog_id) - # Use reverse lookup query to find all collections with this catalog in parent_ids. - # This is more reliable than parsing links, as it captures all collections - # regardless of pagination or link truncation. + # Find all collections with this catalog in parent_ids query_body = {"query": {"term": {"parent_ids": catalog_id}}} search_result = await self.client.database.client.search( index=COLLECTIONS_INDEX, body=query_body, size=10000 ) children = [hit["_source"] for hit in search_result["hits"]["hits"]] - # Process each child collection + # Safe Unlink: Remove catalog from all children's parent_ids + # If a child becomes an orphan, adopt it to root + root_id = self.settings.get("STAC_FASTAPI_LANDING_PAGE_ID", "stac-fastapi") + for child in children: child_id = child.get("id") try: - if cascade: - # DANGER ZONE: User explicitly requested cascade delete. - # Delete the collection entirely, regardless of other parents. - await self.client.database.delete_collection(child_id) - logger.info( - f"Deleted collection {child_id} as part of cascade delete for catalog {catalog_id}" - ) - else: - # SAFE ZONE: Smart Unlink - Remove only this catalog from parent_ids. - # The collection survives and becomes a root-level collection if it has no other parents. - parent_ids = child.get("parent_ids", []) - if catalog_id in parent_ids: - parent_ids.remove(catalog_id) - child["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=child_id, - collection=child, - refresh=False, + parent_ids = child.get("parent_ids", []) + if catalog_id in parent_ids: + parent_ids.remove(catalog_id) + + # If orphan, move to root + if len(parent_ids) == 0: + parent_ids.append(root_id) + logger.info( + f"Collection {child_id} adopted by root after catalog deletion." ) - - # Log the result - if len(parent_ids) == 0: - logger.info( - f"Collection {child_id} is now a root-level orphan (no parent catalogs)" - ) - else: - logger.info( - f"Removed catalog {catalog_id} from collection {child_id}; still belongs to {len(parent_ids)} other catalog(s)" - ) else: - logger.debug( - f"Catalog {catalog_id} not in parent_ids for collection {child_id}" + logger.info( + f"Removed catalog {catalog_id} from collection {child_id}; still belongs to {len(parent_ids)} other catalog(s)" ) + + child["parent_ids"] = parent_ids + await self.client.database.update_collection( + collection_id=child_id, collection=child, refresh=False + ) except Exception as e: error_msg = str(e) if "not found" in error_msg.lower(): @@ -929,11 +912,11 @@ async def get_catalog_children( async def delete_catalog_collection( self, catalog_id: str, collection_id: str, request: Request ) -> None: - """Delete a collection from a catalog. + """Delete a collection from a catalog (Unlink only). - If the collection has multiple parent catalogs, only removes this catalog - from the parent_ids. If this is the only parent catalog, deletes the - collection entirely. + Removes the catalog from the collection's parent_ids. + If the collection becomes an orphan (no parents), it is adopted by the Root. + It NEVER deletes the collection data. Args: catalog_id: The ID of the catalog. @@ -959,37 +942,39 @@ async def delete_catalog_collection( detail=f"Collection {collection_id} does not belong to catalog {catalog_id}", ) - # If the collection has multiple parents, just remove this catalog from parent_ids - if len(parent_ids) > 1: - parent_ids.remove(catalog_id) - collection_db["parent_ids"] = parent_ids + # SAFE UNLINK LOGIC + parent_ids.remove(catalog_id) - # 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 + # Check if it is now an orphan (empty list) + if len(parent_ids) == 0: + # Fallback to Root / Landing Page + # You can hardcode 'root' or fetch the ID from settings + root_id = self.settings.get( + "STAC_FASTAPI_LANDING_PAGE_ID", "stac-fastapi" ) - + parent_ids.append(root_id) logger.info( - f"Removed catalog {catalog_id} from collection {collection_id} parent_ids" + f"Collection {collection_id} unlinked from {catalog_id}. Orphaned, so adopted by root ({root_id})." ) else: - # If this is the only parent, delete the collection entirely - await self.client.database.delete_collection( - collection_id, refresh=True - ) logger.info( - f"Deleted collection {collection_id} (only parent was catalog {catalog_id})" + f"Removed catalog {catalog_id} from collection {collection_id}; still belongs to {len(parent_ids)} other catalog(s)" ) + # Update the collection in the database + collection_db["parent_ids"] = parent_ids + await self.client.database.update_collection( + collection_id=collection_id, collection=collection_db, refresh=True + ) + except HTTPException: raise except Exception as e: logger.error( - f"Error deleting collection {collection_id} from catalog {catalog_id}: {e}", + f"Error removing collection {collection_id} from catalog {catalog_id}: {e}", exc_info=True, ) raise HTTPException( status_code=500, - detail=f"Failed to delete collection from catalog: {str(e)}", + detail=f"Failed to remove collection from catalog: {str(e)}", ) diff --git a/stac_fastapi/core/stac_fastapi/core/version.py b/stac_fastapi/core/stac_fastapi/core/version.py index 006e9b15..8a68b5cb 100644 --- a/stac_fastapi/core/stac_fastapi/core/version.py +++ b/stac_fastapi/core/stac_fastapi/core/version.py @@ -1,2 +1,2 @@ """library version.""" -__version__ = "6.8.0" +__version__ = "6.8.1" diff --git a/stac_fastapi/elasticsearch/pyproject.toml b/stac_fastapi/elasticsearch/pyproject.toml index 99ca977c..03ef3d80 100644 --- a/stac_fastapi/elasticsearch/pyproject.toml +++ b/stac_fastapi/elasticsearch/pyproject.toml @@ -28,8 +28,8 @@ keywords = [ ] dynamic = ["version"] dependencies = [ - "stac-fastapi-core==6.8.0", - "sfeos-helpers==6.8.0", + "stac-fastapi-core==6.8.1", + "sfeos-helpers==6.8.1", "elasticsearch[async]~=8.19.1", "uvicorn~=0.23.0", "starlette>=0.35.0,<0.36.0", @@ -48,7 +48,7 @@ dev = [ "httpx>=0.24.0,<0.28.0", "redis~=6.4.0", "retry~=0.9.2", - "stac-fastapi-core[redis]==6.8.0", + "stac-fastapi-core[redis]==6.8.1", ] docs = [ "mkdocs~=1.4.0", @@ -58,7 +58,7 @@ docs = [ "retry~=0.9.2", ] redis = [ - "stac-fastapi-core[redis]==6.8.0", + "stac-fastapi-core[redis]==6.8.1", ] server = [ "uvicorn[standard]~=0.23.0", diff --git a/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/app.py b/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/app.py index 634f6f23..915775f4 100644 --- a/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/app.py +++ b/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/app.py @@ -244,7 +244,7 @@ app_config = { "title": os.getenv("STAC_FASTAPI_TITLE", "stac-fastapi-elasticsearch"), "description": os.getenv("STAC_FASTAPI_DESCRIPTION", "stac-fastapi-elasticsearch"), - "api_version": os.getenv("STAC_FASTAPI_VERSION", "6.8.0"), + "api_version": os.getenv("STAC_FASTAPI_VERSION", "6.8.1"), "settings": settings, "extensions": extensions, "client": CoreClient( diff --git a/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/version.py b/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/version.py index 006e9b15..8a68b5cb 100644 --- a/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/version.py +++ b/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/version.py @@ -1,2 +1,2 @@ """library version.""" -__version__ = "6.8.0" +__version__ = "6.8.1" diff --git a/stac_fastapi/opensearch/pyproject.toml b/stac_fastapi/opensearch/pyproject.toml index 657ca1d7..7dc9349b 100644 --- a/stac_fastapi/opensearch/pyproject.toml +++ b/stac_fastapi/opensearch/pyproject.toml @@ -28,8 +28,8 @@ keywords = [ ] dynamic = ["version"] dependencies = [ - "stac-fastapi-core==6.8.0", - "sfeos-helpers==6.8.0", + "stac-fastapi-core==6.8.1", + "sfeos-helpers==6.8.1", "opensearch-py~=2.8.0", "opensearch-py[async]~=2.8.0", "uvicorn~=0.23.0", @@ -49,7 +49,7 @@ dev = [ "httpx>=0.24.0,<0.28.0", "redis~=6.4.0", "retry~=0.9.2", - "stac-fastapi-core[redis]==6.8.0", + "stac-fastapi-core[redis]==6.8.1", ] docs = [ "mkdocs~=1.4.0", @@ -57,7 +57,7 @@ docs = [ "pdocs~=1.2.0", ] redis = [ - "stac-fastapi-core[redis]==6.8.0", + "stac-fastapi-core[redis]==6.8.1", ] server = [ "uvicorn[standard]~=0.23.0", diff --git a/stac_fastapi/opensearch/stac_fastapi/opensearch/app.py b/stac_fastapi/opensearch/stac_fastapi/opensearch/app.py index e122155b..ee552446 100644 --- a/stac_fastapi/opensearch/stac_fastapi/opensearch/app.py +++ b/stac_fastapi/opensearch/stac_fastapi/opensearch/app.py @@ -243,7 +243,7 @@ app_config = { "title": os.getenv("STAC_FASTAPI_TITLE", "stac-fastapi-opensearch"), "description": os.getenv("STAC_FASTAPI_DESCRIPTION", "stac-fastapi-opensearch"), - "api_version": os.getenv("STAC_FASTAPI_VERSION", "6.8.0"), + "api_version": os.getenv("STAC_FASTAPI_VERSION", "6.8.1"), "settings": settings, "extensions": extensions, "client": CoreClient( diff --git a/stac_fastapi/opensearch/stac_fastapi/opensearch/version.py b/stac_fastapi/opensearch/stac_fastapi/opensearch/version.py index 006e9b15..8a68b5cb 100644 --- a/stac_fastapi/opensearch/stac_fastapi/opensearch/version.py +++ b/stac_fastapi/opensearch/stac_fastapi/opensearch/version.py @@ -1,2 +1,2 @@ """library version.""" -__version__ = "6.8.0" +__version__ = "6.8.1" diff --git a/stac_fastapi/sfeos_helpers/pyproject.toml b/stac_fastapi/sfeos_helpers/pyproject.toml index 0388b408..c893211e 100644 --- a/stac_fastapi/sfeos_helpers/pyproject.toml +++ b/stac_fastapi/sfeos_helpers/pyproject.toml @@ -29,7 +29,7 @@ keywords = [ ] dynamic = ["version"] dependencies = [ - "stac-fastapi.core==6.8.0", + "stac-fastapi.core==6.8.1", ] [project.urls] diff --git a/stac_fastapi/sfeos_helpers/stac_fastapi/sfeos_helpers/version.py b/stac_fastapi/sfeos_helpers/stac_fastapi/sfeos_helpers/version.py index 006e9b15..8a68b5cb 100644 --- a/stac_fastapi/sfeos_helpers/stac_fastapi/sfeos_helpers/version.py +++ b/stac_fastapi/sfeos_helpers/stac_fastapi/sfeos_helpers/version.py @@ -1,2 +1,2 @@ """library version.""" -__version__ = "6.8.0" +__version__ = "6.8.1" diff --git a/stac_fastapi/tests/extensions/test_catalogs.py b/stac_fastapi/tests/extensions/test_catalogs.py index b22cf669..610673aa 100644 --- a/stac_fastapi/tests/extensions/test_catalogs.py +++ b/stac_fastapi/tests/extensions/test_catalogs.py @@ -485,7 +485,7 @@ async def test_create_catalog_collection_nonexistent_catalog( @pytest.mark.asyncio async def test_delete_catalog(catalogs_app_client, load_test_data): - """Test deleting a catalog without cascade.""" + """Test deleting an empty catalog.""" # Create a catalog test_catalog = load_test_data("test_catalog.json") test_catalog["id"] = f"test-catalog-{uuid.uuid4()}" @@ -510,52 +510,9 @@ async def test_delete_catalog(catalogs_app_client, load_test_data): assert get_resp.status_code == 404 -@pytest.mark.asyncio -async def test_delete_catalog_cascade(catalogs_app_client, load_test_data): - """Test deleting a catalog with cascade delete of collections.""" - # Create a catalog - test_catalog = load_test_data("test_catalog.json") - test_catalog["id"] = f"test-catalog-{uuid.uuid4()}" - test_catalog["links"] = [ - link for link in test_catalog.get("links", []) if link.get("rel") != "child" - ] - - create_resp = await catalogs_app_client.post("/catalogs", json=test_catalog) - assert create_resp.status_code == 201 - catalog_id = test_catalog["id"] - - # Create a collection in the catalog - test_collection = load_test_data("test_collection.json") - test_collection["id"] = f"test-collection-{uuid.uuid4()}" - - coll_resp = await catalogs_app_client.post( - f"/catalogs/{catalog_id}/collections", json=test_collection - ) - assert coll_resp.status_code == 201 - collection_id = test_collection["id"] - - # Verify collection exists - get_coll_resp = await catalogs_app_client.get(f"/collections/{collection_id}") - assert get_coll_resp.status_code == 200 - - # Delete the catalog with cascade=true - delete_resp = await catalogs_app_client.delete( - f"/catalogs/{catalog_id}?cascade=true" - ) - assert delete_resp.status_code == 204 - - # Verify catalog is deleted - get_resp = await catalogs_app_client.get(f"/catalogs/{catalog_id}") - assert get_resp.status_code == 404 - - # Verify collection is also deleted (cascade delete) - get_coll_resp = await catalogs_app_client.get(f"/collections/{collection_id}") - assert get_coll_resp.status_code == 404 - - @pytest.mark.asyncio async def test_delete_catalog_no_cascade(catalogs_app_client, load_test_data): - """Test deleting a catalog without cascade (collections remain).""" + """Test deleting a catalog (collections remain and are adopted by root).""" # Create a catalog test_catalog = load_test_data("test_catalog.json") test_catalog["id"] = f"test-catalog-{uuid.uuid4()}" @@ -577,7 +534,7 @@ async def test_delete_catalog_no_cascade(catalogs_app_client, load_test_data): assert coll_resp.status_code == 201 collection_id = test_collection["id"] - # Delete the catalog with cascade=false (default) + # Delete the catalog (cascade is no longer supported) delete_resp = await catalogs_app_client.delete(f"/catalogs/{catalog_id}") assert delete_resp.status_code == 204 @@ -585,7 +542,7 @@ async def test_delete_catalog_no_cascade(catalogs_app_client, load_test_data): get_resp = await catalogs_app_client.get(f"/catalogs/{catalog_id}") assert get_resp.status_code == 404 - # Verify collection still exists (no cascade delete) + # Verify collection still exists (never deleted, only unlinked) get_coll_resp = await catalogs_app_client.get(f"/collections/{collection_id}") assert get_coll_resp.status_code == 200 @@ -643,7 +600,7 @@ async def test_delete_catalog_removes_parent_ids_from_collections( for collection_id in collection_ids: assert collection_id in returned_ids - # Delete the catalog without cascade + # Delete the catalog delete_resp = await catalogs_app_client.delete(f"/catalogs/{catalog_id}") assert delete_resp.status_code == 204 @@ -815,7 +772,11 @@ async def test_get_catalog_collections_uses_parent_ids( async def test_delete_collection_from_catalog_single_parent( catalogs_app_client, load_test_data ): - """Test deleting a collection from a catalog when it's the only parent.""" + """Test deleting a collection from a catalog when it's the only parent. + + With the "Unlink & Adopt" safety net, the collection should be adopted by root + instead of being deleted entirely. + """ # Create a catalog test_catalog = load_test_data("test_catalog.json") catalog_id = f"test-catalog-{uuid.uuid4()}" @@ -840,9 +801,15 @@ async def test_delete_collection_from_catalog_single_parent( ) assert delete_resp.status_code == 204 - # Verify the collection is completely deleted + # Verify the collection still exists (adopted by root, not deleted) get_resp = await catalogs_app_client.get(f"/collections/{collection_id}") - assert get_resp.status_code == 404 + assert get_resp.status_code == 200 + + # Verify we cannot get it from the original catalog anymore + get_from_catalog_resp = await catalogs_app_client.get( + f"/catalogs/{catalog_id}/collections/{collection_id}" + ) + assert get_from_catalog_resp.status_code == 404 @pytest.mark.asyncio @@ -1008,10 +975,8 @@ async def test_catalog_links_contain_all_collections( @pytest.mark.asyncio -async def test_delete_catalog_no_cascade_orphans_collections( - catalogs_app_client, load_test_data -): - """Test that deleting a catalog without cascade makes collections root-level orphans.""" +async def test_delete_catalog_orphans_collections(catalogs_app_client, load_test_data): + """Test that deleting a catalog makes orphaned collections adopt root as parent.""" # Create a catalog test_catalog = load_test_data("test_catalog.json") catalog_id = f"test-catalog-{uuid.uuid4()}" @@ -1065,10 +1030,10 @@ async def test_delete_catalog_no_cascade_orphans_collections( @pytest.mark.asyncio -async def test_delete_catalog_no_cascade_multi_parent_collection( +async def test_delete_catalog_preserves_multi_parent_collections( catalogs_app_client, load_test_data ): - """Test that deleting a catalog without cascade preserves collections with other parents.""" + """Test that deleting a catalog preserves collections with other parents.""" # Create two catalogs catalog_ids = [] for i in range(2): @@ -1096,7 +1061,7 @@ async def test_delete_catalog_no_cascade_multi_parent_collection( ) assert add_resp.status_code == 201 - # Delete the first catalog without cascade + # Delete the first catalog delete_resp = await catalogs_app_client.delete(f"/catalogs/{catalog_ids[0]}") assert delete_resp.status_code == 204