Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,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)

### Changed

Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,7 @@ This implementation follows the [STAC API Catalogs Extension](https://github.com
- **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)
- **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
Expand All @@ -267,6 +268,15 @@ curl "http://localhost:8081/catalogs"
# Get specific catalog
curl "http://localhost:8081/catalogs/earth-observation"

# Get all children (catalogs and collections) of a catalog
curl "http://localhost:8081/catalogs/earth-observation/children"

# Get only catalog children of a catalog
curl "http://localhost:8081/catalogs/earth-observation/children?type=Catalog"

# Get only collection children of a catalog
curl "http://localhost:8081/catalogs/earth-observation/children?type=Collection"

# Get collections in a catalog
curl "http://localhost:8081/catalogs/earth-observation/collections"

Expand Down
155 changes: 152 additions & 3 deletions stac_fastapi/core/stac_fastapi/core/extensions/catalogs.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
"""Catalogs extension."""

import logging
from typing import List, Optional, Type
from urllib.parse import urlencode
from typing import Any, Dict, List, Optional, Type
from urllib.parse import parse_qs, urlencode, urlparse

import attr
from fastapi import APIRouter, FastAPI, HTTPException, Query, Request
Expand Down Expand Up @@ -42,7 +42,9 @@ class CatalogsExtension(ApiExtension):

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

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

# Add endpoint for Children Extension
self.router.add_api_route(
path="/catalogs/{catalog_id}/children",
endpoint=self.get_catalog_children,
methods=["GET"],
response_class=self.response_class,
summary="Get Catalog Children",
description="Retrieve all children (Catalogs and Collections) of this catalog.",
tags=["Catalogs"],
)

app.include_router(self.router, tags=["Catalogs"])

async def catalogs(
Expand Down Expand Up @@ -852,6 +865,142 @@ async def get_catalog_collection_item(
item_id=item_id, collection_id=collection_id, request=request
)

async def get_catalog_children(
self,
catalog_id: str,
request: Request,
limit: int = 10,
token: str = None,
type: Optional[str] = Query(
None, description="Filter by resource type (Catalog or Collection)"
),
) -> Dict[str, Any]:
"""
Get all children (Catalogs and Collections) of a specific catalog.

This is a 'Union' endpoint that returns mixed content types.
"""
# 1. Verify the parent catalog exists
await self.client.database.find_catalog(catalog_id)

# 2. Build the Search Query
# We search the COLLECTIONS_INDEX because it holds both Catalogs and Collections

# Base filter: Parent match
# This finds anything where 'parent_ids' contains this catalog_id
filter_queries = [{"term": {"parent_ids": catalog_id}}]

# Optional filter: Type
if type:
# If user asks for ?type=Catalog, we only return Catalogs
filter_queries.append({"term": {"type": type}})

# 3. Calculate Pagination (Search After)
body = {
"query": {"bool": {"filter": filter_queries}},
"sort": [{"id": {"order": "asc"}}], # Stable sort for pagination
"size": limit,
}

# Handle search_after token - split by '|' to get all sort values
search_after: Optional[List[str]] = None
if token:
try:
# The token should be a pipe-separated string of sort values
# e.g., "collection-1"
from typing import cast

search_after_parts = cast(List[str], token.split("|"))
# If the number of sort fields doesn't match token parts, ignore the token
if len(search_after_parts) != len(body["sort"]): # type: ignore
search_after = None
else:
search_after = search_after_parts
except Exception:
search_after = None

if search_after is not None:
body["search_after"] = search_after

# 4. Execute Search
search_result = await self.client.database.client.search(
index=COLLECTIONS_INDEX, body=body
)

# 5. Process Results
hits = search_result.get("hits", {}).get("hits", [])
total = search_result.get("hits", {}).get("total", {}).get("value", 0)

children = []
for hit in hits:
doc = hit["_source"]
resource_type = doc.get(
"type", "Collection"
) # Default to Collection if missing

# Serialize based on type
# This ensures we hide internal fields like 'parent_ids' correctly
if resource_type == "Catalog":
child = self.client.catalog_serializer.db_to_stac(doc, request)
else:
child = self.client.collection_serializer.db_to_stac(doc, request)

children.append(child)

# 6. Format Response
# The Children extension uses a specific response format
response = {
"children": children,
"links": [
{"rel": "self", "type": "application/json", "href": str(request.url)},
{
"rel": "root",
"type": "application/json",
"href": str(request.base_url),
},
{
"rel": "parent",
"type": "application/json",
"href": f"{str(request.base_url)}catalogs/{catalog_id}",
},
],
"numberReturned": len(children),
"numberMatched": total,
}

# 7. Generate Next Link
next_token = None
if len(hits) == limit:
next_token_values = hits[-1].get("sort")
if next_token_values:
# Join all sort values with '|' to create the token
next_token = "|".join(str(val) for val in next_token_values)

if next_token:
# Get existing query params
parsed_url = urlparse(str(request.url))
params = parse_qs(parsed_url.query)

# Update params
params["token"] = [next_token]
params["limit"] = [str(limit)]
if type:
params["type"] = [type]

# Flatten params for urlencode (parse_qs returns lists)
flat_params = {
k: v[0] if isinstance(v, list) else v for k, v in params.items()
}

next_link = {
"rel": "next",
"type": "application/json",
"href": f"{request.base_url}catalogs/{catalog_id}/children?{urlencode(flat_params)}",
}
response["links"].append(next_link)

return response

async def delete_catalog_collection(
self, catalog_id: str, collection_id: str, request: Request
) -> None:
Expand Down
1 change: 1 addition & 0 deletions stac_fastapi/tests/api/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
"POST /catalogs",
"GET /catalogs/{catalog_id}",
"DELETE /catalogs/{catalog_id}",
"GET /catalogs/{catalog_id}/children",
"GET /catalogs/{catalog_id}/collections",
"POST /catalogs/{catalog_id}/collections",
"GET /catalogs/{catalog_id}/collections/{collection_id}",
Expand Down
Loading