From b8fb415b227104d48f67ca06e0ea9a1c7afd577d Mon Sep 17 00:00:00 2001 From: jonhealy1 Date: Tue, 25 Nov 2025 15:37:42 +0800 Subject: [PATCH 01/28] add redis deps to docs --- stac_fastapi/elasticsearch/pyproject.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/stac_fastapi/elasticsearch/pyproject.toml b/stac_fastapi/elasticsearch/pyproject.toml index bd2eb340..26429e0e 100644 --- a/stac_fastapi/elasticsearch/pyproject.toml +++ b/stac_fastapi/elasticsearch/pyproject.toml @@ -54,6 +54,8 @@ docs = [ "mkdocs~=1.4.0", "mkdocs-material~=9.0.0", "pdocs~=1.2.0", + "redis~=6.4.0", + "retry~=0.9.2", ] redis = [ "stac-fastapi-core[redis]==6.7.5", From d5087fdf7ee60c922a6d416d4122d2fc7810ab24 Mon Sep 17 00:00:00 2001 From: jonhealy1 Date: Fri, 28 Nov 2025 19:45:06 +0800 Subject: [PATCH 02/28] add very basic /catalogs route --- compose.yml | 2 + .../stac_fastapi/core/extensions/__init__.py | 2 + .../stac_fastapi/core/extensions/catalogs.py | 64 +++++++++++++++++++ .../stac_fastapi/elasticsearch/app.py | 16 +++++ .../opensearch/stac_fastapi/opensearch/app.py | 16 +++++ 5 files changed, 100 insertions(+) create mode 100644 stac_fastapi/core/stac_fastapi/core/extensions/catalogs.py diff --git a/compose.yml b/compose.yml index 2c1d7be3..c39e0f14 100644 --- a/compose.yml +++ b/compose.yml @@ -23,6 +23,7 @@ services: - BACKEND=elasticsearch - DATABASE_REFRESH=true - ENABLE_COLLECTIONS_SEARCH_ROUTE=true + - ENABLE_CATALOG_ROUTE=true - REDIS_ENABLE=true - REDIS_HOST=redis - REDIS_PORT=6379 @@ -62,6 +63,7 @@ services: - BACKEND=opensearch - STAC_FASTAPI_RATE_LIMIT=200/minute - ENABLE_COLLECTIONS_SEARCH_ROUTE=true + - ENABLE_CATALOG_ROUTE=true - REDIS_ENABLE=true - REDIS_HOST=redis - REDIS_PORT=6379 diff --git a/stac_fastapi/core/stac_fastapi/core/extensions/__init__.py b/stac_fastapi/core/stac_fastapi/core/extensions/__init__.py index 9216e8ec..f74d53ae 100644 --- a/stac_fastapi/core/stac_fastapi/core/extensions/__init__.py +++ b/stac_fastapi/core/stac_fastapi/core/extensions/__init__.py @@ -1,5 +1,6 @@ """elasticsearch extensions modifications.""" +from .catalogs import CatalogsExtension from .collections_search import CollectionsSearchEndpointExtension from .query import Operator, QueryableTypes, QueryExtension @@ -8,4 +9,5 @@ "QueryableTypes", "QueryExtension", "CollectionsSearchEndpointExtension", + "CatalogsExtension", ] diff --git a/stac_fastapi/core/stac_fastapi/core/extensions/catalogs.py b/stac_fastapi/core/stac_fastapi/core/extensions/catalogs.py new file mode 100644 index 00000000..3ea27ce1 --- /dev/null +++ b/stac_fastapi/core/stac_fastapi/core/extensions/catalogs.py @@ -0,0 +1,64 @@ +"""Catalogs extension.""" + +from typing import List, Type, Union + +import attr +from fastapi import APIRouter, FastAPI, Request +from fastapi.responses import JSONResponse +from starlette.responses import Response + +from stac_fastapi.types.core import BaseCoreClient +from stac_fastapi.types.extension import ApiExtension +from stac_fastapi.types.stac import LandingPage + + +@attr.s +class CatalogsExtension(ApiExtension): + """Catalogs Extension. + + The Catalogs extension adds a /catalogs endpoint that returns the root catalog. + """ + + client: BaseCoreClient = attr.ib(default=None) + settings: dict = attr.ib(default=attr.Factory(dict)) + conformance_classes: List[str] = attr.ib(default=attr.Factory(list)) + router: APIRouter = attr.ib(default=attr.Factory(APIRouter)) + response_class: Type[Response] = attr.ib(default=JSONResponse) + + def register(self, app: FastAPI) -> None: + """Register the extension with a FastAPI application. + + Args: + app: target FastAPI application. + + Returns: + None + """ + response_model = ( + self.settings.get("response_model") + if isinstance(self.settings, dict) + else getattr(self.settings, "response_model", None) + ) + + self.router.add_api_route( + path="/catalogs", + endpoint=self.catalogs, + methods=["GET"], + response_model=LandingPage if response_model else None, + response_class=self.response_class, + summary="Get Catalogs", + description="Returns the root catalog.", + tags=["Catalogs"], + ) + app.include_router(self.router, tags=["Catalogs"]) + + async def catalogs(self, request: Request) -> Union[LandingPage, Response]: + """Get catalogs. + + Args: + request: Request object. + + Returns: + The root catalog (landing page). + """ + return await self.client.landing_page(request=request) diff --git a/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/app.py b/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/app.py index bcb870f3..90d8d2da 100644 --- a/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/app.py +++ b/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/app.py @@ -23,6 +23,7 @@ EsAggregationExtensionGetRequest, EsAggregationExtensionPostRequest, ) +from stac_fastapi.core.extensions.catalogs import CatalogsExtension from stac_fastapi.core.extensions.collections_search import ( CollectionsSearchEndpointExtension, ) @@ -65,11 +66,13 @@ ENABLE_COLLECTIONS_SEARCH_ROUTE = get_bool_env( "ENABLE_COLLECTIONS_SEARCH_ROUTE", default=False ) +ENABLE_CATALOG_ROUTE = get_bool_env("ENABLE_CATALOG_ROUTE", default=False) logger.info("TRANSACTIONS_EXTENSIONS is set to %s", TRANSACTIONS_EXTENSIONS) logger.info("ENABLE_COLLECTIONS_SEARCH is set to %s", ENABLE_COLLECTIONS_SEARCH) logger.info( "ENABLE_COLLECTIONS_SEARCH_ROUTE is set to %s", ENABLE_COLLECTIONS_SEARCH_ROUTE ) +logger.info("ENABLE_CATALOG_ROUTE is set to %s", ENABLE_CATALOG_ROUTE) settings = ElasticsearchSettings() session = Session.create_from_settings(settings) @@ -202,6 +205,19 @@ extensions.append(collections_search_endpoint_ext) +if ENABLE_CATALOG_ROUTE: + catalogs_extension = CatalogsExtension( + client=CoreClient( + database=database_logic, + session=session, + post_request_model=collection_search_post_request_model, + landing_page_id=os.getenv("STAC_FASTAPI_LANDING_PAGE_ID", "stac-fastapi"), + ), + settings=settings, + ) + extensions.append(catalogs_extension) + + database_logic.extensions = [type(ext).__name__ for ext in extensions] post_request_model = create_post_request_model(search_extensions) diff --git a/stac_fastapi/opensearch/stac_fastapi/opensearch/app.py b/stac_fastapi/opensearch/stac_fastapi/opensearch/app.py index 181d8a7a..d8bc04ec 100644 --- a/stac_fastapi/opensearch/stac_fastapi/opensearch/app.py +++ b/stac_fastapi/opensearch/stac_fastapi/opensearch/app.py @@ -23,6 +23,7 @@ EsAggregationExtensionGetRequest, EsAggregationExtensionPostRequest, ) +from stac_fastapi.core.extensions.catalogs import CatalogsExtension from stac_fastapi.core.extensions.collections_search import ( CollectionsSearchEndpointExtension, ) @@ -65,11 +66,13 @@ ENABLE_COLLECTIONS_SEARCH_ROUTE = get_bool_env( "ENABLE_COLLECTIONS_SEARCH_ROUTE", default=False ) +ENABLE_CATALOG_ROUTE = get_bool_env("ENABLE_CATALOG_ROUTE", default=False) logger.info("TRANSACTIONS_EXTENSIONS is set to %s", TRANSACTIONS_EXTENSIONS) logger.info("ENABLE_COLLECTIONS_SEARCH is set to %s", ENABLE_COLLECTIONS_SEARCH) logger.info( "ENABLE_COLLECTIONS_SEARCH_ROUTE is set to %s", ENABLE_COLLECTIONS_SEARCH_ROUTE ) +logger.info("ENABLE_CATALOG_ROUTE is set to %s", ENABLE_CATALOG_ROUTE) settings = OpensearchSettings() session = Session.create_from_settings(settings) @@ -201,6 +204,19 @@ extensions.append(collections_search_endpoint_ext) +if ENABLE_CATALOG_ROUTE: + catalogs_extension = CatalogsExtension( + client=CoreClient( + database=database_logic, + session=session, + post_request_model=collection_search_post_request_model, + landing_page_id=os.getenv("STAC_FASTAPI_LANDING_PAGE_ID", "stac-fastapi"), + ), + settings=settings, + ) + extensions.append(catalogs_extension) + + database_logic.extensions = [type(ext).__name__ for ext in extensions] post_request_model = create_post_request_model(search_extensions) From e516bd551314f8dcaee08ea32fe1b1be0c68f538 Mon Sep 17 00:00:00 2001 From: jonhealy1 Date: Sun, 30 Nov 2025 00:12:48 +0800 Subject: [PATCH 03/28] route scratch --- .../stac_fastapi/core/base_database_logic.py | 36 +++ stac_fastapi/core/stac_fastapi/core/core.py | 21 +- .../stac_fastapi/core/extensions/catalogs.py | 231 ++++++++++++++++-- .../core/stac_fastapi/core/models/__init__.py | 28 ++- .../core/stac_fastapi/core/serializers.py | 55 +++++ .../elasticsearch/database_logic.py | 229 ++++++++++++++++- 6 files changed, 575 insertions(+), 25 deletions(-) diff --git a/stac_fastapi/core/stac_fastapi/core/base_database_logic.py b/stac_fastapi/core/stac_fastapi/core/base_database_logic.py index 105fdf92..4f543453 100644 --- a/stac_fastapi/core/stac_fastapi/core/base_database_logic.py +++ b/stac_fastapi/core/stac_fastapi/core/base_database_logic.py @@ -138,3 +138,39 @@ async def delete_collection( ) -> None: """Delete a collection from the database.""" pass + + @abc.abstractmethod + async def get_all_catalogs( + self, + token: Optional[str], + limit: int, + request: Any = None, + sort: Optional[List[Dict[str, Any]]] = None, + ) -> Tuple[List[Dict[str, Any]], Optional[str], Optional[int]]: + """Retrieve a list of catalogs from the database, supporting pagination. + + Args: + token (Optional[str]): The pagination token. + limit (int): The number of results to return. + request (Any, optional): The FastAPI request object. Defaults to None. + sort (Optional[List[Dict[str, Any]]], optional): Optional sort parameter. Defaults to None. + + Returns: + A tuple of (catalogs, next pagination token if any, optional count). + """ + pass + + @abc.abstractmethod + async def create_catalog(self, catalog: Dict, refresh: bool = False) -> None: + """Create a catalog in the database.""" + pass + + @abc.abstractmethod + async def find_catalog(self, catalog_id: str) -> Dict: + """Find a catalog in the database.""" + pass + + @abc.abstractmethod + async def delete_catalog(self, catalog_id: str, refresh: bool = False) -> None: + """Delete a catalog from the database.""" + pass diff --git a/stac_fastapi/core/stac_fastapi/core/core.py b/stac_fastapi/core/stac_fastapi/core/core.py index 3334a4db..acc17515 100644 --- a/stac_fastapi/core/stac_fastapi/core/core.py +++ b/stac_fastapi/core/stac_fastapi/core/core.py @@ -23,9 +23,14 @@ from stac_fastapi.core.base_database_logic import BaseDatabaseLogic from stac_fastapi.core.base_settings import ApiBaseSettings from stac_fastapi.core.datetime_utils import format_datetime_range +from stac_fastapi.core.models import Catalog from stac_fastapi.core.models.links import PagingLinks from stac_fastapi.core.redis_utils import redis_pagination_links -from stac_fastapi.core.serializers import CollectionSerializer, ItemSerializer +from stac_fastapi.core.serializers import ( + CatalogSerializer, + CollectionSerializer, + ItemSerializer, +) from stac_fastapi.core.session import Session from stac_fastapi.core.utilities import filter_fields, get_bool_env from stac_fastapi.extensions.core.transaction import AsyncBaseTransactionsClient @@ -82,6 +87,7 @@ class CoreClient(AsyncBaseCoreClient): collection_serializer: Type[CollectionSerializer] = attr.ib( default=CollectionSerializer ) + catalog_serializer: Type[CatalogSerializer] = attr.ib(default=CatalogSerializer) post_request_model = attr.ib(default=BaseSearchPostRequest) stac_version: str = attr.ib(default=STAC_VERSION) landing_page_id: str = attr.ib(default="stac-fastapi") @@ -142,15 +148,24 @@ def _landing_page( ) return landing_page - async def landing_page(self, **kwargs) -> stac_types.LandingPage: + async def landing_page(self, **kwargs) -> Union[stac_types.LandingPage, Catalog]: """Landing page. Called with `GET /`. Returns: - API landing page, serving as an entry point to the API. + API landing page, serving as an entry point to the API, or root catalog if CatalogsExtension is enabled. """ request: Request = kwargs["request"] + + # If CatalogsExtension is enabled, return root catalog instead of landing page + if self.extension_is_enabled("CatalogsExtension"): + # Find the CatalogsExtension and call its catalogs method + for extension in self.extensions: + if extension.__class__.__name__ == "CatalogsExtension": + return await extension.catalogs(request) + + # Normal landing page logic base_url = get_base_url(request) landing_page = self._landing_page( base_url=base_url, diff --git a/stac_fastapi/core/stac_fastapi/core/extensions/catalogs.py b/stac_fastapi/core/stac_fastapi/core/extensions/catalogs.py index 3ea27ce1..f4d0bc61 100644 --- a/stac_fastapi/core/stac_fastapi/core/extensions/catalogs.py +++ b/stac_fastapi/core/stac_fastapi/core/extensions/catalogs.py @@ -1,22 +1,24 @@ """Catalogs extension.""" -from typing import List, Type, Union +from typing import List, Type import attr -from fastapi import APIRouter, FastAPI, Request +from fastapi import APIRouter, FastAPI, HTTPException, Request from fastapi.responses import JSONResponse from starlette.responses import Response +from stac_fastapi.core.models import Catalog +from stac_fastapi.types import stac as stac_types from stac_fastapi.types.core import BaseCoreClient from stac_fastapi.types.extension import ApiExtension -from stac_fastapi.types.stac import LandingPage @attr.s class CatalogsExtension(ApiExtension): """Catalogs Extension. - The Catalogs extension adds a /catalogs endpoint that returns the root catalog. + The Catalogs extension adds a /catalogs endpoint that returns the root catalog + containing child links to all catalogs in the database. """ client: BaseCoreClient = attr.ib(default=None) @@ -25,40 +27,229 @@ class CatalogsExtension(ApiExtension): router: APIRouter = attr.ib(default=attr.Factory(APIRouter)) response_class: Type[Response] = attr.ib(default=JSONResponse) - def register(self, app: FastAPI) -> None: + def register(self, app: FastAPI, settings=None) -> None: """Register the extension with a FastAPI application. Args: app: target FastAPI application. - - Returns: - None + settings: extension settings (unused for now). """ - response_model = ( - self.settings.get("response_model") - if isinstance(self.settings, dict) - else getattr(self.settings, "response_model", None) - ) + self.settings = settings or {} self.router.add_api_route( path="/catalogs", endpoint=self.catalogs, methods=["GET"], - response_model=LandingPage if response_model else None, + response_model=Catalog, + response_class=self.response_class, + summary="Get Root Catalog", + description="Returns the root catalog containing links to all catalogs.", + tags=["Catalogs"], + ) + + # Add endpoint for creating catalogs + self.router.add_api_route( + path="/catalogs", + endpoint=self.create_catalog, + methods=["POST"], + response_model=Catalog, + response_class=self.response_class, + status_code=201, + summary="Create Catalog", + description="Create a new STAC catalog.", + tags=["Catalogs"], + ) + + # Add endpoint for getting individual catalogs + self.router.add_api_route( + path="/catalogs/{catalog_id}", + endpoint=self.get_catalog, + methods=["GET"], + response_model=Catalog, response_class=self.response_class, - summary="Get Catalogs", - description="Returns the root catalog.", + summary="Get Catalog", + description="Get a specific STAC catalog by ID.", tags=["Catalogs"], ) + + # Add endpoint for getting collections in a catalog + self.router.add_api_route( + path="/catalogs/{catalog_id}/collections", + endpoint=self.get_catalog_collections, + methods=["GET"], + response_model=stac_types.Collections, + response_class=self.response_class, + summary="Get Catalog Collections", + description="Get collections linked from a specific catalog.", + tags=["Catalogs"], + ) + app.include_router(self.router, tags=["Catalogs"]) - async def catalogs(self, request: Request) -> Union[LandingPage, Response]: - """Get catalogs. + async def catalogs(self, request: Request) -> Catalog: + """Get root catalog with links to all catalogs. Args: request: Request object. Returns: - The root catalog (landing page). + Root catalog containing child links to all catalogs in the database. """ - return await self.client.landing_page(request=request) + base_url = str(request.base_url) + + # Get all catalogs from database + catalogs, _, _ = await self.client.database.get_all_catalogs( + token=None, + limit=1000, # Large limit to get all catalogs + request=request, + sort=[{"field": "id", "direction": "asc"}], + ) + + # Create child links to each catalog + child_links = [] + for catalog in catalogs: + child_links.append( + { + "rel": "child", + "href": f"{base_url}catalogs/{catalog.id}", + "type": "application/json", + "title": catalog.title or catalog.id, + } + ) + + # Create root catalog + root_catalog = { + "type": "Catalog", + "stac_version": "1.0.0", + "id": "root", + "title": "Root Catalog", + "description": "Root catalog containing all available catalogs", + "links": [ + { + "rel": "self", + "href": f"{base_url}catalogs", + "type": "application/json", + }, + { + "rel": "root", + "href": f"{base_url}catalogs", + "type": "application/json", + }, + { + "rel": "parent", + "href": base_url.rstrip("/"), + "type": "application/json", + }, + ] + + child_links, + } + + return Catalog(**root_catalog) + + async def create_catalog(self, catalog: Catalog, request: Request) -> Catalog: + """Create a new catalog. + + Args: + catalog: The catalog to create. + request: Request object. + + Returns: + The created catalog. + """ + # Convert STAC catalog to database format + db_catalog = self.client.catalog_serializer.stac_to_db(catalog, request) + + # Create the catalog in the database + await self.client.database.create_catalog(db_catalog.model_dump()) + + # Return the created catalog + return catalog + + async def get_catalog(self, catalog_id: str, request: Request) -> Catalog: + """Get a specific catalog by ID. + + Args: + catalog_id: The ID of the catalog to retrieve. + request: Request object. + + Returns: + The requested catalog. + """ + try: + # Get the catalog from the database + db_catalog = await self.client.database.find_catalog(catalog_id) + + # Convert to STAC format + catalog = self.client.catalog_serializer.db_to_stac(db_catalog, request) + + return catalog + except Exception: + raise HTTPException( + status_code=404, detail=f"Catalog {catalog_id} not found" + ) + + async def get_catalog_collections( + self, catalog_id: str, request: Request + ) -> stac_types.Collections: + """Get collections linked from a specific catalog. + + Args: + catalog_id: The ID of the catalog. + request: Request object. + + Returns: + Collections object containing collections linked from the catalog. + """ + try: + # Get the catalog from the database + db_catalog = await self.client.database.find_catalog(catalog_id) + + # Convert to STAC format to access links + catalog = self.client.catalog_serializer.db_to_stac(db_catalog, request) + + # Extract collection IDs from catalog links + collection_ids = [] + if hasattr(catalog, "links") and catalog.links: + for link in catalog.links: + if link.get("rel") in ["child", "item"]: + # Extract collection ID from href + href = link.get("href", "") + # Look for patterns like /collections/{id} or collections/{id} + if "/collections/" in href: + collection_id = href.split("/collections/")[-1].split("/")[ + 0 + ] + if collection_id and collection_id not in collection_ids: + collection_ids.append(collection_id) + + # Fetch the collections + collections = [] + for coll_id in collection_ids: + try: + collection = await self.client.get_collection( + coll_id, request=request + ) + collections.append(collection) + except Exception: + # Skip collections that can't be found + continue + + # Return in Collections format + base_url = str(request.base_url) + return stac_types.Collections( + collections=collections, + links=[ + {"rel": "root", "type": "application/json", "href": base_url}, + {"rel": "parent", "type": "application/json", "href": base_url}, + { + "rel": "self", + "type": "application/json", + "href": f"{base_url}catalogs/{catalog_id}/collections", + }, + ], + ) + + except Exception: + raise HTTPException( + status_code=404, detail=f"Catalog {catalog_id} not found" + ) diff --git a/stac_fastapi/core/stac_fastapi/core/models/__init__.py b/stac_fastapi/core/stac_fastapi/core/models/__init__.py index d0748bcc..ce88bc57 100644 --- a/stac_fastapi/core/stac_fastapi/core/models/__init__.py +++ b/stac_fastapi/core/stac_fastapi/core/models/__init__.py @@ -1 +1,27 @@ -"""stac_fastapi.elasticsearch.models module.""" +"""STAC models.""" + +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel + + +class Catalog(BaseModel): + """STAC Catalog model.""" + + type: str = "Catalog" + stac_version: str + id: str + title: Optional[str] = None + description: Optional[str] = None + links: List[Dict[str, Any]] + stac_extensions: Optional[List[str]] = None + + +class PartialCatalog(BaseModel): + """Partial STAC Catalog model for updates.""" + + id: str + title: Optional[str] = None + description: Optional[str] = None + links: Optional[List[Dict[str, Any]]] = None + stac_extensions: Optional[List[str]] = None diff --git a/stac_fastapi/core/stac_fastapi/core/serializers.py b/stac_fastapi/core/stac_fastapi/core/serializers.py index 0e156ce4..f935bff5 100644 --- a/stac_fastapi/core/stac_fastapi/core/serializers.py +++ b/stac_fastapi/core/stac_fastapi/core/serializers.py @@ -10,6 +10,7 @@ from starlette.requests import Request from stac_fastapi.core.datetime_utils import now_to_rfc3339_str +from stac_fastapi.core.models import Catalog from stac_fastapi.core.models.links import CollectionLinks from stac_fastapi.core.utilities import get_bool_env, get_excluded_from_items from stac_fastapi.types import stac as stac_types @@ -225,3 +226,57 @@ def db_to_stac( # Return the stac_types.Collection object return stac_types.Collection(**collection) + + +class CatalogSerializer(Serializer): + """Serialization methods for STAC catalogs.""" + + @classmethod + def stac_to_db(cls, catalog: Catalog, request: Request) -> Catalog: + """ + Transform STAC Catalog to database-ready STAC catalog. + + Args: + catalog: the STAC Catalog object to be transformed + request: the API request + + Returns: + Catalog: The database-ready STAC Catalog object. + """ + catalog = deepcopy(catalog) + catalog.links = resolve_links(catalog.links, str(request.base_url)) + return catalog + + @classmethod + def db_to_stac( + cls, catalog: dict, request: Request, extensions: Optional[List[str]] = [] + ) -> Catalog: + """Transform database model to STAC catalog. + + Args: + catalog (dict): The catalog data in dictionary form, extracted from the database. + request (Request): the API request + extensions: A list of the extension class names (`ext.__name__`) or all enabled STAC API extensions. + + Returns: + Catalog: The STAC catalog object. + """ + # Avoid modifying the input dict in-place + catalog = deepcopy(catalog) + + # Set defaults + catalog.setdefault("type", "Catalog") + catalog.setdefault("stac_extensions", []) + catalog.setdefault("stac_version", "") + catalog.setdefault("title", "") + catalog.setdefault("description", "") + + # Create the catalog links - for now, just resolve existing links + original_links = catalog.get("links", []) + if original_links: + catalog["links"] = resolve_links(original_links, str(request.base_url)) + else: + catalog["links"] = [] + + # Return the Catalog object + return Catalog(**catalog) diff --git a/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py b/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py index 35b6ae31..2b1ed23c 100644 --- a/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py +++ b/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py @@ -16,7 +16,11 @@ from starlette.requests import Request from stac_fastapi.core.base_database_logic import BaseDatabaseLogic -from stac_fastapi.core.serializers import CollectionSerializer, ItemSerializer +from stac_fastapi.core.serializers import ( + CatalogSerializer, + CollectionSerializer, + ItemSerializer, +) from stac_fastapi.core.utilities import MAX_LIMIT, bbox2polygon, get_bool_env from stac_fastapi.elasticsearch.config import AsyncElasticsearchSettings from stac_fastapi.elasticsearch.config import ( @@ -144,6 +148,7 @@ def __attrs_post_init__(self): collection_serializer: Type[CollectionSerializer] = attr.ib( default=CollectionSerializer ) + catalog_serializer: Type[CatalogSerializer] = attr.ib(default=CatalogSerializer) extensions: List[str] = attr.ib(default=attr.Factory(list)) @@ -1597,6 +1602,228 @@ async def delete_collection(self, collection_id: str, **kwargs: Any): ) await delete_item_index(collection_id) + async def get_all_catalogs( + self, + token: Optional[str], + limit: int, + request: Request, + sort: Optional[List[Dict[str, Any]]] = None, + ) -> Tuple[List[Dict[str, Any]], Optional[str], Optional[int]]: + """Retrieve a list of catalogs from Elasticsearch, supporting pagination. + + Args: + token (Optional[str]): The pagination token. + limit (int): The number of results to return. + request (Request): The FastAPI request object. + sort (Optional[List[Dict[str, Any]]], optional): Optional sort parameter. Defaults to None. + + Returns: + A tuple of (catalogs, next pagination token if any, optional count). + """ + # Define sortable fields based on the ES_CATALOGS_MAPPINGS + sortable_fields = ["id"] + + # Format the sort parameter + formatted_sort = [] + if sort: + for item in sort: + field = item.get("field") + direction = item.get("direction", "asc") + if field: + # Validate that the field is sortable + if field not in sortable_fields: + raise HTTPException( + status_code=400, + detail=f"Field '{field}' is not sortable. Sortable fields are: {', '.join(sortable_fields)}. " + + "Text fields are not sortable by default in Elasticsearch. " + + "To make a field sortable, update the mapping to use 'keyword' type or add a '.keyword' subfield. ", + ) + formatted_sort.append({field: {"order": direction}}) + # Always include id as a secondary sort to ensure consistent pagination + if not any("id" in item for item in formatted_sort): + formatted_sort.append({"id": {"order": "asc"}}) + else: + formatted_sort = [{"id": {"order": "asc"}}] + + body = { + "sort": formatted_sort, + "size": limit, + } + + # Handle search_after token + search_after = None + if token: + try: + # The token should be a pipe-separated string of sort values + search_after = token.split("|") + # If the number of sort fields doesn't match token parts, ignore the token + if len(search_after) != len(formatted_sort): + search_after = None + except Exception: + search_after = None + + if search_after is not None: + body["search_after"] = search_after + + # Build the query part of the body + query_parts: List[Dict[str, Any]] = [] + + # Combine all query parts with AND logic + if query_parts: + body["query"] = ( + query_parts[0] + if len(query_parts) == 1 + else {"bool": {"must": query_parts}} + ) + + # Always filter for type: "Catalog" + type_filter = {"term": {"type": "Catalog"}} + if body.get("query"): + body["query"] = {"bool": {"must": [body["query"], type_filter]}} + else: + body["query"] = type_filter + + # Create async tasks for both search and count + search_task = asyncio.create_task( + self.client.search( + index=COLLECTIONS_INDEX, + body=body, + ) + ) + + count_task = asyncio.create_task( + self.client.count( + index=COLLECTIONS_INDEX, + body={"query": body.get("query", {"match_all": {}})}, + ) + ) + + # Wait for search task to complete + response = await search_task + + hits = response["hits"]["hits"] + catalogs = [ + self.catalog_serializer.db_to_stac( + catalog=hit["_source"], request=request, extensions=self.extensions + ) + for hit in hits + ] + + 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) + + # Get the total count of catalogs + matched = ( + response["hits"]["total"]["value"] + if response["hits"]["total"]["relation"] == "eq" + else None + ) + + # If count task is done, use its result + if count_task.done(): + try: + matched = count_task.result().get("count") + except Exception as e: + logger.error(f"Count task failed: {e}") + + return catalogs, next_token, matched + + async def create_catalog(self, catalog: Dict, **kwargs: Any): + """Create a single catalog in the database. + + Args: + catalog (Dict): The catalog object to be created. + **kwargs: Additional keyword arguments. + - refresh (str): Whether to refresh the index after the operation. Can be "true", "false", or "wait_for". + - refresh (bool): Whether to refresh the index after the operation. Defaults to the value in `self.async_settings.database_refresh`. + + Raises: + ConflictError: If a Catalog with the same id already exists in the database. + + Returns: + None + """ + catalog_id = catalog["id"] + + # Ensure kwargs is a dictionary + kwargs = kwargs or {} + + # Resolve the `refresh` parameter + refresh = kwargs.get("refresh", self.async_settings.database_refresh) + refresh = validate_refresh(refresh) + + # Log the creation attempt + logger.info(f"Creating catalog {catalog_id} with refresh={refresh}") + + # Check if the catalog already exists + if await self.client.exists(index=COLLECTIONS_INDEX, id=catalog_id): + raise ConflictError(f"Catalog {catalog_id} already exists") + + # Ensure the document has the correct type + catalog["type"] = "Catalog" + + # Index the catalog in the database + await self.client.index( + index=COLLECTIONS_INDEX, + id=catalog_id, + document=catalog, + refresh=refresh, + ) + + async def find_catalog(self, catalog_id: str) -> Dict: + """Find and return a catalog from the database. + + Args: + catalog_id (str): The ID of the catalog to be found. + + Returns: + Dict: The found catalog. + + Raises: + NotFoundError: If the catalog with the given `catalog_id` is not found in the database. + """ + try: + catalog = await self.client.get(index=COLLECTIONS_INDEX, id=catalog_id) + except ESNotFoundError: + raise NotFoundError(f"Catalog {catalog_id} not found") + + # Validate that this is actually a catalog, not a collection + if catalog["_source"].get("type") != "Catalog": + raise NotFoundError(f"Catalog {catalog_id} not found") + + return catalog["_source"] + + async def delete_catalog(self, catalog_id: str, **kwargs: Any): + """Delete a catalog from the database. + + Parameters: + catalog_id (str): The ID of the catalog to be deleted. + kwargs (Any, optional): Additional keyword arguments, including `refresh`. + - refresh (str): Whether to refresh the index after the operation. Can be "true", "false", or "wait_for". + - refresh (bool): Whether to refresh the index after the operation. Defaults to the value in `self.async_settings.database_refresh`. + + Raises: + NotFoundError: If the catalog with the given `catalog_id` is not found in the database. + + Returns: + None + """ + # Ensure kwargs is a dictionary + kwargs = kwargs or {} + + refresh = kwargs.get("refresh", self.async_settings.database_refresh) + refresh = validate_refresh(refresh) + + # Verify that the catalog exists and is actually a catalog + await self.find_catalog(catalog_id=catalog_id) + await self.client.delete( + index=COLLECTIONS_INDEX, id=catalog_id, refresh=refresh + ) + async def bulk_async( self, collection_id: str, From 2e14974b1c199cca100277f4a69c23838dc62d8c Mon Sep 17 00:00:00 2001 From: jonhealy1 Date: Tue, 2 Dec 2025 12:53:42 +0800 Subject: [PATCH 04/28] add tests --- stac_fastapi/tests/api/test_api.py | 4 + stac_fastapi/tests/conftest.py | 61 +++++++++ stac_fastapi/tests/data/test_catalog.json | 37 +++++ .../tests/extensions/test_catalogs.py | 127 ++++++++++++++++++ 4 files changed, 229 insertions(+) create mode 100644 stac_fastapi/tests/data/test_catalog.json create mode 100644 stac_fastapi/tests/extensions/test_catalogs.py diff --git a/stac_fastapi/tests/api/test_api.py b/stac_fastapi/tests/api/test_api.py index 38d7e597..20253faa 100644 --- a/stac_fastapi/tests/api/test_api.py +++ b/stac_fastapi/tests/api/test_api.py @@ -50,6 +50,10 @@ "POST /collections/{collection_id}/aggregate", "GET /collections-search", "POST /collections-search", + "GET /catalogs", + "POST /catalogs", + "GET /catalogs/{catalog_id}", + "GET /catalogs/{catalog_id}/collections", } diff --git a/stac_fastapi/tests/conftest.py b/stac_fastapi/tests/conftest.py index b461e722..3bd8b243 100644 --- a/stac_fastapi/tests/conftest.py +++ b/stac_fastapi/tests/conftest.py @@ -39,6 +39,7 @@ from stac_fastapi.types.config import Settings os.environ.setdefault("ENABLE_COLLECTIONS_SEARCH_ROUTE", "true") +os.environ.setdefault("ENABLE_CATALOG_ROUTE", "false") if os.getenv("BACKEND", "elasticsearch").lower() == "opensearch": from stac_fastapi.opensearch.app import app_config @@ -396,3 +397,63 @@ def build_test_app(): # Create and return the app api = StacApi(**test_config) return api.app + + +def build_test_app_with_catalogs(): + """Build a test app with catalogs extension enabled.""" + from stac_fastapi.core.extensions.catalogs import CatalogsExtension + + # Get the base config + test_config = app_config.copy() + + # Get database and settings (already imported above) + test_database = DatabaseLogic() + test_settings = AsyncSettings() + + # Add catalogs extension + catalogs_extension = CatalogsExtension( + client=CoreClient( + database=test_database, + session=None, + landing_page_id=os.getenv("STAC_FASTAPI_LANDING_PAGE_ID", "stac-fastapi"), + ), + settings=test_settings, + conformance_classes=[ + "https://api.stacspec.org/v1.0.0-beta.1/catalogs", + ], + ) + + # Add to extensions if not already present + if not any(isinstance(ext, CatalogsExtension) for ext in test_config["extensions"]): + test_config["extensions"].append(catalogs_extension) + + # Update client with new extensions + test_config["client"] = CoreClient( + database=test_database, + session=None, + extensions=test_config["extensions"], + post_request_model=test_config["search_post_request_model"], + landing_page_id=os.getenv("STAC_FASTAPI_LANDING_PAGE_ID", "stac-fastapi"), + ) + + # Create and return the app + api = StacApi(**test_config) + return api.app + + +@pytest_asyncio.fixture(scope="session") +async def catalogs_app(): + """Fixture to get the FastAPI app with catalogs extension enabled.""" + return build_test_app_with_catalogs() + + +@pytest_asyncio.fixture(scope="session") +async def catalogs_app_client(catalogs_app): + """Fixture to get an async client for the app with catalogs extension enabled.""" + await create_index_templates() + await create_collection_index() + + async with AsyncClient( + transport=ASGITransport(app=catalogs_app), base_url="http://test-server" + ) as c: + yield c diff --git a/stac_fastapi/tests/data/test_catalog.json b/stac_fastapi/tests/data/test_catalog.json new file mode 100644 index 00000000..ede39131 --- /dev/null +++ b/stac_fastapi/tests/data/test_catalog.json @@ -0,0 +1,37 @@ +{ + "id": "test-catalog", + "type": "Catalog", + "stac_version": "1.0.0", + "title": "Test Catalog", + "description": "A test catalog for STAC API testing", + "links": [ + { + "rel": "self", + "href": "http://test-server/catalogs/test-catalog", + "type": "application/json" + }, + { + "rel": "root", + "href": "http://test-server/catalogs", + "type": "application/json" + }, + { + "rel": "child", + "href": "http://test-server/collections/test-collection-1", + "type": "application/json", + "title": "Test Collection 1" + }, + { + "rel": "child", + "href": "http://test-server/collections/test-collection-2", + "type": "application/json", + "title": "Test Collection 2" + }, + { + "rel": "child", + "href": "http://test-server/collections/test-collection-3", + "type": "application/json", + "title": "Test Collection 3" + } + ] +} diff --git a/stac_fastapi/tests/extensions/test_catalogs.py b/stac_fastapi/tests/extensions/test_catalogs.py new file mode 100644 index 00000000..5410d3cf --- /dev/null +++ b/stac_fastapi/tests/extensions/test_catalogs.py @@ -0,0 +1,127 @@ +import uuid + +import pytest + + +@pytest.mark.asyncio +async def test_get_root_catalog(catalogs_app_client, load_test_data): + """Test getting the root catalog.""" + resp = await catalogs_app_client.get("/catalogs") + assert resp.status_code == 200 + + catalog = resp.json() + assert catalog["type"] == "Catalog" + assert catalog["id"] == "root" + assert catalog["stac_version"] == "1.0.0" + assert "links" in catalog + + # Check for required links + links = catalog["links"] + link_rels = [link["rel"] for link in links] + assert "self" in link_rels + assert "root" in link_rels + assert "parent" in link_rels + + +@pytest.mark.asyncio +async def test_create_catalog(catalogs_app_client, load_test_data): + """Test creating a new catalog.""" + test_catalog = load_test_data("test_catalog.json") + test_catalog["id"] = f"test-catalog-{uuid.uuid4()}" + + resp = await catalogs_app_client.post("/catalogs", json=test_catalog) + assert resp.status_code == 201 + + created_catalog = resp.json() + assert created_catalog["id"] == test_catalog["id"] + assert created_catalog["type"] == "Catalog" + assert created_catalog["title"] == test_catalog["title"] + + +@pytest.mark.asyncio +async def test_get_catalog(catalogs_app_client, load_test_data): + """Test getting a specific catalog.""" + # First create a catalog + test_catalog = load_test_data("test_catalog.json") + test_catalog["id"] = f"test-catalog-{uuid.uuid4()}" + + create_resp = await catalogs_app_client.post("/catalogs", json=test_catalog) + assert create_resp.status_code == 201 + + # Now get it back + resp = await catalogs_app_client.get(f"/catalogs/{test_catalog['id']}") + assert resp.status_code == 200 + + catalog = resp.json() + assert catalog["id"] == test_catalog["id"] + assert catalog["title"] == test_catalog["title"] + assert catalog["description"] == test_catalog["description"] + + +@pytest.mark.asyncio +async def test_get_nonexistent_catalog(catalogs_app_client): + """Test getting a catalog that doesn't exist.""" + resp = await catalogs_app_client.get("/catalogs/nonexistent-catalog") + assert resp.status_code == 404 + + +@pytest.mark.asyncio +async def test_get_catalog_collections(catalogs_app_client, load_test_data, ctx): + """Test getting collections linked from a catalog.""" + # First create a catalog with a link to the test collection + test_catalog = load_test_data("test_catalog.json") + test_catalog["id"] = f"test-catalog-{uuid.uuid4()}" + + # Update the catalog links to point to the actual test collection + for link in test_catalog["links"]: + if link["rel"] == "child": + link["href"] = f"http://test-server/collections/{ctx.collection['id']}" + + create_resp = await catalogs_app_client.post("/catalogs", json=test_catalog) + assert create_resp.status_code == 201 + + # Now get collections from the catalog + resp = await catalogs_app_client.get(f"/catalogs/{test_catalog['id']}/collections") + assert resp.status_code == 200 + + collections_response = resp.json() + assert "collections" in collections_response + assert "links" in collections_response + + # Should contain the test collection + collection_ids = [col["id"] for col in collections_response["collections"]] + assert ctx.collection["id"] in collection_ids + + +@pytest.mark.asyncio +async def test_get_catalog_collections_nonexistent_catalog(catalogs_app_client): + """Test getting collections from a catalog that doesn't exist.""" + resp = await catalogs_app_client.get("/catalogs/nonexistent-catalog/collections") + assert resp.status_code == 404 + + +@pytest.mark.asyncio +async def test_root_catalog_with_multiple_catalogs(catalogs_app_client, load_test_data): + """Test that root catalog includes links to multiple catalogs.""" + # Create multiple catalogs + catalog_ids = [] + for i in range(3): + test_catalog = load_test_data("test_catalog.json") + test_catalog["id"] = f"test-catalog-{uuid.uuid4()}-{i}" + test_catalog["title"] = f"Test Catalog {i}" + + resp = await catalogs_app_client.post("/catalogs", json=test_catalog) + assert resp.status_code == 201 + catalog_ids.append(test_catalog["id"]) + + # Get root catalog + resp = await catalogs_app_client.get("/catalogs") + assert resp.status_code == 200 + + catalog = resp.json() + child_links = [link for link in catalog["links"] if link["rel"] == "child"] + + # Should have child links for all created catalogs + child_hrefs = [link["href"] for link in child_links] + for catalog_id in catalog_ids: + assert any(catalog_id in href for href in child_hrefs) From 6cbea936878cd37438423120bc0f6a83d7f2f9ef Mon Sep 17 00:00:00 2001 From: jonhealy1 Date: Tue, 2 Dec 2025 12:55:38 +0800 Subject: [PATCH 05/28] add item collections routes etc --- stac_fastapi/core/stac_fastapi/core/core.py | 34 ++++-- .../stac_fastapi/core/extensions/catalogs.py | 115 ++++++++++++++++++ .../stac_fastapi/elasticsearch/app.py | 3 + .../opensearch/stac_fastapi/opensearch/app.py | 3 + 4 files changed, 144 insertions(+), 11 deletions(-) diff --git a/stac_fastapi/core/stac_fastapi/core/core.py b/stac_fastapi/core/stac_fastapi/core/core.py index acc17515..40cdd784 100644 --- a/stac_fastapi/core/stac_fastapi/core/core.py +++ b/stac_fastapi/core/stac_fastapi/core/core.py @@ -23,7 +23,6 @@ from stac_fastapi.core.base_database_logic import BaseDatabaseLogic from stac_fastapi.core.base_settings import ApiBaseSettings from stac_fastapi.core.datetime_utils import format_datetime_range -from stac_fastapi.core.models import Catalog from stac_fastapi.core.models.links import PagingLinks from stac_fastapi.core.redis_utils import redis_pagination_links from stac_fastapi.core.serializers import ( @@ -94,6 +93,17 @@ class CoreClient(AsyncBaseCoreClient): title: str = attr.ib(default="stac-fastapi") description: str = attr.ib(default="stac-fastapi") + def extension_is_enabled(self, extension_name: str) -> bool: + """Check if an extension is enabled by checking self.extensions. + + Args: + extension_name: Name of the extension class to check for. + + Returns: + True if the extension is in self.extensions, False otherwise. + """ + return any(ext.__class__.__name__ == extension_name for ext in self.extensions) + def _landing_page( self, base_url: str, @@ -148,24 +158,16 @@ def _landing_page( ) return landing_page - async def landing_page(self, **kwargs) -> Union[stac_types.LandingPage, Catalog]: + async def landing_page(self, **kwargs) -> stac_types.LandingPage: """Landing page. Called with `GET /`. Returns: - API landing page, serving as an entry point to the API, or root catalog if CatalogsExtension is enabled. + API landing page, serving as an entry point to the API. """ request: Request = kwargs["request"] - # If CatalogsExtension is enabled, return root catalog instead of landing page - if self.extension_is_enabled("CatalogsExtension"): - # Find the CatalogsExtension and call its catalogs method - for extension in self.extensions: - if extension.__class__.__name__ == "CatalogsExtension": - return await extension.catalogs(request) - - # Normal landing page logic base_url = get_base_url(request) landing_page = self._landing_page( base_url=base_url, @@ -223,6 +225,16 @@ async def landing_page(self, **kwargs) -> Union[stac_types.LandingPage, Catalog] ] ) + if self.extension_is_enabled("CatalogsExtension"): + landing_page["links"].append( + { + "rel": "catalogs", + "type": "application/json", + "title": "Catalogs", + "href": urljoin(base_url, "catalogs"), + } + ) + # Add OpenAPI URL landing_page["links"].append( { diff --git a/stac_fastapi/core/stac_fastapi/core/extensions/catalogs.py b/stac_fastapi/core/stac_fastapi/core/extensions/catalogs.py index f4d0bc61..5ef5bca4 100644 --- a/stac_fastapi/core/stac_fastapi/core/extensions/catalogs.py +++ b/stac_fastapi/core/stac_fastapi/core/extensions/catalogs.py @@ -84,6 +84,42 @@ def register(self, app: FastAPI, settings=None) -> None: tags=["Catalogs"], ) + # Add endpoint for getting a specific collection in a catalog + self.router.add_api_route( + path="/catalogs/{catalog_id}/collections/{collection_id}", + endpoint=self.get_catalog_collection, + methods=["GET"], + response_model=stac_types.Collection, + response_class=self.response_class, + summary="Get Catalog Collection", + description="Get a specific collection from a catalog.", + tags=["Catalogs"], + ) + + # Add endpoint for getting items in a collection within a catalog + self.router.add_api_route( + path="/catalogs/{catalog_id}/collections/{collection_id}/items", + endpoint=self.get_catalog_collection_items, + methods=["GET"], + response_model=stac_types.ItemCollection, + response_class=self.response_class, + summary="Get Catalog Collection Items", + description="Get items from a collection in a catalog.", + tags=["Catalogs"], + ) + + # Add endpoint for getting a specific item in a collection within a catalog + self.router.add_api_route( + path="/catalogs/{catalog_id}/collections/{collection_id}/items/{item_id}", + endpoint=self.get_catalog_collection_item, + methods=["GET"], + response_model=stac_types.Item, + response_class=self.response_class, + summary="Get Catalog Collection Item", + description="Get a specific item from a collection in a catalog.", + tags=["Catalogs"], + ) + app.include_router(self.router, tags=["Catalogs"]) async def catalogs(self, request: Request) -> Catalog: @@ -253,3 +289,82 @@ async def get_catalog_collections( raise HTTPException( status_code=404, detail=f"Catalog {catalog_id} not found" ) + + async def get_catalog_collection( + self, catalog_id: str, collection_id: str, request: Request + ) -> stac_types.Collection: + """Get a specific collection from a catalog. + + Args: + catalog_id: The ID of the catalog. + collection_id: The ID of the collection. + request: Request object. + + Returns: + The requested collection. + """ + # Verify the catalog exists + try: + await self.client.database.find_catalog(catalog_id) + except Exception: + raise HTTPException( + status_code=404, detail=f"Catalog {catalog_id} not found" + ) + + # Delegate to the core client's get_collection method + return await self.client.get_collection( + collection_id=collection_id, request=request + ) + + async def get_catalog_collection_items( + self, catalog_id: str, collection_id: str, request: Request + ) -> stac_types.ItemCollection: + """Get items from a collection in a catalog. + + Args: + catalog_id: The ID of the catalog. + collection_id: The ID of the collection. + request: Request object. + + Returns: + ItemCollection containing items from the collection. + """ + # Verify the catalog exists + try: + await self.client.database.find_catalog(catalog_id) + except Exception: + raise HTTPException( + status_code=404, detail=f"Catalog {catalog_id} not found" + ) + + # Delegate to the core client's item_collection method + return await self.client.item_collection( + collection_id=collection_id, request=request + ) + + async def get_catalog_collection_item( + self, catalog_id: str, collection_id: str, item_id: str, request: Request + ) -> stac_types.Item: + """Get a specific item from a collection in a catalog. + + Args: + catalog_id: The ID of the catalog. + collection_id: The ID of the collection. + item_id: The ID of the item. + request: Request object. + + Returns: + The requested item. + """ + # Verify the catalog exists + try: + await self.client.database.find_catalog(catalog_id) + except Exception: + raise HTTPException( + status_code=404, detail=f"Catalog {catalog_id} not found" + ) + + # Delegate to the core client's get_item method + return await self.client.get_item( + item_id=item_id, collection_id=collection_id, request=request + ) diff --git a/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/app.py b/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/app.py index 90d8d2da..6bbfa88a 100644 --- a/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/app.py +++ b/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/app.py @@ -214,6 +214,9 @@ landing_page_id=os.getenv("STAC_FASTAPI_LANDING_PAGE_ID", "stac-fastapi"), ), settings=settings, + conformance_classes=[ + "https://api.stacspec.org/v1.0.0-beta.1/catalogs", + ], ) extensions.append(catalogs_extension) diff --git a/stac_fastapi/opensearch/stac_fastapi/opensearch/app.py b/stac_fastapi/opensearch/stac_fastapi/opensearch/app.py index d8bc04ec..f5b226c5 100644 --- a/stac_fastapi/opensearch/stac_fastapi/opensearch/app.py +++ b/stac_fastapi/opensearch/stac_fastapi/opensearch/app.py @@ -213,6 +213,9 @@ landing_page_id=os.getenv("STAC_FASTAPI_LANDING_PAGE_ID", "stac-fastapi"), ), settings=settings, + conformance_classes=[ + "https://api.stacspec.org/v1.0.0-beta.1/catalogs", + ], ) extensions.append(catalogs_extension) From 83f779d80169f4d8437b71b01106047238576fb7 Mon Sep 17 00:00:00 2001 From: jonhealy1 Date: Tue, 2 Dec 2025 15:07:26 +0800 Subject: [PATCH 06/28] item collection tests --- .../stac_fastapi/core/extensions/catalogs.py | 57 ++- .../elasticsearch/database_logic.py | 353 +++++++----------- .../stac_fastapi/opensearch/database_logic.py | 131 ++++++- stac_fastapi/tests/api/test_api.py | 4 + .../tests/extensions/test_catalogs.py | 176 +++++++++ 5 files changed, 489 insertions(+), 232 deletions(-) diff --git a/stac_fastapi/core/stac_fastapi/core/extensions/catalogs.py b/stac_fastapi/core/stac_fastapi/core/extensions/catalogs.py index 5ef5bca4..8bf3eee6 100644 --- a/stac_fastapi/core/stac_fastapi/core/extensions/catalogs.py +++ b/stac_fastapi/core/stac_fastapi/core/extensions/catalogs.py @@ -1,6 +1,6 @@ """Catalogs extension.""" -from typing import List, Type +from typing import List, Optional, Type import attr from fastapi import APIRouter, FastAPI, HTTPException, Request @@ -144,12 +144,18 @@ async def catalogs(self, request: Request) -> Catalog: # Create child links to each catalog child_links = [] for catalog in catalogs: + catalog_id = catalog.get("id") if isinstance(catalog, dict) else catalog.id + catalog_title = ( + catalog.get("title") or catalog_id + if isinstance(catalog, dict) + else catalog.title or catalog.id + ) child_links.append( { "rel": "child", - "href": f"{base_url}catalogs/{catalog.id}", + "href": f"{base_url}catalogs/{catalog_id}", "type": "application/json", - "title": catalog.title or catalog.id, + "title": catalog_title, } ) @@ -195,8 +201,12 @@ async def create_catalog(self, catalog: Catalog, request: Request) -> Catalog: # Convert STAC catalog to database format db_catalog = self.client.catalog_serializer.stac_to_db(catalog, request) - # Create the catalog in the database - await self.client.database.create_catalog(db_catalog.model_dump()) + # Convert to dict and ensure type is set to Catalog + db_catalog_dict = db_catalog.model_dump() + db_catalog_dict["type"] = "Catalog" + + # Create the catalog in the database with refresh to ensure immediate availability + await self.client.database.create_catalog(db_catalog_dict, refresh=True) # Return the created catalog return catalog @@ -317,7 +327,19 @@ async def get_catalog_collection( ) async def get_catalog_collection_items( - self, catalog_id: str, collection_id: str, request: Request + self, + catalog_id: str, + collection_id: str, + request: Request, + bbox: Optional[List[float]] = None, + datetime: Optional[str] = None, + limit: Optional[int] = None, + sortby: Optional[str] = None, + filter_expr: Optional[str] = None, + filter_lang: Optional[str] = None, + token: Optional[str] = None, + query: Optional[str] = None, + fields: Optional[List[str]] = None, ) -> stac_types.ItemCollection: """Get items from a collection in a catalog. @@ -325,6 +347,15 @@ async def get_catalog_collection_items( catalog_id: The ID of the catalog. collection_id: The ID of the collection. request: Request object. + bbox: Optional bounding box filter. + datetime: Optional datetime or interval filter. + limit: Optional page size. + sortby: Optional sort specification. + filter_expr: Optional filter expression. + filter_lang: Optional filter language. + token: Optional pagination token. + query: Optional query string. + fields: Optional fields to include or exclude. Returns: ItemCollection containing items from the collection. @@ -337,9 +368,19 @@ async def get_catalog_collection_items( status_code=404, detail=f"Catalog {catalog_id} not found" ) - # Delegate to the core client's item_collection method + # Delegate to the core client's item_collection method with all parameters return await self.client.item_collection( - collection_id=collection_id, request=request + collection_id=collection_id, + request=request, + bbox=bbox, + datetime=datetime, + limit=limit, + sortby=sortby, + filter_expr=filter_expr, + filter_lang=filter_lang, + token=token, + query=query, + fields=fields, ) async def get_catalog_collection_item( diff --git a/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py b/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py index 2b1ed23c..75915607 100644 --- a/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py +++ b/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py @@ -15,6 +15,7 @@ from fastapi import HTTPException from starlette.requests import Request +import stac_fastapi.sfeos_helpers.filter as filter_module from stac_fastapi.core.base_database_logic import BaseDatabaseLogic from stac_fastapi.core.serializers import ( CatalogSerializer, @@ -31,7 +32,6 @@ PartialItem, PatchOperation, ) -from stac_fastapi.sfeos_helpers import filter as filter_module from stac_fastapi.sfeos_helpers.database import ( add_bbox_shape_to_collection, apply_collections_bbox_filter_shared, @@ -1602,228 +1602,6 @@ async def delete_collection(self, collection_id: str, **kwargs: Any): ) await delete_item_index(collection_id) - async def get_all_catalogs( - self, - token: Optional[str], - limit: int, - request: Request, - sort: Optional[List[Dict[str, Any]]] = None, - ) -> Tuple[List[Dict[str, Any]], Optional[str], Optional[int]]: - """Retrieve a list of catalogs from Elasticsearch, supporting pagination. - - Args: - token (Optional[str]): The pagination token. - limit (int): The number of results to return. - request (Request): The FastAPI request object. - sort (Optional[List[Dict[str, Any]]], optional): Optional sort parameter. Defaults to None. - - Returns: - A tuple of (catalogs, next pagination token if any, optional count). - """ - # Define sortable fields based on the ES_CATALOGS_MAPPINGS - sortable_fields = ["id"] - - # Format the sort parameter - formatted_sort = [] - if sort: - for item in sort: - field = item.get("field") - direction = item.get("direction", "asc") - if field: - # Validate that the field is sortable - if field not in sortable_fields: - raise HTTPException( - status_code=400, - detail=f"Field '{field}' is not sortable. Sortable fields are: {', '.join(sortable_fields)}. " - + "Text fields are not sortable by default in Elasticsearch. " - + "To make a field sortable, update the mapping to use 'keyword' type or add a '.keyword' subfield. ", - ) - formatted_sort.append({field: {"order": direction}}) - # Always include id as a secondary sort to ensure consistent pagination - if not any("id" in item for item in formatted_sort): - formatted_sort.append({"id": {"order": "asc"}}) - else: - formatted_sort = [{"id": {"order": "asc"}}] - - body = { - "sort": formatted_sort, - "size": limit, - } - - # Handle search_after token - search_after = None - if token: - try: - # The token should be a pipe-separated string of sort values - search_after = token.split("|") - # If the number of sort fields doesn't match token parts, ignore the token - if len(search_after) != len(formatted_sort): - search_after = None - except Exception: - search_after = None - - if search_after is not None: - body["search_after"] = search_after - - # Build the query part of the body - query_parts: List[Dict[str, Any]] = [] - - # Combine all query parts with AND logic - if query_parts: - body["query"] = ( - query_parts[0] - if len(query_parts) == 1 - else {"bool": {"must": query_parts}} - ) - - # Always filter for type: "Catalog" - type_filter = {"term": {"type": "Catalog"}} - if body.get("query"): - body["query"] = {"bool": {"must": [body["query"], type_filter]}} - else: - body["query"] = type_filter - - # Create async tasks for both search and count - search_task = asyncio.create_task( - self.client.search( - index=COLLECTIONS_INDEX, - body=body, - ) - ) - - count_task = asyncio.create_task( - self.client.count( - index=COLLECTIONS_INDEX, - body={"query": body.get("query", {"match_all": {}})}, - ) - ) - - # Wait for search task to complete - response = await search_task - - hits = response["hits"]["hits"] - catalogs = [ - self.catalog_serializer.db_to_stac( - catalog=hit["_source"], request=request, extensions=self.extensions - ) - for hit in hits - ] - - 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) - - # Get the total count of catalogs - matched = ( - response["hits"]["total"]["value"] - if response["hits"]["total"]["relation"] == "eq" - else None - ) - - # If count task is done, use its result - if count_task.done(): - try: - matched = count_task.result().get("count") - except Exception as e: - logger.error(f"Count task failed: {e}") - - return catalogs, next_token, matched - - async def create_catalog(self, catalog: Dict, **kwargs: Any): - """Create a single catalog in the database. - - Args: - catalog (Dict): The catalog object to be created. - **kwargs: Additional keyword arguments. - - refresh (str): Whether to refresh the index after the operation. Can be "true", "false", or "wait_for". - - refresh (bool): Whether to refresh the index after the operation. Defaults to the value in `self.async_settings.database_refresh`. - - Raises: - ConflictError: If a Catalog with the same id already exists in the database. - - Returns: - None - """ - catalog_id = catalog["id"] - - # Ensure kwargs is a dictionary - kwargs = kwargs or {} - - # Resolve the `refresh` parameter - refresh = kwargs.get("refresh", self.async_settings.database_refresh) - refresh = validate_refresh(refresh) - - # Log the creation attempt - logger.info(f"Creating catalog {catalog_id} with refresh={refresh}") - - # Check if the catalog already exists - if await self.client.exists(index=COLLECTIONS_INDEX, id=catalog_id): - raise ConflictError(f"Catalog {catalog_id} already exists") - - # Ensure the document has the correct type - catalog["type"] = "Catalog" - - # Index the catalog in the database - await self.client.index( - index=COLLECTIONS_INDEX, - id=catalog_id, - document=catalog, - refresh=refresh, - ) - - async def find_catalog(self, catalog_id: str) -> Dict: - """Find and return a catalog from the database. - - Args: - catalog_id (str): The ID of the catalog to be found. - - Returns: - Dict: The found catalog. - - Raises: - NotFoundError: If the catalog with the given `catalog_id` is not found in the database. - """ - try: - catalog = await self.client.get(index=COLLECTIONS_INDEX, id=catalog_id) - except ESNotFoundError: - raise NotFoundError(f"Catalog {catalog_id} not found") - - # Validate that this is actually a catalog, not a collection - if catalog["_source"].get("type") != "Catalog": - raise NotFoundError(f"Catalog {catalog_id} not found") - - return catalog["_source"] - - async def delete_catalog(self, catalog_id: str, **kwargs: Any): - """Delete a catalog from the database. - - Parameters: - catalog_id (str): The ID of the catalog to be deleted. - kwargs (Any, optional): Additional keyword arguments, including `refresh`. - - refresh (str): Whether to refresh the index after the operation. Can be "true", "false", or "wait_for". - - refresh (bool): Whether to refresh the index after the operation. Defaults to the value in `self.async_settings.database_refresh`. - - Raises: - NotFoundError: If the catalog with the given `catalog_id` is not found in the database. - - Returns: - None - """ - # Ensure kwargs is a dictionary - kwargs = kwargs or {} - - refresh = kwargs.get("refresh", self.async_settings.database_refresh) - refresh = validate_refresh(refresh) - - # Verify that the catalog exists and is actually a catalog - await self.find_catalog(catalog_id=catalog_id) - await self.client.delete( - index=COLLECTIONS_INDEX, id=catalog_id, refresh=refresh - ) - async def bulk_async( self, collection_id: str, @@ -1976,3 +1754,132 @@ async def delete_collections(self) -> None: body={"query": {"match_all": {}}}, wait_for_completion=True, ) + + """CATALOGS LOGIC""" + + async def get_all_catalogs( + self, + token: Optional[str], + limit: int, + request: Any = None, + sort: Optional[List[Dict[str, Any]]] = None, + ) -> Tuple[List[Dict[str, Any]], Optional[str], Optional[int]]: + """Retrieve a list of catalogs from Elasticsearch, supporting pagination. + + Args: + token (Optional[str]): The pagination token. + limit (int): The number of results to return. + request (Any, optional): The FastAPI request object. Defaults to None. + sort (Optional[List[Dict[str, Any]]], optional): Optional sort parameter. Defaults to None. + + Returns: + A tuple of (catalogs, next pagination token if any, optional count). + """ + # Define sortable fields for catalogs + sortable_fields = ["id"] + + # Format the sort parameter + formatted_sort = [] + if sort: + for item in sort: + field = item.get("field") + direction = item.get("direction", "asc") + if field and field in sortable_fields: + formatted_sort.append({field: {"order": direction}}) + + if not formatted_sort: + formatted_sort = [{"id": {"order": "asc"}}] + + body = { + "sort": formatted_sort, + "size": limit, + "query": {"term": {"type": "Catalog"}}, + } + + # Handle search_after token + search_after = None + if token: + try: + search_after = token.split("|") + if len(search_after) != len(formatted_sort): + search_after = None + except Exception: + search_after = None + + if search_after is not None: + body["search_after"] = search_after + + # Search for catalogs in collections index + response = await self.client.search( + index=COLLECTIONS_INDEX, + body=body, + ) + + hits = response["hits"]["hits"] + catalogs = [hit["_source"] for hit in hits] + + next_token = None + if len(hits) == limit: + next_token_values = hits[-1].get("sort") + if next_token_values: + next_token = "|".join(str(val) for val in next_token_values) + + # Get the total count + matched = ( + response["hits"]["total"]["value"] + if response["hits"]["total"]["relation"] == "eq" + else None + ) + + return catalogs, next_token, matched + + async def create_catalog(self, catalog: Dict, refresh: bool = False) -> None: + """Create a catalog in Elasticsearch. + + Args: + catalog (Dict): The catalog document to create. + refresh (bool): Whether to refresh the index after creation. + """ + await self.client.index( + index=COLLECTIONS_INDEX, + id=catalog.get("id"), + body=catalog, + refresh=refresh, + ) + + async def find_catalog(self, catalog_id: str) -> Dict: + """Find a catalog in Elasticsearch by ID. + + Args: + catalog_id (str): The ID of the catalog to find. + + Returns: + Dict: The catalog document. + + Raises: + NotFoundError: If the catalog is not found. + """ + try: + response = await self.client.get( + index=COLLECTIONS_INDEX, + id=catalog_id, + ) + # Verify it's a catalog + if response["_source"].get("type") != "Catalog": + raise NotFoundError(f"Catalog {catalog_id} not found") + return response["_source"] + except ESNotFoundError: + raise NotFoundError(f"Catalog {catalog_id} not found") + + async def delete_catalog(self, catalog_id: str, refresh: bool = False) -> None: + """Delete a catalog from Elasticsearch. + + Args: + catalog_id (str): The ID of the catalog to delete. + refresh (bool): Whether to refresh the index after deletion. + """ + await self.client.delete( + index=COLLECTIONS_INDEX, + id=catalog_id, + refresh=refresh, + ) diff --git a/stac_fastapi/opensearch/stac_fastapi/opensearch/database_logic.py b/stac_fastapi/opensearch/stac_fastapi/opensearch/database_logic.py index 05aac176..14529ac3 100644 --- a/stac_fastapi/opensearch/stac_fastapi/opensearch/database_logic.py +++ b/stac_fastapi/opensearch/stac_fastapi/opensearch/database_logic.py @@ -15,6 +15,7 @@ from opensearchpy.helpers.search import Search from starlette.requests import Request +import stac_fastapi.sfeos_helpers.filter as filter_module from stac_fastapi.core.base_database_logic import BaseDatabaseLogic from stac_fastapi.core.serializers import CollectionSerializer, ItemSerializer from stac_fastapi.core.utilities import MAX_LIMIT, bbox2polygon, get_bool_env @@ -27,7 +28,6 @@ AsyncOpensearchSettings as AsyncSearchSettings, ) from stac_fastapi.opensearch.config import OpensearchSettings as SyncSearchSettings -from stac_fastapi.sfeos_helpers import filter as filter_module from stac_fastapi.sfeos_helpers.database import ( add_bbox_shape_to_collection, apply_collections_bbox_filter_shared, @@ -1723,3 +1723,132 @@ async def delete_collections(self) -> None: body={"query": {"match_all": {}}}, wait_for_completion=True, ) + + """CATALOGS LOGIC""" + + async def get_all_catalogs( + self, + token: Optional[str], + limit: int, + request: Any = None, + sort: Optional[List[Dict[str, Any]]] = None, + ) -> Tuple[List[Dict[str, Any]], Optional[str], Optional[int]]: + """Retrieve a list of catalogs from OpenSearch, supporting pagination. + + Args: + token (Optional[str]): The pagination token. + limit (int): The number of results to return. + request (Any, optional): The FastAPI request object. Defaults to None. + sort (Optional[List[Dict[str, Any]]], optional): Optional sort parameter. Defaults to None. + + Returns: + A tuple of (catalogs, next pagination token if any, optional count). + """ + # Define sortable fields for catalogs + sortable_fields = ["id"] + + # Format the sort parameter + formatted_sort = [] + if sort: + for item in sort: + field = item.get("field") + direction = item.get("direction", "asc") + if field and field in sortable_fields: + formatted_sort.append({field: {"order": direction}}) + + if not formatted_sort: + formatted_sort = [{"id": {"order": "asc"}}] + + body = { + "sort": formatted_sort, + "size": limit, + "query": {"term": {"type": "Catalog"}}, + } + + # Handle search_after token + search_after = None + if token: + try: + search_after = token.split("|") + if len(search_after) != len(formatted_sort): + search_after = None + except Exception: + search_after = None + + if search_after is not None: + body["search_after"] = search_after + + # Search for catalogs in collections index + response = await self.client.search( + index=COLLECTIONS_INDEX, + body=body, + ) + + hits = response["hits"]["hits"] + catalogs = [hit["_source"] for hit in hits] + + next_token = None + if len(hits) == limit: + next_token_values = hits[-1].get("sort") + if next_token_values: + next_token = "|".join(str(val) for val in next_token_values) + + # Get the total count + matched = ( + response["hits"]["total"]["value"] + if response["hits"]["total"]["relation"] == "eq" + else None + ) + + return catalogs, next_token, matched + + async def create_catalog(self, catalog: Dict, refresh: bool = False) -> None: + """Create a catalog in OpenSearch. + + Args: + catalog (Dict): The catalog document to create. + refresh (bool): Whether to refresh the index after creation. + """ + await self.client.index( + index=COLLECTIONS_INDEX, + id=catalog.get("id"), + body=catalog, + refresh=refresh, + ) + + async def find_catalog(self, catalog_id: str) -> Dict: + """Find a catalog in OpenSearch by ID. + + Args: + catalog_id (str): The ID of the catalog to find. + + Returns: + Dict: The catalog document. + + Raises: + NotFoundError: If the catalog is not found. + """ + try: + response = await self.client.get( + index=COLLECTIONS_INDEX, + id=catalog_id, + ) + # Verify it's a catalog + if response["_source"].get("type") != "Catalog": + raise NotFoundError(f"Catalog {catalog_id} not found") + return response["_source"] + except exceptions.NotFoundError: + raise NotFoundError(f"Catalog {catalog_id} not found") + + async def delete_catalog(self, catalog_id: str, refresh: bool = False) -> None: + """Delete a catalog from OpenSearch. + + Args: + catalog_id (str): The ID of the catalog to delete. + refresh (bool): Whether to refresh the index after deletion. + """ + await self.client.delete( + index=COLLECTIONS_INDEX, + id=catalog_id, + refresh=refresh, + ) diff --git a/stac_fastapi/tests/api/test_api.py b/stac_fastapi/tests/api/test_api.py index 20253faa..b51c9dc8 100644 --- a/stac_fastapi/tests/api/test_api.py +++ b/stac_fastapi/tests/api/test_api.py @@ -54,6 +54,10 @@ "POST /catalogs", "GET /catalogs/{catalog_id}", "GET /catalogs/{catalog_id}/collections", + "GET /catalogs/{catalog_id}/collections/{collection_id}", + "GET /catalogs/{catalog_id}/collections/{collection_id}/items", + "GET /catalogs/{catalog_id}/collections/{collection_id}/items/{item_id}", + "", } diff --git a/stac_fastapi/tests/extensions/test_catalogs.py b/stac_fastapi/tests/extensions/test_catalogs.py index 5410d3cf..c972818d 100644 --- a/stac_fastapi/tests/extensions/test_catalogs.py +++ b/stac_fastapi/tests/extensions/test_catalogs.py @@ -125,3 +125,179 @@ async def test_root_catalog_with_multiple_catalogs(catalogs_app_client, load_tes child_hrefs = [link["href"] for link in child_links] for catalog_id in catalog_ids: assert any(catalog_id in href for href in child_hrefs) + + +@pytest.mark.asyncio +async def test_get_catalog_collection(catalogs_app_client, load_test_data, ctx): + """Test getting a specific collection from a catalog.""" + # First create a catalog + test_catalog = load_test_data("test_catalog.json") + test_catalog["id"] = f"test-catalog-{uuid.uuid4()}" + + create_resp = await catalogs_app_client.post("/catalogs", json=test_catalog) + assert create_resp.status_code == 201 + + # Get a specific collection through the catalog route + resp = await catalogs_app_client.get( + f"/catalogs/{test_catalog['id']}/collections/{ctx.collection['id']}" + ) + assert resp.status_code == 200 + + collection = resp.json() + assert collection["id"] == ctx.collection["id"] + assert collection["type"] == "Collection" + assert "links" in collection + + +@pytest.mark.asyncio +async def test_get_catalog_collection_nonexistent_catalog(catalogs_app_client, ctx): + """Test getting a collection from a catalog that doesn't exist.""" + resp = await catalogs_app_client.get( + f"/catalogs/nonexistent-catalog/collections/{ctx.collection['id']}" + ) + assert resp.status_code == 404 + + +@pytest.mark.asyncio +async def test_get_catalog_collection_nonexistent_collection( + catalogs_app_client, load_test_data +): + """Test getting a collection that doesn't exist from a catalog.""" + # First create a catalog + test_catalog = load_test_data("test_catalog.json") + test_catalog["id"] = f"test-catalog-{uuid.uuid4()}" + + create_resp = await catalogs_app_client.post("/catalogs", json=test_catalog) + assert create_resp.status_code == 201 + + # Try to get a nonexistent collection + resp = await catalogs_app_client.get( + f"/catalogs/{test_catalog['id']}/collections/nonexistent-collection" + ) + assert resp.status_code == 404 + + +@pytest.mark.asyncio +async def test_get_catalog_collection_items(catalogs_app_client, load_test_data, ctx): + """Test getting items from a collection in a catalog.""" + # First create a catalog + test_catalog = load_test_data("test_catalog.json") + test_catalog["id"] = f"test-catalog-{uuid.uuid4()}" + + create_resp = await catalogs_app_client.post("/catalogs", json=test_catalog) + assert create_resp.status_code == 201 + + # Get items from a collection through the catalog route + resp = await catalogs_app_client.get( + f"/catalogs/{test_catalog['id']}/collections/{ctx.collection['id']}/items" + ) + assert resp.status_code == 200 + + items_response = resp.json() + assert items_response["type"] == "FeatureCollection" + assert "features" in items_response + assert "links" in items_response + # Should contain the test item + assert len(items_response["features"]) > 0 + + +@pytest.mark.asyncio +async def test_get_catalog_collection_items_nonexistent_catalog( + catalogs_app_client, ctx +): + """Test getting items from a collection in a catalog that doesn't exist.""" + resp = await catalogs_app_client.get( + f"/catalogs/nonexistent-catalog/collections/{ctx.collection['id']}/items" + ) + assert resp.status_code == 404 + + +@pytest.mark.asyncio +async def test_get_catalog_collection_items_nonexistent_collection( + catalogs_app_client, load_test_data +): + """Test getting items from a collection that doesn't exist in a catalog.""" + # First create a catalog + test_catalog = load_test_data("test_catalog.json") + test_catalog["id"] = f"test-catalog-{uuid.uuid4()}" + + create_resp = await catalogs_app_client.post("/catalogs", json=test_catalog) + assert create_resp.status_code == 201 + + # Try to get items from a nonexistent collection + resp = await catalogs_app_client.get( + f"/catalogs/{test_catalog['id']}/collections/nonexistent-collection/items" + ) + assert resp.status_code == 404 + + +@pytest.mark.asyncio +async def test_get_catalog_collection_item(catalogs_app_client, load_test_data, ctx): + """Test getting a specific item from a collection in a catalog.""" + # First create a catalog + test_catalog = load_test_data("test_catalog.json") + test_catalog["id"] = f"test-catalog-{uuid.uuid4()}" + + create_resp = await catalogs_app_client.post("/catalogs", json=test_catalog) + assert create_resp.status_code == 201 + + # Get a specific item through the catalog route + resp = await catalogs_app_client.get( + f"/catalogs/{test_catalog['id']}/collections/{ctx.collection['id']}/items/{ctx.item['id']}" + ) + assert resp.status_code == 200 + + item = resp.json() + assert item["id"] == ctx.item["id"] + assert item["type"] == "Feature" + assert "properties" in item + assert "geometry" in item + + +@pytest.mark.asyncio +async def test_get_catalog_collection_item_nonexistent_catalog( + catalogs_app_client, ctx +): + """Test getting an item from a collection in a catalog that doesn't exist.""" + resp = await catalogs_app_client.get( + f"/catalogs/nonexistent-catalog/collections/{ctx.collection['id']}/items/{ctx.item['id']}" + ) + assert resp.status_code == 404 + + +@pytest.mark.asyncio +async def test_get_catalog_collection_item_nonexistent_collection( + catalogs_app_client, load_test_data, ctx +): + """Test getting an item from a collection that doesn't exist in a catalog.""" + # First create a catalog + test_catalog = load_test_data("test_catalog.json") + test_catalog["id"] = f"test-catalog-{uuid.uuid4()}" + + create_resp = await catalogs_app_client.post("/catalogs", json=test_catalog) + assert create_resp.status_code == 201 + + # Try to get an item from a nonexistent collection + resp = await catalogs_app_client.get( + f"/catalogs/{test_catalog['id']}/collections/nonexistent-collection/items/{ctx.item['id']}" + ) + assert resp.status_code == 404 + + +@pytest.mark.asyncio +async def test_get_catalog_collection_item_nonexistent_item( + catalogs_app_client, load_test_data, ctx +): + """Test getting an item that doesn't exist from a collection in a catalog.""" + # First create a catalog + test_catalog = load_test_data("test_catalog.json") + test_catalog["id"] = f"test-catalog-{uuid.uuid4()}" + + create_resp = await catalogs_app_client.post("/catalogs", json=test_catalog) + assert create_resp.status_code == 201 + + # Try to get a nonexistent item + resp = await catalogs_app_client.get( + f"/catalogs/{test_catalog['id']}/collections/{ctx.collection['id']}/items/nonexistent-item" + ) + assert resp.status_code == 404 From cf5f47173bd88f716b9c9288e7140462d6bf2ade Mon Sep 17 00:00:00 2001 From: jonhealy1 Date: Tue, 2 Dec 2025 20:16:03 +0800 Subject: [PATCH 07/28] update changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6862fd8e..f6d16cc4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Added +- Added catalogs route support to enable hierarchical catalog browsing and navigation in the STAC API. + ### Changed ### Fixed From 66f5cc5ab7e76cb68a004bb647c728225fca3057 Mon Sep 17 00:00:00 2001 From: jonhealy1 Date: Tue, 2 Dec 2025 20:23:11 +0800 Subject: [PATCH 08/28] Update readme --- README.md | 62 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/README.md b/README.md index f2a7f498..9dc5a55b 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,7 @@ This project is built on the following technologies: STAC, stac-fastapi, FastAPI - [Technologies](#technologies) - [Table of Contents](#table-of-contents) - [Collection Search Extensions](#collection-search-extensions) + - [Catalogs Route](#catalogs-route) - [Documentation & Resources](#documentation--resources) - [SFEOS STAC Viewer](#sfeos-stac-viewer) - [Package Structure](#package-structure) @@ -168,6 +169,8 @@ SFEOS provides enhanced collection search capabilities through two primary route - **GET/POST `/collections`**: The standard STAC endpoint with extended query parameters - **GET/POST `/collections-search`**: A custom endpoint that supports the same parameters, created to avoid conflicts with the STAC Transactions extension if enabled (which uses POST `/collections` for collection creation) +The `/collections-search` endpoint follows the [STAC API Collection Search Endpoint](https://github.com/Healy-Hyperspatial/stac-api-extensions-collection-search-endpoint) specification, which provides a dedicated, conflict-free mechanism for advanced collection searching. + These endpoints support advanced collection discovery features including: - **Sorting**: Sort collections by sortable fields using the `sortby` parameter @@ -227,6 +230,64 @@ These extensions make it easier to build user interfaces that display and naviga > **Important**: Adding keyword fields to make text fields sortable can significantly increase the index size, especially for large text fields. Consider the storage implications when deciding which fields to make sortable. +## Catalogs Route + +SFEOS supports 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. + +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. + +### Features + +- **Hierarchical Navigation**: Browse catalogs and sub-catalogs in a parent-child relationship structure +- **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 + +### 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 +- **GET `/catalogs/{catalog_id}/collections`**: Retrieve collections within a specific catalog +- **GET `/catalogs/{catalog_id}/collections/{collection_id}`**: Retrieve a specific collection within a catalog +- **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 + +### Usage Examples + +```bash +# Get root catalog +curl "http://localhost:8081/catalogs" + +# Get specific catalog +curl "http://localhost:8081/catalogs/earth-observation" + +# Get collections in a catalog +curl "http://localhost:8081/catalogs/earth-observation/collections" + +# Get specific collection within a catalog +curl "http://localhost:8081/catalogs/earth-observation/collections/sentinel-2" + +# Get items in a collection within a catalog +curl "http://localhost:8081/catalogs/earth-observation/collections/sentinel-2/items" + +# Get specific item within a catalog +curl "http://localhost:8081/catalogs/earth-observation/collections/sentinel-2/items/S2A_20231015_123456" +``` + +### Response Structure + +Catalog responses include: +- **Catalog metadata**: ID, title, description, and other catalog properties +- **Child catalogs**: Links to sub-catalogs for hierarchical navigation +- **Collections**: Links to collections contained within the catalog +- **STAC links**: Properly formatted STAC API links for navigation + +This feature enables building user interfaces that provide organized, hierarchical browsing of STAC collections, making it easier for users to discover and navigate through large collections organized by theme, provider, or any other categorization scheme. + +> **Configuration**: The catalogs route can be enabled or disabled by setting the `ENABLE_CATALOGS_ROUTE` environment variable to `true` or `false`. By default, this endpoint is **disabled**. + + ## Package Structure This project is organized into several packages, each with a specific purpose: @@ -360,6 +421,7 @@ You can customize additional settings in your `.env` file: | `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 hierarchical catalog browsing and navigation. When enabled, provides access to federated STAC API architecture with hub-and-spoke pattern. | `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 | From 9d8e60019e11e2704db7aae2518c239da8c7d8fc Mon Sep 17 00:00:00 2001 From: jonhealy1 Date: Tue, 2 Dec 2025 20:30:28 +0800 Subject: [PATCH 09/28] add pr number --- CHANGELOG.md | 2 +- README.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ab369182..dc545254 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Added -- Added catalogs route support to enable hierarchical catalog browsing and navigation in the STAC API. +- Added catalogs route support to enable federated hierarchical catalog browsing and navigation in the STAC API. [#547](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/pull/547) ### Changed diff --git a/README.md b/README.md index 9dc5a55b..d51a40c2 100644 --- a/README.md +++ b/README.md @@ -232,7 +232,7 @@ These extensions make it easier to build user interfaces that display and naviga ## Catalogs Route -SFEOS supports 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 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. 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. @@ -421,7 +421,7 @@ You can customize additional settings in your `.env` file: | `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 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 federated hierarchical catalog browsing and navigation. When enabled, provides access to federated STAC API architecture with hub-and-spoke pattern. | `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 | From 7e729478a2985dae16ec184b71f6e1ca858e034e Mon Sep 17 00:00:00 2001 From: jonhealy1 Date: Wed, 3 Dec 2025 23:21:29 +0800 Subject: [PATCH 10/28] update env var names --- compose.yml | 4 ++-- .../elasticsearch/stac_fastapi/elasticsearch/app.py | 6 +++--- stac_fastapi/opensearch/stac_fastapi/opensearch/app.py | 6 +++--- stac_fastapi/tests/conftest.py | 2 +- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/compose.yml b/compose.yml index c39e0f14..00fb5938 100644 --- a/compose.yml +++ b/compose.yml @@ -23,7 +23,7 @@ services: - BACKEND=elasticsearch - DATABASE_REFRESH=true - ENABLE_COLLECTIONS_SEARCH_ROUTE=true - - ENABLE_CATALOG_ROUTE=true + - ENABLE_CATALOGS_ROUTE=true - REDIS_ENABLE=true - REDIS_HOST=redis - REDIS_PORT=6379 @@ -63,7 +63,7 @@ services: - BACKEND=opensearch - STAC_FASTAPI_RATE_LIMIT=200/minute - ENABLE_COLLECTIONS_SEARCH_ROUTE=true - - ENABLE_CATALOG_ROUTE=true + - ENABLE_CATALOGS_ROUTE=true - REDIS_ENABLE=true - REDIS_HOST=redis - REDIS_PORT=6379 diff --git a/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/app.py b/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/app.py index 6bbfa88a..1ffabe21 100644 --- a/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/app.py +++ b/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/app.py @@ -66,13 +66,13 @@ ENABLE_COLLECTIONS_SEARCH_ROUTE = get_bool_env( "ENABLE_COLLECTIONS_SEARCH_ROUTE", default=False ) -ENABLE_CATALOG_ROUTE = get_bool_env("ENABLE_CATALOG_ROUTE", default=False) +ENABLE_CATALOGS_ROUTE = get_bool_env("ENABLE_CATALOGS_ROUTE", default=False) logger.info("TRANSACTIONS_EXTENSIONS is set to %s", TRANSACTIONS_EXTENSIONS) logger.info("ENABLE_COLLECTIONS_SEARCH is set to %s", ENABLE_COLLECTIONS_SEARCH) logger.info( "ENABLE_COLLECTIONS_SEARCH_ROUTE is set to %s", ENABLE_COLLECTIONS_SEARCH_ROUTE ) -logger.info("ENABLE_CATALOG_ROUTE is set to %s", ENABLE_CATALOG_ROUTE) +logger.info("ENABLE_CATALOGS_ROUTE is set to %s", ENABLE_CATALOGS_ROUTE) settings = ElasticsearchSettings() session = Session.create_from_settings(settings) @@ -205,7 +205,7 @@ extensions.append(collections_search_endpoint_ext) -if ENABLE_CATALOG_ROUTE: +if ENABLE_CATALOGS_ROUTE: catalogs_extension = CatalogsExtension( client=CoreClient( database=database_logic, diff --git a/stac_fastapi/opensearch/stac_fastapi/opensearch/app.py b/stac_fastapi/opensearch/stac_fastapi/opensearch/app.py index f5b226c5..07beaee9 100644 --- a/stac_fastapi/opensearch/stac_fastapi/opensearch/app.py +++ b/stac_fastapi/opensearch/stac_fastapi/opensearch/app.py @@ -66,13 +66,13 @@ ENABLE_COLLECTIONS_SEARCH_ROUTE = get_bool_env( "ENABLE_COLLECTIONS_SEARCH_ROUTE", default=False ) -ENABLE_CATALOG_ROUTE = get_bool_env("ENABLE_CATALOG_ROUTE", default=False) +ENABLE_CATALOGS_ROUTE = get_bool_env("ENABLE_CATALOGS_ROUTE", default=False) logger.info("TRANSACTIONS_EXTENSIONS is set to %s", TRANSACTIONS_EXTENSIONS) logger.info("ENABLE_COLLECTIONS_SEARCH is set to %s", ENABLE_COLLECTIONS_SEARCH) logger.info( "ENABLE_COLLECTIONS_SEARCH_ROUTE is set to %s", ENABLE_COLLECTIONS_SEARCH_ROUTE ) -logger.info("ENABLE_CATALOG_ROUTE is set to %s", ENABLE_CATALOG_ROUTE) +logger.info("ENABLE_CATALOGS_ROUTE is set to %s", ENABLE_CATALOGS_ROUTE) settings = OpensearchSettings() session = Session.create_from_settings(settings) @@ -204,7 +204,7 @@ extensions.append(collections_search_endpoint_ext) -if ENABLE_CATALOG_ROUTE: +if ENABLE_CATALOGS_ROUTE: catalogs_extension = CatalogsExtension( client=CoreClient( database=database_logic, diff --git a/stac_fastapi/tests/conftest.py b/stac_fastapi/tests/conftest.py index 3bd8b243..2e2d8a50 100644 --- a/stac_fastapi/tests/conftest.py +++ b/stac_fastapi/tests/conftest.py @@ -39,7 +39,7 @@ from stac_fastapi.types.config import Settings os.environ.setdefault("ENABLE_COLLECTIONS_SEARCH_ROUTE", "true") -os.environ.setdefault("ENABLE_CATALOG_ROUTE", "false") +os.environ.setdefault("ENABLE_CATALOGS_ROUTE", "false") if os.getenv("BACKEND", "elasticsearch").lower() == "opensearch": from stac_fastapi.opensearch.app import app_config From a5354ab00325b4fba800af65ad7d98ab816b63c7 Mon Sep 17 00:00:00 2001 From: jonhealy1 Date: Wed, 3 Dec 2025 23:31:11 +0800 Subject: [PATCH 11/28] pagination, limit fix for catalogs --- .../stac_fastapi/core/extensions/catalogs.py | 54 +++++++++---- .../tests/extensions/test_catalogs.py | 77 +++++++++++++++++++ 2 files changed, 118 insertions(+), 13 deletions(-) diff --git a/stac_fastapi/core/stac_fastapi/core/extensions/catalogs.py b/stac_fastapi/core/stac_fastapi/core/extensions/catalogs.py index 8bf3eee6..a227e6fe 100644 --- a/stac_fastapi/core/stac_fastapi/core/extensions/catalogs.py +++ b/stac_fastapi/core/stac_fastapi/core/extensions/catalogs.py @@ -1,9 +1,10 @@ """Catalogs extension.""" from typing import List, Optional, Type +from urllib.parse import urlparse import attr -from fastapi import APIRouter, FastAPI, HTTPException, Request +from fastapi import APIRouter, FastAPI, HTTPException, Query, Request from fastapi.responses import JSONResponse from starlette.responses import Response @@ -122,21 +123,37 @@ def register(self, app: FastAPI, settings=None) -> None: app.include_router(self.router, tags=["Catalogs"]) - async def catalogs(self, request: Request) -> Catalog: + async def catalogs( + self, + request: Request, + limit: Optional[int] = Query( + 10, + ge=1, + description=( + "The maximum number of catalogs to return (page size). Defaults to 10." + ), + ), + token: Optional[str] = Query( + None, + description="Pagination token for the next page of results", + ), + ) -> Catalog: """Get root catalog with links to all catalogs. Args: request: Request object. + limit: The maximum number of catalogs to return (page size). Defaults to 10. + token: Pagination token for the next page of results. Returns: Root catalog containing child links to all catalogs in the database. """ base_url = str(request.base_url) - # Get all catalogs from database + # Get all catalogs from database with pagination catalogs, _, _ = await self.client.database.get_all_catalogs( - token=None, - limit=1000, # Large limit to get all catalogs + token=token, + limit=limit, request=request, sort=[{"field": "id", "direction": "asc"}], ) @@ -258,15 +275,26 @@ async def get_catalog_collections( if hasattr(catalog, "links") and catalog.links: for link in catalog.links: if link.get("rel") in ["child", "item"]: - # Extract collection ID from href + # Extract collection ID from href using proper URL parsing href = link.get("href", "") - # Look for patterns like /collections/{id} or collections/{id} - if "/collections/" in href: - collection_id = href.split("/collections/")[-1].split("/")[ - 0 - ] - if collection_id and collection_id not in collection_ids: - collection_ids.append(collection_id) + if href: + try: + parsed_url = urlparse(href) + path = parsed_url.path + # Look for patterns like /collections/{id} or collections/{id} + if "/collections/" in path: + # Split by /collections/ and take the last segment + path_parts = path.split("/collections/") + if len(path_parts) > 1: + collection_id = path_parts[1].split("/")[0] + if ( + collection_id + and collection_id not in collection_ids + ): + collection_ids.append(collection_id) + except Exception: + # If URL parsing fails, skip this link + continue # Fetch the collections collections = [] diff --git a/stac_fastapi/tests/extensions/test_catalogs.py b/stac_fastapi/tests/extensions/test_catalogs.py index c972818d..a49d799d 100644 --- a/stac_fastapi/tests/extensions/test_catalogs.py +++ b/stac_fastapi/tests/extensions/test_catalogs.py @@ -301,3 +301,80 @@ async def test_get_catalog_collection_item_nonexistent_item( f"/catalogs/{test_catalog['id']}/collections/{ctx.collection['id']}/items/nonexistent-item" ) assert resp.status_code == 404 + + +@pytest.mark.asyncio +async def test_catalogs_pagination_limit(catalogs_app_client, load_test_data): + """Test that pagination limit parameter works for catalogs endpoint.""" + # Create multiple catalogs + catalog_ids = [] + for i in range(5): + test_catalog = load_test_data("test_catalog.json") + test_catalog["id"] = f"test-catalog-{uuid.uuid4()}-{i}" + test_catalog["title"] = f"Test Catalog {i}" + + resp = await catalogs_app_client.post("/catalogs", json=test_catalog) + assert resp.status_code == 201 + catalog_ids.append(test_catalog["id"]) + + # Test with limit=2 + resp = await catalogs_app_client.get("/catalogs?limit=2") + assert resp.status_code == 200 + + catalog = resp.json() + child_links = [link for link in catalog["links"] if link["rel"] == "child"] + + # Should only return 2 child links + assert len(child_links) == 2 + + +@pytest.mark.asyncio +async def test_catalogs_pagination_default_limit(catalogs_app_client, load_test_data): + """Test that pagination uses default limit when no limit parameter is provided.""" + # Create multiple catalogs + catalog_ids = [] + for i in range(15): + test_catalog = load_test_data("test_catalog.json") + test_catalog["id"] = f"test-catalog-{uuid.uuid4()}-{i}" + test_catalog["title"] = f"Test Catalog {i}" + + resp = await catalogs_app_client.post("/catalogs", json=test_catalog) + assert resp.status_code == 201 + catalog_ids.append(test_catalog["id"]) + + # Test without limit parameter (should default to 10) + resp = await catalogs_app_client.get("/catalogs") + assert resp.status_code == 200 + + catalog = resp.json() + child_links = [link for link in catalog["links"] if link["rel"] == "child"] + + # Should return default limit of 10 child links + assert len(child_links) == 10 + + +@pytest.mark.asyncio +async def test_catalogs_pagination_limit_validation(catalogs_app_client): + """Test that pagination limit parameter validation works.""" + # Test with limit=0 (should be invalid) + resp = await catalogs_app_client.get("/catalogs?limit=0") + assert resp.status_code == 400 # Validation error returns 400 for Query parameters + + +@pytest.mark.asyncio +async def test_catalogs_pagination_token_parameter(catalogs_app_client, load_test_data): + """Test that pagination token parameter is accepted (even if token is invalid).""" + # Create a catalog first + test_catalog = load_test_data("test_catalog.json") + test_catalog["id"] = f"test-catalog-{uuid.uuid4()}" + + resp = await catalogs_app_client.post("/catalogs", json=test_catalog) + assert resp.status_code == 201 + + # Test with token parameter (even if invalid, should be accepted) + resp = await catalogs_app_client.get("/catalogs?token=invalid_token") + assert resp.status_code == 200 + + catalog = resp.json() + assert catalog["type"] == "Catalog" + assert "links" in catalog From 530c89814ba39599e9102b65cc66bff58cdd5db8 Mon Sep 17 00:00:00 2001 From: jonhealy1 Date: Wed, 3 Dec 2025 23:39:05 +0800 Subject: [PATCH 12/28] add warning, todo --- .../stac_fastapi/core/extensions/catalogs.py | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/stac_fastapi/core/stac_fastapi/core/extensions/catalogs.py b/stac_fastapi/core/stac_fastapi/core/extensions/catalogs.py index a227e6fe..29759f6f 100644 --- a/stac_fastapi/core/stac_fastapi/core/extensions/catalogs.py +++ b/stac_fastapi/core/stac_fastapi/core/extensions/catalogs.py @@ -271,8 +271,21 @@ async def get_catalog_collections( catalog = self.client.catalog_serializer.db_to_stac(db_catalog, request) # Extract collection IDs from catalog links + # + # FRAGILE IMPLEMENTATION WARNING: + # This approach relies on parsing URL patterns to determine catalog-collection relationships. + # This is fragile and will break if: + # - URLs don't follow the expected /collections/{id} pattern + # - Base URLs contain /collections/ in other segments + # - Relative links are used instead of absolute URLs + # - Links have trailing slashes or query parameters + # + # TODO: In a future version, this should be replaced with a proper database relationship + # (e.g., parent_catalog_id field on Collection documents) + # collection_ids = [] if hasattr(catalog, "links") and catalog.links: + base_url = str(request.base_url).rstrip("/") for link in catalog.links: if link.get("rel") in ["child", "item"]: # Extract collection ID from href using proper URL parsing @@ -281,6 +294,17 @@ async def get_catalog_collections( try: parsed_url = urlparse(href) path = parsed_url.path + + # Verify this is our expected URL pattern by checking it starts with base_url + # or is a relative path that would resolve to our server + full_href = ( + href + if href.startswith(("http://", "https://")) + else f"{base_url}{href}" + ) + if not full_href.startswith(base_url): + continue + # Look for patterns like /collections/{id} or collections/{id} if "/collections/" in path: # Split by /collections/ and take the last segment From 290bfb64681d3ca1a2a285da011aaca55190d744 Mon Sep 17 00:00:00 2001 From: jonhealy1 Date: Wed, 3 Dec 2025 23:41:25 +0800 Subject: [PATCH 13/28] update conformance link --- stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/app.py | 2 +- stac_fastapi/opensearch/stac_fastapi/opensearch/app.py | 2 +- stac_fastapi/tests/conftest.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/app.py b/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/app.py index 1ffabe21..bb2db937 100644 --- a/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/app.py +++ b/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/app.py @@ -215,7 +215,7 @@ ), settings=settings, conformance_classes=[ - "https://api.stacspec.org/v1.0.0-beta.1/catalogs", + "https://api.stacspec.org/v1.0.0-beta.1/catalogs-endpoint", ], ) extensions.append(catalogs_extension) diff --git a/stac_fastapi/opensearch/stac_fastapi/opensearch/app.py b/stac_fastapi/opensearch/stac_fastapi/opensearch/app.py index 07beaee9..7339eab5 100644 --- a/stac_fastapi/opensearch/stac_fastapi/opensearch/app.py +++ b/stac_fastapi/opensearch/stac_fastapi/opensearch/app.py @@ -214,7 +214,7 @@ ), settings=settings, conformance_classes=[ - "https://api.stacspec.org/v1.0.0-beta.1/catalogs", + "https://api.stacspec.org/v1.0.0-beta.1/catalogs-endpoint", ], ) extensions.append(catalogs_extension) diff --git a/stac_fastapi/tests/conftest.py b/stac_fastapi/tests/conftest.py index 2e2d8a50..1a6a2b66 100644 --- a/stac_fastapi/tests/conftest.py +++ b/stac_fastapi/tests/conftest.py @@ -419,7 +419,7 @@ def build_test_app_with_catalogs(): ), settings=test_settings, conformance_classes=[ - "https://api.stacspec.org/v1.0.0-beta.1/catalogs", + "https://api.stacspec.org/v1.0.0-beta.1/catalogs-endpoint", ], ) From 310ec6a4b7538752f44beddb95635f191fdbd207 Mon Sep 17 00:00:00 2001 From: jonhealy1 Date: Wed, 3 Dec 2025 23:43:40 +0800 Subject: [PATCH 14/28] improve missing collections error msgng --- .../stac_fastapi/core/extensions/catalogs.py | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/stac_fastapi/core/stac_fastapi/core/extensions/catalogs.py b/stac_fastapi/core/stac_fastapi/core/extensions/catalogs.py index 29759f6f..5c04fe71 100644 --- a/stac_fastapi/core/stac_fastapi/core/extensions/catalogs.py +++ b/stac_fastapi/core/stac_fastapi/core/extensions/catalogs.py @@ -1,5 +1,6 @@ """Catalogs extension.""" +import logging from typing import List, Optional, Type from urllib.parse import urlparse @@ -13,6 +14,8 @@ from stac_fastapi.types.core import BaseCoreClient from stac_fastapi.types.extension import ApiExtension +logger = logging.getLogger(__name__) + @attr.s class CatalogsExtension(ApiExtension): @@ -328,9 +331,21 @@ async def get_catalog_collections( coll_id, request=request ) collections.append(collection) - except Exception: - # Skip collections that can't be found - continue + except HTTPException as e: + # Only skip collections that are not found (404) + if e.status_code == 404: + logger.debug(f"Collection {coll_id} not found, skipping") + continue + else: + # Re-raise other HTTP exceptions (5xx server errors, etc.) + logger.error(f"HTTP error retrieving collection {coll_id}: {e}") + raise + except Exception as e: + # Log unexpected errors and re-raise them + logger.error( + f"Unexpected error retrieving collection {coll_id}: {e}" + ) + raise # Return in Collections format base_url = str(request.base_url) From c0c4341bc7e5e03f2459d8389bd84cf44be58025 Mon Sep 17 00:00:00 2001 From: jonhealy1 Date: Wed, 3 Dec 2025 23:44:38 +0800 Subject: [PATCH 15/28] allow sorting catalogs on title --- .../elasticsearch/stac_fastapi/elasticsearch/database_logic.py | 2 +- .../opensearch/stac_fastapi/opensearch/database_logic.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py b/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py index 75915607..e4b6cd1e 100644 --- a/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py +++ b/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py @@ -1776,7 +1776,7 @@ async def get_all_catalogs( A tuple of (catalogs, next pagination token if any, optional count). """ # Define sortable fields for catalogs - sortable_fields = ["id"] + sortable_fields = ["id", "title"] # Format the sort parameter formatted_sort = [] diff --git a/stac_fastapi/opensearch/stac_fastapi/opensearch/database_logic.py b/stac_fastapi/opensearch/stac_fastapi/opensearch/database_logic.py index 14529ac3..0d429843 100644 --- a/stac_fastapi/opensearch/stac_fastapi/opensearch/database_logic.py +++ b/stac_fastapi/opensearch/stac_fastapi/opensearch/database_logic.py @@ -1745,7 +1745,7 @@ async def get_all_catalogs( A tuple of (catalogs, next pagination token if any, optional count). """ # Define sortable fields for catalogs - sortable_fields = ["id"] + sortable_fields = ["id", "title"] # Format the sort parameter formatted_sort = [] From bc185549d6d971aa596f910ab9de095676928254 Mon Sep 17 00:00:00 2001 From: jonhealy1 Date: Thu, 4 Dec 2025 00:02:38 +0800 Subject: [PATCH 16/28] return list of catalogs --- .../stac_fastapi/core/extensions/catalogs.py | 107 +++++++++--------- .../elasticsearch/database_logic.py | 2 +- .../stac_fastapi/opensearch/database_logic.py | 2 +- .../tests/extensions/test_catalogs.py | 53 ++++----- 4 files changed, 81 insertions(+), 83 deletions(-) diff --git a/stac_fastapi/core/stac_fastapi/core/extensions/catalogs.py b/stac_fastapi/core/stac_fastapi/core/extensions/catalogs.py index 5c04fe71..db527d0d 100644 --- a/stac_fastapi/core/stac_fastapi/core/extensions/catalogs.py +++ b/stac_fastapi/core/stac_fastapi/core/extensions/catalogs.py @@ -2,12 +2,13 @@ import logging from typing import List, Optional, Type -from urllib.parse import urlparse +from urllib.parse import urlencode, urlparse import attr from fastapi import APIRouter, FastAPI, HTTPException, Query, Request from fastapi.responses import JSONResponse from starlette.responses import Response +from typing_extensions import TypedDict from stac_fastapi.core.models import Catalog from stac_fastapi.types import stac as stac_types @@ -17,12 +18,24 @@ logger = logging.getLogger(__name__) +class Catalogs(TypedDict, total=False): + """Catalogs endpoint response. + + Similar to Collections but for catalogs. + """ + + catalogs: List[Catalog] + links: List[dict] + numberMatched: Optional[int] + numberReturned: Optional[int] + + @attr.s class CatalogsExtension(ApiExtension): """Catalogs Extension. - The Catalogs extension adds a /catalogs endpoint that returns the root catalog - containing child links to all catalogs in the database. + The Catalogs extension adds a /catalogs endpoint that returns a list of all catalogs + in the database, similar to how /collections returns a list of collections. """ client: BaseCoreClient = attr.ib(default=None) @@ -44,10 +57,10 @@ def register(self, app: FastAPI, settings=None) -> None: path="/catalogs", endpoint=self.catalogs, methods=["GET"], - response_model=Catalog, + response_model=Catalogs, response_class=self.response_class, - summary="Get Root Catalog", - description="Returns the root catalog containing links to all catalogs.", + summary="Get All Catalogs", + description="Returns a list of all catalogs in the database.", tags=["Catalogs"], ) @@ -140,8 +153,8 @@ async def catalogs( None, description="Pagination token for the next page of results", ), - ) -> Catalog: - """Get root catalog with links to all catalogs. + ) -> Catalogs: + """Get all catalogs with pagination support. Args: request: Request object. @@ -149,64 +162,48 @@ async def catalogs( token: Pagination token for the next page of results. Returns: - Root catalog containing child links to all catalogs in the database. + Catalogs object containing catalogs and pagination links. """ base_url = str(request.base_url) # Get all catalogs from database with pagination - catalogs, _, _ = await self.client.database.get_all_catalogs( + catalogs, next_token, _ = await self.client.database.get_all_catalogs( token=token, limit=limit, request=request, sort=[{"field": "id", "direction": "asc"}], ) - # Create child links to each catalog - child_links = [] + # Convert database catalogs to STAC format + catalog_stac_objects = [] for catalog in catalogs: - catalog_id = catalog.get("id") if isinstance(catalog, dict) else catalog.id - catalog_title = ( - catalog.get("title") or catalog_id - if isinstance(catalog, dict) - else catalog.title or catalog.id - ) - child_links.append( - { - "rel": "child", - "href": f"{base_url}catalogs/{catalog_id}", - "type": "application/json", - "title": catalog_title, - } - ) - - # Create root catalog - root_catalog = { - "type": "Catalog", - "stac_version": "1.0.0", - "id": "root", - "title": "Root Catalog", - "description": "Root catalog containing all available catalogs", - "links": [ - { - "rel": "self", - "href": f"{base_url}catalogs", - "type": "application/json", - }, - { - "rel": "root", - "href": f"{base_url}catalogs", - "type": "application/json", - }, - { - "rel": "parent", - "href": base_url.rstrip("/"), - "type": "application/json", - }, - ] - + child_links, - } - - return Catalog(**root_catalog) + catalog_stac = self.client.catalog_serializer.db_to_stac(catalog, request) + catalog_stac_objects.append(catalog_stac) + + # Create pagination links + links = [ + {"rel": "root", "type": "application/json", "href": base_url}, + {"rel": "parent", "type": "application/json", "href": base_url}, + {"rel": "self", "type": "application/json", "href": str(request.url)}, + ] + + # Add next link if there are more pages + if next_token: + query_params = {"limit": limit, "token": next_token} + next_link = { + "rel": "next", + "href": f"{base_url}catalogs?{urlencode(query_params)}", + "type": "application/json", + "title": "Next page of catalogs", + } + links.append(next_link) + + # Return Catalogs object with catalogs + return Catalogs( + catalogs=catalog_stac_objects, + links=links, + numberReturned=len(catalog_stac_objects), + ) async def create_catalog(self, catalog: Catalog, request: Request) -> Catalog: """Create a new catalog. diff --git a/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py b/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py index e4b6cd1e..75915607 100644 --- a/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py +++ b/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py @@ -1776,7 +1776,7 @@ async def get_all_catalogs( A tuple of (catalogs, next pagination token if any, optional count). """ # Define sortable fields for catalogs - sortable_fields = ["id", "title"] + sortable_fields = ["id"] # Format the sort parameter formatted_sort = [] diff --git a/stac_fastapi/opensearch/stac_fastapi/opensearch/database_logic.py b/stac_fastapi/opensearch/stac_fastapi/opensearch/database_logic.py index 0d429843..14529ac3 100644 --- a/stac_fastapi/opensearch/stac_fastapi/opensearch/database_logic.py +++ b/stac_fastapi/opensearch/stac_fastapi/opensearch/database_logic.py @@ -1745,7 +1745,7 @@ async def get_all_catalogs( A tuple of (catalogs, next pagination token if any, optional count). """ # Define sortable fields for catalogs - sortable_fields = ["id", "title"] + sortable_fields = ["id"] # Format the sort parameter formatted_sort = [] diff --git a/stac_fastapi/tests/extensions/test_catalogs.py b/stac_fastapi/tests/extensions/test_catalogs.py index a49d799d..033f34dc 100644 --- a/stac_fastapi/tests/extensions/test_catalogs.py +++ b/stac_fastapi/tests/extensions/test_catalogs.py @@ -5,18 +5,17 @@ @pytest.mark.asyncio async def test_get_root_catalog(catalogs_app_client, load_test_data): - """Test getting the root catalog.""" + """Test getting the catalogs list.""" resp = await catalogs_app_client.get("/catalogs") assert resp.status_code == 200 - catalog = resp.json() - assert catalog["type"] == "Catalog" - assert catalog["id"] == "root" - assert catalog["stac_version"] == "1.0.0" - assert "links" in catalog + catalogs_response = resp.json() + assert "catalogs" in catalogs_response + assert "links" in catalogs_response + assert "numberReturned" in catalogs_response - # Check for required links - links = catalog["links"] + # Check for required pagination links + links = catalogs_response["links"] link_rels = [link["rel"] for link in links] assert "self" in link_rels assert "root" in link_rels @@ -102,7 +101,7 @@ async def test_get_catalog_collections_nonexistent_catalog(catalogs_app_client): @pytest.mark.asyncio async def test_root_catalog_with_multiple_catalogs(catalogs_app_client, load_test_data): - """Test that root catalog includes links to multiple catalogs.""" + """Test that catalogs response includes multiple catalog objects.""" # Create multiple catalogs catalog_ids = [] for i in range(3): @@ -114,17 +113,17 @@ async def test_root_catalog_with_multiple_catalogs(catalogs_app_client, load_tes assert resp.status_code == 201 catalog_ids.append(test_catalog["id"]) - # Get root catalog + # Get catalogs list resp = await catalogs_app_client.get("/catalogs") assert resp.status_code == 200 - catalog = resp.json() - child_links = [link for link in catalog["links"] if link["rel"] == "child"] + catalogs_response = resp.json() + returned_catalogs = catalogs_response["catalogs"] + returned_ids = [catalog["id"] for catalog in returned_catalogs] - # Should have child links for all created catalogs - child_hrefs = [link["href"] for link in child_links] + # Should have all created catalogs in the response for catalog_id in catalog_ids: - assert any(catalog_id in href for href in child_hrefs) + assert catalog_id in returned_ids @pytest.mark.asyncio @@ -321,11 +320,12 @@ async def test_catalogs_pagination_limit(catalogs_app_client, load_test_data): resp = await catalogs_app_client.get("/catalogs?limit=2") assert resp.status_code == 200 - catalog = resp.json() - child_links = [link for link in catalog["links"] if link["rel"] == "child"] + catalogs_response = resp.json() + returned_catalogs = catalogs_response["catalogs"] - # Should only return 2 child links - assert len(child_links) == 2 + # Should only return 2 catalogs + assert len(returned_catalogs) == 2 + assert catalogs_response["numberReturned"] == 2 @pytest.mark.asyncio @@ -346,11 +346,12 @@ async def test_catalogs_pagination_default_limit(catalogs_app_client, load_test_ resp = await catalogs_app_client.get("/catalogs") assert resp.status_code == 200 - catalog = resp.json() - child_links = [link for link in catalog["links"] if link["rel"] == "child"] + catalogs_response = resp.json() + returned_catalogs = catalogs_response["catalogs"] - # Should return default limit of 10 child links - assert len(child_links) == 10 + # Should return default limit of 10 catalogs + assert len(returned_catalogs) == 10 + assert catalogs_response["numberReturned"] == 10 @pytest.mark.asyncio @@ -375,6 +376,6 @@ async def test_catalogs_pagination_token_parameter(catalogs_app_client, load_tes resp = await catalogs_app_client.get("/catalogs?token=invalid_token") assert resp.status_code == 200 - catalog = resp.json() - assert catalog["type"] == "Catalog" - assert "links" in catalog + catalogs_response = resp.json() + assert "catalogs" in catalogs_response + assert "links" in catalogs_response From 641823e16fce8122e71b2d9b40a069ce75840cf1 Mon Sep 17 00:00:00 2001 From: jonhealy1 Date: Thu, 4 Dec 2025 00:11:07 +0800 Subject: [PATCH 17/28] add more robust url parsing --- .../stac_fastapi/core/extensions/catalogs.py | 57 +++++++++++++------ 1 file changed, 39 insertions(+), 18 deletions(-) diff --git a/stac_fastapi/core/stac_fastapi/core/extensions/catalogs.py b/stac_fastapi/core/stac_fastapi/core/extensions/catalogs.py index db527d0d..02b39981 100644 --- a/stac_fastapi/core/stac_fastapi/core/extensions/catalogs.py +++ b/stac_fastapi/core/stac_fastapi/core/extensions/catalogs.py @@ -286,6 +286,8 @@ async def get_catalog_collections( collection_ids = [] if hasattr(catalog, "links") and catalog.links: base_url = str(request.base_url).rstrip("/") + base_path = urlparse(base_url).path.rstrip("/") + for link in catalog.links: if link.get("rel") in ["child", "item"]: # Extract collection ID from href using proper URL parsing @@ -293,29 +295,48 @@ async def get_catalog_collections( if href: try: parsed_url = urlparse(href) - path = parsed_url.path - - # Verify this is our expected URL pattern by checking it starts with base_url - # or is a relative path that would resolve to our server - full_href = ( - href - if href.startswith(("http://", "https://")) - else f"{base_url}{href}" - ) - if not full_href.startswith(base_url): - continue - - # Look for patterns like /collections/{id} or collections/{id} - if "/collections/" in path: - # Split by /collections/ and take the last segment - path_parts = path.split("/collections/") - if len(path_parts) > 1: - collection_id = path_parts[1].split("/")[0] + path = parsed_url.path.rstrip("/") + + # Resolve relative URLs against base URL + if not href.startswith(("http://", "https://")): + full_path = ( + f"{base_path}{path}" if path else base_path + ) + else: + # For absolute URLs, ensure they belong to our base domain + if parsed_url.netloc != urlparse(base_url).netloc: + continue + full_path = path + + # Look for collections endpoint at the end of the path + # This prevents false positives when /collections/ appears in base URL + collections_pattern = "/collections/" + if collections_pattern in full_path: + # Find the LAST occurrence of /collections/ to avoid base URL conflicts + last_collections_pos = full_path.rfind( + collections_pattern + ) + if last_collections_pos != -1: + # Extract everything after the last /collections/ + after_collections = full_path[ + last_collections_pos + + len(collections_pattern) : + ] + + # Handle cases where there might be additional path segments + # We only want the immediate collection ID + collection_id = ( + after_collections.split("/")[0] + if after_collections + else None + ) + if ( collection_id and collection_id not in collection_ids ): collection_ids.append(collection_id) + except Exception: # If URL parsing fails, skip this link continue From d823914d46f09165c178701c5f7e8a44520a4423 Mon Sep 17 00:00:00 2001 From: jonhealy1 Date: Thu, 4 Dec 2025 23:22:41 +0800 Subject: [PATCH 18/28] post collection to catalog --- README.md | 17 ++ .../stac_fastapi/core/extensions/catalogs.py | 210 +++++++++++++++++- stac_fastapi/tests/api/test_api.py | 1 + stac_fastapi/tests/data/test_catalog.json | 16 +- .../tests/extensions/test_catalogs.py | 97 ++++++++ 5 files changed, 324 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index d51a40c2..3cc67234 100644 --- a/README.md +++ b/README.md @@ -249,6 +249,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 - **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 - **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 @@ -265,6 +266,22 @@ curl "http://localhost:8081/catalogs/earth-observation" # Get collections in a catalog curl "http://localhost:8081/catalogs/earth-observation/collections" +# Create a new collection within a catalog +curl -X POST "http://localhost:8081/catalogs/earth-observation/collections" \ + -H "Content-Type: application/json" \ + -d '{ + "id": "landsat-9", + "type": "Collection", + "stac_version": "1.0.0", + "description": "Landsat 9 satellite imagery collection", + "title": "Landsat 9", + "license": "MIT", + "extent": { + "spatial": {"bbox": [[-180, -90, 180, 90]]}, + "temporal": {"interval": [["2021-09-27T00:00:00Z", null]]} + } + }' + # Get specific collection within a catalog curl "http://localhost:8081/catalogs/earth-observation/collections/sentinel-2" diff --git a/stac_fastapi/core/stac_fastapi/core/extensions/catalogs.py b/stac_fastapi/core/stac_fastapi/core/extensions/catalogs.py index 02b39981..3f5a2122 100644 --- a/stac_fastapi/core/stac_fastapi/core/extensions/catalogs.py +++ b/stac_fastapi/core/stac_fastapi/core/extensions/catalogs.py @@ -7,10 +7,12 @@ import attr from fastapi import APIRouter, FastAPI, HTTPException, Query, Request from fastapi.responses import JSONResponse +from stac_pydantic import Collection from starlette.responses import Response from typing_extensions import TypedDict from stac_fastapi.core.models import Catalog +from stac_fastapi.sfeos_helpers.mappings import COLLECTIONS_INDEX from stac_fastapi.types import stac as stac_types from stac_fastapi.types.core import BaseCoreClient from stac_fastapi.types.extension import ApiExtension @@ -101,6 +103,19 @@ def register(self, app: FastAPI, settings=None) -> None: tags=["Catalogs"], ) + # Add endpoint for creating collections in a catalog + self.router.add_api_route( + path="/catalogs/{catalog_id}/collections", + endpoint=self.create_catalog_collection, + methods=["POST"], + response_model=stac_types.Collection, + response_class=self.response_class, + status_code=201, + summary="Create Catalog Collection", + description="Create a new collection and link it to a specific catalog.", + tags=["Catalogs"], + ) + # Add endpoint for getting a specific collection in a catalog self.router.add_api_route( path="/catalogs/{catalog_id}/collections/{collection_id}", @@ -289,9 +304,18 @@ async def get_catalog_collections( base_path = urlparse(base_url).path.rstrip("/") for link in catalog.links: - if link.get("rel") in ["child", "item"]: + rel = ( + link.get("rel") + if hasattr(link, "get") + else getattr(link, "rel", None) + ) + if rel in ["child", "item"]: # Extract collection ID from href using proper URL parsing - href = link.get("href", "") + href = ( + link.get("href", "") + if hasattr(link, "get") + else getattr(link, "href", "") + ) if href: try: parsed_url = urlparse(href) @@ -380,11 +404,191 @@ async def get_catalog_collections( ], ) - except Exception: + except HTTPException: + # Re-raise HTTP exceptions as-is + raise + except Exception as e: + logger.error( + f"Error retrieving collections for catalog {catalog_id}: {e}", + exc_info=True, + ) raise HTTPException( status_code=404, detail=f"Catalog {catalog_id} not found" ) + async def create_catalog_collection( + self, catalog_id: str, collection: Collection, request: Request + ) -> stac_types.Collection: + """Create a new collection and link it to a specific catalog. + + Args: + catalog_id: The ID of the catalog to link the collection to. + collection: The collection to create. + request: Request object. + + Returns: + The created collection. + + Raises: + HTTPException: If the catalog is not found or collection creation fails. + """ + try: + # Verify the catalog exists + await self.client.database.find_catalog(catalog_id) + + # Create the collection using the same pattern as TransactionsClient.create_collection + # This handles the Collection model from stac_pydantic correctly + collection_dict = collection.model_dump(mode="json") + + # Add a link from the collection back to its parent catalog BEFORE saving to database + base_url = str(request.base_url) + catalog_link = { + "rel": "catalog", + "type": "application/json", + "href": f"{base_url}catalogs/{catalog_id}", + "title": catalog_id, + } + + # Add the catalog link to the collection dict + if "links" not in collection_dict: + collection_dict["links"] = [] + + # Check if the catalog link already exists + catalog_href = catalog_link["href"] + link_exists = any( + link.get("href") == catalog_href and link.get("rel") == "catalog" + for link in collection_dict.get("links", []) + ) + + if not link_exists: + collection_dict["links"].append(catalog_link) + + # Now convert to database format (this will process the links) + collection_db = self.client.database.collection_serializer.stac_to_db( + collection_dict, request + ) + await self.client.database.create_collection( + collection=collection_db, refresh=True + ) + + # Convert back to STAC format for the response + created_collection = self.client.database.collection_serializer.db_to_stac( + collection_db, + request, + extensions=[ + type(ext).__name__ for ext in self.client.database.extensions + ], + ) + + # Update the catalog to include a link to the new collection + await self._add_collection_to_catalog_links( + catalog_id, collection.id, request + ) + + return created_collection + + except HTTPException as e: + # Re-raise HTTP exceptions (e.g., catalog not found, collection validation errors) + raise e + except Exception as e: + # Check if this is a "not found" error from find_catalog + error_msg = str(e) + if "not found" in error_msg.lower(): + raise HTTPException(status_code=404, detail=error_msg) + + # Handle unexpected errors + logger.error(f"Error creating collection in catalog {catalog_id}: {e}") + raise HTTPException( + status_code=500, + detail=f"Failed to create collection in catalog: {str(e)}", + ) + + async def _add_collection_to_catalog_links( + self, catalog_id: str, collection_id: str, request: Request + ) -> None: + """Add a collection link to a catalog. + + This helper method updates a catalog's links to include a reference + to a collection by reindexing the updated catalog document. + + Args: + catalog_id: The ID of the catalog to update. + collection_id: The ID of the collection to link. + request: Request object for base URL construction. + """ + try: + # Get the current catalog + db_catalog = await self.client.database.find_catalog(catalog_id) + catalog = self.client.catalog_serializer.db_to_stac(db_catalog, request) + + # Create the collection link + base_url = str(request.base_url) + collection_link = { + "rel": "child", + "href": f"{base_url}collections/{collection_id}", + "type": "application/json", + "title": collection_id, + } + + # Add the link to the catalog if it doesn't already exist + catalog_links = ( + catalog.get("links") + if isinstance(catalog, dict) + else getattr(catalog, "links", None) + ) + if not catalog_links: + catalog_links = [] + if isinstance(catalog, dict): + catalog["links"] = catalog_links + else: + catalog.links = catalog_links + + # Check if the collection link already exists + collection_href = collection_link["href"] + link_exists = any( + ( + link.get("href") + if hasattr(link, "get") + else getattr(link, "href", None) + ) + == collection_href + for link in catalog_links + ) + + if not link_exists: + catalog_links.append(collection_link) + + # Update the catalog in the database by reindexing it + # Convert back to database format + updated_db_catalog = self.client.catalog_serializer.stac_to_db( + catalog, request + ) + updated_db_catalog_dict = ( + updated_db_catalog.model_dump() + if hasattr(updated_db_catalog, "model_dump") + else updated_db_catalog + ) + updated_db_catalog_dict["type"] = "Catalog" + + # Use the same approach as create_catalog to update the document + await self.client.database.client.index( + index=COLLECTIONS_INDEX, + id=catalog_id, + body=updated_db_catalog_dict, + refresh=True, + ) + + logger.info( + f"Updated catalog {catalog_id} to include link to collection {collection_id}" + ) + + except Exception as e: + logger.error( + f"Failed to update catalog {catalog_id} links: {e}", exc_info=True + ) + # Don't fail the entire operation if link update fails + # The collection was created successfully, just the catalog link is missing + async def get_catalog_collection( self, catalog_id: str, collection_id: str, request: Request ) -> stac_types.Collection: diff --git a/stac_fastapi/tests/api/test_api.py b/stac_fastapi/tests/api/test_api.py index b51c9dc8..22e94ea6 100644 --- a/stac_fastapi/tests/api/test_api.py +++ b/stac_fastapi/tests/api/test_api.py @@ -54,6 +54,7 @@ "POST /catalogs", "GET /catalogs/{catalog_id}", "GET /catalogs/{catalog_id}/collections", + "POST /catalogs/{catalog_id}/collections", "GET /catalogs/{catalog_id}/collections/{collection_id}", "GET /catalogs/{catalog_id}/collections/{collection_id}/items", "GET /catalogs/{catalog_id}/collections/{collection_id}/items/{item_id}", diff --git a/stac_fastapi/tests/data/test_catalog.json b/stac_fastapi/tests/data/test_catalog.json index ede39131..49a59f8d 100644 --- a/stac_fastapi/tests/data/test_catalog.json +++ b/stac_fastapi/tests/data/test_catalog.json @@ -17,21 +17,9 @@ }, { "rel": "child", - "href": "http://test-server/collections/test-collection-1", + "href": "http://test-server/collections/placeholder-collection", "type": "application/json", - "title": "Test Collection 1" - }, - { - "rel": "child", - "href": "http://test-server/collections/test-collection-2", - "type": "application/json", - "title": "Test Collection 2" - }, - { - "rel": "child", - "href": "http://test-server/collections/test-collection-3", - "type": "application/json", - "title": "Test Collection 3" + "title": "Placeholder Collection" } ] } diff --git a/stac_fastapi/tests/extensions/test_catalogs.py b/stac_fastapi/tests/extensions/test_catalogs.py index 033f34dc..f0ce4d9d 100644 --- a/stac_fastapi/tests/extensions/test_catalogs.py +++ b/stac_fastapi/tests/extensions/test_catalogs.py @@ -379,3 +379,100 @@ async def test_catalogs_pagination_token_parameter(catalogs_app_client, load_tes catalogs_response = resp.json() assert "catalogs" in catalogs_response assert "links" in catalogs_response + + +@pytest.mark.asyncio +async def test_create_catalog_collection(catalogs_app_client, load_test_data, ctx): + """Test creating a collection within a catalog.""" + # First create a catalog + test_catalog = load_test_data("test_catalog.json") + test_catalog["id"] = f"test-catalog-{uuid.uuid4()}" + + # Remove placeholder collection links so we start with a clean catalog + 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 new collection within the catalog + test_collection = load_test_data("test_collection.json") + test_collection["id"] = f"test-collection-{uuid.uuid4()}" + + resp = await catalogs_app_client.post( + f"/catalogs/{catalog_id}/collections", json=test_collection + ) + assert resp.status_code == 201 + + created_collection = resp.json() + assert created_collection["id"] == test_collection["id"] + assert created_collection["type"] == "Collection" + + # Verify the collection was created by getting it directly + get_resp = await catalogs_app_client.get(f"/collections/{test_collection['id']}") + assert get_resp.status_code == 200 + assert get_resp.json()["id"] == test_collection["id"] + + # Verify the collection has a catalog link to the catalog + collection_data = get_resp.json() + collection_links = collection_data.get("links", []) + catalog_link = None + for link in collection_links: + if link.get("rel") == "catalog" and f"/catalogs/{catalog_id}" in link.get( + "href", "" + ): + catalog_link = link + break + + assert ( + catalog_link is not None + ), f"Collection should have catalog link to /catalogs/{catalog_id}" + assert catalog_link["type"] == "application/json" + assert catalog_link["href"].endswith(f"/catalogs/{catalog_id}") + + # Verify the catalog has a child link to the collection + catalog_resp = await catalogs_app_client.get(f"/catalogs/{catalog_id}") + assert catalog_resp.status_code == 200 + catalog_data = catalog_resp.json() + catalog_links = catalog_data.get("links", []) + collection_child_link = None + for link in catalog_links: + if link.get( + "rel" + ) == "child" and f"/collections/{test_collection['id']}" in link.get( + "href", "" + ): + collection_child_link = link + break + + assert ( + collection_child_link is not None + ), f"Catalog should have child link to collection /collections/{test_collection['id']}" + assert collection_child_link["type"] == "application/json" + assert collection_child_link["href"].endswith( + f"/collections/{test_collection['id']}" + ) + + # Verify the catalog now includes the collection in its collections endpoint + catalog_resp = await catalogs_app_client.get(f"/catalogs/{catalog_id}/collections") + assert catalog_resp.status_code == 200 + + collections_response = catalog_resp.json() + collection_ids = [col["id"] for col in collections_response["collections"]] + assert test_collection["id"] in collection_ids + + +@pytest.mark.asyncio +async def test_create_catalog_collection_nonexistent_catalog( + catalogs_app_client, load_test_data +): + """Test creating a collection in a catalog that doesn't exist.""" + test_collection = load_test_data("test_collection.json") + test_collection["id"] = f"test-collection-{uuid.uuid4()}" + + resp = await catalogs_app_client.post( + "/catalogs/nonexistent-catalog/collections", json=test_collection + ) + assert resp.status_code == 404 From b77f9919c09984c9150b2fccd35ef01aebb7914f Mon Sep 17 00:00:00 2001 From: jonhealy1 Date: Thu, 4 Dec 2025 23:41:22 +0800 Subject: [PATCH 19/28] delete catalog --- README.md | 15 +++ .../stac_fastapi/core/extensions/catalogs.py | 99 ++++++++++++++++ .../tests/extensions/test_catalogs.py | 107 ++++++++++++++++++ 3 files changed, 221 insertions(+) diff --git a/README.md b/README.md index 3cc67234..106b8d2d 100644 --- a/README.md +++ b/README.md @@ -248,6 +248,7 @@ This implementation follows the [STAC API Catalogs Extension](https://github.com - **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) - **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 @@ -290,8 +291,22 @@ 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 catalog (collections remain intact) +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" ``` +### Delete Catalog Parameters + +The DELETE endpoint supports the following query parameter: + +- **`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. + ### Response Structure Catalog responses include: diff --git a/stac_fastapi/core/stac_fastapi/core/extensions/catalogs.py b/stac_fastapi/core/stac_fastapi/core/extensions/catalogs.py index 3f5a2122..672bd334 100644 --- a/stac_fastapi/core/stac_fastapi/core/extensions/catalogs.py +++ b/stac_fastapi/core/stac_fastapi/core/extensions/catalogs.py @@ -116,6 +116,18 @@ def register(self, app: FastAPI, settings=None) -> None: tags=["Catalogs"], ) + # Add endpoint for deleting a catalog + self.router.add_api_route( + path="/catalogs/{catalog_id}", + endpoint=self.delete_catalog, + methods=["DELETE"], + response_class=self.response_class, + status_code=204, + summary="Delete Catalog", + description="Delete a catalog. Optionally cascade delete all collections in the catalog.", + tags=["Catalogs"], + ) + # Add endpoint for getting a specific collection in a catalog self.router.add_api_route( path="/catalogs/{catalog_id}/collections/{collection_id}", @@ -266,6 +278,93 @@ 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. + + 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) + + Raises: + HTTPException: If the catalog is not found. + """ + try: + # Get the catalog to verify it exists and get its collections + db_catalog = await self.client.database.find_catalog(catalog_id) + catalog = self.client.catalog_serializer.db_to_stac(db_catalog, request) + + # If cascade is true, delete all collections linked to this catalog + if cascade: + # Extract collection IDs from catalog links + collection_ids = [] + if hasattr(catalog, "links") and catalog.links: + for link in catalog.links: + rel = ( + link.get("rel") + if hasattr(link, "get") + else getattr(link, "rel", None) + ) + if rel == "child": + href = ( + link.get("href", "") + if hasattr(link, "get") + else getattr(link, "href", "") + ) + if href and "/collections/" in href: + # Extract collection ID from href + collection_id = href.split("/collections/")[-1].split( + "/" + )[0] + if collection_id: + collection_ids.append(collection_id) + + # Delete each collection + for coll_id in collection_ids: + try: + await self.client.database.delete_collection(coll_id) + logger.info( + f"Deleted collection {coll_id} as part of cascade delete for catalog {catalog_id}" + ) + except Exception as e: + error_msg = str(e) + if "not found" in error_msg.lower(): + logger.debug( + f"Collection {coll_id} not found, skipping (may have been deleted elsewhere)" + ) + else: + logger.warning( + f"Failed to delete collection {coll_id}: {e}" + ) + + # Delete the catalog + await self.client.database.delete_catalog(catalog_id) + logger.info(f"Deleted catalog {catalog_id}") + + except Exception as e: + error_msg = str(e) + if "not found" in error_msg.lower(): + raise HTTPException( + status_code=404, detail=f"Catalog {catalog_id} not found" + ) + logger.error(f"Error deleting catalog {catalog_id}: {e}") + raise HTTPException( + status_code=500, + detail=f"Failed to delete catalog: {str(e)}", + ) + async def get_catalog_collections( self, catalog_id: str, request: Request ) -> stac_types.Collections: diff --git a/stac_fastapi/tests/extensions/test_catalogs.py b/stac_fastapi/tests/extensions/test_catalogs.py index f0ce4d9d..2a272057 100644 --- a/stac_fastapi/tests/extensions/test_catalogs.py +++ b/stac_fastapi/tests/extensions/test_catalogs.py @@ -476,3 +476,110 @@ async def test_create_catalog_collection_nonexistent_catalog( "/catalogs/nonexistent-catalog/collections", json=test_collection ) assert resp.status_code == 404 + + +@pytest.mark.asyncio +async def test_delete_catalog(catalogs_app_client, load_test_data): + """Test deleting a catalog without cascade.""" + # 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"] + + # Verify catalog exists + get_resp = await catalogs_app_client.get(f"/catalogs/{catalog_id}") + assert get_resp.status_code == 200 + + # Delete the catalog + delete_resp = await catalogs_app_client.delete(f"/catalogs/{catalog_id}") + 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 + + +@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).""" + # 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"] + + # Delete the catalog with cascade=false (default) + delete_resp = await catalogs_app_client.delete(f"/catalogs/{catalog_id}") + 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 still exists (no cascade delete) + get_coll_resp = await catalogs_app_client.get(f"/collections/{collection_id}") + assert get_coll_resp.status_code == 200 From 8b20bd5d3395f53a3296bff6fda501631ba93ef1 Mon Sep 17 00:00:00 2001 From: jonhealy1 Date: Thu, 4 Dec 2025 23:41:50 +0800 Subject: [PATCH 20/28] remove catalog links --- .../stac_fastapi/core/extensions/catalogs.py | 100 ++++++++++++++---- stac_fastapi/tests/api/test_api.py | 1 + .../tests/extensions/test_catalogs.py | 13 +++ 3 files changed, 92 insertions(+), 22 deletions(-) diff --git a/stac_fastapi/core/stac_fastapi/core/extensions/catalogs.py b/stac_fastapi/core/stac_fastapi/core/extensions/catalogs.py index 672bd334..872fc54d 100644 --- a/stac_fastapi/core/stac_fastapi/core/extensions/catalogs.py +++ b/stac_fastapi/core/stac_fastapi/core/extensions/catalogs.py @@ -306,31 +306,30 @@ async def delete_catalog( db_catalog = await self.client.database.find_catalog(catalog_id) catalog = self.client.catalog_serializer.db_to_stac(db_catalog, request) - # If cascade is true, delete all collections linked to this catalog - if cascade: - # Extract collection IDs from catalog links - collection_ids = [] - if hasattr(catalog, "links") and catalog.links: - for link in catalog.links: - rel = ( - link.get("rel") + # Extract collection IDs from catalog links + collection_ids = [] + if hasattr(catalog, "links") and catalog.links: + for link in catalog.links: + rel = ( + link.get("rel") + if hasattr(link, "get") + else getattr(link, "rel", None) + ) + if rel == "child": + href = ( + link.get("href", "") if hasattr(link, "get") - else getattr(link, "rel", None) + else getattr(link, "href", "") ) - if rel == "child": - href = ( - link.get("href", "") - if hasattr(link, "get") - else getattr(link, "href", "") - ) - if href and "/collections/" in href: - # Extract collection ID from href - collection_id = href.split("/collections/")[-1].split( - "/" - )[0] - if collection_id: - collection_ids.append(collection_id) + if href and "/collections/" in href: + # Extract collection ID from href + collection_id = href.split("/collections/")[-1].split("/")[ + 0 + ] + if collection_id: + collection_ids.append(collection_id) + if cascade: # Delete each collection for coll_id in collection_ids: try: @@ -348,6 +347,63 @@ async def delete_catalog( logger.warning( f"Failed to delete collection {coll_id}: {e}" ) + else: + # Remove catalog link from each collection (orphan them) + for coll_id in collection_ids: + try: + collection = await self.client.get_collection( + coll_id, request=request + ) + # Remove the catalog link from the collection + if hasattr(collection, "links"): + collection.links = [ + link + for link in collection.links + if not ( + getattr(link, "rel", None) == "catalog" + and catalog_id in getattr(link, "href", "") + ) + ] + elif isinstance(collection, dict): + collection["links"] = [ + link + for link in collection.get("links", []) + if not ( + link.get("rel") == "catalog" + and catalog_id in link.get("href", "") + ) + ] + + # Update the collection in the database + collection_dict = ( + collection.model_dump(mode="json") + if hasattr(collection, "model_dump") + else collection + ) + collection_db = ( + self.client.database.collection_serializer.stac_to_db( + collection_dict, request + ) + ) + await self.client.database.client.index( + index=COLLECTIONS_INDEX, + id=coll_id, + body=collection_db.model_dump() + if hasattr(collection_db, "model_dump") + else collection_db, + refresh=True, + ) + logger.info(f"Removed catalog link from collection {coll_id}") + except Exception as e: + error_msg = str(e) + if "not found" in error_msg.lower(): + logger.debug( + f"Collection {coll_id} not found, skipping (may have been deleted elsewhere)" + ) + else: + logger.warning( + f"Failed to remove catalog link from collection {coll_id}: {e}" + ) # Delete the catalog await self.client.database.delete_catalog(catalog_id) diff --git a/stac_fastapi/tests/api/test_api.py b/stac_fastapi/tests/api/test_api.py index 22e94ea6..76830a06 100644 --- a/stac_fastapi/tests/api/test_api.py +++ b/stac_fastapi/tests/api/test_api.py @@ -53,6 +53,7 @@ "GET /catalogs", "POST /catalogs", "GET /catalogs/{catalog_id}", + "DELETE /catalogs/{catalog_id}", "GET /catalogs/{catalog_id}/collections", "POST /catalogs/{catalog_id}/collections", "GET /catalogs/{catalog_id}/collections/{collection_id}", diff --git a/stac_fastapi/tests/extensions/test_catalogs.py b/stac_fastapi/tests/extensions/test_catalogs.py index 2a272057..7c9960e7 100644 --- a/stac_fastapi/tests/extensions/test_catalogs.py +++ b/stac_fastapi/tests/extensions/test_catalogs.py @@ -583,3 +583,16 @@ async def test_delete_catalog_no_cascade(catalogs_app_client, load_test_data): # Verify collection still exists (no cascade delete) get_coll_resp = await catalogs_app_client.get(f"/collections/{collection_id}") assert get_coll_resp.status_code == 200 + + # Verify the catalog link was removed from the collection + collection_data = get_coll_resp.json() + collection_links = collection_data.get("links", []) + catalog_link = None + for link in collection_links: + if link.get("rel") == "catalog" and catalog_id in link.get("href", ""): + catalog_link = link + break + + assert ( + catalog_link is None + ), "Collection should not have catalog link after catalog deletion" From 11e7a4ed16a60c5960bac15c9236b22752f53f1f Mon Sep 17 00:00:00 2001 From: jonhealy1 Date: Mon, 8 Dec 2025 12:16:59 +0800 Subject: [PATCH 21/28] delete collection from catalog --- README.md | 6 + .../stac_fastapi/core/extensions/catalogs.py | 436 +++++++++++++----- .../core/stac_fastapi/core/serializers.py | 3 +- .../stac_fastapi/sfeos_helpers/mappings.py | 1 + stac_fastapi/tests/api/test_api.py | 1 + .../tests/extensions/test_catalogs.py | 341 +++++++++++++- 6 files changed, 655 insertions(+), 133 deletions(-) diff --git a/README.md b/README.md index 106b8d2d..efa063b6 100644 --- a/README.md +++ b/README.md @@ -252,6 +252,7 @@ This implementation follows the [STAC API Catalogs Extension](https://github.com - **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) - **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 @@ -292,6 +293,11 @@ 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 +curl -X DELETE "http://localhost:8081/catalogs/earth-observation/collections/sentinel-2" + # Delete a catalog (collections remain intact) curl -X DELETE "http://localhost:8081/catalogs/earth-observation" diff --git a/stac_fastapi/core/stac_fastapi/core/extensions/catalogs.py b/stac_fastapi/core/stac_fastapi/core/extensions/catalogs.py index 872fc54d..567bde70 100644 --- a/stac_fastapi/core/stac_fastapi/core/extensions/catalogs.py +++ b/stac_fastapi/core/stac_fastapi/core/extensions/catalogs.py @@ -2,7 +2,7 @@ import logging from typing import List, Optional, Type -from urllib.parse import urlencode, urlparse +from urllib.parse import urlencode import attr from fastapi import APIRouter, FastAPI, HTTPException, Query, Request @@ -140,6 +140,18 @@ def register(self, app: FastAPI, settings=None) -> None: tags=["Catalogs"], ) + # Add endpoint for deleting a collection from a catalog + self.router.add_api_route( + path="/catalogs/{catalog_id}/collections/{collection_id}", + endpoint=self.delete_catalog_collection, + methods=["DELETE"], + response_class=self.response_class, + status_code=204, + summary="Delete Catalog Collection", + 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.", + tags=["Catalogs"], + ) + # Add endpoint for getting items in a collection within a catalog self.router.add_api_route( path="/catalogs/{catalog_id}/collections/{collection_id}/items", @@ -434,91 +446,30 @@ async def get_catalog_collections( Collections object containing collections linked from the catalog. """ try: - # Get the catalog from the database - db_catalog = await self.client.database.find_catalog(catalog_id) + # Verify the catalog exists + await self.client.database.find_catalog(catalog_id) - # Convert to STAC format to access links - catalog = self.client.catalog_serializer.db_to_stac(db_catalog, request) + # Query collections by parent_ids field using Elasticsearch directly + # This uses the parent_ids field in the collection mapping to find all + # collections that have this catalog as a parent + query_body = {"query": {"term": {"parent_ids": catalog_id}}} - # Extract collection IDs from catalog links - # - # FRAGILE IMPLEMENTATION WARNING: - # This approach relies on parsing URL patterns to determine catalog-collection relationships. - # This is fragile and will break if: - # - URLs don't follow the expected /collections/{id} pattern - # - Base URLs contain /collections/ in other segments - # - Relative links are used instead of absolute URLs - # - Links have trailing slashes or query parameters - # - # TODO: In a future version, this should be replaced with a proper database relationship - # (e.g., parent_catalog_id field on Collection documents) - # - collection_ids = [] - if hasattr(catalog, "links") and catalog.links: - base_url = str(request.base_url).rstrip("/") - base_path = urlparse(base_url).path.rstrip("/") + # Execute the search to get collection IDs + try: + search_result = await self.client.database.client.search( + index=COLLECTIONS_INDEX, body=query_body + ) + except Exception as e: + logger.error( + f"Error searching for collections with parent {catalog_id}: {e}" + ) + search_result = {"hits": {"hits": []}} - for link in catalog.links: - rel = ( - link.get("rel") - if hasattr(link, "get") - else getattr(link, "rel", None) - ) - if rel in ["child", "item"]: - # Extract collection ID from href using proper URL parsing - href = ( - link.get("href", "") - if hasattr(link, "get") - else getattr(link, "href", "") - ) - if href: - try: - parsed_url = urlparse(href) - path = parsed_url.path.rstrip("/") - - # Resolve relative URLs against base URL - if not href.startswith(("http://", "https://")): - full_path = ( - f"{base_path}{path}" if path else base_path - ) - else: - # For absolute URLs, ensure they belong to our base domain - if parsed_url.netloc != urlparse(base_url).netloc: - continue - full_path = path - - # Look for collections endpoint at the end of the path - # This prevents false positives when /collections/ appears in base URL - collections_pattern = "/collections/" - if collections_pattern in full_path: - # Find the LAST occurrence of /collections/ to avoid base URL conflicts - last_collections_pos = full_path.rfind( - collections_pattern - ) - if last_collections_pos != -1: - # Extract everything after the last /collections/ - after_collections = full_path[ - last_collections_pos - + len(collections_pattern) : - ] - - # Handle cases where there might be additional path segments - # We only want the immediate collection ID - collection_id = ( - after_collections.split("/")[0] - if after_collections - else None - ) - - if ( - collection_id - and collection_id not in collection_ids - ): - collection_ids.append(collection_id) - - except Exception: - # If URL parsing fails, skip this link - continue + # Extract collection IDs from search results + collection_ids = [] + hits = search_result.get("hits", {}).get("hits", []) + for hit in hits: + collection_ids.append(hit.get("_id")) # Fetch the collections collections = [] @@ -591,56 +542,115 @@ async def create_catalog_collection( # Verify the catalog exists await self.client.database.find_catalog(catalog_id) - # Create the collection using the same pattern as TransactionsClient.create_collection - # This handles the Collection model from stac_pydantic correctly - collection_dict = collection.model_dump(mode="json") + # Check if the collection already exists in the database + try: + existing_collection_db = await self.client.database.find_collection( + collection.id + ) + # Collection exists, just add the parent ID if not already present + existing_collection_dict = existing_collection_db + + # Ensure parent_ids field exists + if "parent_ids" not in existing_collection_dict: + existing_collection_dict["parent_ids"] = [] + + # Add catalog_id to parent_ids if not already present + if catalog_id not in existing_collection_dict["parent_ids"]: + existing_collection_dict["parent_ids"].append(catalog_id) + + # Update the collection in the database + await self.client.database.update_collection( + collection_id=collection.id, + collection=existing_collection_dict, + refresh=True, + ) - # Add a link from the collection back to its parent catalog BEFORE saving to database - base_url = str(request.base_url) - catalog_link = { - "rel": "catalog", - "type": "application/json", - "href": f"{base_url}catalogs/{catalog_id}", - "title": catalog_id, - } + # Convert back to STAC format for the response + updated_collection = ( + self.client.database.collection_serializer.db_to_stac( + existing_collection_dict, + request, + extensions=[ + type(ext).__name__ + for ext in self.client.database.extensions + ], + ) + ) - # Add the catalog link to the collection dict - if "links" not in collection_dict: - collection_dict["links"] = [] + # Update the catalog to include a link to the collection + await self._add_collection_to_catalog_links( + catalog_id, collection.id, request + ) - # Check if the catalog link already exists - catalog_href = catalog_link["href"] - link_exists = any( - link.get("href") == catalog_href and link.get("rel") == "catalog" - for link in collection_dict.get("links", []) - ) + return updated_collection - if not link_exists: - collection_dict["links"].append(catalog_link) + except Exception as e: + # Only proceed to create if collection truly doesn't exist + error_msg = str(e) + if "not found" not in error_msg.lower(): + # Re-raise if it's a different error + raise + # Collection doesn't exist, create it + # Create the collection using the same pattern as TransactionsClient.create_collection + # This handles the Collection model from stac_pydantic correctly + collection_dict = collection.model_dump(mode="json") + + # Add the catalog ID to the parent_ids field + if "parent_ids" not in collection_dict: + collection_dict["parent_ids"] = [] + + if catalog_id not in collection_dict["parent_ids"]: + collection_dict["parent_ids"].append(catalog_id) + + # Add a link from the collection back to its parent catalog BEFORE saving to database + base_url = str(request.base_url) + catalog_link = { + "rel": "catalog", + "type": "application/json", + "href": f"{base_url}catalogs/{catalog_id}", + "title": catalog_id, + } + + # Add the catalog link to the collection dict + if "links" not in collection_dict: + collection_dict["links"] = [] + + # Check if the catalog link already exists + catalog_href = catalog_link["href"] + link_exists = any( + link.get("href") == catalog_href and link.get("rel") == "catalog" + for link in collection_dict.get("links", []) + ) - # Now convert to database format (this will process the links) - collection_db = self.client.database.collection_serializer.stac_to_db( - collection_dict, request - ) - await self.client.database.create_collection( - collection=collection_db, refresh=True - ) + if not link_exists: + collection_dict["links"].append(catalog_link) - # Convert back to STAC format for the response - created_collection = self.client.database.collection_serializer.db_to_stac( - collection_db, - request, - extensions=[ - type(ext).__name__ for ext in self.client.database.extensions - ], - ) + # Now convert to database format (this will process the links) + collection_db = self.client.database.collection_serializer.stac_to_db( + collection_dict, request + ) + await self.client.database.create_collection( + collection=collection_db, refresh=True + ) - # Update the catalog to include a link to the new collection - await self._add_collection_to_catalog_links( - catalog_id, collection.id, request - ) + # Convert back to STAC format for the response + created_collection = ( + self.client.database.collection_serializer.db_to_stac( + collection_db, + request, + extensions=[ + type(ext).__name__ + for ext in self.client.database.extensions + ], + ) + ) - return created_collection + # Update the catalog to include a link to the new collection + await self._add_collection_to_catalog_links( + catalog_id, collection.id, request + ) + + return created_collection except HTTPException as e: # Re-raise HTTP exceptions (e.g., catalog not found, collection validation errors) @@ -765,7 +775,25 @@ async def get_catalog_collection( status_code=404, detail=f"Catalog {catalog_id} not found" ) - # Delegate to the core client's get_collection method + # Verify the collection exists and has the catalog as a parent + try: + collection_db = await self.client.database.find_collection(collection_id) + + # Check if the catalog_id is in the collection's parent_ids + parent_ids = collection_db.get("parent_ids", []) + if catalog_id not in parent_ids: + raise HTTPException( + status_code=404, + detail=f"Collection {collection_id} does not belong to catalog {catalog_id}", + ) + except HTTPException: + raise + except Exception: + raise HTTPException( + status_code=404, detail=f"Collection {collection_id} not found" + ) + + # Return the collection return await self.client.get_collection( collection_id=collection_id, request=request ) @@ -853,3 +881,159 @@ async def get_catalog_collection_item( return await self.client.get_item( item_id=item_id, collection_id=collection_id, request=request ) + + async def delete_catalog_collection( + self, catalog_id: str, collection_id: str, request: Request + ) -> None: + """Delete a collection from a catalog. + + 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. + + Args: + catalog_id: The ID of the catalog. + collection_id: The ID of the collection. + request: Request object. + + Raises: + HTTPException: If the catalog or collection is not found, or if the + collection does not belong to the catalog. + """ + try: + # Verify the catalog exists + await self.client.database.find_catalog(catalog_id) + + # Get the collection + collection_db = await self.client.database.find_collection(collection_id) + + # Check if the catalog_id is in the collection's parent_ids + parent_ids = collection_db.get("parent_ids", []) + if catalog_id not in parent_ids: + raise HTTPException( + status_code=404, + 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 + + # Update the collection in the database + await self.client.database.update_collection( + collection_id=collection_id, collection=collection_db, refresh=True + ) + + logger.info( + f"Removed catalog {catalog_id} from collection {collection_id} parent_ids" + ) + 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})" + ) + + # Remove the collection link from the catalog + await self._remove_collection_from_catalog_links( + catalog_id, collection_id, request + ) + + except HTTPException: + raise + except Exception as e: + logger.error( + f"Error deleting 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)}", + ) + + async def _remove_collection_from_catalog_links( + self, catalog_id: str, collection_id: str, request: Request + ) -> None: + """Remove a collection link from a catalog. + + This helper method updates a catalog's links to remove a reference + to a collection by reindexing the updated catalog document. + + Args: + catalog_id: The ID of the catalog to update. + collection_id: The ID of the collection to unlink. + request: Request object for base URL construction. + """ + try: + # Get the current catalog + db_catalog = await self.client.database.find_catalog(catalog_id) + catalog = self.client.catalog_serializer.db_to_stac(db_catalog, request) + + # Get the catalog links + catalog_links = ( + catalog.get("links") + if isinstance(catalog, dict) + else getattr(catalog, "links", None) + ) + + if not catalog_links: + return + + # Find and remove the collection link + collection_href = ( + f"{str(request.base_url).rstrip('/')}/collections/{collection_id}" + ) + links_to_keep = [] + link_removed = False + + for link in catalog_links: + link_href = ( + link.get("href") + if hasattr(link, "get") + else getattr(link, "href", None) + ) + if link_href == collection_href and not link_removed: + # Skip this link (remove it) + link_removed = True + else: + links_to_keep.append(link) + + if link_removed: + # Update the catalog with the modified links + if isinstance(catalog, dict): + catalog["links"] = links_to_keep + else: + catalog.links = links_to_keep + + # Convert back to database format and update + updated_db_catalog = self.client.catalog_serializer.stac_to_db( + catalog, request + ) + updated_db_catalog_dict = ( + updated_db_catalog.model_dump() + if hasattr(updated_db_catalog, "model_dump") + else updated_db_catalog + ) + updated_db_catalog_dict["type"] = "Catalog" + + # Update the document + await self.client.database.client.index( + index=COLLECTIONS_INDEX, + id=catalog_id, + body=updated_db_catalog_dict, + refresh=True, + ) + + logger.info( + f"Removed collection {collection_id} link from catalog {catalog_id}" + ) + + except Exception as e: + logger.error( + f"Failed to remove collection link from catalog {catalog_id}: {e}", + exc_info=True, + ) + # Don't fail the entire operation if link removal fails diff --git a/stac_fastapi/core/stac_fastapi/core/serializers.py b/stac_fastapi/core/stac_fastapi/core/serializers.py index f935bff5..6cae58af 100644 --- a/stac_fastapi/core/stac_fastapi/core/serializers.py +++ b/stac_fastapi/core/stac_fastapi/core/serializers.py @@ -181,8 +181,9 @@ def db_to_stac( # Avoid modifying the input dict in-place ... doing so breaks some tests collection = deepcopy(collection) - # Remove internal bbox_shape field (not part of STAC spec) + # Remove internal fields (not part of STAC spec) collection.pop("bbox_shape", None) + collection.pop("parent_ids", None) # Set defaults collection_id = collection.get("id") diff --git a/stac_fastapi/sfeos_helpers/stac_fastapi/sfeos_helpers/mappings.py b/stac_fastapi/sfeos_helpers/stac_fastapi/sfeos_helpers/mappings.py index 129194da..8cad42b5 100644 --- a/stac_fastapi/sfeos_helpers/stac_fastapi/sfeos_helpers/mappings.py +++ b/stac_fastapi/sfeos_helpers/stac_fastapi/sfeos_helpers/mappings.py @@ -160,6 +160,7 @@ class Geometry(Protocol): # noqa "dynamic_templates": ES_MAPPINGS_DYNAMIC_TEMPLATES, "properties": { "id": {"type": "keyword"}, + "parent_ids": {"type": "keyword"}, "bbox_shape": {"type": "geo_shape"}, "extent.temporal.interval": { "type": "date", diff --git a/stac_fastapi/tests/api/test_api.py b/stac_fastapi/tests/api/test_api.py index 76830a06..fe10bfaa 100644 --- a/stac_fastapi/tests/api/test_api.py +++ b/stac_fastapi/tests/api/test_api.py @@ -57,6 +57,7 @@ "GET /catalogs/{catalog_id}/collections", "POST /catalogs/{catalog_id}/collections", "GET /catalogs/{catalog_id}/collections/{collection_id}", + "DELETE /catalogs/{catalog_id}/collections/{collection_id}", "GET /catalogs/{catalog_id}/collections/{collection_id}/items", "GET /catalogs/{catalog_id}/collections/{collection_id}/items/{item_id}", "", diff --git a/stac_fastapi/tests/extensions/test_catalogs.py b/stac_fastapi/tests/extensions/test_catalogs.py index 7c9960e7..51d53187 100644 --- a/stac_fastapi/tests/extensions/test_catalogs.py +++ b/stac_fastapi/tests/extensions/test_catalogs.py @@ -67,18 +67,19 @@ async def test_get_nonexistent_catalog(catalogs_app_client): @pytest.mark.asyncio async def test_get_catalog_collections(catalogs_app_client, load_test_data, ctx): """Test getting collections linked from a catalog.""" - # First create a catalog with a link to the test collection + # First create a catalog test_catalog = load_test_data("test_catalog.json") test_catalog["id"] = f"test-catalog-{uuid.uuid4()}" - # Update the catalog links to point to the actual test collection - for link in test_catalog["links"]: - if link["rel"] == "child": - link["href"] = f"http://test-server/collections/{ctx.collection['id']}" - create_resp = await catalogs_app_client.post("/catalogs", json=test_catalog) assert create_resp.status_code == 201 + # Add the existing collection to the catalog + add_resp = await catalogs_app_client.post( + f"/catalogs/{test_catalog['id']}/collections", json=ctx.collection + ) + assert add_resp.status_code == 201 + # Now get collections from the catalog resp = await catalogs_app_client.get(f"/catalogs/{test_catalog['id']}/collections") assert resp.status_code == 200 @@ -136,6 +137,12 @@ async def test_get_catalog_collection(catalogs_app_client, load_test_data, ctx): create_resp = await catalogs_app_client.post("/catalogs", json=test_catalog) assert create_resp.status_code == 201 + # Add the existing collection to the catalog + add_resp = await catalogs_app_client.post( + f"/catalogs/{test_catalog['id']}/collections", json=ctx.collection + ) + assert add_resp.status_code == 201 + # Get a specific collection through the catalog route resp = await catalogs_app_client.get( f"/catalogs/{test_catalog['id']}/collections/{ctx.collection['id']}" @@ -596,3 +603,325 @@ async def test_delete_catalog_no_cascade(catalogs_app_client, load_test_data): assert ( catalog_link is None ), "Collection should not have catalog link after catalog deletion" + + +@pytest.mark.asyncio +async def test_create_catalog_collection_adds_parent_id( + catalogs_app_client, load_test_data +): + """Test that creating a collection in a catalog adds the catalog to parent_ids.""" + # Create a catalog + test_catalog = load_test_data("test_catalog.json") + catalog_id = f"test-catalog-{uuid.uuid4()}" + test_catalog["id"] = catalog_id + + catalog_resp = await catalogs_app_client.post("/catalogs", json=test_catalog) + assert catalog_resp.status_code == 201 + + # Create a new collection through the catalog endpoint + test_collection = load_test_data("test_collection.json") + collection_id = f"test-collection-{uuid.uuid4()}" + test_collection["id"] = collection_id + + create_resp = await catalogs_app_client.post( + f"/catalogs/{catalog_id}/collections", json=test_collection + ) + assert create_resp.status_code == 201 + + created_collection = create_resp.json() + assert created_collection["id"] == collection_id + + # Verify the collection has the catalog in parent_ids by getting it directly + get_resp = await catalogs_app_client.get(f"/collections/{collection_id}") + assert get_resp.status_code == 200 + + collection_data = get_resp.json() + # parent_ids should be in the collection data (from database) + # We can verify it exists by checking the catalog link + catalog_link = None + for link in collection_data.get("links", []): + if link.get("rel") == "catalog" and catalog_id in link.get("href", ""): + catalog_link = link + break + + assert catalog_link is not None, "Collection should have catalog link" + + +@pytest.mark.asyncio +async def test_add_existing_collection_to_catalog( + catalogs_app_client, load_test_data, ctx +): + """Test adding an existing collection to a catalog adds parent_id.""" + # Create a catalog + test_catalog = load_test_data("test_catalog.json") + catalog_id = f"test-catalog-{uuid.uuid4()}" + test_catalog["id"] = catalog_id + + catalog_resp = await catalogs_app_client.post("/catalogs", json=test_catalog) + assert catalog_resp.status_code == 201 + + # Add existing collection to the catalog + add_resp = await catalogs_app_client.post( + f"/catalogs/{catalog_id}/collections", json=ctx.collection + ) + assert add_resp.status_code == 201 + + # Verify we can get the collection through the catalog endpoint + get_resp = await catalogs_app_client.get( + f"/catalogs/{catalog_id}/collections/{ctx.collection['id']}" + ) + assert get_resp.status_code == 200 + + +@pytest.mark.asyncio +async def test_collection_with_multiple_parent_catalogs( + catalogs_app_client, load_test_data +): + """Test that a collection can have multiple parent catalogs.""" + # Create two catalogs + catalog_ids = [] + for i in range(2): + test_catalog = load_test_data("test_catalog.json") + catalog_id = f"test-catalog-{uuid.uuid4()}-{i}" + test_catalog["id"] = catalog_id + + catalog_resp = await catalogs_app_client.post("/catalogs", json=test_catalog) + assert catalog_resp.status_code == 201 + catalog_ids.append(catalog_id) + + # Create a collection in the first catalog + test_collection = load_test_data("test_collection.json") + collection_id = f"test-collection-{uuid.uuid4()}" + test_collection["id"] = collection_id + + create_resp = await catalogs_app_client.post( + f"/catalogs/{catalog_ids[0]}/collections", json=test_collection + ) + assert create_resp.status_code == 201 + + # Add the same collection to the second catalog + add_resp = await catalogs_app_client.post( + f"/catalogs/{catalog_ids[1]}/collections", json=test_collection + ) + assert add_resp.status_code == 201 + + # Verify we can get the collection from both catalogs + for catalog_id in catalog_ids: + get_resp = await catalogs_app_client.get( + f"/catalogs/{catalog_id}/collections/{collection_id}" + ) + assert get_resp.status_code == 200 + + +@pytest.mark.asyncio +async def test_get_catalog_collections_uses_parent_ids( + catalogs_app_client, load_test_data +): + """Test that get_catalog_collections queries by parent_ids.""" + # Create a catalog + test_catalog = load_test_data("test_catalog.json") + catalog_id = f"test-catalog-{uuid.uuid4()}" + test_catalog["id"] = catalog_id + + catalog_resp = await catalogs_app_client.post("/catalogs", json=test_catalog) + assert catalog_resp.status_code == 201 + + # Create multiple collections in the catalog + collection_ids = [] + for i in range(3): + test_collection = load_test_data("test_collection.json") + collection_id = f"test-collection-{uuid.uuid4()}-{i}" + test_collection["id"] = collection_id + + create_resp = await catalogs_app_client.post( + f"/catalogs/{catalog_id}/collections", json=test_collection + ) + assert create_resp.status_code == 201 + collection_ids.append(collection_id) + + # Get all collections from the catalog + get_resp = await catalogs_app_client.get(f"/catalogs/{catalog_id}/collections") + assert get_resp.status_code == 200 + + collections_response = get_resp.json() + returned_ids = [col["id"] for col in collections_response["collections"]] + + # All created collections should be returned + for collection_id in collection_ids: + assert collection_id in returned_ids + + +@pytest.mark.asyncio +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.""" + # Create a catalog + test_catalog = load_test_data("test_catalog.json") + catalog_id = f"test-catalog-{uuid.uuid4()}" + test_catalog["id"] = catalog_id + + catalog_resp = await catalogs_app_client.post("/catalogs", json=test_catalog) + assert catalog_resp.status_code == 201 + + # Create a collection in the catalog + test_collection = load_test_data("test_collection.json") + collection_id = f"test-collection-{uuid.uuid4()}" + test_collection["id"] = collection_id + + create_resp = await catalogs_app_client.post( + f"/catalogs/{catalog_id}/collections", json=test_collection + ) + assert create_resp.status_code == 201 + + # Delete the collection from the catalog + delete_resp = await catalogs_app_client.delete( + f"/catalogs/{catalog_id}/collections/{collection_id}" + ) + assert delete_resp.status_code == 204 + + # Verify the collection is completely deleted + get_resp = await catalogs_app_client.get(f"/collections/{collection_id}") + assert get_resp.status_code == 404 + + +@pytest.mark.asyncio +async def test_delete_collection_from_catalog_multiple_parents( + catalogs_app_client, load_test_data +): + """Test deleting a collection from a catalog when it has multiple parents.""" + # Create two catalogs + catalog_ids = [] + for i in range(2): + test_catalog = load_test_data("test_catalog.json") + catalog_id = f"test-catalog-{uuid.uuid4()}-{i}" + test_catalog["id"] = catalog_id + + catalog_resp = await catalogs_app_client.post("/catalogs", json=test_catalog) + assert catalog_resp.status_code == 201 + catalog_ids.append(catalog_id) + + # Create a collection in the first catalog + test_collection = load_test_data("test_collection.json") + collection_id = f"test-collection-{uuid.uuid4()}" + test_collection["id"] = collection_id + + create_resp = await catalogs_app_client.post( + f"/catalogs/{catalog_ids[0]}/collections", json=test_collection + ) + assert create_resp.status_code == 201 + + # Add the collection to the second catalog + add_resp = await catalogs_app_client.post( + f"/catalogs/{catalog_ids[1]}/collections", json=test_collection + ) + assert add_resp.status_code == 201 + + # Delete the collection from the first catalog + delete_resp = await catalogs_app_client.delete( + f"/catalogs/{catalog_ids[0]}/collections/{collection_id}" + ) + assert delete_resp.status_code == 204 + + # Verify the collection still exists + get_resp = await catalogs_app_client.get(f"/collections/{collection_id}") + assert get_resp.status_code == 200 + + # Verify we can still get it from the second catalog + get_from_catalog_resp = await catalogs_app_client.get( + f"/catalogs/{catalog_ids[1]}/collections/{collection_id}" + ) + assert get_from_catalog_resp.status_code == 200 + + # Verify we cannot get it from the first catalog anymore + get_from_deleted_catalog_resp = await catalogs_app_client.get( + f"/catalogs/{catalog_ids[0]}/collections/{collection_id}" + ) + assert get_from_deleted_catalog_resp.status_code == 404 + + +@pytest.mark.asyncio +async def test_get_collection_not_in_catalog_returns_404( + catalogs_app_client, load_test_data, ctx +): + """Test that getting a collection from a catalog it doesn't belong to returns 404.""" + # Create a catalog + test_catalog = load_test_data("test_catalog.json") + catalog_id = f"test-catalog-{uuid.uuid4()}" + test_catalog["id"] = catalog_id + + catalog_resp = await catalogs_app_client.post("/catalogs", json=test_catalog) + assert catalog_resp.status_code == 201 + + # Try to get a collection that's not in this catalog + get_resp = await catalogs_app_client.get( + f"/catalogs/{catalog_id}/collections/{ctx.collection['id']}" + ) + assert get_resp.status_code == 404 + + +@pytest.mark.asyncio +async def test_delete_collection_not_in_catalog_returns_404( + catalogs_app_client, load_test_data, ctx +): + """Test that deleting a collection from a catalog it doesn't belong to returns 404.""" + # Create a catalog + test_catalog = load_test_data("test_catalog.json") + catalog_id = f"test-catalog-{uuid.uuid4()}" + test_catalog["id"] = catalog_id + + catalog_resp = await catalogs_app_client.post("/catalogs", json=test_catalog) + assert catalog_resp.status_code == 201 + + # Try to delete a collection that's not in this catalog + delete_resp = await catalogs_app_client.delete( + f"/catalogs/{catalog_id}/collections/{ctx.collection['id']}" + ) + assert delete_resp.status_code == 404 + + +@pytest.mark.asyncio +async def test_parent_ids_not_exposed_to_client(catalogs_app_client, load_test_data): + """Test that parent_ids field is not exposed in API responses.""" + # Create a catalog + test_catalog = load_test_data("test_catalog.json") + catalog_id = f"test-catalog-{uuid.uuid4()}" + test_catalog["id"] = catalog_id + + catalog_resp = await catalogs_app_client.post("/catalogs", json=test_catalog) + assert catalog_resp.status_code == 201 + + # Create a collection in the catalog + test_collection = load_test_data("test_collection.json") + collection_id = f"test-collection-{uuid.uuid4()}" + test_collection["id"] = collection_id + + create_resp = await catalogs_app_client.post( + f"/catalogs/{catalog_id}/collections", json=test_collection + ) + assert create_resp.status_code == 201 + + # Verify parent_ids is not in the creation response + created_collection = create_resp.json() + assert ( + "parent_ids" not in created_collection + ), "parent_ids should not be exposed in API response" + + # Verify parent_ids is not in the get response + get_resp = await catalogs_app_client.get(f"/collections/{collection_id}") + assert get_resp.status_code == 200 + collection_data = get_resp.json() + assert ( + "parent_ids" not in collection_data + ), "parent_ids should not be exposed in API response" + + # Verify parent_ids is not in the catalog collection endpoint response + catalog_collections_resp = await catalogs_app_client.get( + f"/catalogs/{catalog_id}/collections" + ) + assert catalog_collections_resp.status_code == 200 + collections_data = catalog_collections_resp.json() + for collection in collections_data.get("collections", []): + assert ( + "parent_ids" not in collection + ), "parent_ids should not be exposed in API response" From cfdcfc1dd346400634960d565cbecd1b74077108 Mon Sep 17 00:00:00 2001 From: jonhealy1 Date: Tue, 9 Dec 2025 14:45:39 +0800 Subject: [PATCH 22/28] ensure parent ids are removed --- .../stac_fastapi/core/extensions/catalogs.py | 76 ++++++------ .../tests/extensions/test_catalogs.py | 112 ++++++++++++++++++ 2 files changed, 146 insertions(+), 42 deletions(-) diff --git a/stac_fastapi/core/stac_fastapi/core/extensions/catalogs.py b/stac_fastapi/core/stac_fastapi/core/extensions/catalogs.py index 567bde70..0e0c13e6 100644 --- a/stac_fastapi/core/stac_fastapi/core/extensions/catalogs.py +++ b/stac_fastapi/core/stac_fastapi/core/extensions/catalogs.py @@ -360,52 +360,44 @@ async def delete_catalog( f"Failed to delete collection {coll_id}: {e}" ) else: - # Remove catalog link from each collection (orphan them) + # Remove catalog from each collection's parent_ids and links (orphan them) for coll_id in collection_ids: try: - collection = await self.client.get_collection( - coll_id, request=request + # Get the collection from database to access parent_ids + collection_db = await self.client.database.find_collection( + coll_id ) - # Remove the catalog link from the collection - if hasattr(collection, "links"): - collection.links = [ - link - for link in collection.links - if not ( - getattr(link, "rel", None) == "catalog" - and catalog_id in getattr(link, "href", "") - ) - ] - elif isinstance(collection, dict): - collection["links"] = [ - link - for link in collection.get("links", []) - if not ( - link.get("rel") == "catalog" - and catalog_id in link.get("href", "") - ) - ] - # Update the collection in the database - collection_dict = ( - collection.model_dump(mode="json") - if hasattr(collection, "model_dump") - else collection - ) - collection_db = ( - self.client.database.collection_serializer.stac_to_db( - collection_dict, request + # Remove catalog_id from parent_ids + parent_ids = collection_db.get("parent_ids", []) + if catalog_id in parent_ids: + parent_ids.remove(catalog_id) + collection_db["parent_ids"] = parent_ids + + # Also remove the catalog link from the collection's links + if "links" in collection_db: + collection_db["links"] = [ + link + for link in collection_db.get("links", []) + if not ( + link.get("rel") == "catalog" + and catalog_id in link.get("href", "") + ) + ] + + # Update the collection in the database + await self.client.database.update_collection( + collection_id=coll_id, + collection=collection_db, + refresh=True, + ) + logger.info( + f"Removed catalog {catalog_id} from collection {coll_id} parent_ids and links" + ) + else: + logger.debug( + f"Catalog {catalog_id} not in parent_ids for collection {coll_id}" ) - ) - await self.client.database.client.index( - index=COLLECTIONS_INDEX, - id=coll_id, - body=collection_db.model_dump() - if hasattr(collection_db, "model_dump") - else collection_db, - refresh=True, - ) - logger.info(f"Removed catalog link from collection {coll_id}") except Exception as e: error_msg = str(e) if "not found" in error_msg.lower(): @@ -414,7 +406,7 @@ async def delete_catalog( ) else: logger.warning( - f"Failed to remove catalog link from collection {coll_id}: {e}" + f"Failed to remove catalog {catalog_id} from collection {coll_id}: {e}" ) # Delete the catalog diff --git a/stac_fastapi/tests/extensions/test_catalogs.py b/stac_fastapi/tests/extensions/test_catalogs.py index 51d53187..62c46a71 100644 --- a/stac_fastapi/tests/extensions/test_catalogs.py +++ b/stac_fastapi/tests/extensions/test_catalogs.py @@ -605,6 +605,64 @@ async def test_delete_catalog_no_cascade(catalogs_app_client, load_test_data): ), "Collection should not have catalog link after catalog deletion" +@pytest.mark.asyncio +async def test_delete_catalog_removes_parent_ids_from_collections( + catalogs_app_client, load_test_data +): + """Test that deleting a catalog removes its ID from child collections' parent_ids.""" + # Create a catalog + test_catalog = load_test_data("test_catalog.json") + catalog_id = f"test-catalog-{uuid.uuid4()}" + test_catalog["id"] = catalog_id + test_catalog["links"] = [ + link for link in test_catalog.get("links", []) if link.get("rel") != "child" + ] + + catalog_resp = await catalogs_app_client.post("/catalogs", json=test_catalog) + assert catalog_resp.status_code == 201 + + # Create 3 collections in the catalog + collection_ids = [] + for i in range(3): + test_collection = load_test_data("test_collection.json") + collection_id = f"test-collection-{uuid.uuid4()}-{i}" + test_collection["id"] = collection_id + + create_resp = await catalogs_app_client.post( + f"/catalogs/{catalog_id}/collections", json=test_collection + ) + assert create_resp.status_code == 201 + collection_ids.append(collection_id) + + # Verify all collections have the catalog in their parent_ids + # (indirectly verified by checking they're accessible via the catalog endpoint) + get_collections_resp = await catalogs_app_client.get( + f"/catalogs/{catalog_id}/collections" + ) + assert get_collections_resp.status_code == 200 + collections_response = get_collections_resp.json() + returned_ids = [col["id"] for col in collections_response["collections"]] + for collection_id in collection_ids: + assert collection_id in returned_ids + + # Delete the catalog without cascade + delete_resp = await catalogs_app_client.delete(f"/catalogs/{catalog_id}") + assert delete_resp.status_code == 204 + + # Verify all collections still exist + for collection_id in collection_ids: + get_resp = await catalogs_app_client.get(f"/collections/{collection_id}") + assert get_resp.status_code == 200 + + # Verify collections are no longer accessible via the deleted catalog + # (This indirectly verifies parent_ids was updated) + for collection_id in collection_ids: + 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 async def test_create_catalog_collection_adds_parent_id( catalogs_app_client, load_test_data @@ -880,6 +938,60 @@ async def test_delete_collection_not_in_catalog_returns_404( assert delete_resp.status_code == 404 +@pytest.mark.asyncio +async def test_catalog_links_contain_all_collections( + catalogs_app_client, load_test_data +): + """Test that a catalog's links contain all 3 collections added to it.""" + # Create a catalog + test_catalog = load_test_data("test_catalog.json") + catalog_id = f"test-catalog-{uuid.uuid4()}" + test_catalog["id"] = catalog_id + # Remove any placeholder child links + test_catalog["links"] = [ + link for link in test_catalog.get("links", []) if link.get("rel") != "child" + ] + + catalog_resp = await catalogs_app_client.post("/catalogs", json=test_catalog) + assert catalog_resp.status_code == 201 + + # Create 3 collections in the catalog + collection_ids = [] + for i in range(3): + test_collection = load_test_data("test_collection.json") + collection_id = f"test-collection-{uuid.uuid4()}-{i}" + test_collection["id"] = collection_id + + create_resp = await catalogs_app_client.post( + f"/catalogs/{catalog_id}/collections", json=test_collection + ) + assert create_resp.status_code == 201 + collection_ids.append(collection_id) + + # Get the catalog and verify all 3 collections are in its links + catalog_get_resp = await catalogs_app_client.get(f"/catalogs/{catalog_id}") + assert catalog_get_resp.status_code == 200 + + catalog_data = catalog_get_resp.json() + catalog_links = catalog_data.get("links", []) + + # Extract all child links (collection links) + child_links = [link for link in catalog_links if link.get("rel") == "child"] + + # Verify we have exactly 3 child links + assert ( + len(child_links) == 3 + ), f"Catalog should have 3 child links, but has {len(child_links)}" + + # Verify each collection ID is in the child links + child_hrefs = [link.get("href", "") for link in child_links] + for collection_id in collection_ids: + collection_href = f"/collections/{collection_id}" + assert any( + collection_href in href for href in child_hrefs + ), f"Collection {collection_id} missing from catalog links. Found links: {child_hrefs}" + + @pytest.mark.asyncio async def test_parent_ids_not_exposed_to_client(catalogs_app_client, load_test_data): """Test that parent_ids field is not exposed in API responses.""" From 521b31129d4402eba4111d7e149da4c4a382867d Mon Sep 17 00:00:00 2001 From: jonhealy1 Date: Tue, 9 Dec 2025 14:54:24 +0800 Subject: [PATCH 23/28] update changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 679f1ba6..634ae9c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Added - Added catalogs route support to enable federated hierarchical catalog browsing and navigation in the STAC API. [#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) ### Changed From 5c72e9a47f113cc6de538d2fb962568c67d9e265 Mon Sep 17 00:00:00 2001 From: jonhealy1 Date: Tue, 9 Dec 2025 14:59:44 +0800 Subject: [PATCH 24/28] update latest news --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index efa063b6..add5a23b 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ The following organizations have contributed time and/or funding to support the ## Latest News +- **12/09/2025:** Catalogs extension merged to main! The `/catalogs` endpoint is now available (optional via `ENABLE_CATALOGS_EXTENSION` environment variable) to enable federated hierarchical catalog browsing and navigation. - **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). From 583837da40396967fc31db664c07e343b1a8847a Mon Sep 17 00:00:00 2001 From: jonhealy1 Date: Tue, 9 Dec 2025 15:37:03 +0800 Subject: [PATCH 25/28] changelog phrasing --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 634ae9c5..ebf5a6a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Added -- Added catalogs route support to enable federated hierarchical catalog browsing and navigation in the STAC API. [#547](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/pull/547) +- 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 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) From 3c88f64941e0f754d0c87e3cebb167209396838b Mon Sep 17 00:00:00 2001 From: jonhealy1 Date: Tue, 9 Dec 2025 18:54:09 +0800 Subject: [PATCH 26/28] update readme for collections w multi parents --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index add5a23b..fa85a124 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:** Catalogs extension merged to main! The `/catalogs` endpoint is now available (optional via `ENABLE_CATALOGS_EXTENSION` environment variable) to enable federated hierarchical catalog browsing and navigation. +- **12/09/2025:** Catalogs extension merged to main! The `/catalogs` endpoint is now available (optional via `ENABLE_CATALOGS_EXTENSION` environment variable) to enable federated hierarchical catalog browsing and navigation. Collections can belong to multiple catalogs for flexible hierarchical organization. - **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). @@ -240,6 +240,7 @@ This implementation follows the [STAC API Catalogs Extension](https://github.com ### Features - **Hierarchical Navigation**: Browse catalogs and sub-catalogs in a parent-child relationship structure +- **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 From e750b740baf74708017ffe215ba2f338941235fe Mon Sep 17 00:00:00 2001 From: jonhealy1 Date: Tue, 9 Dec 2025 19:52:46 +0800 Subject: [PATCH 27/28] update latest news --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index fa85a124..a7db4d8b 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:** Catalogs extension merged to main! The `/catalogs` endpoint is now available (optional via `ENABLE_CATALOGS_EXTENSION` environment variable) to enable federated hierarchical catalog browsing and navigation. Collections can belong to multiple catalogs for flexible hierarchical organization. +- **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._ - **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). From 144ee74b9d74348c5c7b7c1111e9fc0b6b252396 Mon Sep 17 00:00:00 2001 From: jonhealy1 Date: Tue, 9 Dec 2025 20:03:23 +0800 Subject: [PATCH 28/28] update stac-fastapi version for readme button --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a7db4d8b..53998be3 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ [![GitHub forks](https://img.shields.io/github/forks/stac-utils/stac-fastapi-elasticsearch-opensearch.svg?color=blue)](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/network/members) [![PyPI version](https://img.shields.io/pypi/v/stac-fastapi-elasticsearch.svg?color=blue)](https://pypi.org/project/stac-fastapi-elasticsearch/) [![STAC](https://img.shields.io/badge/STAC-1.1.0-blue.svg)](https://github.com/radiantearth/stac-spec/tree/v1.1.0) - [![stac-fastapi](https://img.shields.io/badge/stac--fastapi-6.0.0-blue.svg)](https://github.com/stac-utils/stac-fastapi) + [![stac-fastapi](https://img.shields.io/badge/stac--fastapi-6.1.1-blue.svg)](https://github.com/stac-utils/stac-fastapi) ## Sponsors & Supporters