stac-fastapi-core 6.7.5__py3-none-any.whl → 6.8.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,995 @@
1
+ """Catalogs extension."""
2
+
3
+ import logging
4
+ from typing import Any, Dict, List, Optional, Type
5
+ from urllib.parse import parse_qs, urlencode, urlparse
6
+
7
+ import attr
8
+ from fastapi import APIRouter, FastAPI, HTTPException, Query, Request
9
+ from fastapi.responses import JSONResponse
10
+ from stac_pydantic import Collection
11
+ from starlette.responses import Response
12
+ from typing_extensions import TypedDict
13
+
14
+ from stac_fastapi.core.models import Catalog
15
+ from stac_fastapi.sfeos_helpers.mappings import COLLECTIONS_INDEX
16
+ from stac_fastapi.types import stac as stac_types
17
+ from stac_fastapi.types.core import BaseCoreClient
18
+ from stac_fastapi.types.extension import ApiExtension
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ class Catalogs(TypedDict, total=False):
24
+ """Catalogs endpoint response.
25
+
26
+ Similar to Collections but for catalogs.
27
+ """
28
+
29
+ catalogs: List[Catalog]
30
+ links: List[dict]
31
+ numberMatched: Optional[int]
32
+ numberReturned: Optional[int]
33
+
34
+
35
+ @attr.s
36
+ class CatalogsExtension(ApiExtension):
37
+ """Catalogs Extension.
38
+
39
+ The Catalogs extension adds a /catalogs endpoint that returns a list of all catalogs
40
+ in the database, similar to how /collections returns a list of collections.
41
+ """
42
+
43
+ client: BaseCoreClient = attr.ib(default=None)
44
+ settings: dict = attr.ib(default=attr.Factory(dict))
45
+ conformance_classes: List[str] = attr.ib(
46
+ default=attr.Factory(lambda: ["https://api.stacspec.org/v1.0.0-rc.2/children"])
47
+ )
48
+ router: APIRouter = attr.ib(default=attr.Factory(APIRouter))
49
+ response_class: Type[Response] = attr.ib(default=JSONResponse)
50
+
51
+ def register(self, app: FastAPI, settings=None) -> None:
52
+ """Register the extension with a FastAPI application.
53
+
54
+ Args:
55
+ app: target FastAPI application.
56
+ settings: extension settings (unused for now).
57
+ """
58
+ self.settings = settings or {}
59
+
60
+ self.router.add_api_route(
61
+ path="/catalogs",
62
+ endpoint=self.catalogs,
63
+ methods=["GET"],
64
+ response_model=Catalogs,
65
+ response_class=self.response_class,
66
+ summary="Get All Catalogs",
67
+ description="Returns a list of all catalogs in the database.",
68
+ tags=["Catalogs"],
69
+ )
70
+
71
+ # Add endpoint for creating catalogs
72
+ self.router.add_api_route(
73
+ path="/catalogs",
74
+ endpoint=self.create_catalog,
75
+ methods=["POST"],
76
+ response_model=Catalog,
77
+ response_class=self.response_class,
78
+ status_code=201,
79
+ summary="Create Catalog",
80
+ description="Create a new STAC catalog.",
81
+ tags=["Catalogs"],
82
+ )
83
+
84
+ # Add endpoint for getting individual catalogs
85
+ self.router.add_api_route(
86
+ path="/catalogs/{catalog_id}",
87
+ endpoint=self.get_catalog,
88
+ methods=["GET"],
89
+ response_model=Catalog,
90
+ response_class=self.response_class,
91
+ summary="Get Catalog",
92
+ description="Get a specific STAC catalog by ID.",
93
+ tags=["Catalogs"],
94
+ )
95
+
96
+ # Add endpoint for getting collections in a catalog
97
+ self.router.add_api_route(
98
+ path="/catalogs/{catalog_id}/collections",
99
+ endpoint=self.get_catalog_collections,
100
+ methods=["GET"],
101
+ response_model=stac_types.Collections,
102
+ response_class=self.response_class,
103
+ summary="Get Catalog Collections",
104
+ description="Get collections linked from a specific catalog.",
105
+ tags=["Catalogs"],
106
+ )
107
+
108
+ # Add endpoint for creating collections in a catalog
109
+ self.router.add_api_route(
110
+ path="/catalogs/{catalog_id}/collections",
111
+ endpoint=self.create_catalog_collection,
112
+ methods=["POST"],
113
+ response_model=stac_types.Collection,
114
+ response_class=self.response_class,
115
+ status_code=201,
116
+ summary="Create Catalog Collection",
117
+ description="Create a new collection and link it to a specific catalog.",
118
+ tags=["Catalogs"],
119
+ )
120
+
121
+ # Add endpoint for deleting a catalog
122
+ self.router.add_api_route(
123
+ path="/catalogs/{catalog_id}",
124
+ endpoint=self.delete_catalog,
125
+ methods=["DELETE"],
126
+ response_class=self.response_class,
127
+ status_code=204,
128
+ summary="Delete Catalog",
129
+ description="Delete a catalog. Optionally cascade delete all collections in the catalog.",
130
+ tags=["Catalogs"],
131
+ )
132
+
133
+ # Add endpoint for getting a specific collection in a catalog
134
+ self.router.add_api_route(
135
+ path="/catalogs/{catalog_id}/collections/{collection_id}",
136
+ endpoint=self.get_catalog_collection,
137
+ methods=["GET"],
138
+ response_model=stac_types.Collection,
139
+ response_class=self.response_class,
140
+ summary="Get Catalog Collection",
141
+ description="Get a specific collection from a catalog.",
142
+ tags=["Catalogs"],
143
+ )
144
+
145
+ # Add endpoint for deleting a collection from a catalog
146
+ self.router.add_api_route(
147
+ path="/catalogs/{catalog_id}/collections/{collection_id}",
148
+ endpoint=self.delete_catalog_collection,
149
+ methods=["DELETE"],
150
+ response_class=self.response_class,
151
+ status_code=204,
152
+ summary="Delete Catalog Collection",
153
+ description="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, deletes the collection entirely.",
154
+ tags=["Catalogs"],
155
+ )
156
+
157
+ # Add endpoint for getting items in a collection within a catalog
158
+ self.router.add_api_route(
159
+ path="/catalogs/{catalog_id}/collections/{collection_id}/items",
160
+ endpoint=self.get_catalog_collection_items,
161
+ methods=["GET"],
162
+ response_model=stac_types.ItemCollection,
163
+ response_class=self.response_class,
164
+ summary="Get Catalog Collection Items",
165
+ description="Get items from a collection in a catalog.",
166
+ tags=["Catalogs"],
167
+ )
168
+
169
+ # Add endpoint for getting a specific item in a collection within a catalog
170
+ self.router.add_api_route(
171
+ path="/catalogs/{catalog_id}/collections/{collection_id}/items/{item_id}",
172
+ endpoint=self.get_catalog_collection_item,
173
+ methods=["GET"],
174
+ response_model=stac_types.Item,
175
+ response_class=self.response_class,
176
+ summary="Get Catalog Collection Item",
177
+ description="Get a specific item from a collection in a catalog.",
178
+ tags=["Catalogs"],
179
+ )
180
+
181
+ # Add endpoint for Children Extension
182
+ self.router.add_api_route(
183
+ path="/catalogs/{catalog_id}/children",
184
+ endpoint=self.get_catalog_children,
185
+ methods=["GET"],
186
+ response_class=self.response_class,
187
+ summary="Get Catalog Children",
188
+ description="Retrieve all children (Catalogs and Collections) of this catalog.",
189
+ tags=["Catalogs"],
190
+ )
191
+
192
+ app.include_router(self.router, tags=["Catalogs"])
193
+
194
+ async def catalogs(
195
+ self,
196
+ request: Request,
197
+ limit: Optional[int] = Query(
198
+ 10,
199
+ ge=1,
200
+ description=(
201
+ "The maximum number of catalogs to return (page size). Defaults to 10."
202
+ ),
203
+ ),
204
+ token: Optional[str] = Query(
205
+ None,
206
+ description="Pagination token for the next page of results",
207
+ ),
208
+ ) -> Catalogs:
209
+ """Get all catalogs with pagination support.
210
+
211
+ Args:
212
+ request: Request object.
213
+ limit: The maximum number of catalogs to return (page size). Defaults to 10.
214
+ token: Pagination token for the next page of results.
215
+
216
+ Returns:
217
+ Catalogs object containing catalogs and pagination links.
218
+ """
219
+ base_url = str(request.base_url)
220
+
221
+ # Get all catalogs from database with pagination
222
+ catalogs, next_token, _ = await self.client.database.get_all_catalogs(
223
+ token=token,
224
+ limit=limit,
225
+ request=request,
226
+ sort=[{"field": "id", "direction": "asc"}],
227
+ )
228
+
229
+ # Convert database catalogs to STAC format
230
+ catalog_stac_objects = []
231
+ for catalog in catalogs:
232
+ catalog_stac = self.client.catalog_serializer.db_to_stac(catalog, request)
233
+ catalog_stac_objects.append(catalog_stac)
234
+
235
+ # Create pagination links
236
+ links = [
237
+ {"rel": "root", "type": "application/json", "href": base_url},
238
+ {"rel": "parent", "type": "application/json", "href": base_url},
239
+ {"rel": "self", "type": "application/json", "href": str(request.url)},
240
+ ]
241
+
242
+ # Add next link if there are more pages
243
+ if next_token:
244
+ query_params = {"limit": limit, "token": next_token}
245
+ next_link = {
246
+ "rel": "next",
247
+ "href": f"{base_url}catalogs?{urlencode(query_params)}",
248
+ "type": "application/json",
249
+ "title": "Next page of catalogs",
250
+ }
251
+ links.append(next_link)
252
+
253
+ # Return Catalogs object with catalogs
254
+ return Catalogs(
255
+ catalogs=catalog_stac_objects,
256
+ links=links,
257
+ numberReturned=len(catalog_stac_objects),
258
+ )
259
+
260
+ async def create_catalog(self, catalog: Catalog, request: Request) -> Catalog:
261
+ """Create a new catalog.
262
+
263
+ Args:
264
+ catalog: The catalog to create.
265
+ request: Request object.
266
+
267
+ Returns:
268
+ The created catalog.
269
+ """
270
+ # Convert STAC catalog to database format
271
+ db_catalog = self.client.catalog_serializer.stac_to_db(catalog, request)
272
+
273
+ # Convert to dict and ensure type is set to Catalog
274
+ db_catalog_dict = db_catalog.model_dump()
275
+ db_catalog_dict["type"] = "Catalog"
276
+
277
+ # Create the catalog in the database with refresh to ensure immediate availability
278
+ await self.client.database.create_catalog(db_catalog_dict, refresh=True)
279
+
280
+ # Return the created catalog
281
+ return catalog
282
+
283
+ async def get_catalog(self, catalog_id: str, request: Request) -> Catalog:
284
+ """Get a specific catalog by ID.
285
+
286
+ Args:
287
+ catalog_id: The ID of the catalog to retrieve.
288
+ request: Request object.
289
+
290
+ Returns:
291
+ The requested catalog.
292
+ """
293
+ try:
294
+ # Get the catalog from the database
295
+ db_catalog = await self.client.database.find_catalog(catalog_id)
296
+
297
+ # Convert to STAC format
298
+ catalog = self.client.catalog_serializer.db_to_stac(db_catalog, request)
299
+
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}")
336
+ raise HTTPException(
337
+ status_code=404, detail=f"Catalog {catalog_id} not found"
338
+ )
339
+
340
+ async def delete_catalog(
341
+ self,
342
+ catalog_id: str,
343
+ request: Request,
344
+ cascade: bool = Query(
345
+ False,
346
+ description="If true, delete all collections linked to this catalog. If false, only delete the catalog.",
347
+ ),
348
+ ) -> None:
349
+ """Delete a catalog.
350
+
351
+ Args:
352
+ catalog_id: The ID of the catalog to delete.
353
+ request: Request object.
354
+ cascade: If true, delete all collections linked to this catalog.
355
+ If false, only delete the catalog.
356
+
357
+ Returns:
358
+ None (204 No Content)
359
+
360
+ Raises:
361
+ HTTPException: If the catalog is not found.
362
+ """
363
+ try:
364
+ # Get the catalog to verify it exists
365
+ await self.client.database.find_catalog(catalog_id)
366
+
367
+ # Use reverse lookup query to find all collections with this catalog in parent_ids.
368
+ # This is more reliable than parsing links, as it captures all collections
369
+ # regardless of pagination or link truncation.
370
+ query_body = {"query": {"term": {"parent_ids": catalog_id}}}
371
+ search_result = await self.client.database.client.search(
372
+ index=COLLECTIONS_INDEX, body=query_body, size=10000
373
+ )
374
+ children = [hit["_source"] for hit in search_result["hits"]["hits"]]
375
+
376
+ # Process each child collection
377
+ for child in children:
378
+ child_id = child.get("id")
379
+ try:
380
+ if cascade:
381
+ # DANGER ZONE: User explicitly requested cascade delete.
382
+ # Delete the collection entirely, regardless of other parents.
383
+ await self.client.database.delete_collection(child_id)
384
+ logger.info(
385
+ f"Deleted collection {child_id} as part of cascade delete for catalog {catalog_id}"
386
+ )
387
+ else:
388
+ # SAFE ZONE: Smart Unlink - Remove only this catalog from parent_ids.
389
+ # The collection survives and becomes a root-level collection if it has no other parents.
390
+ parent_ids = child.get("parent_ids", [])
391
+ if catalog_id in parent_ids:
392
+ parent_ids.remove(catalog_id)
393
+ child["parent_ids"] = parent_ids
394
+
395
+ # Update the collection in the database
396
+ # Note: Catalog links are now dynamically generated, so no need to remove them
397
+ await self.client.database.update_collection(
398
+ collection_id=child_id,
399
+ collection=child,
400
+ refresh=False,
401
+ )
402
+
403
+ # Log the result
404
+ if len(parent_ids) == 0:
405
+ logger.info(
406
+ f"Collection {child_id} is now a root-level orphan (no parent catalogs)"
407
+ )
408
+ else:
409
+ logger.info(
410
+ f"Removed catalog {catalog_id} from collection {child_id}; still belongs to {len(parent_ids)} other catalog(s)"
411
+ )
412
+ else:
413
+ logger.debug(
414
+ f"Catalog {catalog_id} not in parent_ids for collection {child_id}"
415
+ )
416
+ except Exception as e:
417
+ error_msg = str(e)
418
+ if "not found" in error_msg.lower():
419
+ logger.debug(
420
+ f"Collection {child_id} not found, skipping (may have been deleted elsewhere)"
421
+ )
422
+ else:
423
+ logger.warning(
424
+ f"Failed to process collection {child_id} during catalog deletion: {e}"
425
+ )
426
+
427
+ # Delete the catalog itself
428
+ await self.client.database.delete_catalog(catalog_id, refresh=True)
429
+ logger.info(f"Deleted catalog {catalog_id}")
430
+
431
+ except Exception as e:
432
+ error_msg = str(e)
433
+ if "not found" in error_msg.lower():
434
+ raise HTTPException(
435
+ status_code=404, detail=f"Catalog {catalog_id} not found"
436
+ )
437
+ logger.error(f"Error deleting catalog {catalog_id}: {e}")
438
+ raise HTTPException(
439
+ status_code=500,
440
+ detail=f"Failed to delete catalog: {str(e)}",
441
+ )
442
+
443
+ async def get_catalog_collections(
444
+ self, catalog_id: str, request: Request
445
+ ) -> stac_types.Collections:
446
+ """Get collections linked from a specific catalog.
447
+
448
+ Args:
449
+ catalog_id: The ID of the catalog.
450
+ request: Request object.
451
+
452
+ Returns:
453
+ Collections object containing collections linked from the catalog.
454
+ """
455
+ try:
456
+ # Verify the catalog exists
457
+ await self.client.database.find_catalog(catalog_id)
458
+
459
+ # Query collections by parent_ids field using Elasticsearch directly
460
+ # This uses the parent_ids field in the collection mapping to find all
461
+ # collections that have this catalog as a parent
462
+ query_body = {"query": {"term": {"parent_ids": catalog_id}}}
463
+
464
+ # Execute the search to get collection IDs
465
+ try:
466
+ search_result = await self.client.database.client.search(
467
+ index=COLLECTIONS_INDEX, body=query_body
468
+ )
469
+ except Exception as e:
470
+ logger.error(
471
+ f"Error searching for collections with parent {catalog_id}: {e}"
472
+ )
473
+ search_result = {"hits": {"hits": []}}
474
+
475
+ # Extract collection IDs from search results
476
+ collection_ids = []
477
+ hits = search_result.get("hits", {}).get("hits", [])
478
+ for hit in hits:
479
+ collection_ids.append(hit.get("_id"))
480
+
481
+ # Fetch the collections
482
+ collections = []
483
+ for coll_id in collection_ids:
484
+ try:
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
+ )
498
+ )
499
+ collections.append(collection)
500
+ except HTTPException as e:
501
+ # Only skip collections that are not found (404)
502
+ if e.status_code == 404:
503
+ logger.debug(f"Collection {coll_id} not found, skipping")
504
+ continue
505
+ else:
506
+ # Re-raise other HTTP exceptions (5xx server errors, etc.)
507
+ logger.error(f"HTTP error retrieving collection {coll_id}: {e}")
508
+ raise
509
+ except Exception as e:
510
+ # Log unexpected errors and re-raise them
511
+ logger.error(
512
+ f"Unexpected error retrieving collection {coll_id}: {e}"
513
+ )
514
+ raise
515
+
516
+ # Return in Collections format
517
+ base_url = str(request.base_url)
518
+ return stac_types.Collections(
519
+ collections=collections,
520
+ links=[
521
+ {"rel": "root", "type": "application/json", "href": base_url},
522
+ {"rel": "parent", "type": "application/json", "href": base_url},
523
+ {
524
+ "rel": "self",
525
+ "type": "application/json",
526
+ "href": f"{base_url}catalogs/{catalog_id}/collections",
527
+ },
528
+ ],
529
+ )
530
+
531
+ except HTTPException:
532
+ # Re-raise HTTP exceptions as-is
533
+ raise
534
+ except Exception as e:
535
+ logger.error(
536
+ f"Error retrieving collections for catalog {catalog_id}: {e}",
537
+ exc_info=True,
538
+ )
539
+ raise HTTPException(
540
+ status_code=404, detail=f"Catalog {catalog_id} not found"
541
+ )
542
+
543
+ async def create_catalog_collection(
544
+ self, catalog_id: str, collection: Collection, request: Request
545
+ ) -> stac_types.Collection:
546
+ """Create a new collection and link it to a specific catalog.
547
+
548
+ Args:
549
+ catalog_id: The ID of the catalog to link the collection to.
550
+ collection: The collection to create.
551
+ request: Request object.
552
+
553
+ Returns:
554
+ The created collection.
555
+
556
+ Raises:
557
+ HTTPException: If the catalog is not found or collection creation fails.
558
+ """
559
+ try:
560
+ # Verify the catalog exists
561
+ await self.client.database.find_catalog(catalog_id)
562
+
563
+ # Check if the collection already exists in the database
564
+ try:
565
+ existing_collection_db = await self.client.database.find_collection(
566
+ collection.id
567
+ )
568
+ # Collection exists, just add the parent ID if not already present
569
+ existing_collection_dict = existing_collection_db
570
+
571
+ # Ensure parent_ids field exists
572
+ if "parent_ids" not in existing_collection_dict:
573
+ existing_collection_dict["parent_ids"] = []
574
+
575
+ # Add catalog_id to parent_ids if not already present
576
+ if catalog_id not in existing_collection_dict["parent_ids"]:
577
+ existing_collection_dict["parent_ids"].append(catalog_id)
578
+
579
+ # Update the collection in the database
580
+ await self.client.database.update_collection(
581
+ collection_id=collection.id,
582
+ collection=existing_collection_dict,
583
+ refresh=True,
584
+ )
585
+
586
+ # Convert back to STAC format for the response
587
+ updated_collection = (
588
+ self.client.database.collection_serializer.db_to_stac(
589
+ existing_collection_dict,
590
+ request,
591
+ extensions=[
592
+ type(ext).__name__
593
+ for ext in self.client.database.extensions
594
+ ],
595
+ )
596
+ )
597
+
598
+ return updated_collection
599
+
600
+ except Exception as e:
601
+ # Only proceed to create if collection truly doesn't exist
602
+ error_msg = str(e)
603
+ if "not found" not in error_msg.lower():
604
+ # Re-raise if it's a different error
605
+ raise
606
+ # Collection doesn't exist, create it
607
+ # Create the collection using the same pattern as TransactionsClient.create_collection
608
+ # This handles the Collection model from stac_pydantic correctly
609
+ collection_dict = collection.model_dump(mode="json")
610
+
611
+ # Add the catalog ID to the parent_ids field
612
+ if "parent_ids" not in collection_dict:
613
+ collection_dict["parent_ids"] = []
614
+
615
+ if catalog_id not in collection_dict["parent_ids"]:
616
+ collection_dict["parent_ids"].append(catalog_id)
617
+
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.
622
+
623
+ # Now convert to database format (this will process the links)
624
+ collection_db = self.client.database.collection_serializer.stac_to_db(
625
+ collection_dict, request
626
+ )
627
+ await self.client.database.create_collection(
628
+ collection=collection_db, refresh=True
629
+ )
630
+
631
+ # Convert back to STAC format for the response
632
+ created_collection = (
633
+ self.client.database.collection_serializer.db_to_stac(
634
+ collection_db,
635
+ request,
636
+ extensions=[
637
+ type(ext).__name__
638
+ for ext in self.client.database.extensions
639
+ ],
640
+ )
641
+ )
642
+
643
+ return created_collection
644
+
645
+ except HTTPException as e:
646
+ # Re-raise HTTP exceptions (e.g., catalog not found, collection validation errors)
647
+ raise e
648
+ except Exception as e:
649
+ # Check if this is a "not found" error from find_catalog
650
+ error_msg = str(e)
651
+ if "not found" in error_msg.lower():
652
+ raise HTTPException(status_code=404, detail=error_msg)
653
+
654
+ # Handle unexpected errors
655
+ logger.error(f"Error creating collection in catalog {catalog_id}: {e}")
656
+ raise HTTPException(
657
+ status_code=500,
658
+ detail=f"Failed to create collection in catalog: {str(e)}",
659
+ )
660
+
661
+ async def get_catalog_collection(
662
+ self, catalog_id: str, collection_id: str, request: Request
663
+ ) -> stac_types.Collection:
664
+ """Get a specific collection from a catalog.
665
+
666
+ Args:
667
+ catalog_id: The ID of the catalog.
668
+ collection_id: The ID of the collection.
669
+ request: Request object.
670
+
671
+ Returns:
672
+ The requested collection.
673
+ """
674
+ # Verify the catalog exists
675
+ try:
676
+ await self.client.database.find_catalog(catalog_id)
677
+ except Exception:
678
+ raise HTTPException(
679
+ status_code=404, detail=f"Catalog {catalog_id} not found"
680
+ )
681
+
682
+ # Verify the collection exists and has the catalog as a parent
683
+ try:
684
+ collection_db = await self.client.database.find_collection(collection_id)
685
+
686
+ # Check if the catalog_id is in the collection's parent_ids
687
+ parent_ids = collection_db.get("parent_ids", [])
688
+ if catalog_id not in parent_ids:
689
+ raise HTTPException(
690
+ status_code=404,
691
+ detail=f"Collection {collection_id} does not belong to catalog {catalog_id}",
692
+ )
693
+ except HTTPException:
694
+ raise
695
+ except Exception:
696
+ raise HTTPException(
697
+ status_code=404, detail=f"Collection {collection_id} not found"
698
+ )
699
+
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],
707
+ )
708
+
709
+ async def get_catalog_collection_items(
710
+ self,
711
+ catalog_id: str,
712
+ collection_id: str,
713
+ request: Request,
714
+ bbox: Optional[List[float]] = None,
715
+ datetime: Optional[str] = None,
716
+ limit: Optional[int] = None,
717
+ sortby: Optional[str] = None,
718
+ filter_expr: Optional[str] = None,
719
+ filter_lang: Optional[str] = None,
720
+ token: Optional[str] = None,
721
+ query: Optional[str] = None,
722
+ fields: Optional[List[str]] = None,
723
+ ) -> stac_types.ItemCollection:
724
+ """Get items from a collection in a catalog.
725
+
726
+ Args:
727
+ catalog_id: The ID of the catalog.
728
+ collection_id: The ID of the collection.
729
+ request: Request object.
730
+ bbox: Optional bounding box filter.
731
+ datetime: Optional datetime or interval filter.
732
+ limit: Optional page size.
733
+ sortby: Optional sort specification.
734
+ filter_expr: Optional filter expression.
735
+ filter_lang: Optional filter language.
736
+ token: Optional pagination token.
737
+ query: Optional query string.
738
+ fields: Optional fields to include or exclude.
739
+
740
+ Returns:
741
+ ItemCollection containing items from the collection.
742
+ """
743
+ # Verify the catalog exists
744
+ try:
745
+ await self.client.database.find_catalog(catalog_id)
746
+ except Exception:
747
+ raise HTTPException(
748
+ status_code=404, detail=f"Catalog {catalog_id} not found"
749
+ )
750
+
751
+ # Delegate to the core client's item_collection method with all parameters
752
+ return await self.client.item_collection(
753
+ collection_id=collection_id,
754
+ request=request,
755
+ bbox=bbox,
756
+ datetime=datetime,
757
+ limit=limit,
758
+ sortby=sortby,
759
+ filter_expr=filter_expr,
760
+ filter_lang=filter_lang,
761
+ token=token,
762
+ query=query,
763
+ fields=fields,
764
+ )
765
+
766
+ async def get_catalog_collection_item(
767
+ self, catalog_id: str, collection_id: str, item_id: str, request: Request
768
+ ) -> stac_types.Item:
769
+ """Get a specific item from a collection in a catalog.
770
+
771
+ Args:
772
+ catalog_id: The ID of the catalog.
773
+ collection_id: The ID of the collection.
774
+ item_id: The ID of the item.
775
+ request: Request object.
776
+
777
+ Returns:
778
+ The requested item.
779
+ """
780
+ # Verify the catalog exists
781
+ try:
782
+ await self.client.database.find_catalog(catalog_id)
783
+ except Exception:
784
+ raise HTTPException(
785
+ status_code=404, detail=f"Catalog {catalog_id} not found"
786
+ )
787
+
788
+ # Delegate to the core client's get_item method
789
+ return await self.client.get_item(
790
+ item_id=item_id, collection_id=collection_id, request=request
791
+ )
792
+
793
+ async def get_catalog_children(
794
+ self,
795
+ catalog_id: str,
796
+ request: Request,
797
+ limit: int = 10,
798
+ token: str = None,
799
+ type: Optional[str] = Query(
800
+ None, description="Filter by resource type (Catalog or Collection)"
801
+ ),
802
+ ) -> Dict[str, Any]:
803
+ """
804
+ Get all children (Catalogs and Collections) of a specific catalog.
805
+
806
+ This is a 'Union' endpoint that returns mixed content types.
807
+ """
808
+ # 1. Verify the parent catalog exists
809
+ await self.client.database.find_catalog(catalog_id)
810
+
811
+ # 2. Build the Search Query
812
+ # We search the COLLECTIONS_INDEX because it holds both Catalogs and Collections
813
+
814
+ # Base filter: Parent match
815
+ # This finds anything where 'parent_ids' contains this catalog_id
816
+ filter_queries = [{"term": {"parent_ids": catalog_id}}]
817
+
818
+ # Optional filter: Type
819
+ if type:
820
+ # If user asks for ?type=Catalog, we only return Catalogs
821
+ filter_queries.append({"term": {"type": type}})
822
+
823
+ # 3. Calculate Pagination (Search After)
824
+ body = {
825
+ "query": {"bool": {"filter": filter_queries}},
826
+ "sort": [{"id": {"order": "asc"}}], # Stable sort for pagination
827
+ "size": limit,
828
+ }
829
+
830
+ # Handle search_after token - split by '|' to get all sort values
831
+ search_after: Optional[List[str]] = None
832
+ if token:
833
+ try:
834
+ # The token should be a pipe-separated string of sort values
835
+ # e.g., "collection-1"
836
+ from typing import cast
837
+
838
+ search_after_parts = cast(List[str], token.split("|"))
839
+ # If the number of sort fields doesn't match token parts, ignore the token
840
+ if len(search_after_parts) != len(body["sort"]): # type: ignore
841
+ search_after = None
842
+ else:
843
+ search_after = search_after_parts
844
+ except Exception:
845
+ search_after = None
846
+
847
+ if search_after is not None:
848
+ body["search_after"] = search_after
849
+
850
+ # 4. Execute Search
851
+ search_result = await self.client.database.client.search(
852
+ index=COLLECTIONS_INDEX, body=body
853
+ )
854
+
855
+ # 5. Process Results
856
+ hits = search_result.get("hits", {}).get("hits", [])
857
+ total = search_result.get("hits", {}).get("total", {}).get("value", 0)
858
+
859
+ children = []
860
+ for hit in hits:
861
+ doc = hit["_source"]
862
+ resource_type = doc.get(
863
+ "type", "Collection"
864
+ ) # Default to Collection if missing
865
+
866
+ # Serialize based on type
867
+ # This ensures we hide internal fields like 'parent_ids' correctly
868
+ if resource_type == "Catalog":
869
+ child = self.client.catalog_serializer.db_to_stac(doc, request)
870
+ else:
871
+ child = self.client.collection_serializer.db_to_stac(doc, request)
872
+
873
+ children.append(child)
874
+
875
+ # 6. Format Response
876
+ # The Children extension uses a specific response format
877
+ response = {
878
+ "children": children,
879
+ "links": [
880
+ {"rel": "self", "type": "application/json", "href": str(request.url)},
881
+ {
882
+ "rel": "root",
883
+ "type": "application/json",
884
+ "href": str(request.base_url),
885
+ },
886
+ {
887
+ "rel": "parent",
888
+ "type": "application/json",
889
+ "href": f"{str(request.base_url)}catalogs/{catalog_id}",
890
+ },
891
+ ],
892
+ "numberReturned": len(children),
893
+ "numberMatched": total,
894
+ }
895
+
896
+ # 7. Generate Next Link
897
+ next_token = None
898
+ if len(hits) == limit:
899
+ next_token_values = hits[-1].get("sort")
900
+ if next_token_values:
901
+ # Join all sort values with '|' to create the token
902
+ next_token = "|".join(str(val) for val in next_token_values)
903
+
904
+ if next_token:
905
+ # Get existing query params
906
+ parsed_url = urlparse(str(request.url))
907
+ params = parse_qs(parsed_url.query)
908
+
909
+ # Update params
910
+ params["token"] = [next_token]
911
+ params["limit"] = [str(limit)]
912
+ if type:
913
+ params["type"] = [type]
914
+
915
+ # Flatten params for urlencode (parse_qs returns lists)
916
+ flat_params = {
917
+ k: v[0] if isinstance(v, list) else v for k, v in params.items()
918
+ }
919
+
920
+ next_link = {
921
+ "rel": "next",
922
+ "type": "application/json",
923
+ "href": f"{request.base_url}catalogs/{catalog_id}/children?{urlencode(flat_params)}",
924
+ }
925
+ response["links"].append(next_link)
926
+
927
+ return response
928
+
929
+ async def delete_catalog_collection(
930
+ self, catalog_id: str, collection_id: str, request: Request
931
+ ) -> None:
932
+ """Delete a collection from a catalog.
933
+
934
+ If the collection has multiple parent catalogs, only removes this catalog
935
+ from the parent_ids. If this is the only parent catalog, deletes the
936
+ collection entirely.
937
+
938
+ Args:
939
+ catalog_id: The ID of the catalog.
940
+ collection_id: The ID of the collection.
941
+ request: Request object.
942
+
943
+ Raises:
944
+ HTTPException: If the catalog or collection is not found, or if the
945
+ collection does not belong to the catalog.
946
+ """
947
+ try:
948
+ # Verify the catalog exists
949
+ await self.client.database.find_catalog(catalog_id)
950
+
951
+ # Get the collection
952
+ collection_db = await self.client.database.find_collection(collection_id)
953
+
954
+ # Check if the catalog_id is in the collection's parent_ids
955
+ parent_ids = collection_db.get("parent_ids", [])
956
+ if catalog_id not in parent_ids:
957
+ raise HTTPException(
958
+ status_code=404,
959
+ detail=f"Collection {collection_id} does not belong to catalog {catalog_id}",
960
+ )
961
+
962
+ # If the collection has multiple parents, just remove this catalog from parent_ids
963
+ if len(parent_ids) > 1:
964
+ parent_ids.remove(catalog_id)
965
+ collection_db["parent_ids"] = parent_ids
966
+
967
+ # Update the collection in the database
968
+ # Note: Catalog links are now dynamically generated, so no need to remove them
969
+ await self.client.database.update_collection(
970
+ collection_id=collection_id, collection=collection_db, refresh=True
971
+ )
972
+
973
+ logger.info(
974
+ f"Removed catalog {catalog_id} from collection {collection_id} parent_ids"
975
+ )
976
+ else:
977
+ # If this is the only parent, delete the collection entirely
978
+ await self.client.database.delete_collection(
979
+ collection_id, refresh=True
980
+ )
981
+ logger.info(
982
+ f"Deleted collection {collection_id} (only parent was catalog {catalog_id})"
983
+ )
984
+
985
+ except HTTPException:
986
+ raise
987
+ except Exception as e:
988
+ logger.error(
989
+ f"Error deleting collection {collection_id} from catalog {catalog_id}: {e}",
990
+ exc_info=True,
991
+ )
992
+ raise HTTPException(
993
+ status_code=500,
994
+ detail=f"Failed to delete collection from catalog: {str(e)}",
995
+ )