Skip to content

Commit 4e30406

Browse files
authored
Add /children extension to /catalogs route (#558)
**Related Issue(s):** - #555 - #308 **Description:** - 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. **PR Checklist:** - [x] Code is formatted and linted (run `pre-commit run --all-files`) - [x] Tests pass (run `make test`) - [x] Documentation has been updated to reflect changes, if applicable - [x] Changes are added to the changelog
1 parent d0f01d4 commit 4e30406

File tree

5 files changed

+365
-3
lines changed

5 files changed

+365
-3
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
1818
- 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)
1919

2020
- 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)
21+
- 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)
2122

2223
### Changed
2324

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,7 @@ This implementation follows the [STAC API Catalogs Extension](https://github.com
251251
- **POST `/catalogs`**: Create a new catalog (requires appropriate permissions)
252252
- **GET `/catalogs/{catalog_id}`**: Retrieve a specific catalog and its children
253253
- **DELETE `/catalogs/{catalog_id}`**: Delete a catalog (optionally cascade delete all collections)
254+
- **GET `/catalogs/{catalog_id}/children`**: Retrieve all children (Catalogs and Collections) of this catalog with optional type filtering
254255
- **GET `/catalogs/{catalog_id}/collections`**: Retrieve collections within a specific catalog
255256
- **POST `/catalogs/{catalog_id}/collections`**: Create a new collection within a specific catalog
256257
- **GET `/catalogs/{catalog_id}/collections/{collection_id}`**: Retrieve a specific collection within a catalog
@@ -267,6 +268,15 @@ curl "http://localhost:8081/catalogs"
267268
# Get specific catalog
268269
curl "http://localhost:8081/catalogs/earth-observation"
269270

271+
# Get all children (catalogs and collections) of a catalog
272+
curl "http://localhost:8081/catalogs/earth-observation/children"
273+
274+
# Get only catalog children of a catalog
275+
curl "http://localhost:8081/catalogs/earth-observation/children?type=Catalog"
276+
277+
# Get only collection children of a catalog
278+
curl "http://localhost:8081/catalogs/earth-observation/children?type=Collection"
279+
270280
# Get collections in a catalog
271281
curl "http://localhost:8081/catalogs/earth-observation/collections"
272282

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

Lines changed: 152 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
"""Catalogs extension."""
22

33
import logging
4-
from typing import List, Optional, Type
5-
from urllib.parse import urlencode
4+
from typing import Any, Dict, List, Optional, Type
5+
from urllib.parse import parse_qs, urlencode, urlparse
66

77
import attr
88
from fastapi import APIRouter, FastAPI, HTTPException, Query, Request
@@ -42,7 +42,9 @@ class CatalogsExtension(ApiExtension):
4242

4343
client: BaseCoreClient = attr.ib(default=None)
4444
settings: dict = attr.ib(default=attr.Factory(dict))
45-
conformance_classes: List[str] = attr.ib(default=attr.Factory(list))
45+
conformance_classes: List[str] = attr.ib(
46+
default=attr.Factory(lambda: ["https://api.stacspec.org/v1.0.0-rc.2/children"])
47+
)
4648
router: APIRouter = attr.ib(default=attr.Factory(APIRouter))
4749
response_class: Type[Response] = attr.ib(default=JSONResponse)
4850

@@ -176,6 +178,17 @@ def register(self, app: FastAPI, settings=None) -> None:
176178
tags=["Catalogs"],
177179
)
178180

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+
179192
app.include_router(self.router, tags=["Catalogs"])
180193

181194
async def catalogs(
@@ -852,6 +865,142 @@ async def get_catalog_collection_item(
852865
item_id=item_id, collection_id=collection_id, request=request
853866
)
854867

868+
async def get_catalog_children(
869+
self,
870+
catalog_id: str,
871+
request: Request,
872+
limit: int = 10,
873+
token: str = None,
874+
type: Optional[str] = Query(
875+
None, description="Filter by resource type (Catalog or Collection)"
876+
),
877+
) -> Dict[str, Any]:
878+
"""
879+
Get all children (Catalogs and Collections) of a specific catalog.
880+
881+
This is a 'Union' endpoint that returns mixed content types.
882+
"""
883+
# 1. Verify the parent catalog exists
884+
await self.client.database.find_catalog(catalog_id)
885+
886+
# 2. Build the Search Query
887+
# We search the COLLECTIONS_INDEX because it holds both Catalogs and Collections
888+
889+
# Base filter: Parent match
890+
# This finds anything where 'parent_ids' contains this catalog_id
891+
filter_queries = [{"term": {"parent_ids": catalog_id}}]
892+
893+
# Optional filter: Type
894+
if type:
895+
# If user asks for ?type=Catalog, we only return Catalogs
896+
filter_queries.append({"term": {"type": type}})
897+
898+
# 3. Calculate Pagination (Search After)
899+
body = {
900+
"query": {"bool": {"filter": filter_queries}},
901+
"sort": [{"id": {"order": "asc"}}], # Stable sort for pagination
902+
"size": limit,
903+
}
904+
905+
# Handle search_after token - split by '|' to get all sort values
906+
search_after: Optional[List[str]] = None
907+
if token:
908+
try:
909+
# The token should be a pipe-separated string of sort values
910+
# e.g., "collection-1"
911+
from typing import cast
912+
913+
search_after_parts = cast(List[str], token.split("|"))
914+
# If the number of sort fields doesn't match token parts, ignore the token
915+
if len(search_after_parts) != len(body["sort"]): # type: ignore
916+
search_after = None
917+
else:
918+
search_after = search_after_parts
919+
except Exception:
920+
search_after = None
921+
922+
if search_after is not None:
923+
body["search_after"] = search_after
924+
925+
# 4. Execute Search
926+
search_result = await self.client.database.client.search(
927+
index=COLLECTIONS_INDEX, body=body
928+
)
929+
930+
# 5. Process Results
931+
hits = search_result.get("hits", {}).get("hits", [])
932+
total = search_result.get("hits", {}).get("total", {}).get("value", 0)
933+
934+
children = []
935+
for hit in hits:
936+
doc = hit["_source"]
937+
resource_type = doc.get(
938+
"type", "Collection"
939+
) # Default to Collection if missing
940+
941+
# Serialize based on type
942+
# This ensures we hide internal fields like 'parent_ids' correctly
943+
if resource_type == "Catalog":
944+
child = self.client.catalog_serializer.db_to_stac(doc, request)
945+
else:
946+
child = self.client.collection_serializer.db_to_stac(doc, request)
947+
948+
children.append(child)
949+
950+
# 6. Format Response
951+
# The Children extension uses a specific response format
952+
response = {
953+
"children": children,
954+
"links": [
955+
{"rel": "self", "type": "application/json", "href": str(request.url)},
956+
{
957+
"rel": "root",
958+
"type": "application/json",
959+
"href": str(request.base_url),
960+
},
961+
{
962+
"rel": "parent",
963+
"type": "application/json",
964+
"href": f"{str(request.base_url)}catalogs/{catalog_id}",
965+
},
966+
],
967+
"numberReturned": len(children),
968+
"numberMatched": total,
969+
}
970+
971+
# 7. Generate Next Link
972+
next_token = None
973+
if len(hits) == limit:
974+
next_token_values = hits[-1].get("sort")
975+
if next_token_values:
976+
# Join all sort values with '|' to create the token
977+
next_token = "|".join(str(val) for val in next_token_values)
978+
979+
if next_token:
980+
# Get existing query params
981+
parsed_url = urlparse(str(request.url))
982+
params = parse_qs(parsed_url.query)
983+
984+
# Update params
985+
params["token"] = [next_token]
986+
params["limit"] = [str(limit)]
987+
if type:
988+
params["type"] = [type]
989+
990+
# Flatten params for urlencode (parse_qs returns lists)
991+
flat_params = {
992+
k: v[0] if isinstance(v, list) else v for k, v in params.items()
993+
}
994+
995+
next_link = {
996+
"rel": "next",
997+
"type": "application/json",
998+
"href": f"{request.base_url}catalogs/{catalog_id}/children?{urlencode(flat_params)}",
999+
}
1000+
response["links"].append(next_link)
1001+
1002+
return response
1003+
8551004
async def delete_catalog_collection(
8561005
self, catalog_id: str, collection_id: str, request: Request
8571006
) -> None:

stac_fastapi/tests/api/test_api.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
"POST /catalogs",
5555
"GET /catalogs/{catalog_id}",
5656
"DELETE /catalogs/{catalog_id}",
57+
"GET /catalogs/{catalog_id}/children",
5758
"GET /catalogs/{catalog_id}/collections",
5859
"POST /catalogs/{catalog_id}/collections",
5960
"GET /catalogs/{catalog_id}/collections/{collection_id}",

0 commit comments

Comments
 (0)