Skip to content

Commit b77f991

Browse files
committed
delete catalog
1 parent d823914 commit b77f991

File tree

3 files changed

+221
-0
lines changed

3 files changed

+221
-0
lines changed

README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,7 @@ This implementation follows the [STAC API Catalogs Extension](https://github.com
248248
- **GET `/catalogs`**: Retrieve the root catalog and its child catalogs
249249
- **POST `/catalogs`**: Create a new catalog (requires appropriate permissions)
250250
- **GET `/catalogs/{catalog_id}`**: Retrieve a specific catalog and its children
251+
- **DELETE `/catalogs/{catalog_id}`**: Delete a catalog (optionally cascade delete all collections)
251252
- **GET `/catalogs/{catalog_id}/collections`**: Retrieve collections within a specific catalog
252253
- **POST `/catalogs/{catalog_id}/collections`**: Create a new collection within a specific catalog
253254
- **GET `/catalogs/{catalog_id}/collections/{collection_id}`**: Retrieve a specific collection within a catalog
@@ -290,8 +291,22 @@ curl "http://localhost:8081/catalogs/earth-observation/collections/sentinel-2/it
290291

291292
# Get specific item within a catalog
292293
curl "http://localhost:8081/catalogs/earth-observation/collections/sentinel-2/items/S2A_20231015_123456"
294+
295+
# Delete a catalog (collections remain intact)
296+
curl -X DELETE "http://localhost:8081/catalogs/earth-observation"
297+
298+
# Delete a catalog and all its collections (cascade delete)
299+
curl -X DELETE "http://localhost:8081/catalogs/earth-observation?cascade=true"
293300
```
294301

302+
### Delete Catalog Parameters
303+
304+
The DELETE endpoint supports the following query parameter:
305+
306+
- **`cascade`** (boolean, default: `false`):
307+
- If `false`: Only deletes the catalog. Collections linked to the catalog remain in the database but lose their catalog link.
308+
- If `true`: Deletes the catalog AND all collections linked to it. Use with caution as this is a destructive operation.
309+
295310
### Response Structure
296311

297312
Catalog responses include:

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

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,18 @@ def register(self, app: FastAPI, settings=None) -> None:
116116
tags=["Catalogs"],
117117
)
118118

119+
# Add endpoint for deleting a catalog
120+
self.router.add_api_route(
121+
path="/catalogs/{catalog_id}",
122+
endpoint=self.delete_catalog,
123+
methods=["DELETE"],
124+
response_class=self.response_class,
125+
status_code=204,
126+
summary="Delete Catalog",
127+
description="Delete a catalog. Optionally cascade delete all collections in the catalog.",
128+
tags=["Catalogs"],
129+
)
130+
119131
# Add endpoint for getting a specific collection in a catalog
120132
self.router.add_api_route(
121133
path="/catalogs/{catalog_id}/collections/{collection_id}",
@@ -266,6 +278,93 @@ async def get_catalog(self, catalog_id: str, request: Request) -> Catalog:
266278
status_code=404, detail=f"Catalog {catalog_id} not found"
267279
)
268280

281+
async def delete_catalog(
282+
self,
283+
catalog_id: str,
284+
request: Request,
285+
cascade: bool = Query(
286+
False,
287+
description="If true, delete all collections linked to this catalog. If false, only delete the catalog.",
288+
),
289+
) -> None:
290+
"""Delete a catalog.
291+
292+
Args:
293+
catalog_id: The ID of the catalog to delete.
294+
request: Request object.
295+
cascade: If true, delete all collections linked to this catalog.
296+
If false, only delete the catalog.
297+
298+
Returns:
299+
None (204 No Content)
300+
301+
Raises:
302+
HTTPException: If the catalog is not found.
303+
"""
304+
try:
305+
# Get the catalog to verify it exists and get its collections
306+
db_catalog = await self.client.database.find_catalog(catalog_id)
307+
catalog = self.client.catalog_serializer.db_to_stac(db_catalog, request)
308+
309+
# If cascade is true, delete all collections linked to this catalog
310+
if cascade:
311+
# Extract collection IDs from catalog links
312+
collection_ids = []
313+
if hasattr(catalog, "links") and catalog.links:
314+
for link in catalog.links:
315+
rel = (
316+
link.get("rel")
317+
if hasattr(link, "get")
318+
else getattr(link, "rel", None)
319+
)
320+
if rel == "child":
321+
href = (
322+
link.get("href", "")
323+
if hasattr(link, "get")
324+
else getattr(link, "href", "")
325+
)
326+
if href and "/collections/" in href:
327+
# Extract collection ID from href
328+
collection_id = href.split("/collections/")[-1].split(
329+
"/"
330+
)[0]
331+
if collection_id:
332+
collection_ids.append(collection_id)
333+
334+
# Delete each collection
335+
for coll_id in collection_ids:
336+
try:
337+
await self.client.database.delete_collection(coll_id)
338+
logger.info(
339+
f"Deleted collection {coll_id} as part of cascade delete for catalog {catalog_id}"
340+
)
341+
except Exception as e:
342+
error_msg = str(e)
343+
if "not found" in error_msg.lower():
344+
logger.debug(
345+
f"Collection {coll_id} not found, skipping (may have been deleted elsewhere)"
346+
)
347+
else:
348+
logger.warning(
349+
f"Failed to delete collection {coll_id}: {e}"
350+
)
351+
352+
# Delete the catalog
353+
await self.client.database.delete_catalog(catalog_id)
354+
logger.info(f"Deleted catalog {catalog_id}")
355+
356+
except Exception as e:
357+
error_msg = str(e)
358+
if "not found" in error_msg.lower():
359+
raise HTTPException(
360+
status_code=404, detail=f"Catalog {catalog_id} not found"
361+
)
362+
logger.error(f"Error deleting catalog {catalog_id}: {e}")
363+
raise HTTPException(
364+
status_code=500,
365+
detail=f"Failed to delete catalog: {str(e)}",
366+
)
367+
269368
async def get_catalog_collections(
270369
self, catalog_id: str, request: Request
271370
) -> stac_types.Collections:

stac_fastapi/tests/extensions/test_catalogs.py

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -476,3 +476,110 @@ async def test_create_catalog_collection_nonexistent_catalog(
476476
"/catalogs/nonexistent-catalog/collections", json=test_collection
477477
)
478478
assert resp.status_code == 404
479+
480+
481+
@pytest.mark.asyncio
482+
async def test_delete_catalog(catalogs_app_client, load_test_data):
483+
"""Test deleting a catalog without cascade."""
484+
# Create a catalog
485+
test_catalog = load_test_data("test_catalog.json")
486+
test_catalog["id"] = f"test-catalog-{uuid.uuid4()}"
487+
test_catalog["links"] = [
488+
link for link in test_catalog.get("links", []) if link.get("rel") != "child"
489+
]
490+
491+
create_resp = await catalogs_app_client.post("/catalogs", json=test_catalog)
492+
assert create_resp.status_code == 201
493+
catalog_id = test_catalog["id"]
494+
495+
# Verify catalog exists
496+
get_resp = await catalogs_app_client.get(f"/catalogs/{catalog_id}")
497+
assert get_resp.status_code == 200
498+
499+
# Delete the catalog
500+
delete_resp = await catalogs_app_client.delete(f"/catalogs/{catalog_id}")
501+
assert delete_resp.status_code == 204
502+
503+
# Verify catalog is deleted
504+
get_resp = await catalogs_app_client.get(f"/catalogs/{catalog_id}")
505+
assert get_resp.status_code == 404
506+
507+
508+
@pytest.mark.asyncio
509+
async def test_delete_catalog_cascade(catalogs_app_client, load_test_data):
510+
"""Test deleting a catalog with cascade delete of collections."""
511+
# Create a catalog
512+
test_catalog = load_test_data("test_catalog.json")
513+
test_catalog["id"] = f"test-catalog-{uuid.uuid4()}"
514+
test_catalog["links"] = [
515+
link for link in test_catalog.get("links", []) if link.get("rel") != "child"
516+
]
517+
518+
create_resp = await catalogs_app_client.post("/catalogs", json=test_catalog)
519+
assert create_resp.status_code == 201
520+
catalog_id = test_catalog["id"]
521+
522+
# Create a collection in the catalog
523+
test_collection = load_test_data("test_collection.json")
524+
test_collection["id"] = f"test-collection-{uuid.uuid4()}"
525+
526+
coll_resp = await catalogs_app_client.post(
527+
f"/catalogs/{catalog_id}/collections", json=test_collection
528+
)
529+
assert coll_resp.status_code == 201
530+
collection_id = test_collection["id"]
531+
532+
# Verify collection exists
533+
get_coll_resp = await catalogs_app_client.get(f"/collections/{collection_id}")
534+
assert get_coll_resp.status_code == 200
535+
536+
# Delete the catalog with cascade=true
537+
delete_resp = await catalogs_app_client.delete(
538+
f"/catalogs/{catalog_id}?cascade=true"
539+
)
540+
assert delete_resp.status_code == 204
541+
542+
# Verify catalog is deleted
543+
get_resp = await catalogs_app_client.get(f"/catalogs/{catalog_id}")
544+
assert get_resp.status_code == 404
545+
546+
# Verify collection is also deleted (cascade delete)
547+
get_coll_resp = await catalogs_app_client.get(f"/collections/{collection_id}")
548+
assert get_coll_resp.status_code == 404
549+
550+
551+
@pytest.mark.asyncio
552+
async def test_delete_catalog_no_cascade(catalogs_app_client, load_test_data):
553+
"""Test deleting a catalog without cascade (collections remain)."""
554+
# Create a catalog
555+
test_catalog = load_test_data("test_catalog.json")
556+
test_catalog["id"] = f"test-catalog-{uuid.uuid4()}"
557+
test_catalog["links"] = [
558+
link for link in test_catalog.get("links", []) if link.get("rel") != "child"
559+
]
560+
561+
create_resp = await catalogs_app_client.post("/catalogs", json=test_catalog)
562+
assert create_resp.status_code == 201
563+
catalog_id = test_catalog["id"]
564+
565+
# Create a collection in the catalog
566+
test_collection = load_test_data("test_collection.json")
567+
test_collection["id"] = f"test-collection-{uuid.uuid4()}"
568+
569+
coll_resp = await catalogs_app_client.post(
570+
f"/catalogs/{catalog_id}/collections", json=test_collection
571+
)
572+
assert coll_resp.status_code == 201
573+
collection_id = test_collection["id"]
574+
575+
# Delete the catalog with cascade=false (default)
576+
delete_resp = await catalogs_app_client.delete(f"/catalogs/{catalog_id}")
577+
assert delete_resp.status_code == 204
578+
579+
# Verify catalog is deleted
580+
get_resp = await catalogs_app_client.get(f"/catalogs/{catalog_id}")
581+
assert get_resp.status_code == 404
582+
583+
# Verify collection still exists (no cascade delete)
584+
get_coll_resp = await catalogs_app_client.get(f"/collections/{collection_id}")
585+
assert get_coll_resp.status_code == 200

0 commit comments

Comments
 (0)