From 635a9c6f6d96bf92b359bca19139987c0b4cc600 Mon Sep 17 00:00:00 2001 From: jonhealy1 Date: Sat, 7 Feb 2026 20:06:15 +0800 Subject: [PATCH 01/40] catalogs extension scratch --- .../stac_fastapi/extensions/core/__init__.py | 2 + .../core/multi_tenant_catalogs/__init__.py | 17 + .../core/multi_tenant_catalogs/catalogs.py | 248 ++++++++ .../core/multi_tenant_catalogs/client.py | 543 ++++++++++++++++++ .../core/multi_tenant_catalogs/types.py | 31 + 5 files changed, 841 insertions(+) create mode 100644 stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/__init__.py create mode 100644 stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/catalogs.py create mode 100644 stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/client.py create mode 100644 stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/types.py diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/__init__.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/__init__.py index d6b5f7589..031408b37 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/__init__.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/__init__.py @@ -1,6 +1,7 @@ """stac_api.extensions.core module.""" from .aggregation import AggregationExtension +from .multi_tenant_catalogs import CatalogsExtension from .collection_search import CollectionSearchExtension, CollectionSearchPostExtension from .fields import FieldsExtension from .filter import ( @@ -21,6 +22,7 @@ __all__ = ( "AggregationExtension", + "CatalogsExtension", "FieldsExtension", "FilterExtension", "FreeTextExtension", diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/__init__.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/__init__.py new file mode 100644 index 000000000..8151e2e18 --- /dev/null +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/__init__.py @@ -0,0 +1,17 @@ +"""Catalogs extension module.""" + +from stac_pydantic.api.collections import Collections + +from .catalogs import CATALOGS_CONFORMANCE_CLASSES, CatalogsExtension +from .client import AsyncBaseCatalogsClient, BaseCatalogsClient +from .types import Catalogs, Children + +__all__ = [ + "CatalogsExtension", + "AsyncBaseCatalogsClient", + "BaseCatalogsClient", + "Catalogs", + "Collections", + "Children", + "CATALOGS_CONFORMANCE_CLASSES", +] diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/catalogs.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/catalogs.py new file mode 100644 index 000000000..f50d689ba --- /dev/null +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/catalogs.py @@ -0,0 +1,248 @@ +"""Catalogs extension.""" + +from typing import List, Optional, Type + +import attr +from fastapi import APIRouter, FastAPI, Query +from fastapi.responses import JSONResponse +from starlette.responses import Response + +from stac_pydantic.catalog import Catalog +from stac_pydantic.collection import Collection +from stac_pydantic.item import Item +from stac_pydantic.item_collection import ItemCollection +from stac_fastapi.types.extension import ApiExtension + +from .client import AsyncBaseCatalogsClient +from .types import Catalogs, Children, Collections + +CATALOGS_CONFORMANCE_CLASSES = [ + "https://api.stacspec.org/v1.0.0/core", + "https://api.stacspec.org/v1.0.0-beta.1/multi-tenant-catalogs", + "https://api.stacspec.org/v1.0.0-rc.2/children", + "https://api.stacspec.org/v1.0.0-rc.2/children#type-filter", +] + + +@attr.s +class CatalogsExtension(ApiExtension): + """Catalogs Extension. + + 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. + + Attributes: + client: A client implementing the catalogs extension pattern. + settings: Extension settings. + conformance_classes: List of conformance classes for this extension. + router: FastAPI router for the extension endpoints. + response_class: Response class for the extension. + """ + + client: AsyncBaseCatalogsClient = attr.ib(default=None) + settings: dict = attr.ib(default=attr.Factory(dict)) + conformance_classes: List[str] = attr.ib( + default=attr.Factory(lambda: CATALOGS_CONFORMANCE_CLASSES) + ) + router: APIRouter = attr.ib(default=attr.Factory(APIRouter)) + response_class: Type[Response] = attr.ib(default=JSONResponse) + + def register(self, app: FastAPI, settings=None) -> None: + """Register the extension with a FastAPI application. + + Args: + app: target FastAPI application. + settings: extension settings. + """ + self.settings = settings or {} + self.router = APIRouter() + + self.router.add_api_route( + path="/catalogs", + endpoint=self.client.get_catalogs, + methods=["GET"], + response_model=Catalogs, + response_class=self.response_class, + summary="Get All Catalogs", + description="Returns a list of all catalogs in the database.", + tags=["Catalogs"], + ) + + self.router.add_api_route( + path="/catalogs", + endpoint=self.client.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"], + ) + + self.router.add_api_route( + path="/catalogs/{catalog_id}", + endpoint=self.client.get_catalog, + methods=["GET"], + response_model=Catalog, + response_class=self.response_class, + summary="Get Catalog", + description="Get a specific STAC catalog by ID.", + tags=["Catalogs"], + ) + + self.router.add_api_route( + path="/catalogs/{catalog_id}", + endpoint=self.client.update_catalog, + methods=["PUT"], + response_model=Catalog, + response_class=self.response_class, + summary="Update Catalog", + description="Update an existing STAC catalog.", + tags=["Catalogs"], + ) + + self.router.add_api_route( + path="/catalogs/{catalog_id}", + endpoint=self.client.delete_catalog, + methods=["DELETE"], + response_class=self.response_class, + status_code=204, + summary="Delete Catalog", + description="Delete a catalog.", + tags=["Catalogs"], + ) + + self.router.add_api_route( + path="/catalogs/{catalog_id}/collections", + endpoint=self.client.get_catalog_collections, + methods=["GET"], + response_model=Collections, + response_class=self.response_class, + summary="Get Catalog Collections", + description="Get collections linked from a specific catalog.", + tags=["Catalogs"], + ) + + self.router.add_api_route( + path="/catalogs/{catalog_id}/collections", + endpoint=self.client.create_catalog_collection, + methods=["POST"], + response_model=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"], + ) + + self.router.add_api_route( + path="/catalogs/{catalog_id}/collections/{collection_id}", + endpoint=self.client.get_catalog_collection, + methods=["GET"], + response_model=Collection, + response_class=self.response_class, + summary="Get Catalog Collection", + description="Get a specific collection from a catalog.", + tags=["Catalogs"], + ) + + self.router.add_api_route( + path="/catalogs/{catalog_id}/collections/{collection_id}", + endpoint=self.client.unlink_catalog_collection, + methods=["DELETE"], + response_class=self.response_class, + status_code=204, + summary="Unlink Collection from Catalog", + description="Removes the link between the catalog and collection. The Collection data is NOT deleted.", + tags=["Catalogs"], + ) + + self.router.add_api_route( + path="/catalogs/{catalog_id}/collections/{collection_id}/items", + endpoint=self.client.get_catalog_collection_items, + methods=["GET"], + response_model=ItemCollection, + response_class=self.response_class, + summary="Get Catalog Collection Items", + description="Get items from a collection in a catalog.", + tags=["Catalogs"], + ) + + self.router.add_api_route( + path="/catalogs/{catalog_id}/collections/{collection_id}/items/{item_id}", + endpoint=self.client.get_catalog_collection_item, + methods=["GET"], + response_model=Item, + response_class=self.response_class, + summary="Get Catalog Collection Item", + description="Get a specific item from a collection in a catalog.", + tags=["Catalogs"], + ) + + self.router.add_api_route( + path="/catalogs/{catalog_id}/catalogs", + endpoint=self.client.get_sub_catalogs, + methods=["GET"], + response_model=Catalogs, + response_class=self.response_class, + summary="Get Catalog Sub-Catalogs", + description="Get sub-catalogs linked from a specific catalog.", + tags=["Catalogs"], + ) + + self.router.add_api_route( + path="/catalogs/{catalog_id}/catalogs", + endpoint=self.client.create_sub_catalog, + methods=["POST"], + response_model=Catalog, + response_class=self.response_class, + status_code=201, + summary="Create Catalog Sub-Catalog", + description="Create a new catalog and link it as a sub-catalog of a specific catalog.", + tags=["Catalogs"], + ) + + self.router.add_api_route( + path="/catalogs/{catalog_id}/children", + endpoint=self.client.get_catalog_children, + methods=["GET"], + response_model=Children, + response_class=self.response_class, + summary="Get Catalog Children", + description="Retrieve all children (Catalogs and Collections) of this catalog.", + tags=["Catalogs"], + ) + + self.router.add_api_route( + path="/catalogs/{catalog_id}/conformance", + endpoint=self.client.get_catalog_conformance, + methods=["GET"], + response_class=self.response_class, + summary="Get Catalog Conformance", + description="Get conformance classes specific to this sub-catalog.", + tags=["Catalogs"], + ) + + self.router.add_api_route( + path="/catalogs/{catalog_id}/queryables", + endpoint=self.client.get_catalog_queryables, + methods=["GET"], + response_class=self.response_class, + summary="Get Catalog Queryables", + description="Get queryable fields available for filtering in this sub-catalog (Filter Extension).", + tags=["Catalogs"], + ) + + self.router.add_api_route( + path="/catalogs/{catalog_id}/catalogs/{sub_catalog_id}", + endpoint=self.client.unlink_sub_catalog, + methods=["DELETE"], + response_class=self.response_class, + status_code=204, + summary="Unlink Sub-Catalog", + description="Unlink a sub-catalog from its parent. Does not delete the sub-catalog.", + tags=["Catalogs"], + ) + + app.include_router(self.router, tags=["Catalogs"]) diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/client.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/client.py new file mode 100644 index 000000000..d3b3c351c --- /dev/null +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/client.py @@ -0,0 +1,543 @@ +"""Catalogs extension clients.""" + +import abc +from typing import Literal, Optional + +import attr +from stac_pydantic.api.collections import Collections +from stac_pydantic.catalog import Catalog +from stac_pydantic.collection import Collection +from stac_pydantic.item import Item +from stac_pydantic.item_collection import ItemCollection + +from .types import Catalogs, Children + + +@attr.s +class AsyncBaseCatalogsClient(abc.ABC): + """Defines an async pattern for implementing the STAC catalogs extension.""" + + @abc.abstractmethod + async def get_catalogs( + self, + limit: Optional[int] = None, + token: Optional[str] = None, + **kwargs, + ) -> Catalogs: + """Get all catalogs with pagination support. + + Args: + limit: The maximum number of catalogs to return. + token: Pagination token for the next page of results. + + Returns: + Catalogs object containing catalogs and pagination links. + """ + ... + + @abc.abstractmethod + async def create_catalog(self, catalog: Catalog, **kwargs) -> Catalog: + """Create a new catalog. + + Args: + catalog: The catalog to create. + + Returns: + The created catalog. + """ + ... + + @abc.abstractmethod + async def get_catalog(self, catalog_id: str, **kwargs) -> Catalog: + """Get a specific catalog by ID. + + Args: + catalog_id: The ID of the catalog to retrieve. + + Returns: + The requested catalog. + """ + ... + + @abc.abstractmethod + async def update_catalog( + self, catalog_id: str, catalog: Catalog, **kwargs + ) -> Catalog: + """Update an existing catalog. + + Args: + catalog_id: The ID of the catalog to update. + catalog: The updated catalog data. + + Returns: + The updated catalog. + """ + ... + + @abc.abstractmethod + async def delete_catalog(self, catalog_id: str, **kwargs) -> None: + """Delete a catalog. + + Args: + catalog_id: The ID of the catalog to delete. + """ + ... + + @abc.abstractmethod + async def get_catalog_collections( + self, catalog_id: str, **kwargs + ) -> Collections: + """Get collections linked from a specific catalog. + + Args: + catalog_id: The ID of the catalog. + + Returns: + Collections object containing collections linked from the catalog. + """ + ... + + @abc.abstractmethod + async def get_sub_catalogs( + self, + catalog_id: str, + limit: Optional[int] = None, + token: Optional[str] = None, + **kwargs, + ) -> Catalogs: + """Get all sub-catalogs of a specific catalog with pagination support. + + Args: + catalog_id: The ID of the parent catalog. + limit: Maximum number of results to return. + token: Pagination token for cursor-based pagination. + + Returns: + A Catalogs response containing sub-catalogs with pagination links. + """ + ... + + @abc.abstractmethod + async def create_sub_catalog( + self, catalog_id: str, catalog: Catalog, **kwargs + ) -> Catalog: + """Create a new catalog or link an existing catalog as a sub-catalog. + + Supports two modes: + - Mode A (Creation): Full Catalog JSON body with id that doesn't exist → creates new catalog + - Mode B (Linking): Minimal body with just id of existing catalog → links as sub-catalog + + Args: + catalog_id: The ID of the parent catalog. + catalog: The catalog to create or link. + + Returns: + The created or linked catalog. + """ + ... + + @abc.abstractmethod + async def create_catalog_collection( + self, catalog_id: str, collection: Collection, **kwargs + ) -> Collection: + """Create a new collection or link an existing collection to a specific catalog. + + Supports two modes: + - Mode A (Creation): Full Collection JSON body with id that doesn't exist → creates new collection + - Mode B (Linking): Minimal body with just id of existing collection → links to catalog + + Args: + catalog_id: The ID of the catalog to link the collection to. + collection: The collection to create or link. + + Returns: + The created or linked collection. + """ + ... + + @abc.abstractmethod + async def get_catalog_collection( + self, catalog_id: str, collection_id: str, **kwargs + ) -> Collection: + """Get a specific collection from a catalog. + + Args: + catalog_id: The ID of the catalog. + collection_id: The ID of the collection. + + Returns: + The requested collection. + """ + ... + + @abc.abstractmethod + async def unlink_catalog_collection( + self, catalog_id: str, collection_id: str, **kwargs + ) -> None: + """Unlink a collection from a catalog. + + Removes the link between the catalog and collection. + The Collection data is NOT deleted. + + Args: + catalog_id: The ID of the catalog. + collection_id: The ID of the collection. + """ + ... + + @abc.abstractmethod + async def get_catalog_collection_items( + self, + catalog_id: str, + collection_id: str, + **kwargs, + ) -> ItemCollection: + """Get items from a collection in a catalog. + + Args: + catalog_id: The ID of the catalog. + collection_id: The ID of the collection. + + Returns: + ItemCollection containing items from the collection. + """ + ... + + @abc.abstractmethod + async def get_catalog_collection_item( + self, catalog_id: str, collection_id: str, item_id: str, **kwargs + ) -> 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. + + Returns: + The requested item. + """ + ... + + @abc.abstractmethod + async def get_catalog_children( + self, + catalog_id: str, + limit: Optional[int] = None, + token: Optional[str] = None, + type: Optional[Literal["Catalog", "Collection"]] = None, + **kwargs, + ) -> Children: + """Get all children (Catalogs and Collections) of a specific catalog. + + Args: + catalog_id: The ID of the catalog. + limit: Maximum number of results to return. + token: Pagination token. + type: Filter by resource type (Catalog or Collection). + + Returns: + Dictionary containing children and pagination links. + """ + ... + + @abc.abstractmethod + async def get_catalog_conformance(self, catalog_id: str, **kwargs) -> dict: + """Get conformance classes specific to this sub-catalog. + + Args: + catalog_id: The ID of the catalog. + + Returns: + Dictionary containing conformance classes. + """ + ... + + @abc.abstractmethod + async def get_catalog_queryables(self, catalog_id: str, **kwargs) -> dict: + """Get queryable fields available for filtering in this sub-catalog. + + Args: + catalog_id: The ID of the catalog. + + Returns: + Dictionary containing queryable fields (Filter Extension). + """ + ... + + @abc.abstractmethod + async def unlink_sub_catalog( + self, catalog_id: str, sub_catalog_id: str, **kwargs + ) -> None: + """Unlink a sub-catalog from its parent. + + Args: + catalog_id: The ID of the parent catalog. + sub_catalog_id: The ID of the sub-catalog to unlink. + """ + ... + + +@attr.s +class BaseCatalogsClient(abc.ABC): + """Defines a synchronous pattern for implementing the STAC catalogs extension.""" + + @abc.abstractmethod + def get_catalogs( + self, + limit: Optional[int] = None, + token: Optional[str] = None, + **kwargs, + ) -> Catalogs: + """Get all catalogs with pagination support. + + Args: + limit: The maximum number of catalogs to return. + token: Pagination token for the next page of results. + + Returns: + Catalogs object containing catalogs and pagination links. + """ + ... + + @abc.abstractmethod + def create_catalog(self, catalog: Catalog, **kwargs) -> Catalog: + """Create a new catalog. + + Args: + catalog: The catalog to create. + + Returns: + The created catalog. + """ + ... + + @abc.abstractmethod + def get_catalog(self, catalog_id: str, **kwargs) -> Catalog: + """Get a specific catalog by ID. + + Args: + catalog_id: The ID of the catalog to retrieve. + + Returns: + The requested catalog. + """ + ... + + @abc.abstractmethod + def update_catalog( + self, catalog_id: str, catalog: Catalog, **kwargs + ) -> Catalog: + """Update an existing catalog. + + Args: + catalog_id: The ID of the catalog to update. + catalog: The updated catalog data. + + Returns: + The updated catalog. + """ + ... + + @abc.abstractmethod + def delete_catalog(self, catalog_id: str, **kwargs) -> None: + """Delete a catalog. + + Args: + catalog_id: The ID of the catalog to delete. + """ + ... + + @abc.abstractmethod + def get_catalog_collections( + self, catalog_id: str, **kwargs + ) -> Collections: + """Get collections linked from a specific catalog. + + Args: + catalog_id: The ID of the catalog. + + Returns: + Collections object containing collections linked from the catalog. + """ + ... + + @abc.abstractmethod + def get_sub_catalogs( + self, + catalog_id: str, + limit: Optional[int] = None, + token: Optional[str] = None, + **kwargs, + ) -> Catalogs: + """Get all sub-catalogs of a specific catalog with pagination support. + + Args: + catalog_id: The ID of the parent catalog. + limit: Maximum number of results to return. + token: Pagination token for cursor-based pagination. + + Returns: + A Catalogs response containing sub-catalogs with pagination links. + """ + ... + + @abc.abstractmethod + def create_sub_catalog( + self, catalog_id: str, catalog: Catalog, **kwargs + ) -> Catalog: + """Create a new catalog or link an existing catalog as a sub-catalog. + + Supports two modes: + - Mode A (Creation): Full Catalog JSON body with id that doesn't exist → creates new catalog + - Mode B (Linking): Minimal body with just id of existing catalog → links as sub-catalog + + Args: + catalog_id: The ID of the parent catalog. + catalog: The catalog to create or link. + + Returns: + The created or linked catalog. + """ + ... + + @abc.abstractmethod + def create_catalog_collection( + self, catalog_id: str, collection: Collection, **kwargs + ) -> Collection: + """Create a new collection or link an existing collection to a specific catalog. + + Supports two modes: + - Mode A (Creation): Full Collection JSON body with id that doesn't exist → creates new collection + - Mode B (Linking): Minimal body with just id of existing collection → links to catalog + + Args: + catalog_id: The ID of the catalog to link the collection to. + collection: The collection to create or link. + + Returns: + The created or linked collection. + """ + ... + + @abc.abstractmethod + def get_catalog_collection( + self, catalog_id: str, collection_id: str, **kwargs + ) -> Collection: + """Get a specific collection from a catalog. + + Args: + catalog_id: The ID of the catalog. + collection_id: The ID of the collection. + + Returns: + The requested collection. + """ + ... + + @abc.abstractmethod + def unlink_catalog_collection( + self, catalog_id: str, collection_id: str, **kwargs + ) -> None: + """Unlink a collection from a catalog. + + Removes the link between the catalog and collection. + The Collection data is NOT deleted. + + Args: + catalog_id: The ID of the catalog. + collection_id: The ID of the collection. + """ + ... + + @abc.abstractmethod + def get_catalog_collection_items( + self, + catalog_id: str, + collection_id: str, + **kwargs, + ) -> ItemCollection: + """Get items from a collection in a catalog. + + Args: + catalog_id: The ID of the catalog. + collection_id: The ID of the collection. + + Returns: + ItemCollection containing items from the collection. + """ + ... + + @abc.abstractmethod + def get_catalog_collection_item( + self, catalog_id: str, collection_id: str, item_id: str, **kwargs + ) -> 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. + + Returns: + The requested item. + """ + ... + + @abc.abstractmethod + def get_catalog_children( + self, + catalog_id: str, + limit: Optional[int] = None, + token: Optional[str] = None, + type: Optional[Literal["Catalog", "Collection"]] = None, + **kwargs, + ) -> Children: + """Get all children (Catalogs and Collections) of a specific catalog. + + Args: + catalog_id: The ID of the catalog. + limit: Maximum number of results to return. + token: Pagination token. + type: Filter by resource type (Catalog or Collection). + + Returns: + Dictionary containing children and pagination links. + """ + ... + + @abc.abstractmethod + def get_catalog_conformance(self, catalog_id: str, **kwargs) -> dict: + """Get conformance classes specific to this sub-catalog. + + Args: + catalog_id: The ID of the catalog. + + Returns: + Dictionary containing conformance classes. + """ + ... + + @abc.abstractmethod + def get_catalog_queryables(self, catalog_id: str, **kwargs) -> dict: + """Get queryable fields available for filtering in this sub-catalog. + + Args: + catalog_id: The ID of the catalog. + + Returns: + Dictionary containing queryable fields (Filter Extension). + """ + ... + + @abc.abstractmethod + def unlink_sub_catalog( + self, catalog_id: str, sub_catalog_id: str, **kwargs + ) -> None: + """Unlink a sub-catalog from its parent. + + Args: + catalog_id: The ID of the parent catalog. + sub_catalog_id: The ID of the sub-catalog to unlink. + """ + ... diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/types.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/types.py new file mode 100644 index 000000000..70c80cc41 --- /dev/null +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/types.py @@ -0,0 +1,31 @@ +"""Catalogs extension types.""" + +from typing import Any, Dict, List, Optional + +from stac_pydantic.catalog import Catalog +from stac_pydantic.links import Links +from stac_pydantic.shared import StacBaseModel + + +class Catalogs(StacBaseModel): + """Catalogs endpoint response. + + Similar to Collections but for catalogs. + """ + + catalogs: List[Catalog] + links: Links + numberMatched: Optional[int] = None + numberReturned: Optional[int] = None + + +class Children(StacBaseModel): + """Children endpoint response. + + Returns a mixed list of Catalogs and Collections as children. + """ + + children: List[Dict[str, Any]] + links: Links + numberMatched: Optional[int] = None + numberReturned: Optional[int] = None From 00d925df1a2873b3f996007496d435d1bb7c0fcb Mon Sep 17 00:00:00 2001 From: jonhealy1 Date: Sat, 7 Feb 2026 20:15:38 +0800 Subject: [PATCH 02/40] import fixes --- .../core/multi_tenant_catalogs/catalogs.py | 5 +++-- .../core/multi_tenant_catalogs/client.py | 18 ++++++++++++------ 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/catalogs.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/catalogs.py index f50d689ba..609609a2e 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/catalogs.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/catalogs.py @@ -3,10 +3,11 @@ from typing import List, Optional, Type import attr -from fastapi import APIRouter, FastAPI, Query +from fastapi import APIRouter, FastAPI from fastapi.responses import JSONResponse from starlette.responses import Response +from stac_pydantic.api.collections import Collections from stac_pydantic.catalog import Catalog from stac_pydantic.collection import Collection from stac_pydantic.item import Item @@ -14,7 +15,7 @@ from stac_fastapi.types.extension import ApiExtension from .client import AsyncBaseCatalogsClient -from .types import Catalogs, Children, Collections +from .types import Catalogs, Children CATALOGS_CONFORMANCE_CLASSES = [ "https://api.stacspec.org/v1.0.0/core", diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/client.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/client.py index d3b3c351c..93fbe3cb6 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/client.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/client.py @@ -98,14 +98,14 @@ async def get_catalog_collections( ... @abc.abstractmethod - async def get_sub_catalogs( + async def get_catalog_catalogs( self, catalog_id: str, limit: Optional[int] = None, token: Optional[str] = None, **kwargs, ) -> Catalogs: - """Get all sub-catalogs of a specific catalog with pagination support. + """Get all sub-catalogs of a specific catalog with pagination. Args: catalog_id: The ID of the parent catalog. @@ -118,7 +118,7 @@ async def get_sub_catalogs( ... @abc.abstractmethod - async def create_sub_catalog( + async def create_catalog_catalog( self, catalog_id: str, catalog: Catalog, **kwargs ) -> Catalog: """Create a new catalog or link an existing catalog as a sub-catalog. @@ -127,6 +127,12 @@ async def create_sub_catalog( - Mode A (Creation): Full Catalog JSON body with id that doesn't exist → creates new catalog - Mode B (Linking): Minimal body with just id of existing catalog → links as sub-catalog + Logic: + 1. Verifies the parent catalog exists. + 2. If the sub-catalog already exists: Appends the parent ID to its parent_ids + (enabling poly-hierarchy - a catalog can have multiple parents). + 3. If the sub-catalog is new: Creates it with parent_ids initialized to [catalog_id]. + Args: catalog_id: The ID of the parent catalog. catalog: The catalog to create or link. @@ -363,14 +369,14 @@ def get_catalog_collections( ... @abc.abstractmethod - def get_sub_catalogs( + def get_catalog_catalogs( self, catalog_id: str, limit: Optional[int] = None, token: Optional[str] = None, **kwargs, ) -> Catalogs: - """Get all sub-catalogs of a specific catalog with pagination support. + """Get all sub-catalogs of a specific catalog with pagination. Args: catalog_id: The ID of the parent catalog. @@ -383,7 +389,7 @@ def get_sub_catalogs( ... @abc.abstractmethod - def create_sub_catalog( + def create_catalog_catalog( self, catalog_id: str, catalog: Catalog, **kwargs ) -> Catalog: """Create a new catalog or link an existing catalog as a sub-catalog. From 9137faa5886a45504cc4f548a7dcbb87bb10b5e7 Mon Sep 17 00:00:00 2001 From: jonhealy1 Date: Sat, 7 Feb 2026 20:15:50 +0800 Subject: [PATCH 03/40] pre-commit --- .../stac_fastapi/extensions/core/__init__.py | 2 +- .../core/multi_tenant_catalogs/catalogs.py | 30 ++++++--- .../core/multi_tenant_catalogs/client.py | 62 +++++++++---------- 3 files changed, 54 insertions(+), 40 deletions(-) diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/__init__.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/__init__.py index 031408b37..ff5601aa3 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/__init__.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/__init__.py @@ -1,7 +1,6 @@ """stac_api.extensions.core module.""" from .aggregation import AggregationExtension -from .multi_tenant_catalogs import CatalogsExtension from .collection_search import CollectionSearchExtension, CollectionSearchPostExtension from .fields import FieldsExtension from .filter import ( @@ -11,6 +10,7 @@ SearchFilterExtension, ) from .free_text import FreeTextAdvancedExtension, FreeTextExtension +from .multi_tenant_catalogs import CatalogsExtension from .pagination import ( OffsetPaginationExtension, PaginationExtension, diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/catalogs.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/catalogs.py index 609609a2e..f8619ad32 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/catalogs.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/catalogs.py @@ -1,17 +1,17 @@ """Catalogs extension.""" -from typing import List, Optional, Type +from typing import List, Type import attr from fastapi import APIRouter, FastAPI from fastapi.responses import JSONResponse -from starlette.responses import Response - from stac_pydantic.api.collections import Collections from stac_pydantic.catalog import Catalog from stac_pydantic.collection import Collection from stac_pydantic.item import Item from stac_pydantic.item_collection import ItemCollection +from starlette.responses import Response + from stac_fastapi.types.extension import ApiExtension from .client import AsyncBaseCatalogsClient @@ -155,7 +155,10 @@ def register(self, app: FastAPI, settings=None) -> None: response_class=self.response_class, status_code=204, summary="Unlink Collection from Catalog", - description="Removes the link between the catalog and collection. The Collection data is NOT deleted.", + description=( + "Removes the link between the catalog and collection. " + "The Collection data is NOT deleted." + ), tags=["Catalogs"], ) @@ -200,7 +203,10 @@ def register(self, app: FastAPI, settings=None) -> None: response_class=self.response_class, status_code=201, summary="Create Catalog Sub-Catalog", - description="Create a new catalog and link it as a sub-catalog of a specific catalog.", + description=( + "Create a new catalog and link it as a sub-catalog " + "of a specific catalog." + ), tags=["Catalogs"], ) @@ -211,7 +217,9 @@ def register(self, app: FastAPI, settings=None) -> None: response_model=Children, response_class=self.response_class, summary="Get Catalog Children", - description="Retrieve all children (Catalogs and Collections) of this catalog.", + description=( + "Retrieve all children (Catalogs and Collections) " "of this catalog." + ), tags=["Catalogs"], ) @@ -231,7 +239,10 @@ def register(self, app: FastAPI, settings=None) -> None: methods=["GET"], response_class=self.response_class, summary="Get Catalog Queryables", - description="Get queryable fields available for filtering in this sub-catalog (Filter Extension).", + description=( + "Get queryable fields available for filtering in this " + "sub-catalog (Filter Extension)." + ), tags=["Catalogs"], ) @@ -242,7 +253,10 @@ def register(self, app: FastAPI, settings=None) -> None: response_class=self.response_class, status_code=204, summary="Unlink Sub-Catalog", - description="Unlink a sub-catalog from its parent. Does not delete the sub-catalog.", + description=( + "Unlink a sub-catalog from its parent. " + "Does not delete the sub-catalog." + ), tags=["Catalogs"], ) diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/client.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/client.py index 93fbe3cb6..0eae1a226 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/client.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/client.py @@ -84,9 +84,7 @@ async def delete_catalog(self, catalog_id: str, **kwargs) -> None: ... @abc.abstractmethod - async def get_catalog_collections( - self, catalog_id: str, **kwargs - ) -> Collections: + async def get_catalog_collections(self, catalog_id: str, **kwargs) -> Collections: """Get collections linked from a specific catalog. Args: @@ -98,7 +96,7 @@ async def get_catalog_collections( ... @abc.abstractmethod - async def get_catalog_catalogs( + async def get_sub_catalogs( self, catalog_id: str, limit: Optional[int] = None, @@ -118,20 +116,24 @@ async def get_catalog_catalogs( ... @abc.abstractmethod - async def create_catalog_catalog( + async def create_sub_catalog( self, catalog_id: str, catalog: Catalog, **kwargs ) -> Catalog: """Create a new catalog or link an existing catalog as a sub-catalog. Supports two modes: - - Mode A (Creation): Full Catalog JSON body with id that doesn't exist → creates new catalog - - Mode B (Linking): Minimal body with just id of existing catalog → links as sub-catalog + - Mode A (Creation): Full Catalog JSON body with id that doesn't exist + → creates new catalog + - Mode B (Linking): Minimal body with just id of existing catalog + → links as sub-catalog Logic: 1. Verifies the parent catalog exists. - 2. If the sub-catalog already exists: Appends the parent ID to its parent_ids - (enabling poly-hierarchy - a catalog can have multiple parents). - 3. If the sub-catalog is new: Creates it with parent_ids initialized to [catalog_id]. + 2. If the sub-catalog already exists: Appends the parent ID to its + parent_ids (enabling poly-hierarchy - a catalog can have multiple + parents). + 3. If the sub-catalog is new: Creates it with parent_ids initialized + to [catalog_id]. Args: catalog_id: The ID of the parent catalog. @@ -146,11 +148,13 @@ async def create_catalog_catalog( async def create_catalog_collection( self, catalog_id: str, collection: Collection, **kwargs ) -> Collection: - """Create a new collection or link an existing collection to a specific catalog. + """Create a new collection or link an existing collection to catalog. Supports two modes: - - Mode A (Creation): Full Collection JSON body with id that doesn't exist → creates new collection - - Mode B (Linking): Minimal body with just id of existing collection → links to catalog + - Mode A (Creation): Full Collection JSON body with id that doesn't + exist → creates new collection + - Mode B (Linking): Minimal body with just id of existing collection + → links to catalog Args: catalog_id: The ID of the catalog to link the collection to. @@ -331,9 +335,7 @@ def get_catalog(self, catalog_id: str, **kwargs) -> Catalog: ... @abc.abstractmethod - def update_catalog( - self, catalog_id: str, catalog: Catalog, **kwargs - ) -> Catalog: + def update_catalog(self, catalog_id: str, catalog: Catalog, **kwargs) -> Catalog: """Update an existing catalog. Args: @@ -355,9 +357,7 @@ def delete_catalog(self, catalog_id: str, **kwargs) -> None: ... @abc.abstractmethod - def get_catalog_collections( - self, catalog_id: str, **kwargs - ) -> Collections: + def get_catalog_collections(self, catalog_id: str, **kwargs) -> Collections: """Get collections linked from a specific catalog. Args: @@ -369,7 +369,7 @@ def get_catalog_collections( ... @abc.abstractmethod - def get_catalog_catalogs( + def get_sub_catalogs( self, catalog_id: str, limit: Optional[int] = None, @@ -389,14 +389,14 @@ def get_catalog_catalogs( ... @abc.abstractmethod - def create_catalog_catalog( - self, catalog_id: str, catalog: Catalog, **kwargs - ) -> Catalog: + def create_sub_catalog(self, catalog_id: str, catalog: Catalog, **kwargs) -> Catalog: """Create a new catalog or link an existing catalog as a sub-catalog. Supports two modes: - - Mode A (Creation): Full Catalog JSON body with id that doesn't exist → creates new catalog - - Mode B (Linking): Minimal body with just id of existing catalog → links as sub-catalog + - Mode A (Creation): Full Catalog JSON body with id that doesn't exist + → creates new catalog + - Mode B (Linking): Minimal body with just id of existing catalog + → links as sub-catalog Args: catalog_id: The ID of the parent catalog. @@ -411,11 +411,13 @@ def create_catalog_catalog( def create_catalog_collection( self, catalog_id: str, collection: Collection, **kwargs ) -> Collection: - """Create a new collection or link an existing collection to a specific catalog. + """Create a new collection or link an existing collection to catalog. Supports two modes: - - Mode A (Creation): Full Collection JSON body with id that doesn't exist → creates new collection - - Mode B (Linking): Minimal body with just id of existing collection → links to catalog + - Mode A (Creation): Full Collection JSON body with id that doesn't + exist → creates new collection + - Mode B (Linking): Minimal body with just id of existing collection + → links to catalog Args: catalog_id: The ID of the catalog to link the collection to. @@ -537,9 +539,7 @@ def get_catalog_queryables(self, catalog_id: str, **kwargs) -> dict: ... @abc.abstractmethod - def unlink_sub_catalog( - self, catalog_id: str, sub_catalog_id: str, **kwargs - ) -> None: + def unlink_sub_catalog(self, catalog_id: str, sub_catalog_id: str, **kwargs) -> None: """Unlink a sub-catalog from its parent. Args: From 9edaba9c4d9b4650d3cef6b42121287a5026a488 Mon Sep 17 00:00:00 2001 From: jonhealy1 Date: Sat, 7 Feb 2026 20:20:36 +0800 Subject: [PATCH 04/40] improve documentation, fixes --- .../core/multi_tenant_catalogs/catalogs.py | 6 +++++- .../extensions/core/multi_tenant_catalogs/client.py | 12 ++++++++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/catalogs.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/catalogs.py index f8619ad32..8100ce42a 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/catalogs.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/catalogs.py @@ -40,7 +40,7 @@ class CatalogsExtension(ApiExtension): response_class: Response class for the extension. """ - client: AsyncBaseCatalogsClient = attr.ib(default=None) + client: AsyncBaseCatalogsClient = attr.ib() settings: dict = attr.ib(default=attr.Factory(dict)) conformance_classes: List[str] = attr.ib( default=attr.Factory(lambda: CATALOGS_CONFORMANCE_CLASSES) @@ -55,6 +55,8 @@ def register(self, app: FastAPI, settings=None) -> None: app: target FastAPI application. settings: extension settings. """ + if self.client is None: + raise ValueError("CatalogsExtension requires a client to be set") self.settings = settings or {} self.router = APIRouter() @@ -231,6 +233,7 @@ def register(self, app: FastAPI, settings=None) -> None: summary="Get Catalog Conformance", description="Get conformance classes specific to this sub-catalog.", tags=["Catalogs"], + responses={200: {"description": "Conformance classes for the catalog"}}, ) self.router.add_api_route( @@ -244,6 +247,7 @@ def register(self, app: FastAPI, settings=None) -> None: "sub-catalog (Filter Extension)." ), tags=["Catalogs"], + responses={200: {"description": "Queryable fields for the catalog"}}, ) self.router.add_api_route( diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/client.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/client.py index 0eae1a226..c53069ebe 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/client.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/client.py @@ -247,7 +247,7 @@ async def get_catalog_children( type: Filter by resource type (Catalog or Collection). Returns: - Dictionary containing children and pagination links. + Children object containing children and pagination links. """ ... @@ -398,6 +398,14 @@ def create_sub_catalog(self, catalog_id: str, catalog: Catalog, **kwargs) -> Cat - Mode B (Linking): Minimal body with just id of existing catalog → links as sub-catalog + Logic: + 1. Verifies the parent catalog exists. + 2. If the sub-catalog already exists: Appends the parent ID to its + parent_ids (enabling poly-hierarchy - a catalog can have multiple + parents). + 3. If the sub-catalog is new: Creates it with parent_ids initialized + to [catalog_id]. + Args: catalog_id: The ID of the parent catalog. catalog: The catalog to create or link. @@ -510,7 +518,7 @@ def get_catalog_children( type: Filter by resource type (Catalog or Collection). Returns: - Dictionary containing children and pagination links. + Children object containing children and pagination links. """ ... From 1056f11dd69e10ac9a20b5ef8a1a85cc28354853 Mon Sep 17 00:00:00 2001 From: jonhealy1 Date: Sat, 7 Feb 2026 20:32:46 +0800 Subject: [PATCH 05/40] use starlette status errors --- .../core/multi_tenant_catalogs/catalogs.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/catalogs.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/catalogs.py index 8100ce42a..33b6eedc9 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/catalogs.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/catalogs.py @@ -11,6 +11,7 @@ from stac_pydantic.item import Item from stac_pydantic.item_collection import ItemCollection from starlette.responses import Response +from starlette.status import HTTP_200_OK, HTTP_201_CREATED, HTTP_204_NO_CONTENT from stac_fastapi.types.extension import ApiExtension @@ -77,7 +78,7 @@ def register(self, app: FastAPI, settings=None) -> None: methods=["POST"], response_model=Catalog, response_class=self.response_class, - status_code=201, + status_code=HTTP_201_CREATED, summary="Create Catalog", description="Create a new STAC catalog.", tags=["Catalogs"], @@ -110,7 +111,7 @@ def register(self, app: FastAPI, settings=None) -> None: endpoint=self.client.delete_catalog, methods=["DELETE"], response_class=self.response_class, - status_code=204, + status_code=HTTP_204_NO_CONTENT, summary="Delete Catalog", description="Delete a catalog.", tags=["Catalogs"], @@ -133,7 +134,7 @@ def register(self, app: FastAPI, settings=None) -> None: methods=["POST"], response_model=Collection, response_class=self.response_class, - status_code=201, + status_code=HTTP_201_CREATED, summary="Create Catalog Collection", description="Create a new collection and link it to a specific catalog.", tags=["Catalogs"], @@ -155,7 +156,7 @@ def register(self, app: FastAPI, settings=None) -> None: endpoint=self.client.unlink_catalog_collection, methods=["DELETE"], response_class=self.response_class, - status_code=204, + status_code=HTTP_204_NO_CONTENT, summary="Unlink Collection from Catalog", description=( "Removes the link between the catalog and collection. " @@ -203,7 +204,7 @@ def register(self, app: FastAPI, settings=None) -> None: methods=["POST"], response_model=Catalog, response_class=self.response_class, - status_code=201, + status_code=HTTP_201_CREATED, summary="Create Catalog Sub-Catalog", description=( "Create a new catalog and link it as a sub-catalog " @@ -233,7 +234,7 @@ def register(self, app: FastAPI, settings=None) -> None: summary="Get Catalog Conformance", description="Get conformance classes specific to this sub-catalog.", tags=["Catalogs"], - responses={200: {"description": "Conformance classes for the catalog"}}, + responses={HTTP_200_OK: {"description": "Conformance classes for the catalog"}}, ) self.router.add_api_route( @@ -247,7 +248,7 @@ def register(self, app: FastAPI, settings=None) -> None: "sub-catalog (Filter Extension)." ), tags=["Catalogs"], - responses={200: {"description": "Queryable fields for the catalog"}}, + responses={HTTP_200_OK: {"description": "Queryable fields for the catalog"}}, ) self.router.add_api_route( @@ -255,7 +256,7 @@ def register(self, app: FastAPI, settings=None) -> None: endpoint=self.client.unlink_sub_catalog, methods=["DELETE"], response_class=self.response_class, - status_code=204, + status_code=HTTP_204_NO_CONTENT, summary="Unlink Sub-Catalog", description=( "Unlink a sub-catalog from its parent. " From 8bb7931f85586d4f86dac2cd9176c5c00bb5e1e5 Mon Sep 17 00:00:00 2001 From: jonhealy1 Date: Sat, 7 Feb 2026 20:35:32 +0800 Subject: [PATCH 06/40] fix router --- .../extensions/core/multi_tenant_catalogs/catalogs.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/catalogs.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/catalogs.py index 33b6eedc9..6b89c3c5f 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/catalogs.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/catalogs.py @@ -46,7 +46,7 @@ class CatalogsExtension(ApiExtension): conformance_classes: List[str] = attr.ib( default=attr.Factory(lambda: CATALOGS_CONFORMANCE_CLASSES) ) - router: APIRouter = attr.ib(default=attr.Factory(APIRouter)) + router: APIRouter = attr.ib(factory=APIRouter) response_class: Type[Response] = attr.ib(default=JSONResponse) def register(self, app: FastAPI, settings=None) -> None: @@ -234,7 +234,9 @@ def register(self, app: FastAPI, settings=None) -> None: summary="Get Catalog Conformance", description="Get conformance classes specific to this sub-catalog.", tags=["Catalogs"], - responses={HTTP_200_OK: {"description": "Conformance classes for the catalog"}}, + responses={ + HTTP_200_OK: {"description": "Conformance classes for the catalog"} + }, ) self.router.add_api_route( From a0f491e302d12dbdfa1b9c6981264f9e7bed9149 Mon Sep 17 00:00:00 2001 From: jonhealy1 Date: Sat, 7 Feb 2026 20:47:26 +0800 Subject: [PATCH 07/40] add default --- .../extensions/core/multi_tenant_catalogs/catalogs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/catalogs.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/catalogs.py index 6b89c3c5f..5430e8d01 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/catalogs.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/catalogs.py @@ -41,7 +41,7 @@ class CatalogsExtension(ApiExtension): response_class: Response class for the extension. """ - client: AsyncBaseCatalogsClient = attr.ib() + client: AsyncBaseCatalogsClient = attr.ib(default=None) settings: dict = attr.ib(default=attr.Factory(dict)) conformance_classes: List[str] = attr.ib( default=attr.Factory(lambda: CATALOGS_CONFORMANCE_CLASSES) From 960533e62d165259283b5c07cc1b7f3d6a648452 Mon Sep 17 00:00:00 2001 From: jonhealy1 Date: Sun, 8 Feb 2026 12:26:02 +0800 Subject: [PATCH 08/40] add tests --- .../extensions/tests/test_catalogs.py | 491 ++++++++++++++++++ 1 file changed, 491 insertions(+) create mode 100644 stac_fastapi/extensions/tests/test_catalogs.py diff --git a/stac_fastapi/extensions/tests/test_catalogs.py b/stac_fastapi/extensions/tests/test_catalogs.py new file mode 100644 index 000000000..97dd6881d --- /dev/null +++ b/stac_fastapi/extensions/tests/test_catalogs.py @@ -0,0 +1,491 @@ +"""Tests for the Catalogs extension.""" + +from typing import Iterator, List + +import pytest +from stac_pydantic.api.collections import Collections +from stac_pydantic.catalog import Catalog +from stac_pydantic.collection import Collection +from stac_pydantic.item import Item +from stac_pydantic.item_collection import ItemCollection +from starlette.testclient import TestClient + +from stac_fastapi.api.app import StacApi +from stac_fastapi.extensions.core import CatalogsExtension +from stac_fastapi.extensions.core.multi_tenant_catalogs.client import ( + BaseCatalogsClient, +) +from stac_fastapi.extensions.core.multi_tenant_catalogs.types import Catalogs, Children +from stac_fastapi.types.config import ApiSettings +from stac_fastapi.types.core import BaseCoreClient + + +class DummyCoreClient(BaseCoreClient): + """Dummy core client for testing.""" + + def all_collections(self, *args, **kwargs): + return {"collections": [], "links": []} + + def get_collection(self, *args, **kwargs): + raise NotImplementedError + + def get_item(self, *args, **kwargs): + raise NotImplementedError + + def get_search(self, *args, **kwargs): + raise NotImplementedError + + def post_search(self, *args, **kwargs): + raise NotImplementedError + + def item_collection(self, *args, **kwargs): + raise NotImplementedError + + +class DummyCatalogsClient(BaseCatalogsClient): + """Dummy catalogs client for testing.""" + + def get_catalogs(self, limit: int = None, token: str = None): + return Catalogs( + catalogs=[ + Catalog( + type="Catalog", + id="test-catalog-1", + description="Test Catalog 1", + stac_version="1.0.0", + links=[], + ), + Catalog( + type="Catalog", + id="test-catalog-2", + description="Test Catalog 2", + stac_version="1.0.0", + links=[], + ), + ], + links=[], + numberMatched=2, + numberReturned=2, + ) + + def create_catalog(self, catalog: Catalog): + return Catalog( + type="Catalog", + id=catalog.id, + description=catalog.description or "Created catalog", + stac_version="1.0.0", + links=[], + ) + + def get_catalog(self, catalog_id: str): + return Catalog( + type="Catalog", + id=catalog_id, + description=f"Catalog {catalog_id}", + stac_version="1.0.0", + links=[], + ) + + def update_catalog(self, catalog_id: str, catalog: Catalog): + return Catalog( + type="Catalog", + id=catalog_id, + description=catalog.description or f"Updated {catalog_id}", + stac_version="1.0.0", + links=[], + ) + + def delete_catalog(self, catalog_id: str): + return None + + def get_catalog_collections(self, catalog_id: str): + return { + "collections": [ + { + "type": "Collection", + "id": "test-collection", + "description": "Test Collection", + "extent": { + "spatial": {"bbox": [[-180, -90, 180, 90]]}, + "temporal": {"interval": [[None, None]]}, + }, + "license": "proprietary", + "links": [ + {"rel": "root", "href": "/", "type": "application/json"}, + {"rel": "self", "href": f"/catalogs/{catalog_id}/collections/test-collection", "type": "application/json"}, + ], + } + ], + "links": [ + {"rel": "root", "href": "/", "type": "application/json"}, + {"rel": "self", "href": f"/catalogs/{catalog_id}/collections", "type": "application/json"}, + ], + } + + def get_sub_catalogs(self, catalog_id: str, limit: int = None, token: str = None): + return Catalogs( + catalogs=[ + Catalog( + type="Catalog", + id=f"{catalog_id}-sub-1", + description=f"Sub-catalog of {catalog_id}", + stac_version="1.0.0", + links=[], + ) + ], + links=[], + numberMatched=1, + numberReturned=1, + ) + + def create_sub_catalog(self, catalog_id: str, catalog: Catalog): + return Catalog( + type="Catalog", + id=catalog.id, + description=catalog.description or f"Sub-catalog of {catalog_id}", + stac_version="1.0.0", + links=[], + ) + + def create_catalog_collection( + self, catalog_id: str, collection: Collection + ): + return Collection( + type="Collection", + id=collection.id, + description=collection.description or f"Collection in {catalog_id}", + extent={ + "spatial": {"bbox": [[-180, -90, 180, 90]]}, + "temporal": {"interval": [[None, None]]}, + }, + license="proprietary", + links=[], + ) + + def get_catalog_collection( + self, catalog_id: str, collection_id: str + ): + return Collection( + type="Collection", + id=collection_id, + description=f"Collection {collection_id} in {catalog_id}", + extent={ + "spatial": {"bbox": [[-180, -90, 180, 90]]}, + "temporal": {"interval": [[None, None]]}, + }, + license="proprietary", + links=[], + ) + + def unlink_catalog_collection( + self, catalog_id: str, collection_id: str + ): + return None + + def get_catalog_collection_items( + self, catalog_id: str, collection_id: str + ): + return ItemCollection( + type="FeatureCollection", + features=[ + Item( + type="Feature", + id="test-item", + geometry={"type": "Point", "coordinates": [0, 0]}, + bbox=[0, 0, 0, 0], + datetime="2024-01-01T00:00:00Z", + properties={"datetime": "2024-01-01T00:00:00Z"}, + links=[], + assets={}, + ) + ], + links=[], + ) + + def get_catalog_collection_item( + self, catalog_id: str, collection_id: str, item_id: str + ): + return Item( + type="Feature", + id=item_id, + geometry={"type": "Point", "coordinates": [0, 0]}, + bbox=[0, 0, 0, 0], + datetime="2024-01-01T00:00:00Z", + properties={"datetime": "2024-01-01T00:00:00Z"}, + links=[], + assets={}, + ) + + def get_catalog_children( + self, catalog_id: str, limit: int = None, token: str = None, type: str = None + ): + return Children( + children=[ + { + "id": f"{catalog_id}-child-1", + "type": "Catalog", + "description": "Child catalog", + }, + { + "id": "collection-1", + "type": "Collection", + "description": "Child collection", + }, + ], + links=[], + numberMatched=2, + numberReturned=2, + ) + + def get_catalog_conformance(self, catalog_id: str): + return { + "conformsTo": [ + "https://api.stacspec.org/v1.0.0/core", + "https://api.stacspec.org/v1.0.0-beta.1/multi-tenant-catalogs", + ] + } + + def get_catalog_queryables(self, catalog_id: str): + return { + "queryables": [ + {"name": "datetime", "type": "string"}, + {"name": "platform", "type": "string"}, + ] + } + + def unlink_sub_catalog(self, catalog_id: str, sub_catalog_id: str): + return None + + +@pytest.fixture +def core_client() -> DummyCoreClient: + """Fixture for core client.""" + return DummyCoreClient() + + +@pytest.fixture +def catalogs_client() -> DummyCatalogsClient: + """Fixture for catalogs client.""" + return DummyCatalogsClient() + + +@pytest.fixture +def client( + core_client: DummyCoreClient, catalogs_client: DummyCatalogsClient +) -> Iterator[TestClient]: + """Fixture for test client.""" + settings = ApiSettings() + api = StacApi( + settings=settings, + client=core_client, + extensions=[ + CatalogsExtension(client=catalogs_client), + ], + ) + with TestClient(api.app) as test_client: + yield test_client + + +def test_get_catalogs(client: TestClient) -> None: + """Test GET /catalogs endpoint.""" + response = client.get("/catalogs") + assert response.status_code == 200, response.text + data = response.json() + assert "catalogs" in data + assert len(data["catalogs"]) == 2 + assert data["catalogs"][0]["id"] == "test-catalog-1" + assert data["numberMatched"] == 2 + assert data["numberReturned"] == 2 + + +def test_create_catalog(client: TestClient) -> None: + """Test POST /catalogs endpoint.""" + catalog_data = { + "type": "Catalog", + "id": "new-catalog", + "description": "A new test catalog", + "stac_version": "1.0.0", + "links": [], + } + response = client.post("/catalogs", json=catalog_data) + assert response.status_code == 201, response.text + data = response.json() + assert data["id"] == "new-catalog" + assert data["description"] == "A new test catalog" + + +def test_get_catalog(client: TestClient) -> None: + """Test GET /catalogs/{catalog_id} endpoint.""" + response = client.get("/catalogs/test-catalog-1") + assert response.status_code == 200, response.text + data = response.json() + assert data["id"] == "test-catalog-1" + assert data["description"] == "Catalog test-catalog-1" + + +def test_update_catalog(client: TestClient) -> None: + """Test PUT /catalogs/{catalog_id} endpoint.""" + catalog_data = { + "type": "Catalog", + "id": "test-catalog-1", + "description": "Updated description", + "stac_version": "1.0.0", + "links": [], + } + response = client.put("/catalogs/test-catalog-1", json=catalog_data) + assert response.status_code == 200, response.text + data = response.json() + assert data["id"] == "test-catalog-1" + + +def test_delete_catalog(client: TestClient) -> None: + """Test DELETE /catalogs/{catalog_id} endpoint.""" + response = client.delete("/catalogs/test-catalog-1") + assert response.status_code == 204, response.text + + +def test_get_catalog_collections(client: TestClient) -> None: + """Test GET /catalogs/{catalog_id}/collections endpoint.""" + response = client.get("/catalogs/test-catalog-1/collections") + assert response.status_code == 200, response.text + data = response.json() + assert "collections" in data + assert len(data["collections"]) == 1 + assert data["collections"][0]["id"] == "test-collection" + + +def test_create_catalog_collection(client: TestClient) -> None: + """Test POST /catalogs/{catalog_id}/collections endpoint.""" + collection_data = { + "type": "Collection", + "id": "new-collection", + "description": "A new collection", + "extent": { + "spatial": {"bbox": [[-180, -90, 180, 90]]}, + "temporal": {"interval": [[None, None]]}, + }, + "license": "proprietary", + "links": [], + } + response = client.post( + "/catalogs/test-catalog-1/collections", json=collection_data + ) + assert response.status_code == 201, response.text + data = response.json() + assert data["id"] == "new-collection" + + +def test_get_catalog_collection(client: TestClient) -> None: + """Test GET /catalogs/{catalog_id}/collections/{collection_id} endpoint.""" + response = client.get("/catalogs/test-catalog-1/collections/test-collection") + assert response.status_code == 200, response.text + data = response.json() + assert data["id"] == "test-collection" + + +def test_unlink_catalog_collection(client: TestClient) -> None: + """Test DELETE /catalogs/{catalog_id}/collections/{collection_id} endpoint.""" + response = client.delete( + "/catalogs/test-catalog-1/collections/test-collection" + ) + assert response.status_code == 204, response.text + + +def test_get_catalog_collection_items(client: TestClient) -> None: + """Test GET /catalogs/{catalog_id}/collections/{collection_id}/items endpoint.""" + response = client.get( + "/catalogs/test-catalog-1/collections/test-collection/items" + ) + assert response.status_code == 200, response.text + data = response.json() + assert data["type"] == "FeatureCollection" + assert len(data["features"]) == 1 + assert data["features"][0]["id"] == "test-item" + + +def test_get_catalog_collection_item(client: TestClient) -> None: + """Test GET /catalogs/{catalog_id}/collections/{collection_id}/items/{item_id} endpoint.""" + response = client.get( + "/catalogs/test-catalog-1/collections/test-collection/items/test-item" + ) + assert response.status_code == 200, response.text + data = response.json() + assert data["id"] == "test-item" + + +def test_get_sub_catalogs(client: TestClient) -> None: + """Test GET /catalogs/{catalog_id}/catalogs endpoint.""" + response = client.get("/catalogs/test-catalog-1/catalogs") + assert response.status_code == 200, response.text + data = response.json() + assert "catalogs" in data + assert len(data["catalogs"]) == 1 + assert data["catalogs"][0]["id"] == "test-catalog-1-sub-1" + + +def test_create_sub_catalog(client: TestClient) -> None: + """Test POST /catalogs/{catalog_id}/catalogs endpoint.""" + catalog_data = { + "type": "Catalog", + "id": "new-sub-catalog", + "description": "A new sub-catalog", + "stac_version": "1.0.0", + "links": [], + } + response = client.post("/catalogs/test-catalog-1/catalogs", json=catalog_data) + assert response.status_code == 201, response.text + data = response.json() + assert data["id"] == "new-sub-catalog" + + +def test_get_catalog_children(client: TestClient) -> None: + """Test GET /catalogs/{catalog_id}/children endpoint.""" + response = client.get("/catalogs/test-catalog-1/children") + assert response.status_code == 200, response.text + data = response.json() + assert "children" in data + assert len(data["children"]) == 2 + assert data["numberMatched"] == 2 + + +def test_get_catalog_children_with_type_filter(client: TestClient) -> None: + """Test GET /catalogs/{catalog_id}/children with type filter.""" + response = client.get("/catalogs/test-catalog-1/children?type=Catalog") + assert response.status_code == 200, response.text + data = response.json() + assert "children" in data + + +def test_get_catalog_conformance(client: TestClient) -> None: + """Test GET /catalogs/{catalog_id}/conformance endpoint.""" + response = client.get("/catalogs/test-catalog-1/conformance") + assert response.status_code == 200, response.text + data = response.json() + assert "conformsTo" in data + assert len(data["conformsTo"]) > 0 + + +def test_get_catalog_queryables(client: TestClient) -> None: + """Test GET /catalogs/{catalog_id}/queryables endpoint.""" + response = client.get("/catalogs/test-catalog-1/queryables") + assert response.status_code == 200, response.text + data = response.json() + assert "queryables" in data + assert len(data["queryables"]) > 0 + + +def test_unlink_sub_catalog(client: TestClient) -> None: + """Test DELETE /catalogs/{catalog_id}/catalogs/{sub_catalog_id} endpoint.""" + response = client.delete("/catalogs/test-catalog-1/catalogs/test-catalog-1-sub-1") + assert response.status_code == 204, response.text + + +def test_landing_page_includes_catalogs_links(client: TestClient) -> None: + """Test that landing page includes catalogs links.""" + response = client.get("/") + assert response.status_code == 200, response.text + data = response.json() + assert "links" in data + # The catalogs extension should be registered and provide links + # Check if any link exists (the exact catalogs link depends on extension registration) + assert len(data["links"]) > 0 From f6327475e25040fc5c5ce2b0818fb98579ecb7fa Mon Sep 17 00:00:00 2001 From: jonhealy1 Date: Sun, 8 Feb 2026 12:26:41 +0800 Subject: [PATCH 09/40] pre-commit --- .../extensions/tests/test_catalogs.py | 45 ++++++++----------- 1 file changed, 19 insertions(+), 26 deletions(-) diff --git a/stac_fastapi/extensions/tests/test_catalogs.py b/stac_fastapi/extensions/tests/test_catalogs.py index 97dd6881d..f443ce93f 100644 --- a/stac_fastapi/extensions/tests/test_catalogs.py +++ b/stac_fastapi/extensions/tests/test_catalogs.py @@ -1,9 +1,8 @@ """Tests for the Catalogs extension.""" -from typing import Iterator, List +from typing import Iterator import pytest -from stac_pydantic.api.collections import Collections from stac_pydantic.catalog import Catalog from stac_pydantic.collection import Collection from stac_pydantic.item import Item @@ -112,13 +111,21 @@ def get_catalog_collections(self, catalog_id: str): "license": "proprietary", "links": [ {"rel": "root", "href": "/", "type": "application/json"}, - {"rel": "self", "href": f"/catalogs/{catalog_id}/collections/test-collection", "type": "application/json"}, + { + "rel": "self", + "href": f"/catalogs/{catalog_id}/collections/test-collection", + "type": "application/json", + }, ], } ], "links": [ {"rel": "root", "href": "/", "type": "application/json"}, - {"rel": "self", "href": f"/catalogs/{catalog_id}/collections", "type": "application/json"}, + { + "rel": "self", + "href": f"/catalogs/{catalog_id}/collections", + "type": "application/json", + }, ], } @@ -147,9 +154,7 @@ def create_sub_catalog(self, catalog_id: str, catalog: Catalog): links=[], ) - def create_catalog_collection( - self, catalog_id: str, collection: Collection - ): + def create_catalog_collection(self, catalog_id: str, collection: Collection): return Collection( type="Collection", id=collection.id, @@ -162,9 +167,7 @@ def create_catalog_collection( links=[], ) - def get_catalog_collection( - self, catalog_id: str, collection_id: str - ): + def get_catalog_collection(self, catalog_id: str, collection_id: str): return Collection( type="Collection", id=collection_id, @@ -177,14 +180,10 @@ def get_catalog_collection( links=[], ) - def unlink_catalog_collection( - self, catalog_id: str, collection_id: str - ): + def unlink_catalog_collection(self, catalog_id: str, collection_id: str): return None - def get_catalog_collection_items( - self, catalog_id: str, collection_id: str - ): + def get_catalog_collection_items(self, catalog_id: str, collection_id: str): return ItemCollection( type="FeatureCollection", features=[ @@ -367,9 +366,7 @@ def test_create_catalog_collection(client: TestClient) -> None: "license": "proprietary", "links": [], } - response = client.post( - "/catalogs/test-catalog-1/collections", json=collection_data - ) + response = client.post("/catalogs/test-catalog-1/collections", json=collection_data) assert response.status_code == 201, response.text data = response.json() assert data["id"] == "new-collection" @@ -385,17 +382,13 @@ def test_get_catalog_collection(client: TestClient) -> None: def test_unlink_catalog_collection(client: TestClient) -> None: """Test DELETE /catalogs/{catalog_id}/collections/{collection_id} endpoint.""" - response = client.delete( - "/catalogs/test-catalog-1/collections/test-collection" - ) + response = client.delete("/catalogs/test-catalog-1/collections/test-collection") assert response.status_code == 204, response.text def test_get_catalog_collection_items(client: TestClient) -> None: """Test GET /catalogs/{catalog_id}/collections/{collection_id}/items endpoint.""" - response = client.get( - "/catalogs/test-catalog-1/collections/test-collection/items" - ) + response = client.get("/catalogs/test-catalog-1/collections/test-collection/items") assert response.status_code == 200, response.text data = response.json() assert data["type"] == "FeatureCollection" @@ -404,7 +397,7 @@ def test_get_catalog_collection_items(client: TestClient) -> None: def test_get_catalog_collection_item(client: TestClient) -> None: - """Test GET /catalogs/{catalog_id}/collections/{collection_id}/items/{item_id} endpoint.""" + """Test GET /catalogs/{catalog_id}/collections/{collection_id}/items/{item_id}.""" response = client.get( "/catalogs/test-catalog-1/collections/test-collection/items/test-item" ) From 2fbd192781f33e2abedf3d2ddb68a0e4343de3c2 Mon Sep 17 00:00:00 2001 From: jonhealy1 Date: Sun, 8 Feb 2026 19:11:46 +0800 Subject: [PATCH 10/40] test types filter for /children --- .../extensions/tests/test_catalogs.py | 40 ++++++++++++------- 1 file changed, 26 insertions(+), 14 deletions(-) diff --git a/stac_fastapi/extensions/tests/test_catalogs.py b/stac_fastapi/extensions/tests/test_catalogs.py index f443ce93f..c1f6676d2 100644 --- a/stac_fastapi/extensions/tests/test_catalogs.py +++ b/stac_fastapi/extensions/tests/test_catalogs.py @@ -218,22 +218,30 @@ def get_catalog_collection_item( def get_catalog_children( self, catalog_id: str, limit: int = None, token: str = None, type: str = None ): + all_children = [ + { + "id": f"{catalog_id}-child-1", + "type": "Catalog", + "description": "Child catalog", + }, + { + "id": "collection-1", + "type": "Collection", + "description": "Child collection", + }, + ] + + # Filter by type if provided + if type: + filtered_children = [child for child in all_children if child["type"] == type] + else: + filtered_children = all_children + return Children( - children=[ - { - "id": f"{catalog_id}-child-1", - "type": "Catalog", - "description": "Child catalog", - }, - { - "id": "collection-1", - "type": "Collection", - "description": "Child collection", - }, - ], + children=filtered_children, links=[], - numberMatched=2, - numberReturned=2, + numberMatched=len(filtered_children), + numberReturned=len(filtered_children), ) def get_catalog_conformance(self, catalog_id: str): @@ -447,6 +455,10 @@ def test_get_catalog_children_with_type_filter(client: TestClient) -> None: assert response.status_code == 200, response.text data = response.json() assert "children" in data + assert len(data["children"]) == 1 + assert data["children"][0]["type"] == "Catalog" + assert data["numberMatched"] == 1 + assert data["numberReturned"] == 1 def test_get_catalog_conformance(client: TestClient) -> None: From bca26684929c131c7c217d14866b9054c7ec86fc Mon Sep 17 00:00:00 2001 From: jonhealy1 Date: Sun, 8 Feb 2026 19:59:36 +0800 Subject: [PATCH 11/40] update changelog --- CHANGES.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index 14ff092d7..8e8c167f6 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Multi-tenant catalogs extension for managing multiple catalogs with support for catalog hierarchies, collections, items, and catalog-specific conformance and queryable endpoints ([#880](https://github.com/stac-utils/stac-fastapi/pull/880)) + ## [6.2.0] - 2026-01-13 ### Removed From bc683901431a704f894e2ff9716d2d0bd181545e Mon Sep 17 00:00:00 2001 From: jonhealy1 Date: Sun, 8 Feb 2026 20:10:48 +0800 Subject: [PATCH 12/40] add docs --- .../extensions/core/multi_tenant_catalogs/catalogs.md | 3 +++ .../extensions/core/multi_tenant_catalogs/client.md | 3 +++ .../extensions/core/multi_tenant_catalogs/index.md | 9 +++++++++ .../extensions/core/multi_tenant_catalogs/types.md | 3 +++ 4 files changed, 18 insertions(+) create mode 100644 docs/src/api/stac_fastapi/extensions/core/multi_tenant_catalogs/catalogs.md create mode 100644 docs/src/api/stac_fastapi/extensions/core/multi_tenant_catalogs/client.md create mode 100644 docs/src/api/stac_fastapi/extensions/core/multi_tenant_catalogs/index.md create mode 100644 docs/src/api/stac_fastapi/extensions/core/multi_tenant_catalogs/types.md diff --git a/docs/src/api/stac_fastapi/extensions/core/multi_tenant_catalogs/catalogs.md b/docs/src/api/stac_fastapi/extensions/core/multi_tenant_catalogs/catalogs.md new file mode 100644 index 000000000..3de2c8a17 --- /dev/null +++ b/docs/src/api/stac_fastapi/extensions/core/multi_tenant_catalogs/catalogs.md @@ -0,0 +1,3 @@ +::: stac_fastapi.extensions.core.multi_tenant_catalogs.catalogs + options: + show_source: true diff --git a/docs/src/api/stac_fastapi/extensions/core/multi_tenant_catalogs/client.md b/docs/src/api/stac_fastapi/extensions/core/multi_tenant_catalogs/client.md new file mode 100644 index 000000000..fd3598fa6 --- /dev/null +++ b/docs/src/api/stac_fastapi/extensions/core/multi_tenant_catalogs/client.md @@ -0,0 +1,3 @@ +::: stac_fastapi.extensions.core.multi_tenant_catalogs.client + options: + show_source: true diff --git a/docs/src/api/stac_fastapi/extensions/core/multi_tenant_catalogs/index.md b/docs/src/api/stac_fastapi/extensions/core/multi_tenant_catalogs/index.md new file mode 100644 index 000000000..6d81a0b93 --- /dev/null +++ b/docs/src/api/stac_fastapi/extensions/core/multi_tenant_catalogs/index.md @@ -0,0 +1,9 @@ +# Module stac_fastapi.extensions.core.multi_tenant_catalogs + +Multi-Tenant Catalogs Extension submodule. + +## Sub-modules + +* [stac_fastapi.extensions.core.multi_tenant_catalogs.catalogs](catalogs.md) +* [stac_fastapi.extensions.core.multi_tenant_catalogs.client](client.md) +* [stac_fastapi.extensions.core.multi_tenant_catalogs.types](types.md) diff --git a/docs/src/api/stac_fastapi/extensions/core/multi_tenant_catalogs/types.md b/docs/src/api/stac_fastapi/extensions/core/multi_tenant_catalogs/types.md new file mode 100644 index 000000000..5f302b41f --- /dev/null +++ b/docs/src/api/stac_fastapi/extensions/core/multi_tenant_catalogs/types.md @@ -0,0 +1,3 @@ +::: stac_fastapi.extensions.core.multi_tenant_catalogs.types + options: + show_source: true From af6aad6c11dea0ea605c912c6fda1675236a2cf0 Mon Sep 17 00:00:00 2001 From: jonhealy1 Date: Sun, 8 Feb 2026 20:14:48 +0800 Subject: [PATCH 13/40] add to mkdocs yml --- docs/mkdocs.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 531255f0a..731bb1416 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -57,6 +57,11 @@ nav: - module: api/stac_fastapi/extensions/core/free_text/index.md - free_text: api/stac_fastapi/extensions/core/free_text/free_text.md - request: api/stac_fastapi/extensions/core/free_text/request.md + - multi_tenant_catalogs: + - module: api/stac_fastapi/extensions/core/multi_tenant_catalogs/index.md + - catalogs: api/stac_fastapi/extensions/core/multi_tenant_catalogs/catalogs.md + - client: api/stac_fastapi/extensions/core/multi_tenant_catalogs/client.md + - types: api/stac_fastapi/extensions/core/multi_tenant_catalogs/types.md - pagination: - module: api/stac_fastapi/extensions/core/pagination/index.md - pagination: api/stac_fastapi/extensions/core/pagination/pagination.md From bc6e614349a03626ebecc8b20bcb891139900dbe Mon Sep 17 00:00:00 2001 From: jonhealy1 Date: Sun, 8 Feb 2026 20:18:16 +0800 Subject: [PATCH 14/40] type annotation --- .../extensions/core/multi_tenant_catalogs/catalogs.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/catalogs.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/catalogs.py index 5430e8d01..4609b5ce7 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/catalogs.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/catalogs.py @@ -1,6 +1,6 @@ """Catalogs extension.""" -from typing import List, Type +from typing import Any, Dict, List, Optional, Type import attr from fastapi import APIRouter, FastAPI @@ -49,7 +49,7 @@ class CatalogsExtension(ApiExtension): router: APIRouter = attr.ib(factory=APIRouter) response_class: Type[Response] = attr.ib(default=JSONResponse) - def register(self, app: FastAPI, settings=None) -> None: + def register(self, app: FastAPI, settings: Optional[Dict[str, Any]] = None) -> None: """Register the extension with a FastAPI application. Args: From ea311a12657b387fcf6bd08dafa513091a75783a Mon Sep 17 00:00:00 2001 From: jonhealy1 Date: Mon, 9 Feb 2026 11:55:27 +0800 Subject: [PATCH 15/40] Link by id --- .../core/multi_tenant_catalogs/catalogs.py | 4 +- .../core/multi_tenant_catalogs/client.py | 49 ++++++-- .../core/multi_tenant_catalogs/types.py | 11 ++ .../extensions/tests/test_catalogs.py | 107 ++++++++++++++---- 4 files changed, 136 insertions(+), 35 deletions(-) diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/catalogs.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/catalogs.py index 4609b5ce7..56583677d 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/catalogs.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/catalogs.py @@ -207,7 +207,7 @@ def register(self, app: FastAPI, settings: Optional[Dict[str, Any]] = None) -> N status_code=HTTP_201_CREATED, summary="Create Catalog Sub-Catalog", description=( - "Create a new catalog and link it as a sub-catalog " + "Create a new catalog or link an existing catalog as a sub-catalog " "of a specific catalog." ), tags=["Catalogs"], @@ -221,7 +221,7 @@ def register(self, app: FastAPI, settings: Optional[Dict[str, Any]] = None) -> N response_class=self.response_class, summary="Get Catalog Children", description=( - "Retrieve all children (Catalogs and Collections) " "of this catalog." + "Retrieve all children (Catalogs and Collections) of this catalog." ), tags=["Catalogs"], ) diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/client.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/client.py index c53069ebe..9aeba170c 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/client.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/client.py @@ -1,7 +1,8 @@ """Catalogs extension clients.""" import abc -from typing import Literal, Optional +from datetime import datetime +from typing import List, Literal, Optional, Union import attr from stac_pydantic.api.collections import Collections @@ -10,7 +11,7 @@ from stac_pydantic.item import Item from stac_pydantic.item_collection import ItemCollection -from .types import Catalogs, Children +from .types import Catalogs, Children, ObjectUri @attr.s @@ -117,7 +118,7 @@ async def get_sub_catalogs( @abc.abstractmethod async def create_sub_catalog( - self, catalog_id: str, catalog: Catalog, **kwargs + self, catalog_id: str, catalog: Union[Catalog, ObjectUri], **kwargs ) -> Catalog: """Create a new catalog or link an existing catalog as a sub-catalog. @@ -137,7 +138,7 @@ async def create_sub_catalog( Args: catalog_id: The ID of the parent catalog. - catalog: The catalog to create or link. + catalog: The catalog to create or link (full Catalog or ObjectUri with id). Returns: The created or linked catalog. @@ -146,7 +147,7 @@ async def create_sub_catalog( @abc.abstractmethod async def create_catalog_collection( - self, catalog_id: str, collection: Collection, **kwargs + self, catalog_id: str, collection: Union[Collection, ObjectUri], **kwargs ) -> Collection: """Create a new collection or link an existing collection to catalog. @@ -158,7 +159,7 @@ async def create_catalog_collection( Args: catalog_id: The ID of the catalog to link the collection to. - collection: The collection to create or link. + collection: Create or link (full Collection or ObjectUri with id). Returns: The created or linked collection. @@ -200,13 +201,24 @@ async def get_catalog_collection_items( self, catalog_id: str, collection_id: str, + bbox: Optional[List[float]] = None, + datetime: Optional[Union[str, datetime]] = None, + limit: Optional[int] = 10, + token: Optional[str] = None, **kwargs, ) -> ItemCollection: - """Get items from a collection in a catalog. + """Get items from a collection in a catalog with search support. + + Multiple filters are combined using AND logic. If both bbox and datetime + are provided, only items matching both criteria are returned. Args: catalog_id: The ID of the catalog. collection_id: The ID of the collection. + bbox: Bounding box to filter items [minx, miny, maxx, maxy]. + datetime: Datetime or datetime range to filter items. + limit: Maximum number of items to return (default 10). + token: Pagination token for cursor-based pagination. Returns: ItemCollection containing items from the collection. @@ -389,7 +401,9 @@ def get_sub_catalogs( ... @abc.abstractmethod - def create_sub_catalog(self, catalog_id: str, catalog: Catalog, **kwargs) -> Catalog: + def create_sub_catalog( + self, catalog_id: str, catalog: Union[Catalog, ObjectUri], **kwargs + ) -> Catalog: """Create a new catalog or link an existing catalog as a sub-catalog. Supports two modes: @@ -408,7 +422,7 @@ def create_sub_catalog(self, catalog_id: str, catalog: Catalog, **kwargs) -> Cat Args: catalog_id: The ID of the parent catalog. - catalog: The catalog to create or link. + catalog: The catalog to create or link (full Catalog or ObjectUri with id). Returns: The created or linked catalog. @@ -417,7 +431,7 @@ def create_sub_catalog(self, catalog_id: str, catalog: Catalog, **kwargs) -> Cat @abc.abstractmethod def create_catalog_collection( - self, catalog_id: str, collection: Collection, **kwargs + self, catalog_id: str, collection: Union[Collection, ObjectUri], **kwargs ) -> Collection: """Create a new collection or link an existing collection to catalog. @@ -429,7 +443,7 @@ def create_catalog_collection( Args: catalog_id: The ID of the catalog to link the collection to. - collection: The collection to create or link. + collection: Create or link (full Collection or ObjectUri with id). Returns: The created or linked collection. @@ -471,13 +485,24 @@ def get_catalog_collection_items( self, catalog_id: str, collection_id: str, + bbox: Optional[List[float]] = None, + datetime: Optional[Union[str, datetime]] = None, + limit: Optional[int] = 10, + token: Optional[str] = None, **kwargs, ) -> ItemCollection: - """Get items from a collection in a catalog. + """Get items from a collection in a catalog with search support. + + Multiple filters are combined using AND logic. If both bbox and datetime + are provided, only items matching both criteria are returned. Args: catalog_id: The ID of the catalog. collection_id: The ID of the collection. + bbox: Bounding box to filter items [minx, miny, maxx, maxy]. + datetime: Datetime or datetime range to filter items. + limit: Maximum number of items to return (default 10). + token: Pagination token for cursor-based pagination. Returns: ItemCollection containing items from the collection. diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/types.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/types.py index 70c80cc41..f4d10c905 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/types.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/types.py @@ -2,11 +2,22 @@ from typing import Any, Dict, List, Optional +from pydantic import BaseModel from stac_pydantic.catalog import Catalog from stac_pydantic.links import Links from stac_pydantic.shared import StacBaseModel +class ObjectUri(BaseModel): + """Simple model for linking existing resources by ID. + + Used for Mode B (Linking) operations where only the ID is provided + to reference an existing catalog or collection. + """ + + id: str + + class Catalogs(StacBaseModel): """Catalogs endpoint response. diff --git a/stac_fastapi/extensions/tests/test_catalogs.py b/stac_fastapi/extensions/tests/test_catalogs.py index c1f6676d2..235eca5f4 100644 --- a/stac_fastapi/extensions/tests/test_catalogs.py +++ b/stac_fastapi/extensions/tests/test_catalogs.py @@ -1,6 +1,7 @@ """Tests for the Catalogs extension.""" -from typing import Iterator +from datetime import datetime +from typing import Iterator, List, Optional, Union import pytest from stac_pydantic.catalog import Catalog @@ -14,7 +15,11 @@ from stac_fastapi.extensions.core.multi_tenant_catalogs.client import ( BaseCatalogsClient, ) -from stac_fastapi.extensions.core.multi_tenant_catalogs.types import Catalogs, Children +from stac_fastapi.extensions.core.multi_tenant_catalogs.types import ( + Catalogs, + Children, + ObjectUri, +) from stac_fastapi.types.config import ApiSettings from stac_fastapi.types.core import BaseCoreClient @@ -145,20 +150,38 @@ def get_sub_catalogs(self, catalog_id: str, limit: int = None, token: str = None numberReturned=1, ) - def create_sub_catalog(self, catalog_id: str, catalog: Catalog): + def create_sub_catalog(self, catalog_id: str, catalog: Union[Catalog, ObjectUri]): + catalog_id_val = catalog.id + + description = None + if isinstance(catalog, Catalog): + description = catalog.description or f"Sub-catalog of {catalog_id}" + else: + description = f"Sub-catalog of {catalog_id}" + return Catalog( type="Catalog", - id=catalog.id, - description=catalog.description or f"Sub-catalog of {catalog_id}", + id=catalog_id_val, + description=description, stac_version="1.0.0", links=[], ) - def create_catalog_collection(self, catalog_id: str, collection: Collection): + def create_catalog_collection( + self, catalog_id: str, collection: Union[Collection, ObjectUri] + ): + collection_id_val = collection.id + + description = None + if isinstance(collection, Collection): + description = collection.description or f"Collection in {catalog_id}" + else: + description = f"Collection in {catalog_id}" + return Collection( type="Collection", - id=collection.id, - description=collection.description or f"Collection in {catalog_id}", + id=collection_id_val, + description=description, extent={ "spatial": {"bbox": [[-180, -90, 180, 90]]}, "temporal": {"interval": [[None, None]]}, @@ -183,21 +206,43 @@ def get_catalog_collection(self, catalog_id: str, collection_id: str): def unlink_catalog_collection(self, catalog_id: str, collection_id: str): return None - def get_catalog_collection_items(self, catalog_id: str, collection_id: str): + def get_catalog_collection_items( + self, + catalog_id: str, + collection_id: str, + bbox: Optional[List[float]] = None, + datetime: Optional[Union[str, datetime]] = None, + limit: Optional[int] = 10, + token: Optional[str] = None, + ): + features = [ + Item( + type="Feature", + id="test-item", + geometry={"type": "Point", "coordinates": [0, 0]}, + bbox=[0, 0, 0, 0], + datetime="2024-01-01T00:00:00Z", + properties={"datetime": "2024-01-01T00:00:00Z"}, + links=[], + assets={}, + ) + ] + + if bbox is not None: + if not (bbox[0] <= 0 <= bbox[2] and bbox[1] <= 0 <= bbox[3]): + features = [] + + if datetime is not None: + if isinstance(datetime, str): + if "2024-01-01" not in datetime: + features = [] + + if limit is not None and len(features) > limit: + features = features[:limit] + return ItemCollection( type="FeatureCollection", - features=[ - Item( - type="Feature", - id="test-item", - geometry={"type": "Point", "coordinates": [0, 0]}, - bbox=[0, 0, 0, 0], - datetime="2024-01-01T00:00:00Z", - properties={"datetime": "2024-01-01T00:00:00Z"}, - links=[], - assets={}, - ) - ], + features=features, links=[], ) @@ -439,6 +484,26 @@ def test_create_sub_catalog(client: TestClient) -> None: assert data["id"] == "new-sub-catalog" +def test_create_sub_catalog_with_object_uri(client: TestClient) -> None: + """Test POST /catalogs/{catalog_id}/catalogs with ObjectUri (Mode B - linking).""" + object_uri_data = {"id": "existing-catalog"} + response = client.post("/catalogs/test-catalog-1/catalogs", json=object_uri_data) + assert response.status_code == 201, response.text + data = response.json() + assert data["id"] == "existing-catalog" + assert data["type"] == "Catalog" + + +def test_create_catalog_collection_with_object_uri(client: TestClient) -> None: + """Test POST /catalogs/{catalog_id}/collections with ObjectUri (Mode B - linking).""" + object_uri_data = {"id": "existing-collection"} + response = client.post("/catalogs/test-catalog-1/collections", json=object_uri_data) + assert response.status_code == 201, response.text + data = response.json() + assert data["id"] == "existing-collection" + assert data["type"] == "Collection" + + def test_get_catalog_children(client: TestClient) -> None: """Test GET /catalogs/{catalog_id}/children endpoint.""" response = client.get("/catalogs/test-catalog-1/children") From 8bc451f61ceed0feeb6701cb566397934fb0519f Mon Sep 17 00:00:00 2001 From: jonhealy1 Date: Mon, 9 Feb 2026 12:58:30 +0800 Subject: [PATCH 16/40] use types --- .../core/multi_tenant_catalogs/catalogs.py | 49 ++++++++++++++++++- .../extensions/tests/test_catalogs.py | 40 ++++++++++++++- 2 files changed, 86 insertions(+), 3 deletions(-) diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/catalogs.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/catalogs.py index 56583677d..066a51365 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/catalogs.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/catalogs.py @@ -3,7 +3,7 @@ from typing import Any, Dict, List, Optional, Type import attr -from fastapi import APIRouter, FastAPI +from fastapi import APIRouter, FastAPI, Query, Request from fastapi.responses import JSONResponse from stac_pydantic.api.collections import Collections from stac_pydantic.catalog import Catalog @@ -14,6 +14,7 @@ from starlette.status import HTTP_200_OK, HTTP_201_CREATED, HTTP_204_NO_CONTENT from stac_fastapi.types.extension import ApiExtension +from stac_fastapi.types.search import str2bbox from .client import AsyncBaseCatalogsClient from .types import Catalogs, Children @@ -49,6 +50,50 @@ class CatalogsExtension(ApiExtension): router: APIRouter = attr.ib(factory=APIRouter) response_class: Type[Response] = attr.ib(default=JSONResponse) + async def get_catalog_collection_items( + self, + catalog_id: str, + collection_id: str, + request: Request, + bbox: Optional[str] = Query( + None, + description="Bounding box to filter items.", + ), + datetime: Optional[str] = Query(None, description="Datetime to filter items"), + limit: int = Query(10, ge=1, le=10000, description="Maximum number of items"), + token: Optional[str] = Query(None, description="Pagination token"), + ) -> ItemCollection: + """Get items from a collection in a catalog with search support. + + Parses HTTP query parameters and delegates to the client for database queries. + + Args: + catalog_id: The ID of the catalog. + collection_id: The ID of the collection. + request: The HTTP request object. + bbox: Bounding box string. + datetime: Datetime filter string. + limit: Maximum number of items to return. + token: Pagination token. + + Returns: + ItemCollection containing items from the collection. + + Raises: + HTTPException: If bbox format is invalid. + """ + bbox_list = str2bbox(bbox) if bbox else None + + return await self.client.get_catalog_collection_items( + catalog_id=catalog_id, + collection_id=collection_id, + bbox=bbox_list, + datetime=datetime, + limit=limit, + token=token, + request=request, + ) + def register(self, app: FastAPI, settings: Optional[Dict[str, Any]] = None) -> None: """Register the extension with a FastAPI application. @@ -167,7 +212,7 @@ def register(self, app: FastAPI, settings: Optional[Dict[str, Any]] = None) -> N self.router.add_api_route( path="/catalogs/{catalog_id}/collections/{collection_id}/items", - endpoint=self.client.get_catalog_collection_items, + endpoint=self.get_catalog_collection_items, methods=["GET"], response_model=ItemCollection, response_class=self.response_class, diff --git a/stac_fastapi/extensions/tests/test_catalogs.py b/stac_fastapi/extensions/tests/test_catalogs.py index 235eca5f4..a40b10e57 100644 --- a/stac_fastapi/extensions/tests/test_catalogs.py +++ b/stac_fastapi/extensions/tests/test_catalogs.py @@ -206,7 +206,7 @@ def get_catalog_collection(self, catalog_id: str, collection_id: str): def unlink_catalog_collection(self, catalog_id: str, collection_id: str): return None - def get_catalog_collection_items( + async def get_catalog_collection_items( self, catalog_id: str, collection_id: str, @@ -214,6 +214,7 @@ def get_catalog_collection_items( datetime: Optional[Union[str, datetime]] = None, limit: Optional[int] = 10, token: Optional[str] = None, + **kwargs, ): features = [ Item( @@ -449,6 +450,43 @@ def test_get_catalog_collection_items(client: TestClient) -> None: assert data["features"][0]["id"] == "test-item" +def test_get_catalog_collection_items_with_bbox(client: TestClient) -> None: + """Test GET .../items with bbox filter.""" + params = {"bbox": "-1,-1,1,1"} + response = client.get( + "/catalogs/test-catalog-1/collections/test-collection/items", params=params + ) + assert response.status_code == 200, response.text + data = response.json() + assert len(data["features"]) == 1 + assert data["features"][0]["id"] == "test-item" + + params_out = {"bbox": "10,10,20,20"} + response_out = client.get( + "/catalogs/test-catalog-1/collections/test-collection/items", params=params_out + ) + assert response_out.status_code == 200, response_out.text + assert len(response_out.json()["features"]) == 0 + + +def test_get_catalog_collection_items_with_datetime(client: TestClient) -> None: + """Test GET .../items with datetime filter.""" + params = {"datetime": "2024-01-01T00:00:00Z"} + response = client.get( + "/catalogs/test-catalog-1/collections/test-collection/items", params=params + ) + assert response.status_code == 200, response.text + data = response.json() + assert len(data["features"]) == 1 + + params_out = {"datetime": "2023-01-01T00:00:00Z"} + response_out = client.get( + "/catalogs/test-catalog-1/collections/test-collection/items", params=params_out + ) + assert response_out.status_code == 200, response_out.text + assert len(response_out.json()["features"]) == 0 + + def test_get_catalog_collection_item(client: TestClient) -> None: """Test GET /catalogs/{catalog_id}/collections/{collection_id}/items/{item_id}.""" response = client.get( From f241b98d4a64363122ddc097f64d747fa74da113 Mon Sep 17 00:00:00 2001 From: jonhealy1 Date: Mon, 9 Feb 2026 13:10:01 +0800 Subject: [PATCH 17/40] fixes --- .../core/multi_tenant_catalogs/catalogs.py | 184 ++++++++++++++---- .../extensions/tests/test_catalogs.py | 55 +++--- 2 files changed, 180 insertions(+), 59 deletions(-) diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/catalogs.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/catalogs.py index 066a51365..52b654401 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/catalogs.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/catalogs.py @@ -1,6 +1,6 @@ """Catalogs extension.""" -from typing import Any, Dict, List, Optional, Type +from typing import Any, Dict, List, Literal, Optional, Type, Union import attr from fastapi import APIRouter, FastAPI, Query, Request @@ -17,7 +17,7 @@ from stac_fastapi.types.search import str2bbox from .client import AsyncBaseCatalogsClient -from .types import Catalogs, Children +from .types import Catalogs, Children, ObjectUri CATALOGS_CONFORMANCE_CLASSES = [ "https://api.stacspec.org/v1.0.0/core", @@ -63,25 +63,7 @@ async def get_catalog_collection_items( limit: int = Query(10, ge=1, le=10000, description="Maximum number of items"), token: Optional[str] = Query(None, description="Pagination token"), ) -> ItemCollection: - """Get items from a collection in a catalog with search support. - - Parses HTTP query parameters and delegates to the client for database queries. - - Args: - catalog_id: The ID of the catalog. - collection_id: The ID of the collection. - request: The HTTP request object. - bbox: Bounding box string. - datetime: Datetime filter string. - limit: Maximum number of items to return. - token: Pagination token. - - Returns: - ItemCollection containing items from the collection. - - Raises: - HTTPException: If bbox format is invalid. - """ + """Get items from a collection in a catalog with search support.""" bbox_list = str2bbox(bbox) if bbox else None return await self.client.get_catalog_collection_items( @@ -94,6 +76,134 @@ async def get_catalog_collection_items( request=request, ) + # --- WRAPPERS --- + + async def _get_catalogs_wrapper( + self, + limit: Optional[int] = None, + token: Optional[str] = None, + request: Request = None, + ) -> Catalogs: + return await self.client.get_catalogs(limit=limit, token=token, request=request) + + async def _create_catalog_wrapper( + self, catalog: Catalog, request: Request = None + ) -> Catalog: + return await self.client.create_catalog(catalog=catalog, request=request) + + async def _get_catalog_wrapper( + self, catalog_id: str, request: Request = None + ) -> Catalog: + return await self.client.get_catalog(catalog_id=catalog_id, request=request) + + async def _update_catalog_wrapper( + self, catalog_id: str, catalog: Catalog, request: Request = None + ) -> Catalog: + return await self.client.update_catalog( + catalog_id=catalog_id, catalog=catalog, request=request + ) + + async def _delete_catalog_wrapper( + self, catalog_id: str, request: Request = None + ) -> None: + return await self.client.delete_catalog(catalog_id=catalog_id, request=request) + + async def _get_catalog_collections_wrapper( + self, catalog_id: str, request: Request = None + ) -> Collections: + return await self.client.get_catalog_collections( + catalog_id=catalog_id, request=request + ) + + async def _get_sub_catalogs_wrapper( + self, + catalog_id: str, + limit: Optional[int] = None, + token: Optional[str] = None, + request: Request = None, + ) -> Catalogs: + return await self.client.get_sub_catalogs( + catalog_id=catalog_id, limit=limit, token=token, request=request + ) + + async def _create_sub_catalog_wrapper( + self, catalog_id: str, catalog: Union[Catalog, ObjectUri], request: Request = None + ) -> Catalog: + return await self.client.create_sub_catalog( + catalog_id=catalog_id, catalog=catalog, request=request + ) + + async def _create_catalog_collection_wrapper( + self, + catalog_id: str, + collection: Union[Collection, ObjectUri], + request: Request = None, + ) -> Collection: + return await self.client.create_catalog_collection( + catalog_id=catalog_id, collection=collection, request=request + ) + + async def _get_catalog_collection_wrapper( + self, catalog_id: str, collection_id: str, request: Request = None + ) -> Collection: + return await self.client.get_catalog_collection( + catalog_id=catalog_id, collection_id=collection_id, request=request + ) + + async def _unlink_catalog_collection_wrapper( + self, catalog_id: str, collection_id: str, request: Request = None + ) -> None: + return await self.client.unlink_catalog_collection( + catalog_id=catalog_id, collection_id=collection_id, request=request + ) + + async def _get_catalog_collection_item_wrapper( + self, catalog_id: str, collection_id: str, item_id: str, request: Request = None + ) -> Item: + return await self.client.get_catalog_collection_item( + catalog_id=catalog_id, + collection_id=collection_id, + item_id=item_id, + request=request, + ) + + async def _get_catalog_children_wrapper( + self, + catalog_id: str, + limit: Optional[int] = None, + token: Optional[str] = None, + type: Optional[Literal["Catalog", "Collection"]] = None, + request: Request = None, + ) -> Children: + return await self.client.get_catalog_children( + catalog_id=catalog_id, + limit=limit, + token=token, + type=type, + request=request, + ) + + async def _get_catalog_conformance_wrapper( + self, catalog_id: str, request: Request = None + ) -> dict: + return await self.client.get_catalog_conformance( + catalog_id=catalog_id, request=request + ) + + async def _get_catalog_queryables_wrapper( + self, catalog_id: str, request: Request = None + ) -> dict: + return await self.client.get_catalog_queryables( + catalog_id=catalog_id, request=request + ) + + async def _unlink_sub_catalog_wrapper( + self, catalog_id: str, sub_catalog_id: str, request: Request = None + ) -> None: + return await self.client.unlink_sub_catalog( + catalog_id=catalog_id, sub_catalog_id=sub_catalog_id, request=request + ) + def register(self, app: FastAPI, settings: Optional[Dict[str, Any]] = None) -> None: """Register the extension with a FastAPI application. @@ -108,7 +218,7 @@ def register(self, app: FastAPI, settings: Optional[Dict[str, Any]] = None) -> N self.router.add_api_route( path="/catalogs", - endpoint=self.client.get_catalogs, + endpoint=self._get_catalogs_wrapper, methods=["GET"], response_model=Catalogs, response_class=self.response_class, @@ -119,7 +229,7 @@ def register(self, app: FastAPI, settings: Optional[Dict[str, Any]] = None) -> N self.router.add_api_route( path="/catalogs", - endpoint=self.client.create_catalog, + endpoint=self._create_catalog_wrapper, methods=["POST"], response_model=Catalog, response_class=self.response_class, @@ -131,7 +241,7 @@ def register(self, app: FastAPI, settings: Optional[Dict[str, Any]] = None) -> N self.router.add_api_route( path="/catalogs/{catalog_id}", - endpoint=self.client.get_catalog, + endpoint=self._get_catalog_wrapper, methods=["GET"], response_model=Catalog, response_class=self.response_class, @@ -142,7 +252,7 @@ def register(self, app: FastAPI, settings: Optional[Dict[str, Any]] = None) -> N self.router.add_api_route( path="/catalogs/{catalog_id}", - endpoint=self.client.update_catalog, + endpoint=self._update_catalog_wrapper, methods=["PUT"], response_model=Catalog, response_class=self.response_class, @@ -153,7 +263,7 @@ def register(self, app: FastAPI, settings: Optional[Dict[str, Any]] = None) -> N self.router.add_api_route( path="/catalogs/{catalog_id}", - endpoint=self.client.delete_catalog, + endpoint=self._delete_catalog_wrapper, methods=["DELETE"], response_class=self.response_class, status_code=HTTP_204_NO_CONTENT, @@ -164,7 +274,7 @@ def register(self, app: FastAPI, settings: Optional[Dict[str, Any]] = None) -> N self.router.add_api_route( path="/catalogs/{catalog_id}/collections", - endpoint=self.client.get_catalog_collections, + endpoint=self._get_catalog_collections_wrapper, methods=["GET"], response_model=Collections, response_class=self.response_class, @@ -175,7 +285,7 @@ def register(self, app: FastAPI, settings: Optional[Dict[str, Any]] = None) -> N self.router.add_api_route( path="/catalogs/{catalog_id}/collections", - endpoint=self.client.create_catalog_collection, + endpoint=self._create_catalog_collection_wrapper, methods=["POST"], response_model=Collection, response_class=self.response_class, @@ -187,7 +297,7 @@ def register(self, app: FastAPI, settings: Optional[Dict[str, Any]] = None) -> N self.router.add_api_route( path="/catalogs/{catalog_id}/collections/{collection_id}", - endpoint=self.client.get_catalog_collection, + endpoint=self._get_catalog_collection_wrapper, methods=["GET"], response_model=Collection, response_class=self.response_class, @@ -198,7 +308,7 @@ def register(self, app: FastAPI, settings: Optional[Dict[str, Any]] = None) -> N self.router.add_api_route( path="/catalogs/{catalog_id}/collections/{collection_id}", - endpoint=self.client.unlink_catalog_collection, + endpoint=self._unlink_catalog_collection_wrapper, methods=["DELETE"], response_class=self.response_class, status_code=HTTP_204_NO_CONTENT, @@ -223,7 +333,7 @@ def register(self, app: FastAPI, settings: Optional[Dict[str, Any]] = None) -> N self.router.add_api_route( path="/catalogs/{catalog_id}/collections/{collection_id}/items/{item_id}", - endpoint=self.client.get_catalog_collection_item, + endpoint=self._get_catalog_collection_item_wrapper, methods=["GET"], response_model=Item, response_class=self.response_class, @@ -234,7 +344,7 @@ def register(self, app: FastAPI, settings: Optional[Dict[str, Any]] = None) -> N self.router.add_api_route( path="/catalogs/{catalog_id}/catalogs", - endpoint=self.client.get_sub_catalogs, + endpoint=self._get_sub_catalogs_wrapper, methods=["GET"], response_model=Catalogs, response_class=self.response_class, @@ -245,7 +355,7 @@ def register(self, app: FastAPI, settings: Optional[Dict[str, Any]] = None) -> N self.router.add_api_route( path="/catalogs/{catalog_id}/catalogs", - endpoint=self.client.create_sub_catalog, + endpoint=self._create_sub_catalog_wrapper, methods=["POST"], response_model=Catalog, response_class=self.response_class, @@ -260,7 +370,7 @@ def register(self, app: FastAPI, settings: Optional[Dict[str, Any]] = None) -> N self.router.add_api_route( path="/catalogs/{catalog_id}/children", - endpoint=self.client.get_catalog_children, + endpoint=self._get_catalog_children_wrapper, methods=["GET"], response_model=Children, response_class=self.response_class, @@ -273,7 +383,7 @@ def register(self, app: FastAPI, settings: Optional[Dict[str, Any]] = None) -> N self.router.add_api_route( path="/catalogs/{catalog_id}/conformance", - endpoint=self.client.get_catalog_conformance, + endpoint=self._get_catalog_conformance_wrapper, methods=["GET"], response_class=self.response_class, summary="Get Catalog Conformance", @@ -286,7 +396,7 @@ def register(self, app: FastAPI, settings: Optional[Dict[str, Any]] = None) -> N self.router.add_api_route( path="/catalogs/{catalog_id}/queryables", - endpoint=self.client.get_catalog_queryables, + endpoint=self._get_catalog_queryables_wrapper, methods=["GET"], response_class=self.response_class, summary="Get Catalog Queryables", @@ -300,7 +410,7 @@ def register(self, app: FastAPI, settings: Optional[Dict[str, Any]] = None) -> N self.router.add_api_route( path="/catalogs/{catalog_id}/catalogs/{sub_catalog_id}", - endpoint=self.client.unlink_sub_catalog, + endpoint=self._unlink_sub_catalog_wrapper, methods=["DELETE"], response_class=self.response_class, status_code=HTTP_204_NO_CONTENT, diff --git a/stac_fastapi/extensions/tests/test_catalogs.py b/stac_fastapi/extensions/tests/test_catalogs.py index a40b10e57..cc4d3ffc2 100644 --- a/stac_fastapi/extensions/tests/test_catalogs.py +++ b/stac_fastapi/extensions/tests/test_catalogs.py @@ -13,7 +13,7 @@ from stac_fastapi.api.app import StacApi from stac_fastapi.extensions.core import CatalogsExtension from stac_fastapi.extensions.core.multi_tenant_catalogs.client import ( - BaseCatalogsClient, + AsyncBaseCatalogsClient, ) from stac_fastapi.extensions.core.multi_tenant_catalogs.types import ( Catalogs, @@ -46,10 +46,10 @@ def item_collection(self, *args, **kwargs): raise NotImplementedError -class DummyCatalogsClient(BaseCatalogsClient): +class DummyCatalogsClient(AsyncBaseCatalogsClient): """Dummy catalogs client for testing.""" - def get_catalogs(self, limit: int = None, token: str = None): + async def get_catalogs(self, limit: int = None, token: str = None, **kwargs): return Catalogs( catalogs=[ Catalog( @@ -72,7 +72,7 @@ def get_catalogs(self, limit: int = None, token: str = None): numberReturned=2, ) - def create_catalog(self, catalog: Catalog): + async def create_catalog(self, catalog: Catalog, **kwargs): return Catalog( type="Catalog", id=catalog.id, @@ -81,7 +81,7 @@ def create_catalog(self, catalog: Catalog): links=[], ) - def get_catalog(self, catalog_id: str): + async def get_catalog(self, catalog_id: str, **kwargs): return Catalog( type="Catalog", id=catalog_id, @@ -90,7 +90,7 @@ def get_catalog(self, catalog_id: str): links=[], ) - def update_catalog(self, catalog_id: str, catalog: Catalog): + async def update_catalog(self, catalog_id: str, catalog: Catalog, **kwargs): return Catalog( type="Catalog", id=catalog_id, @@ -99,10 +99,10 @@ def update_catalog(self, catalog_id: str, catalog: Catalog): links=[], ) - def delete_catalog(self, catalog_id: str): + async def delete_catalog(self, catalog_id: str, **kwargs): return None - def get_catalog_collections(self, catalog_id: str): + async def get_catalog_collections(self, catalog_id: str, **kwargs): return { "collections": [ { @@ -134,7 +134,9 @@ def get_catalog_collections(self, catalog_id: str): ], } - def get_sub_catalogs(self, catalog_id: str, limit: int = None, token: str = None): + async def get_sub_catalogs( + self, catalog_id: str, limit: int = None, token: str = None, **kwargs + ): return Catalogs( catalogs=[ Catalog( @@ -150,7 +152,9 @@ def get_sub_catalogs(self, catalog_id: str, limit: int = None, token: str = None numberReturned=1, ) - def create_sub_catalog(self, catalog_id: str, catalog: Union[Catalog, ObjectUri]): + async def create_sub_catalog( + self, catalog_id: str, catalog: Union[Catalog, ObjectUri], **kwargs + ): catalog_id_val = catalog.id description = None @@ -167,8 +171,8 @@ def create_sub_catalog(self, catalog_id: str, catalog: Union[Catalog, ObjectUri] links=[], ) - def create_catalog_collection( - self, catalog_id: str, collection: Union[Collection, ObjectUri] + async def create_catalog_collection( + self, catalog_id: str, collection: Union[Collection, ObjectUri], **kwargs ): collection_id_val = collection.id @@ -190,7 +194,7 @@ def create_catalog_collection( links=[], ) - def get_catalog_collection(self, catalog_id: str, collection_id: str): + async def get_catalog_collection(self, catalog_id: str, collection_id: str, **kwargs): return Collection( type="Collection", id=collection_id, @@ -203,7 +207,9 @@ def get_catalog_collection(self, catalog_id: str, collection_id: str): links=[], ) - def unlink_catalog_collection(self, catalog_id: str, collection_id: str): + async def unlink_catalog_collection( + self, catalog_id: str, collection_id: str, **kwargs + ): return None async def get_catalog_collection_items( @@ -247,8 +253,8 @@ async def get_catalog_collection_items( links=[], ) - def get_catalog_collection_item( - self, catalog_id: str, collection_id: str, item_id: str + async def get_catalog_collection_item( + self, catalog_id: str, collection_id: str, item_id: str, **kwargs ): return Item( type="Feature", @@ -261,9 +267,14 @@ def get_catalog_collection_item( assets={}, ) - def get_catalog_children( - self, catalog_id: str, limit: int = None, token: str = None, type: str = None - ): + async def get_catalog_children( + self, + catalog_id: str, + limit: int = None, + token: str = None, + type: str = None, + **kwargs, + ) -> Children: all_children = [ { "id": f"{catalog_id}-child-1", @@ -290,7 +301,7 @@ def get_catalog_children( numberReturned=len(filtered_children), ) - def get_catalog_conformance(self, catalog_id: str): + async def get_catalog_conformance(self, catalog_id: str, **kwargs): return { "conformsTo": [ "https://api.stacspec.org/v1.0.0/core", @@ -298,7 +309,7 @@ def get_catalog_conformance(self, catalog_id: str): ] } - def get_catalog_queryables(self, catalog_id: str): + async def get_catalog_queryables(self, catalog_id: str, **kwargs): return { "queryables": [ {"name": "datetime", "type": "string"}, @@ -306,7 +317,7 @@ def get_catalog_queryables(self, catalog_id: str): ] } - def unlink_sub_catalog(self, catalog_id: str, sub_catalog_id: str): + async def unlink_sub_catalog(self, catalog_id: str, sub_catalog_id: str, **kwargs): return None From 322e9e76a2ada4e3f74a6831bdfd88c2d3782fa5 Mon Sep 17 00:00:00 2001 From: jonhealy1 Date: Mon, 9 Feb 2026 14:29:58 +0800 Subject: [PATCH 18/40] lint --- .../core/multi_tenant_catalogs/catalogs.py | 49 ++++++++++++++----- 1 file changed, 38 insertions(+), 11 deletions(-) diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/catalogs.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/catalogs.py index 52b654401..70722f47a 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/catalogs.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/catalogs.py @@ -64,7 +64,7 @@ async def get_catalog_collection_items( token: Optional[str] = Query(None, description="Pagination token"), ) -> ItemCollection: """Get items from a collection in a catalog with search support.""" - bbox_list = str2bbox(bbox) if bbox else None + bbox_list = list(str2bbox(bbox)) if bbox else None return await self.client.get_catalog_collection_items( catalog_id=catalog_id, @@ -123,11 +123,17 @@ async def _get_sub_catalogs_wrapper( request: Request = None, ) -> Catalogs: return await self.client.get_sub_catalogs( - catalog_id=catalog_id, limit=limit, token=token, request=request + catalog_id=catalog_id, + limit=limit, + token=token, + request=request, ) async def _create_sub_catalog_wrapper( - self, catalog_id: str, catalog: Union[Catalog, ObjectUri], request: Request = None + self, + catalog_id: str, + catalog: Union[Catalog, ObjectUri], + request: Request = None, ) -> Catalog: return await self.client.create_sub_catalog( catalog_id=catalog_id, catalog=catalog, request=request @@ -140,25 +146,41 @@ async def _create_catalog_collection_wrapper( request: Request = None, ) -> Collection: return await self.client.create_catalog_collection( - catalog_id=catalog_id, collection=collection, request=request + catalog_id=catalog_id, + collection=collection, + request=request, ) async def _get_catalog_collection_wrapper( - self, catalog_id: str, collection_id: str, request: Request = None + self, + catalog_id: str, + collection_id: str, + request: Request = None, ) -> Collection: return await self.client.get_catalog_collection( - catalog_id=catalog_id, collection_id=collection_id, request=request + catalog_id=catalog_id, + collection_id=collection_id, + request=request, ) async def _unlink_catalog_collection_wrapper( - self, catalog_id: str, collection_id: str, request: Request = None + self, + catalog_id: str, + collection_id: str, + request: Request = None, ) -> None: return await self.client.unlink_catalog_collection( - catalog_id=catalog_id, collection_id=collection_id, request=request + catalog_id=catalog_id, + collection_id=collection_id, + request=request, ) async def _get_catalog_collection_item_wrapper( - self, catalog_id: str, collection_id: str, item_id: str, request: Request = None + self, + catalog_id: str, + collection_id: str, + item_id: str, + request: Request = None, ) -> Item: return await self.client.get_catalog_collection_item( catalog_id=catalog_id, @@ -198,10 +220,15 @@ async def _get_catalog_queryables_wrapper( ) async def _unlink_sub_catalog_wrapper( - self, catalog_id: str, sub_catalog_id: str, request: Request = None + self, + catalog_id: str, + sub_catalog_id: str, + request: Request = None, ) -> None: return await self.client.unlink_sub_catalog( - catalog_id=catalog_id, sub_catalog_id=sub_catalog_id, request=request + catalog_id=catalog_id, + sub_catalog_id=sub_catalog_id, + request=request, ) def register(self, app: FastAPI, settings: Optional[Dict[str, Any]] = None) -> None: From 4545be229976650b765928304ac68c550479e96d Mon Sep 17 00:00:00 2001 From: jonhealy1 Date: Mon, 9 Feb 2026 14:42:32 +0800 Subject: [PATCH 19/40] update --- .../core/multi_tenant_catalogs/catalogs.py | 78 ++-- .../core/multi_tenant_catalogs/client.py | 359 ++++++++---------- 2 files changed, 189 insertions(+), 248 deletions(-) diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/catalogs.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/catalogs.py index 70722f47a..a8ee7fa1a 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/catalogs.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/catalogs.py @@ -64,7 +64,12 @@ async def get_catalog_collection_items( token: Optional[str] = Query(None, description="Pagination token"), ) -> ItemCollection: """Get items from a collection in a catalog with search support.""" - bbox_list = list(str2bbox(bbox)) if bbox else None + # Fix 1: Convert Tuple to List explicitly for mypy + bbox_list: Optional[List[float]] = None + if bbox: + bbox_tuple = str2bbox(bbox) + if bbox_tuple: + bbox_list = list(bbox_tuple) return await self.client.get_catalog_collection_items( catalog_id=catalog_id, @@ -77,39 +82,36 @@ async def get_catalog_collection_items( ) # --- WRAPPERS --- + # Fix 2: Use Request type directly in Depends signature to satisfy mypy strict mode async def _get_catalogs_wrapper( self, + request: Request, limit: Optional[int] = None, token: Optional[str] = None, - request: Request = None, ) -> Catalogs: return await self.client.get_catalogs(limit=limit, token=token, request=request) async def _create_catalog_wrapper( - self, catalog: Catalog, request: Request = None + self, catalog: Catalog, request: Request ) -> Catalog: return await self.client.create_catalog(catalog=catalog, request=request) - async def _get_catalog_wrapper( - self, catalog_id: str, request: Request = None - ) -> Catalog: + async def _get_catalog_wrapper(self, catalog_id: str, request: Request) -> Catalog: return await self.client.get_catalog(catalog_id=catalog_id, request=request) async def _update_catalog_wrapper( - self, catalog_id: str, catalog: Catalog, request: Request = None + self, catalog_id: str, catalog: Catalog, request: Request ) -> Catalog: return await self.client.update_catalog( catalog_id=catalog_id, catalog=catalog, request=request ) - async def _delete_catalog_wrapper( - self, catalog_id: str, request: Request = None - ) -> None: + async def _delete_catalog_wrapper(self, catalog_id: str, request: Request) -> None: return await self.client.delete_catalog(catalog_id=catalog_id, request=request) async def _get_catalog_collections_wrapper( - self, catalog_id: str, request: Request = None + self, catalog_id: str, request: Request ) -> Collections: return await self.client.get_catalog_collections( catalog_id=catalog_id, request=request @@ -118,22 +120,19 @@ async def _get_catalog_collections_wrapper( async def _get_sub_catalogs_wrapper( self, catalog_id: str, + request: Request, limit: Optional[int] = None, token: Optional[str] = None, - request: Request = None, ) -> Catalogs: return await self.client.get_sub_catalogs( - catalog_id=catalog_id, - limit=limit, - token=token, - request=request, + catalog_id=catalog_id, limit=limit, token=token, request=request ) async def _create_sub_catalog_wrapper( self, catalog_id: str, catalog: Union[Catalog, ObjectUri], - request: Request = None, + request: Request, ) -> Catalog: return await self.client.create_sub_catalog( catalog_id=catalog_id, catalog=catalog, request=request @@ -143,44 +142,28 @@ async def _create_catalog_collection_wrapper( self, catalog_id: str, collection: Union[Collection, ObjectUri], - request: Request = None, + request: Request, ) -> Collection: return await self.client.create_catalog_collection( - catalog_id=catalog_id, - collection=collection, - request=request, + catalog_id=catalog_id, collection=collection, request=request ) async def _get_catalog_collection_wrapper( - self, - catalog_id: str, - collection_id: str, - request: Request = None, + self, catalog_id: str, collection_id: str, request: Request ) -> Collection: return await self.client.get_catalog_collection( - catalog_id=catalog_id, - collection_id=collection_id, - request=request, + catalog_id=catalog_id, collection_id=collection_id, request=request ) async def _unlink_catalog_collection_wrapper( - self, - catalog_id: str, - collection_id: str, - request: Request = None, + self, catalog_id: str, collection_id: str, request: Request ) -> None: return await self.client.unlink_catalog_collection( - catalog_id=catalog_id, - collection_id=collection_id, - request=request, + catalog_id=catalog_id, collection_id=collection_id, request=request ) async def _get_catalog_collection_item_wrapper( - self, - catalog_id: str, - collection_id: str, - item_id: str, - request: Request = None, + self, catalog_id: str, collection_id: str, item_id: str, request: Request ) -> Item: return await self.client.get_catalog_collection_item( catalog_id=catalog_id, @@ -192,10 +175,10 @@ async def _get_catalog_collection_item_wrapper( async def _get_catalog_children_wrapper( self, catalog_id: str, + request: Request, limit: Optional[int] = None, token: Optional[str] = None, type: Optional[Literal["Catalog", "Collection"]] = None, - request: Request = None, ) -> Children: return await self.client.get_catalog_children( catalog_id=catalog_id, @@ -206,29 +189,24 @@ async def _get_catalog_children_wrapper( ) async def _get_catalog_conformance_wrapper( - self, catalog_id: str, request: Request = None + self, catalog_id: str, request: Request ) -> dict: return await self.client.get_catalog_conformance( catalog_id=catalog_id, request=request ) async def _get_catalog_queryables_wrapper( - self, catalog_id: str, request: Request = None + self, catalog_id: str, request: Request ) -> dict: return await self.client.get_catalog_queryables( catalog_id=catalog_id, request=request ) async def _unlink_sub_catalog_wrapper( - self, - catalog_id: str, - sub_catalog_id: str, - request: Request = None, + self, catalog_id: str, sub_catalog_id: str, request: Request ) -> None: return await self.client.unlink_sub_catalog( - catalog_id=catalog_id, - sub_catalog_id=sub_catalog_id, - request=request, + catalog_id=catalog_id, sub_catalog_id=sub_catalog_id, request=request ) def register(self, app: FastAPI, settings: Optional[Dict[str, Any]] = None) -> None: diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/client.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/client.py index 9aeba170c..3ccdebb22 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/client.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/client.py @@ -5,6 +5,7 @@ from typing import List, Literal, Optional, Union import attr +from fastapi import Request from stac_pydantic.api.collections import Collections from stac_pydantic.catalog import Catalog from stac_pydantic.collection import Collection @@ -23,6 +24,7 @@ async def get_catalogs( self, limit: Optional[int] = None, token: Optional[str] = None, + request: Optional[Request] = None, **kwargs, ) -> Catalogs: """Get all catalogs with pagination support. @@ -30,6 +32,7 @@ async def get_catalogs( Args: limit: The maximum number of catalogs to return. token: Pagination token for the next page of results. + request: Optional FastAPI request object. Returns: Catalogs object containing catalogs and pagination links. @@ -37,11 +40,14 @@ async def get_catalogs( ... @abc.abstractmethod - async def create_catalog(self, catalog: Catalog, **kwargs) -> Catalog: + async def create_catalog( + self, catalog: Catalog, request: Optional[Request] = None, **kwargs + ) -> Catalog: """Create a new catalog. Args: catalog: The catalog to create. + request: Optional FastAPI request object. Returns: The created catalog. @@ -49,11 +55,14 @@ async def create_catalog(self, catalog: Catalog, **kwargs) -> Catalog: ... @abc.abstractmethod - async def get_catalog(self, catalog_id: str, **kwargs) -> Catalog: + async def get_catalog( + self, catalog_id: str, request: Optional[Request] = None, **kwargs + ) -> Catalog: """Get a specific catalog by ID. Args: catalog_id: The ID of the catalog to retrieve. + request: Optional FastAPI request object. Returns: The requested catalog. @@ -62,13 +71,18 @@ async def get_catalog(self, catalog_id: str, **kwargs) -> Catalog: @abc.abstractmethod async def update_catalog( - self, catalog_id: str, catalog: Catalog, **kwargs + self, + catalog_id: str, + catalog: Catalog, + request: Optional[Request] = None, + **kwargs, ) -> Catalog: """Update an existing catalog. Args: catalog_id: The ID of the catalog to update. catalog: The updated catalog data. + request: Optional FastAPI request object. Returns: The updated catalog. @@ -76,20 +90,32 @@ async def update_catalog( ... @abc.abstractmethod - async def delete_catalog(self, catalog_id: str, **kwargs) -> None: + async def delete_catalog( + self, + catalog_id: str, + request: Optional[Request] = None, + **kwargs, + ) -> None: """Delete a catalog. Args: catalog_id: The ID of the catalog to delete. + request: Optional FastAPI request object. """ ... @abc.abstractmethod - async def get_catalog_collections(self, catalog_id: str, **kwargs) -> Collections: + async def get_catalog_collections( + self, + catalog_id: str, + request: Optional[Request] = None, + **kwargs, + ) -> Collections: """Get collections linked from a specific catalog. Args: catalog_id: The ID of the catalog. + request: Optional FastAPI request object. Returns: Collections object containing collections linked from the catalog. @@ -102,6 +128,7 @@ async def get_sub_catalogs( catalog_id: str, limit: Optional[int] = None, token: Optional[str] = None, + request: Optional[Request] = None, **kwargs, ) -> Catalogs: """Get all sub-catalogs of a specific catalog with pagination. @@ -110,6 +137,7 @@ async def get_sub_catalogs( catalog_id: The ID of the parent catalog. limit: Maximum number of results to return. token: Pagination token for cursor-based pagination. + request: Optional FastAPI request object. Returns: A Catalogs response containing sub-catalogs with pagination links. @@ -118,7 +146,11 @@ async def get_sub_catalogs( @abc.abstractmethod async def create_sub_catalog( - self, catalog_id: str, catalog: Union[Catalog, ObjectUri], **kwargs + self, + catalog_id: str, + catalog: Union[Catalog, ObjectUri], + request: Optional[Request] = None, + **kwargs, ) -> Catalog: """Create a new catalog or link an existing catalog as a sub-catalog. @@ -139,6 +171,7 @@ async def create_sub_catalog( Args: catalog_id: The ID of the parent catalog. catalog: The catalog to create or link (full Catalog or ObjectUri with id). + request: Optional FastAPI request object. Returns: The created or linked catalog. @@ -147,7 +180,11 @@ async def create_sub_catalog( @abc.abstractmethod async def create_catalog_collection( - self, catalog_id: str, collection: Union[Collection, ObjectUri], **kwargs + self, + catalog_id: str, + collection: Union[Collection, ObjectUri], + request: Optional[Request] = None, + **kwargs, ) -> Collection: """Create a new collection or link an existing collection to catalog. @@ -160,6 +197,7 @@ async def create_catalog_collection( Args: catalog_id: The ID of the catalog to link the collection to. collection: Create or link (full Collection or ObjectUri with id). + request: Optional FastAPI request object. Returns: The created or linked collection. @@ -168,13 +206,18 @@ async def create_catalog_collection( @abc.abstractmethod async def get_catalog_collection( - self, catalog_id: str, collection_id: str, **kwargs + self, + catalog_id: str, + collection_id: str, + request: Optional[Request] = None, + **kwargs, ) -> Collection: """Get a specific collection from a catalog. Args: catalog_id: The ID of the catalog. collection_id: The ID of the collection. + request: Optional FastAPI request object. Returns: The requested collection. @@ -183,7 +226,11 @@ async def get_catalog_collection( @abc.abstractmethod async def unlink_catalog_collection( - self, catalog_id: str, collection_id: str, **kwargs + self, + catalog_id: str, + collection_id: str, + request: Optional[Request] = None, + **kwargs, ) -> None: """Unlink a collection from a catalog. @@ -193,6 +240,7 @@ async def unlink_catalog_collection( Args: catalog_id: The ID of the catalog. collection_id: The ID of the collection. + request: Optional FastAPI request object. """ ... @@ -205,6 +253,7 @@ async def get_catalog_collection_items( datetime: Optional[Union[str, datetime]] = None, limit: Optional[int] = 10, token: Optional[str] = None, + request: Optional[Request] = None, **kwargs, ) -> ItemCollection: """Get items from a collection in a catalog with search support. @@ -219,6 +268,7 @@ async def get_catalog_collection_items( datetime: Datetime or datetime range to filter items. limit: Maximum number of items to return (default 10). token: Pagination token for cursor-based pagination. + request: Optional FastAPI request object. Returns: ItemCollection containing items from the collection. @@ -227,7 +277,12 @@ async def get_catalog_collection_items( @abc.abstractmethod async def get_catalog_collection_item( - self, catalog_id: str, collection_id: str, item_id: str, **kwargs + self, + catalog_id: str, + collection_id: str, + item_id: str, + request: Optional[Request] = None, + **kwargs, ) -> Item: """Get a specific item from a collection in a catalog. @@ -235,6 +290,7 @@ async def get_catalog_collection_item( catalog_id: The ID of the catalog. collection_id: The ID of the collection. item_id: The ID of the item. + request: Optional FastAPI request object. Returns: The requested item. @@ -248,6 +304,7 @@ async def get_catalog_children( limit: Optional[int] = None, token: Optional[str] = None, type: Optional[Literal["Catalog", "Collection"]] = None, + request: Optional[Request] = None, **kwargs, ) -> Children: """Get all children (Catalogs and Collections) of a specific catalog. @@ -257,6 +314,7 @@ async def get_catalog_children( limit: Maximum number of results to return. token: Pagination token. type: Filter by resource type (Catalog or Collection). + request: Optional FastAPI request object. Returns: Children object containing children and pagination links. @@ -264,11 +322,14 @@ async def get_catalog_children( ... @abc.abstractmethod - async def get_catalog_conformance(self, catalog_id: str, **kwargs) -> dict: + async def get_catalog_conformance( + self, catalog_id: str, request: Optional[Request] = None, **kwargs + ) -> dict: """Get conformance classes specific to this sub-catalog. Args: catalog_id: The ID of the catalog. + request: Optional FastAPI request object. Returns: Dictionary containing conformance classes. @@ -276,11 +337,14 @@ async def get_catalog_conformance(self, catalog_id: str, **kwargs) -> dict: ... @abc.abstractmethod - async def get_catalog_queryables(self, catalog_id: str, **kwargs) -> dict: + async def get_catalog_queryables( + self, catalog_id: str, request: Optional[Request] = None, **kwargs + ) -> dict: """Get queryable fields available for filtering in this sub-catalog. Args: catalog_id: The ID of the catalog. + request: Optional FastAPI request object. Returns: Dictionary containing queryable fields (Filter Extension). @@ -289,13 +353,18 @@ async def get_catalog_queryables(self, catalog_id: str, **kwargs) -> dict: @abc.abstractmethod async def unlink_sub_catalog( - self, catalog_id: str, sub_catalog_id: str, **kwargs + self, + catalog_id: str, + sub_catalog_id: str, + request: Optional[Request] = None, + **kwargs, ) -> None: """Unlink a sub-catalog from its parent. Args: catalog_id: The ID of the parent catalog. sub_catalog_id: The ID of the sub-catalog to unlink. + request: Optional FastAPI request object. """ ... @@ -309,75 +378,49 @@ def get_catalogs( self, limit: Optional[int] = None, token: Optional[str] = None, + request: Optional[Request] = None, **kwargs, ) -> Catalogs: - """Get all catalogs with pagination support. - - Args: - limit: The maximum number of catalogs to return. - token: Pagination token for the next page of results. - - Returns: - Catalogs object containing catalogs and pagination links. - """ + """Get all catalogs with pagination support.""" ... @abc.abstractmethod - def create_catalog(self, catalog: Catalog, **kwargs) -> Catalog: - """Create a new catalog. - - Args: - catalog: The catalog to create. - - Returns: - The created catalog. - """ + def create_catalog( + self, catalog: Catalog, request: Optional[Request] = None, **kwargs + ) -> Catalog: + """Create a new catalog.""" ... @abc.abstractmethod - def get_catalog(self, catalog_id: str, **kwargs) -> Catalog: - """Get a specific catalog by ID. - - Args: - catalog_id: The ID of the catalog to retrieve. - - Returns: - The requested catalog. - """ + def get_catalog( + self, catalog_id: str, request: Optional[Request] = None, **kwargs + ) -> Catalog: + """Get a specific catalog by ID.""" ... @abc.abstractmethod - def update_catalog(self, catalog_id: str, catalog: Catalog, **kwargs) -> Catalog: - """Update an existing catalog. - - Args: - catalog_id: The ID of the catalog to update. - catalog: The updated catalog data. - - Returns: - The updated catalog. - """ + def update_catalog( + self, + catalog_id: str, + catalog: Catalog, + request: Optional[Request] = None, + **kwargs, + ) -> Catalog: + """Update an existing catalog.""" ... @abc.abstractmethod - def delete_catalog(self, catalog_id: str, **kwargs) -> None: - """Delete a catalog. - - Args: - catalog_id: The ID of the catalog to delete. - """ + def delete_catalog( + self, catalog_id: str, request: Optional[Request] = None, **kwargs + ) -> None: + """Delete a catalog.""" ... @abc.abstractmethod - def get_catalog_collections(self, catalog_id: str, **kwargs) -> Collections: - """Get collections linked from a specific catalog. - - Args: - catalog_id: The ID of the catalog. - - Returns: - Collections object containing collections linked from the catalog. - """ + def get_catalog_collections( + self, catalog_id: str, request: Optional[Request] = None, **kwargs + ) -> Collections: + """Get collections linked from a specific catalog.""" ... @abc.abstractmethod @@ -386,98 +429,54 @@ def get_sub_catalogs( catalog_id: str, limit: Optional[int] = None, token: Optional[str] = None, + request: Optional[Request] = None, **kwargs, ) -> Catalogs: - """Get all sub-catalogs of a specific catalog with pagination. - - Args: - catalog_id: The ID of the parent catalog. - limit: Maximum number of results to return. - token: Pagination token for cursor-based pagination. - - Returns: - A Catalogs response containing sub-catalogs with pagination links. - """ + """Get all sub-catalogs of a specific catalog with pagination.""" ... @abc.abstractmethod def create_sub_catalog( - self, catalog_id: str, catalog: Union[Catalog, ObjectUri], **kwargs + self, + catalog_id: str, + catalog: Union[Catalog, ObjectUri], + request: Optional[Request] = None, + **kwargs, ) -> Catalog: - """Create a new catalog or link an existing catalog as a sub-catalog. - - Supports two modes: - - Mode A (Creation): Full Catalog JSON body with id that doesn't exist - → creates new catalog - - Mode B (Linking): Minimal body with just id of existing catalog - → links as sub-catalog - - Logic: - 1. Verifies the parent catalog exists. - 2. If the sub-catalog already exists: Appends the parent ID to its - parent_ids (enabling poly-hierarchy - a catalog can have multiple - parents). - 3. If the sub-catalog is new: Creates it with parent_ids initialized - to [catalog_id]. - - Args: - catalog_id: The ID of the parent catalog. - catalog: The catalog to create or link (full Catalog or ObjectUri with id). - - Returns: - The created or linked catalog. - """ + """Create a new catalog or link an existing catalog as a sub-catalog.""" ... @abc.abstractmethod def create_catalog_collection( - self, catalog_id: str, collection: Union[Collection, ObjectUri], **kwargs + self, + catalog_id: str, + collection: Union[Collection, ObjectUri], + request: Optional[Request] = None, + **kwargs, ) -> Collection: - """Create a new collection or link an existing collection to catalog. - - Supports two modes: - - Mode A (Creation): Full Collection JSON body with id that doesn't - exist → creates new collection - - Mode B (Linking): Minimal body with just id of existing collection - → links to catalog - - Args: - catalog_id: The ID of the catalog to link the collection to. - collection: Create or link (full Collection or ObjectUri with id). - - Returns: - The created or linked collection. - """ + """Create a new collection or link an existing collection to catalog.""" ... @abc.abstractmethod def get_catalog_collection( - self, catalog_id: str, collection_id: str, **kwargs + self, + catalog_id: str, + collection_id: str, + request: Optional[Request] = None, + **kwargs, ) -> Collection: - """Get a specific collection from a catalog. - - Args: - catalog_id: The ID of the catalog. - collection_id: The ID of the collection. - - Returns: - The requested collection. - """ + """Get a specific collection from a catalog.""" ... @abc.abstractmethod def unlink_catalog_collection( - self, catalog_id: str, collection_id: str, **kwargs + self, + catalog_id: str, + collection_id: str, + request: Optional[Request] = None, + **kwargs, ) -> None: - """Unlink a collection from a catalog. - - Removes the link between the catalog and collection. - The Collection data is NOT deleted. - - Args: - catalog_id: The ID of the catalog. - collection_id: The ID of the collection. - """ + """Unlink a collection from a catalog.""" ... @abc.abstractmethod @@ -489,40 +488,22 @@ def get_catalog_collection_items( datetime: Optional[Union[str, datetime]] = None, limit: Optional[int] = 10, token: Optional[str] = None, + request: Optional[Request] = None, **kwargs, ) -> ItemCollection: - """Get items from a collection in a catalog with search support. - - Multiple filters are combined using AND logic. If both bbox and datetime - are provided, only items matching both criteria are returned. - - Args: - catalog_id: The ID of the catalog. - collection_id: The ID of the collection. - bbox: Bounding box to filter items [minx, miny, maxx, maxy]. - datetime: Datetime or datetime range to filter items. - limit: Maximum number of items to return (default 10). - token: Pagination token for cursor-based pagination. - - Returns: - ItemCollection containing items from the collection. - """ + """Get items from a collection in a catalog with search support.""" ... @abc.abstractmethod def get_catalog_collection_item( - self, catalog_id: str, collection_id: str, item_id: str, **kwargs + self, + catalog_id: str, + collection_id: str, + item_id: str, + request: Optional[Request] = None, + **kwargs, ) -> 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. - - Returns: - The requested item. - """ + """Get a specific item from a collection in a catalog.""" ... @abc.abstractmethod @@ -532,51 +513,33 @@ def get_catalog_children( limit: Optional[int] = None, token: Optional[str] = None, type: Optional[Literal["Catalog", "Collection"]] = None, + request: Optional[Request] = None, **kwargs, ) -> Children: - """Get all children (Catalogs and Collections) of a specific catalog. - - Args: - catalog_id: The ID of the catalog. - limit: Maximum number of results to return. - token: Pagination token. - type: Filter by resource type (Catalog or Collection). - - Returns: - Children object containing children and pagination links. - """ + """Get all children (Catalogs and Collections) of a specific catalog.""" ... @abc.abstractmethod - def get_catalog_conformance(self, catalog_id: str, **kwargs) -> dict: - """Get conformance classes specific to this sub-catalog. - - Args: - catalog_id: The ID of the catalog. - - Returns: - Dictionary containing conformance classes. - """ + def get_catalog_conformance( + self, catalog_id: str, request: Optional[Request] = None, **kwargs + ) -> dict: + """Get conformance classes specific to this sub-catalog.""" ... @abc.abstractmethod - def get_catalog_queryables(self, catalog_id: str, **kwargs) -> dict: - """Get queryable fields available for filtering in this sub-catalog. - - Args: - catalog_id: The ID of the catalog. - - Returns: - Dictionary containing queryable fields (Filter Extension). - """ + def get_catalog_queryables( + self, catalog_id: str, request: Optional[Request] = None, **kwargs + ) -> dict: + """Get queryable fields available for filtering in this sub-catalog.""" ... @abc.abstractmethod - def unlink_sub_catalog(self, catalog_id: str, sub_catalog_id: str, **kwargs) -> None: - """Unlink a sub-catalog from its parent. - - Args: - catalog_id: The ID of the parent catalog. - sub_catalog_id: The ID of the sub-catalog to unlink. - """ + def unlink_sub_catalog( + self, + catalog_id: str, + sub_catalog_id: str, + request: Optional[Request] = None, + **kwargs, + ) -> None: + """Unlink a sub-catalog from its parent.""" ... From d86fe80689280268bb18b1daba80882acb21340b Mon Sep 17 00:00:00 2001 From: jonhealy1 Date: Mon, 9 Feb 2026 14:54:01 +0800 Subject: [PATCH 20/40] clean up --- .../extensions/core/multi_tenant_catalogs/__init__.py | 3 ++- .../extensions/core/multi_tenant_catalogs/catalogs.py | 2 -- stac_fastapi/extensions/tests/test_catalogs.py | 11 +++++++++++ 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/__init__.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/__init__.py index 8151e2e18..d5f93f3b4 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/__init__.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/__init__.py @@ -4,7 +4,7 @@ from .catalogs import CATALOGS_CONFORMANCE_CLASSES, CatalogsExtension from .client import AsyncBaseCatalogsClient, BaseCatalogsClient -from .types import Catalogs, Children +from .types import Catalogs, Children, ObjectUri __all__ = [ "CatalogsExtension", @@ -13,5 +13,6 @@ "Catalogs", "Collections", "Children", + "ObjectUri", "CATALOGS_CONFORMANCE_CLASSES", ] diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/catalogs.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/catalogs.py index a8ee7fa1a..fb0b6dbe3 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/catalogs.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/catalogs.py @@ -64,7 +64,6 @@ async def get_catalog_collection_items( token: Optional[str] = Query(None, description="Pagination token"), ) -> ItemCollection: """Get items from a collection in a catalog with search support.""" - # Fix 1: Convert Tuple to List explicitly for mypy bbox_list: Optional[List[float]] = None if bbox: bbox_tuple = str2bbox(bbox) @@ -82,7 +81,6 @@ async def get_catalog_collection_items( ) # --- WRAPPERS --- - # Fix 2: Use Request type directly in Depends signature to satisfy mypy strict mode async def _get_catalogs_wrapper( self, diff --git a/stac_fastapi/extensions/tests/test_catalogs.py b/stac_fastapi/extensions/tests/test_catalogs.py index cc4d3ffc2..f8cd0c2c8 100644 --- a/stac_fastapi/extensions/tests/test_catalogs.py +++ b/stac_fastapi/extensions/tests/test_catalogs.py @@ -599,6 +599,17 @@ def test_unlink_sub_catalog(client: TestClient) -> None: assert response.status_code == 204, response.text +def test_get_catalog_collection_items_invalid_bbox_string(client: TestClient) -> None: + """Test that a garbage bbox string returns 400 Bad Request.""" + params = {"bbox": "not,a,bounding,box"} + response = client.get( + "/catalogs/test-catalog-1/collections/test-collection/items", params=params + ) + # str2bbox raises HTTPException(400) internally on ValueError + assert response.status_code == 400 + assert "invalid bbox" in response.json()["detail"] + + def test_landing_page_includes_catalogs_links(client: TestClient) -> None: """Test that landing page includes catalogs links.""" response = client.get("/") From e1aab98625513d1270ec63c4864658e2fff5d023 Mon Sep 17 00:00:00 2001 From: jonhealy1 Date: Mon, 9 Feb 2026 19:58:14 +0800 Subject: [PATCH 21/40] move to extensions third party --- docs/mkdocs.yml | 14 ++++++++------ .../core/multi_tenant_catalogs/catalogs.md | 3 --- .../core/multi_tenant_catalogs/client.md | 3 --- .../core/multi_tenant_catalogs/index.md | 9 --------- .../core/multi_tenant_catalogs/types.md | 3 --- .../bulk_transactions/bulk_transactions.md | 3 +++ .../index.md} | 0 .../extensions/third_party/index.md | 3 ++- .../multi_tenant_catalogs/catalogs.md | 3 +++ .../multi_tenant_catalogs/client.md | 3 +++ .../third_party/multi_tenant_catalogs/index.md | 9 +++++++++ .../third_party/multi_tenant_catalogs/types.md | 3 +++ .../stac_fastapi/extensions/core/__init__.py | 2 -- .../extensions/third_party/__init__.py | 18 +++++++++++++++++- .../third_party/bulk_transactions/__init__.py | 17 +++++++++++++++++ .../bulk_transactions.py | 9 +++++++++ .../multi_tenant_catalogs/__init__.py | 0 .../multi_tenant_catalogs/catalogs.py | 0 .../multi_tenant_catalogs/client.py | 0 .../multi_tenant_catalogs/types.py | 0 stac_fastapi/extensions/tests/test_catalogs.py | 6 +++--- 21 files changed, 77 insertions(+), 31 deletions(-) delete mode 100644 docs/src/api/stac_fastapi/extensions/core/multi_tenant_catalogs/catalogs.md delete mode 100644 docs/src/api/stac_fastapi/extensions/core/multi_tenant_catalogs/client.md delete mode 100644 docs/src/api/stac_fastapi/extensions/core/multi_tenant_catalogs/index.md delete mode 100644 docs/src/api/stac_fastapi/extensions/core/multi_tenant_catalogs/types.md create mode 100644 docs/src/api/stac_fastapi/extensions/third_party/bulk_transactions/bulk_transactions.md rename docs/src/api/stac_fastapi/extensions/third_party/{bulk_transactions.md => bulk_transactions/index.md} (100%) create mode 100644 docs/src/api/stac_fastapi/extensions/third_party/multi_tenant_catalogs/catalogs.md create mode 100644 docs/src/api/stac_fastapi/extensions/third_party/multi_tenant_catalogs/client.md create mode 100644 docs/src/api/stac_fastapi/extensions/third_party/multi_tenant_catalogs/index.md create mode 100644 docs/src/api/stac_fastapi/extensions/third_party/multi_tenant_catalogs/types.md create mode 100644 stac_fastapi/extensions/stac_fastapi/extensions/third_party/bulk_transactions/__init__.py rename stac_fastapi/extensions/stac_fastapi/extensions/third_party/{ => bulk_transactions}/bulk_transactions.py (96%) rename stac_fastapi/extensions/stac_fastapi/extensions/{core => third_party}/multi_tenant_catalogs/__init__.py (100%) rename stac_fastapi/extensions/stac_fastapi/extensions/{core => third_party}/multi_tenant_catalogs/catalogs.py (100%) rename stac_fastapi/extensions/stac_fastapi/extensions/{core => third_party}/multi_tenant_catalogs/client.py (100%) rename stac_fastapi/extensions/stac_fastapi/extensions/{core => third_party}/multi_tenant_catalogs/types.py (100%) diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 731bb1416..caec1303d 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -57,11 +57,6 @@ nav: - module: api/stac_fastapi/extensions/core/free_text/index.md - free_text: api/stac_fastapi/extensions/core/free_text/free_text.md - request: api/stac_fastapi/extensions/core/free_text/request.md - - multi_tenant_catalogs: - - module: api/stac_fastapi/extensions/core/multi_tenant_catalogs/index.md - - catalogs: api/stac_fastapi/extensions/core/multi_tenant_catalogs/catalogs.md - - client: api/stac_fastapi/extensions/core/multi_tenant_catalogs/client.md - - types: api/stac_fastapi/extensions/core/multi_tenant_catalogs/types.md - pagination: - module: api/stac_fastapi/extensions/core/pagination/index.md - pagination: api/stac_fastapi/extensions/core/pagination/pagination.md @@ -79,7 +74,14 @@ nav: - transaction: api/stac_fastapi/extensions/core/transaction.md - third_party: - module: api/stac_fastapi/extensions/third_party/index.md - - bulk_transactions: api/stac_fastapi/extensions/third_party/bulk_transactions.md + - bulk_transactions: + - module: api/stac_fastapi/extensions/third_party/bulk_transactions/index.md + - bulk_transactions: api/stac_fastapi/extensions/third_party/bulk_transactions/bulk_transactions.md + - multi_tenant_catalogs: + - module: api/stac_fastapi/extensions/third_party/multi_tenant_catalogs/index.md + - catalogs: api/stac_fastapi/extensions/third_party/multi_tenant_catalogs/catalogs.md + - client: api/stac_fastapi/extensions/third_party/multi_tenant_catalogs/client.md + - types: api/stac_fastapi/extensions/third_party/multi_tenant_catalogs/types.md - stac_fastapi.types: - module: api/stac_fastapi/types/index.md - config: api/stac_fastapi/types/config.md diff --git a/docs/src/api/stac_fastapi/extensions/core/multi_tenant_catalogs/catalogs.md b/docs/src/api/stac_fastapi/extensions/core/multi_tenant_catalogs/catalogs.md deleted file mode 100644 index 3de2c8a17..000000000 --- a/docs/src/api/stac_fastapi/extensions/core/multi_tenant_catalogs/catalogs.md +++ /dev/null @@ -1,3 +0,0 @@ -::: stac_fastapi.extensions.core.multi_tenant_catalogs.catalogs - options: - show_source: true diff --git a/docs/src/api/stac_fastapi/extensions/core/multi_tenant_catalogs/client.md b/docs/src/api/stac_fastapi/extensions/core/multi_tenant_catalogs/client.md deleted file mode 100644 index fd3598fa6..000000000 --- a/docs/src/api/stac_fastapi/extensions/core/multi_tenant_catalogs/client.md +++ /dev/null @@ -1,3 +0,0 @@ -::: stac_fastapi.extensions.core.multi_tenant_catalogs.client - options: - show_source: true diff --git a/docs/src/api/stac_fastapi/extensions/core/multi_tenant_catalogs/index.md b/docs/src/api/stac_fastapi/extensions/core/multi_tenant_catalogs/index.md deleted file mode 100644 index 6d81a0b93..000000000 --- a/docs/src/api/stac_fastapi/extensions/core/multi_tenant_catalogs/index.md +++ /dev/null @@ -1,9 +0,0 @@ -# Module stac_fastapi.extensions.core.multi_tenant_catalogs - -Multi-Tenant Catalogs Extension submodule. - -## Sub-modules - -* [stac_fastapi.extensions.core.multi_tenant_catalogs.catalogs](catalogs.md) -* [stac_fastapi.extensions.core.multi_tenant_catalogs.client](client.md) -* [stac_fastapi.extensions.core.multi_tenant_catalogs.types](types.md) diff --git a/docs/src/api/stac_fastapi/extensions/core/multi_tenant_catalogs/types.md b/docs/src/api/stac_fastapi/extensions/core/multi_tenant_catalogs/types.md deleted file mode 100644 index 5f302b41f..000000000 --- a/docs/src/api/stac_fastapi/extensions/core/multi_tenant_catalogs/types.md +++ /dev/null @@ -1,3 +0,0 @@ -::: stac_fastapi.extensions.core.multi_tenant_catalogs.types - options: - show_source: true diff --git a/docs/src/api/stac_fastapi/extensions/third_party/bulk_transactions/bulk_transactions.md b/docs/src/api/stac_fastapi/extensions/third_party/bulk_transactions/bulk_transactions.md new file mode 100644 index 000000000..89f115eb4 --- /dev/null +++ b/docs/src/api/stac_fastapi/extensions/third_party/bulk_transactions/bulk_transactions.md @@ -0,0 +1,3 @@ +::: stac_fastapi.extensions.third_party.bulk_transactions.bulk_transactions + options: + show_source: true diff --git a/docs/src/api/stac_fastapi/extensions/third_party/bulk_transactions.md b/docs/src/api/stac_fastapi/extensions/third_party/bulk_transactions/index.md similarity index 100% rename from docs/src/api/stac_fastapi/extensions/third_party/bulk_transactions.md rename to docs/src/api/stac_fastapi/extensions/third_party/bulk_transactions/index.md diff --git a/docs/src/api/stac_fastapi/extensions/third_party/index.md b/docs/src/api/stac_fastapi/extensions/third_party/index.md index 3ac6c5e1d..b15de031c 100644 --- a/docs/src/api/stac_fastapi/extensions/third_party/index.md +++ b/docs/src/api/stac_fastapi/extensions/third_party/index.md @@ -4,4 +4,5 @@ Third Party Extensions submodule. ## Sub-modules -* [stac_fastapi.extensions.third_party.bulk_transactions](bulk_transactions.md) +* [stac_fastapi.extensions.third_party.bulk_transactions](bulk_transactions/index.md) +* [stac_fastapi.extensions.third_party.multi_tenant_catalogs](multi_tenant_catalogs/index.md) diff --git a/docs/src/api/stac_fastapi/extensions/third_party/multi_tenant_catalogs/catalogs.md b/docs/src/api/stac_fastapi/extensions/third_party/multi_tenant_catalogs/catalogs.md new file mode 100644 index 000000000..8a44296c3 --- /dev/null +++ b/docs/src/api/stac_fastapi/extensions/third_party/multi_tenant_catalogs/catalogs.md @@ -0,0 +1,3 @@ +::: stac_fastapi.extensions.third_party.multi_tenant_catalogs.catalogs + options: + show_source: true diff --git a/docs/src/api/stac_fastapi/extensions/third_party/multi_tenant_catalogs/client.md b/docs/src/api/stac_fastapi/extensions/third_party/multi_tenant_catalogs/client.md new file mode 100644 index 000000000..e96d54de0 --- /dev/null +++ b/docs/src/api/stac_fastapi/extensions/third_party/multi_tenant_catalogs/client.md @@ -0,0 +1,3 @@ +::: stac_fastapi.extensions.third_party.multi_tenant_catalogs.client + options: + show_source: true diff --git a/docs/src/api/stac_fastapi/extensions/third_party/multi_tenant_catalogs/index.md b/docs/src/api/stac_fastapi/extensions/third_party/multi_tenant_catalogs/index.md new file mode 100644 index 000000000..e9f6c4215 --- /dev/null +++ b/docs/src/api/stac_fastapi/extensions/third_party/multi_tenant_catalogs/index.md @@ -0,0 +1,9 @@ +# Module stac_fastapi.extensions.third_party.multi_tenant_catalogs + +Multi-Tenant Catalogs Extension submodule. + +## Sub-modules + +* [stac_fastapi.extensions.third_party.multi_tenant_catalogs.catalogs](catalogs.md) +* [stac_fastapi.extensions.third_party.multi_tenant_catalogs.client](client.md) +* [stac_fastapi.extensions.third_party.multi_tenant_catalogs.types](types.md) diff --git a/docs/src/api/stac_fastapi/extensions/third_party/multi_tenant_catalogs/types.md b/docs/src/api/stac_fastapi/extensions/third_party/multi_tenant_catalogs/types.md new file mode 100644 index 000000000..49e3eda87 --- /dev/null +++ b/docs/src/api/stac_fastapi/extensions/third_party/multi_tenant_catalogs/types.md @@ -0,0 +1,3 @@ +::: stac_fastapi.extensions.third_party.multi_tenant_catalogs.types + options: + show_source: true diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/__init__.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/__init__.py index ff5601aa3..d6b5f7589 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/__init__.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/__init__.py @@ -10,7 +10,6 @@ SearchFilterExtension, ) from .free_text import FreeTextAdvancedExtension, FreeTextExtension -from .multi_tenant_catalogs import CatalogsExtension from .pagination import ( OffsetPaginationExtension, PaginationExtension, @@ -22,7 +21,6 @@ __all__ = ( "AggregationExtension", - "CatalogsExtension", "FieldsExtension", "FilterExtension", "FreeTextExtension", diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/third_party/__init__.py b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/__init__.py index d35c4c8f9..ff1f40ec8 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/third_party/__init__.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/__init__.py @@ -1,5 +1,21 @@ """stac_api.extensions.third_party module.""" from .bulk_transactions import BulkTransactionExtension +from .multi_tenant_catalogs import ( + AsyncBaseCatalogsClient, + BaseCatalogsClient, + Catalogs, + CatalogsExtension, + Children, + ObjectUri, +) -__all__ = ("BulkTransactionExtension",) +__all__ = ( + "BulkTransactionExtension", + "CatalogsExtension", + "AsyncBaseCatalogsClient", + "BaseCatalogsClient", + "Catalogs", + "Children", + "ObjectUri", +) diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/third_party/bulk_transactions/__init__.py b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/bulk_transactions/__init__.py new file mode 100644 index 000000000..d503fe5fc --- /dev/null +++ b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/bulk_transactions/__init__.py @@ -0,0 +1,17 @@ +"""Bulk transactions extension.""" + +from .bulk_transactions import ( + AsyncBaseBulkTransactionsClient, + BaseBulkTransactionsClient, + BulkTransactionExtension, + BulkTransactionMethod, + Items, +) + +__all__ = [ + "BulkTransactionExtension", + "AsyncBaseBulkTransactionsClient", + "BaseBulkTransactionsClient", + "BulkTransactionMethod", + "Items", +] diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/third_party/bulk_transactions.py b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/bulk_transactions/bulk_transactions.py similarity index 96% rename from stac_fastapi/extensions/stac_fastapi/extensions/third_party/bulk_transactions.py rename to stac_fastapi/extensions/stac_fastapi/extensions/third_party/bulk_transactions/bulk_transactions.py index aec905dff..5aea8adba 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/third_party/bulk_transactions.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/bulk_transactions/bulk_transactions.py @@ -138,3 +138,12 @@ def register(self, app: FastAPI) -> None: ), ) app.include_router(router, tags=["Bulk Transaction Extension"]) + + +__all__ = [ + "BulkTransactionExtension", + "AsyncBaseBulkTransactionsClient", + "BaseBulkTransactionsClient", + "BulkTransactionMethod", + "Items", +] diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/__init__.py b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/__init__.py similarity index 100% rename from stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/__init__.py rename to stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/__init__.py diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/catalogs.py b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/catalogs.py similarity index 100% rename from stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/catalogs.py rename to stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/catalogs.py diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/client.py b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/client.py similarity index 100% rename from stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/client.py rename to stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/client.py diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/types.py b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/types.py similarity index 100% rename from stac_fastapi/extensions/stac_fastapi/extensions/core/multi_tenant_catalogs/types.py rename to stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/types.py diff --git a/stac_fastapi/extensions/tests/test_catalogs.py b/stac_fastapi/extensions/tests/test_catalogs.py index f8cd0c2c8..d74edf0fd 100644 --- a/stac_fastapi/extensions/tests/test_catalogs.py +++ b/stac_fastapi/extensions/tests/test_catalogs.py @@ -11,11 +11,11 @@ from starlette.testclient import TestClient from stac_fastapi.api.app import StacApi -from stac_fastapi.extensions.core import CatalogsExtension -from stac_fastapi.extensions.core.multi_tenant_catalogs.client import ( +from stac_fastapi.extensions.third_party import CatalogsExtension +from stac_fastapi.extensions.third_party.multi_tenant_catalogs.client import ( AsyncBaseCatalogsClient, ) -from stac_fastapi.extensions.core.multi_tenant_catalogs.types import ( +from stac_fastapi.extensions.third_party.multi_tenant_catalogs.types import ( Catalogs, Children, ObjectUri, From 95a1fb2b7cd542c64ab27b1d600138a4f8d91e79 Mon Sep 17 00:00:00 2001 From: jonhealy1 Date: Tue, 10 Feb 2026 18:58:48 +0800 Subject: [PATCH 22/40] make transaction routes optional --- .../multi_tenant_catalogs/catalogs.py | 203 ++++++++++-------- .../extensions/tests/test_catalogs.py | 141 +++++++++++- 2 files changed, 250 insertions(+), 94 deletions(-) diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/catalogs.py b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/catalogs.py index fb0b6dbe3..0003e4dcf 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/catalogs.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/catalogs.py @@ -26,6 +26,10 @@ "https://api.stacspec.org/v1.0.0-rc.2/children#type-filter", ] +CATALOGS_TRANSACTION_CONFORMANCE_CLASS = ( + "https://api.stacspec.org/v1.0.0-beta.1/multi-tenant-catalogs/transaction" +) + @attr.s class CatalogsExtension(ApiExtension): @@ -37,6 +41,7 @@ class CatalogsExtension(ApiExtension): Attributes: client: A client implementing the catalogs extension pattern. settings: Extension settings. + enable_transactions: Enable catalog transaction endpoints (POST, PUT, DELETE). conformance_classes: List of conformance classes for this extension. router: FastAPI router for the extension endpoints. response_class: Response class for the extension. @@ -44,12 +49,17 @@ class CatalogsExtension(ApiExtension): client: AsyncBaseCatalogsClient = attr.ib(default=None) settings: dict = attr.ib(default=attr.Factory(dict)) - conformance_classes: List[str] = attr.ib( - default=attr.Factory(lambda: CATALOGS_CONFORMANCE_CLASSES) - ) + enable_transactions: bool = attr.ib(default=False) + conformance_classes: List[str] = attr.ib(factory=list) router: APIRouter = attr.ib(factory=APIRouter) response_class: Type[Response] = attr.ib(default=JSONResponse) + def __attrs_post_init__(self): + """Initialize conformance classes based on settings.""" + self.conformance_classes = CATALOGS_CONFORMANCE_CLASSES.copy() + if self.enable_transactions: + self.conformance_classes.append(CATALOGS_TRANSACTION_CONFORMANCE_CLASS) + async def get_catalog_collection_items( self, catalog_id: str, @@ -189,9 +199,15 @@ async def _get_catalog_children_wrapper( async def _get_catalog_conformance_wrapper( self, catalog_id: str, request: Request ) -> dict: - return await self.client.get_catalog_conformance( + result = await self.client.get_catalog_conformance( catalog_id=catalog_id, request=request ) + # Merge extension conformance classes with client response + if "conformsTo" in result: + result["conformsTo"].extend(self.conformance_classes) + else: + result["conformsTo"] = self.conformance_classes + return result async def _get_catalog_queryables_wrapper( self, catalog_id: str, request: Request @@ -219,6 +235,7 @@ def register(self, app: FastAPI, settings: Optional[Dict[str, Any]] = None) -> N self.settings = settings or {} self.router = APIRouter() + # --- READ-ONLY ROUTES (Always Registered) --- self.router.add_api_route( path="/catalogs", endpoint=self._get_catalogs_wrapper, @@ -230,18 +247,6 @@ def register(self, app: FastAPI, settings: Optional[Dict[str, Any]] = None) -> N tags=["Catalogs"], ) - self.router.add_api_route( - path="/catalogs", - endpoint=self._create_catalog_wrapper, - methods=["POST"], - response_model=Catalog, - response_class=self.response_class, - status_code=HTTP_201_CREATED, - summary="Create Catalog", - description="Create a new STAC catalog.", - tags=["Catalogs"], - ) - self.router.add_api_route( path="/catalogs/{catalog_id}", endpoint=self._get_catalog_wrapper, @@ -253,28 +258,6 @@ def register(self, app: FastAPI, settings: Optional[Dict[str, Any]] = None) -> N tags=["Catalogs"], ) - self.router.add_api_route( - path="/catalogs/{catalog_id}", - endpoint=self._update_catalog_wrapper, - methods=["PUT"], - response_model=Catalog, - response_class=self.response_class, - summary="Update Catalog", - description="Update an existing STAC catalog.", - tags=["Catalogs"], - ) - - self.router.add_api_route( - path="/catalogs/{catalog_id}", - endpoint=self._delete_catalog_wrapper, - methods=["DELETE"], - response_class=self.response_class, - status_code=HTTP_204_NO_CONTENT, - summary="Delete Catalog", - description="Delete a catalog.", - tags=["Catalogs"], - ) - self.router.add_api_route( path="/catalogs/{catalog_id}/collections", endpoint=self._get_catalog_collections_wrapper, @@ -286,18 +269,6 @@ def register(self, app: FastAPI, settings: Optional[Dict[str, Any]] = None) -> N tags=["Catalogs"], ) - self.router.add_api_route( - path="/catalogs/{catalog_id}/collections", - endpoint=self._create_catalog_collection_wrapper, - methods=["POST"], - response_model=Collection, - response_class=self.response_class, - status_code=HTTP_201_CREATED, - summary="Create Catalog Collection", - description="Create a new collection and link it to a specific catalog.", - tags=["Catalogs"], - ) - self.router.add_api_route( path="/catalogs/{catalog_id}/collections/{collection_id}", endpoint=self._get_catalog_collection_wrapper, @@ -309,20 +280,6 @@ def register(self, app: FastAPI, settings: Optional[Dict[str, Any]] = None) -> N tags=["Catalogs"], ) - self.router.add_api_route( - path="/catalogs/{catalog_id}/collections/{collection_id}", - endpoint=self._unlink_catalog_collection_wrapper, - methods=["DELETE"], - response_class=self.response_class, - status_code=HTTP_204_NO_CONTENT, - summary="Unlink Collection from Catalog", - description=( - "Removes the link between the catalog and collection. " - "The Collection data is NOT deleted." - ), - tags=["Catalogs"], - ) - self.router.add_api_route( path="/catalogs/{catalog_id}/collections/{collection_id}/items", endpoint=self.get_catalog_collection_items, @@ -356,21 +313,6 @@ def register(self, app: FastAPI, settings: Optional[Dict[str, Any]] = None) -> N tags=["Catalogs"], ) - self.router.add_api_route( - path="/catalogs/{catalog_id}/catalogs", - endpoint=self._create_sub_catalog_wrapper, - methods=["POST"], - response_model=Catalog, - response_class=self.response_class, - status_code=HTTP_201_CREATED, - summary="Create Catalog Sub-Catalog", - description=( - "Create a new catalog or link an existing catalog as a sub-catalog " - "of a specific catalog." - ), - tags=["Catalogs"], - ) - self.router.add_api_route( path="/catalogs/{catalog_id}/children", endpoint=self._get_catalog_children_wrapper, @@ -411,18 +353,95 @@ def register(self, app: FastAPI, settings: Optional[Dict[str, Any]] = None) -> N responses={HTTP_200_OK: {"description": "Queryable fields for the catalog"}}, ) - self.router.add_api_route( - path="/catalogs/{catalog_id}/catalogs/{sub_catalog_id}", - endpoint=self._unlink_sub_catalog_wrapper, - methods=["DELETE"], - response_class=self.response_class, - status_code=HTTP_204_NO_CONTENT, - summary="Unlink Sub-Catalog", - description=( - "Unlink a sub-catalog from its parent. " - "Does not delete the sub-catalog." - ), - tags=["Catalogs"], - ) + # --- TRANSACTION ROUTES (Conditionally Registered) --- + if self.enable_transactions: + self.router.add_api_route( + path="/catalogs", + endpoint=self._create_catalog_wrapper, + methods=["POST"], + response_model=Catalog, + response_class=self.response_class, + status_code=HTTP_201_CREATED, + summary="Create Catalog", + description="Create a new STAC catalog.", + tags=["Catalogs"], + ) + + self.router.add_api_route( + path="/catalogs/{catalog_id}", + endpoint=self._update_catalog_wrapper, + methods=["PUT"], + response_model=Catalog, + response_class=self.response_class, + summary="Update Catalog", + description="Update an existing STAC catalog.", + tags=["Catalogs"], + ) + + self.router.add_api_route( + path="/catalogs/{catalog_id}", + endpoint=self._delete_catalog_wrapper, + methods=["DELETE"], + response_class=self.response_class, + status_code=HTTP_204_NO_CONTENT, + summary="Delete Catalog", + description="Delete a catalog.", + tags=["Catalogs"], + ) + + self.router.add_api_route( + path="/catalogs/{catalog_id}/collections", + endpoint=self._create_catalog_collection_wrapper, + methods=["POST"], + response_model=Collection, + response_class=self.response_class, + status_code=HTTP_201_CREATED, + summary="Create Catalog Collection", + description="Create a new collection and link it to a specific catalog.", + tags=["Catalogs"], + ) + + self.router.add_api_route( + path="/catalogs/{catalog_id}/collections/{collection_id}", + endpoint=self._unlink_catalog_collection_wrapper, + methods=["DELETE"], + response_class=self.response_class, + status_code=HTTP_204_NO_CONTENT, + summary="Unlink Collection from Catalog", + description=( + "Removes the link between the catalog and collection. " + "The Collection data is NOT deleted." + ), + tags=["Catalogs"], + ) + + self.router.add_api_route( + path="/catalogs/{catalog_id}/catalogs", + endpoint=self._create_sub_catalog_wrapper, + methods=["POST"], + response_model=Catalog, + response_class=self.response_class, + status_code=HTTP_201_CREATED, + summary="Create Catalog Sub-Catalog", + description=( + "Create a new catalog or link an existing catalog as a " + "sub-catalog of a specific catalog." + ), + tags=["Catalogs"], + ) + + self.router.add_api_route( + path="/catalogs/{catalog_id}/catalogs/{sub_catalog_id}", + endpoint=self._unlink_sub_catalog_wrapper, + methods=["DELETE"], + response_class=self.response_class, + status_code=HTTP_204_NO_CONTENT, + summary="Unlink Sub-Catalog", + description=( + "Unlink a sub-catalog from its parent. " + "Does not delete the sub-catalog." + ), + tags=["Catalogs"], + ) app.include_router(self.router, tags=["Catalogs"]) diff --git a/stac_fastapi/extensions/tests/test_catalogs.py b/stac_fastapi/extensions/tests/test_catalogs.py index d74edf0fd..9e6e96155 100644 --- a/stac_fastapi/extensions/tests/test_catalogs.py +++ b/stac_fastapi/extensions/tests/test_catalogs.py @@ -337,13 +337,30 @@ def catalogs_client() -> DummyCatalogsClient: def client( core_client: DummyCoreClient, catalogs_client: DummyCatalogsClient ) -> Iterator[TestClient]: - """Fixture for test client.""" + """Fixture for test client with transactions enabled.""" settings = ApiSettings() api = StacApi( settings=settings, client=core_client, extensions=[ - CatalogsExtension(client=catalogs_client), + CatalogsExtension(client=catalogs_client, enable_transactions=True), + ], + ) + with TestClient(api.app) as test_client: + yield test_client + + +@pytest.fixture +def client_readonly( + core_client: DummyCoreClient, catalogs_client: DummyCatalogsClient +) -> Iterator[TestClient]: + """Fixture for test client with transactions disabled (read-only).""" + settings = ApiSettings() + api = StacApi( + settings=settings, + client=core_client, + extensions=[ + CatalogsExtension(client=catalogs_client, enable_transactions=False), ], ) with TestClient(api.app) as test_client: @@ -619,3 +636,123 @@ def test_landing_page_includes_catalogs_links(client: TestClient) -> None: # The catalogs extension should be registered and provide links # Check if any link exists (the exact catalogs link depends on extension registration) assert len(data["links"]) > 0 + + +# --- READ-ONLY MODE TESTS (enable_transactions=False) --- + + +def test_readonly_get_catalogs(client_readonly: TestClient) -> None: + """Test GET /catalogs works in read-only mode.""" + response = client_readonly.get("/catalogs") + assert response.status_code == 200, response.text + data = response.json() + assert "catalogs" in data + + +def test_readonly_create_catalog_disabled(client_readonly: TestClient) -> None: + """Test POST /catalogs returns 405 in read-only mode.""" + catalog_data = { + "type": "Catalog", + "id": "new-catalog", + "description": "A new test catalog", + "stac_version": "1.0.0", + "links": [], + } + response = client_readonly.post("/catalogs", json=catalog_data) + assert response.status_code == 405 + + +def test_readonly_update_catalog_disabled(client_readonly: TestClient) -> None: + """Test PUT /catalogs/{catalog_id} returns 405 in read-only mode.""" + catalog_data = { + "type": "Catalog", + "id": "test-catalog-1", + "description": "Updated description", + "stac_version": "1.0.0", + "links": [], + } + response = client_readonly.put("/catalogs/test-catalog-1", json=catalog_data) + assert response.status_code == 405 + + +def test_readonly_delete_catalog_disabled(client_readonly: TestClient) -> None: + """Test DELETE /catalogs/{catalog_id} returns 405 in read-only mode.""" + response = client_readonly.delete("/catalogs/test-catalog-1") + assert response.status_code == 405 + + +def test_readonly_create_collection_disabled(client_readonly: TestClient) -> None: + """Test POST /catalogs/{catalog_id}/collections returns 405 in read-only mode.""" + collection_data = { + "type": "Collection", + "id": "new-collection", + "description": "A new collection", + "extent": { + "spatial": {"bbox": [[-180, -90, 180, 90]]}, + "temporal": {"interval": [[None, None]]}, + }, + "license": "proprietary", + "links": [], + } + response = client_readonly.post( + "/catalogs/test-catalog-1/collections", json=collection_data + ) + assert response.status_code == 405 + + +def test_readonly_unlink_collection_disabled(client_readonly: TestClient) -> None: + """Test DELETE /catalogs/{catalog_id}/collections/{collection_id} returns 405.""" + response = client_readonly.delete( + "/catalogs/test-catalog-1/collections/test-collection" + ) + assert response.status_code == 405 + + +def test_readonly_create_subcatalog_disabled(client_readonly: TestClient) -> None: + """Test POST /catalogs/{catalog_id}/catalogs returns 405 in read-only mode.""" + catalog_data = { + "type": "Catalog", + "id": "new-sub-catalog", + "description": "A new sub-catalog", + "stac_version": "1.0.0", + "links": [], + } + response = client_readonly.post( + "/catalogs/test-catalog-1/catalogs", json=catalog_data + ) + assert response.status_code == 405 + + +def test_readonly_unlink_subcatalog_disabled(client_readonly: TestClient) -> None: + """Test DELETE /catalogs/{catalog_id}/catalogs/{sub_catalog_id} is disabled.""" + response = client_readonly.delete( + "/catalogs/test-catalog-1/catalogs/test-catalog-1-sub-1" + ) + # Route is not registered, so we get 404 (Not Found) + assert response.status_code == 404 + + +def test_readonly_conformance_excludes_transaction_class( + client_readonly: TestClient, +) -> None: + """Test that transaction conformance class is not present in read-only mode.""" + response = client_readonly.get("/catalogs/test-catalog-1/conformance") + assert response.status_code == 200, response.text + data = response.json() + assert "conformsTo" in data + transaction_class = ( + "https://api.stacspec.org/v1.0.0-beta.1/multi-tenant-catalogs/transaction" + ) + assert transaction_class not in data["conformsTo"] + + +def test_enabled_conformance_includes_transaction_class(client: TestClient) -> None: + """Test that transaction conformance class is present when enabled.""" + response = client.get("/catalogs/test-catalog-1/conformance") + assert response.status_code == 200, response.text + data = response.json() + assert "conformsTo" in data + transaction_class = ( + "https://api.stacspec.org/v1.0.0-beta.1/multi-tenant-catalogs/transaction" + ) + assert transaction_class in data["conformsTo"] From f605f294e14f6601472d5b26cbcbaec641354442 Mon Sep 17 00:00:00 2001 From: jonhealy1 Date: Tue, 10 Feb 2026 19:20:11 +0800 Subject: [PATCH 23/40] remove BaseCatalogsClient --- .../extensions/third_party/__init__.py | 2 - .../multi_tenant_catalogs/__init__.py | 3 +- .../multi_tenant_catalogs/client.py | 176 ------------------ 3 files changed, 1 insertion(+), 180 deletions(-) diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/third_party/__init__.py b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/__init__.py index ff1f40ec8..ab165951c 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/third_party/__init__.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/__init__.py @@ -3,7 +3,6 @@ from .bulk_transactions import BulkTransactionExtension from .multi_tenant_catalogs import ( AsyncBaseCatalogsClient, - BaseCatalogsClient, Catalogs, CatalogsExtension, Children, @@ -14,7 +13,6 @@ "BulkTransactionExtension", "CatalogsExtension", "AsyncBaseCatalogsClient", - "BaseCatalogsClient", "Catalogs", "Children", "ObjectUri", diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/__init__.py b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/__init__.py index d5f93f3b4..3e24496fc 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/__init__.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/__init__.py @@ -3,13 +3,12 @@ from stac_pydantic.api.collections import Collections from .catalogs import CATALOGS_CONFORMANCE_CLASSES, CatalogsExtension -from .client import AsyncBaseCatalogsClient, BaseCatalogsClient +from .client import AsyncBaseCatalogsClient from .types import Catalogs, Children, ObjectUri __all__ = [ "CatalogsExtension", "AsyncBaseCatalogsClient", - "BaseCatalogsClient", "Catalogs", "Collections", "Children", diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/client.py b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/client.py index 3ccdebb22..3df4f796c 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/client.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/client.py @@ -367,179 +367,3 @@ async def unlink_sub_catalog( request: Optional FastAPI request object. """ ... - - -@attr.s -class BaseCatalogsClient(abc.ABC): - """Defines a synchronous pattern for implementing the STAC catalogs extension.""" - - @abc.abstractmethod - def get_catalogs( - self, - limit: Optional[int] = None, - token: Optional[str] = None, - request: Optional[Request] = None, - **kwargs, - ) -> Catalogs: - """Get all catalogs with pagination support.""" - ... - - @abc.abstractmethod - def create_catalog( - self, catalog: Catalog, request: Optional[Request] = None, **kwargs - ) -> Catalog: - """Create a new catalog.""" - ... - - @abc.abstractmethod - def get_catalog( - self, catalog_id: str, request: Optional[Request] = None, **kwargs - ) -> Catalog: - """Get a specific catalog by ID.""" - ... - - @abc.abstractmethod - def update_catalog( - self, - catalog_id: str, - catalog: Catalog, - request: Optional[Request] = None, - **kwargs, - ) -> Catalog: - """Update an existing catalog.""" - ... - - @abc.abstractmethod - def delete_catalog( - self, catalog_id: str, request: Optional[Request] = None, **kwargs - ) -> None: - """Delete a catalog.""" - ... - - @abc.abstractmethod - def get_catalog_collections( - self, catalog_id: str, request: Optional[Request] = None, **kwargs - ) -> Collections: - """Get collections linked from a specific catalog.""" - ... - - @abc.abstractmethod - def get_sub_catalogs( - self, - catalog_id: str, - limit: Optional[int] = None, - token: Optional[str] = None, - request: Optional[Request] = None, - **kwargs, - ) -> Catalogs: - """Get all sub-catalogs of a specific catalog with pagination.""" - ... - - @abc.abstractmethod - def create_sub_catalog( - self, - catalog_id: str, - catalog: Union[Catalog, ObjectUri], - request: Optional[Request] = None, - **kwargs, - ) -> Catalog: - """Create a new catalog or link an existing catalog as a sub-catalog.""" - ... - - @abc.abstractmethod - def create_catalog_collection( - self, - catalog_id: str, - collection: Union[Collection, ObjectUri], - request: Optional[Request] = None, - **kwargs, - ) -> Collection: - """Create a new collection or link an existing collection to catalog.""" - ... - - @abc.abstractmethod - def get_catalog_collection( - self, - catalog_id: str, - collection_id: str, - request: Optional[Request] = None, - **kwargs, - ) -> Collection: - """Get a specific collection from a catalog.""" - ... - - @abc.abstractmethod - def unlink_catalog_collection( - self, - catalog_id: str, - collection_id: str, - request: Optional[Request] = None, - **kwargs, - ) -> None: - """Unlink a collection from a catalog.""" - ... - - @abc.abstractmethod - def get_catalog_collection_items( - self, - catalog_id: str, - collection_id: str, - bbox: Optional[List[float]] = None, - datetime: Optional[Union[str, datetime]] = None, - limit: Optional[int] = 10, - token: Optional[str] = None, - request: Optional[Request] = None, - **kwargs, - ) -> ItemCollection: - """Get items from a collection in a catalog with search support.""" - ... - - @abc.abstractmethod - def get_catalog_collection_item( - self, - catalog_id: str, - collection_id: str, - item_id: str, - request: Optional[Request] = None, - **kwargs, - ) -> Item: - """Get a specific item from a collection in a catalog.""" - ... - - @abc.abstractmethod - def get_catalog_children( - self, - catalog_id: str, - limit: Optional[int] = None, - token: Optional[str] = None, - type: Optional[Literal["Catalog", "Collection"]] = None, - request: Optional[Request] = None, - **kwargs, - ) -> Children: - """Get all children (Catalogs and Collections) of a specific catalog.""" - ... - - @abc.abstractmethod - def get_catalog_conformance( - self, catalog_id: str, request: Optional[Request] = None, **kwargs - ) -> dict: - """Get conformance classes specific to this sub-catalog.""" - ... - - @abc.abstractmethod - def get_catalog_queryables( - self, catalog_id: str, request: Optional[Request] = None, **kwargs - ) -> dict: - """Get queryable fields available for filtering in this sub-catalog.""" - ... - - @abc.abstractmethod - def unlink_sub_catalog( - self, - catalog_id: str, - sub_catalog_id: str, - request: Optional[Request] = None, - **kwargs, - ) -> None: - """Unlink a sub-catalog from its parent.""" - ... From 9ac97460a07775f3885dc4162fd6374eec9d4146 Mon Sep 17 00:00:00 2001 From: jonhealy1 Date: Tue, 10 Feb 2026 19:54:36 +0800 Subject: [PATCH 24/40] remove settings from superclass --- .../third_party/multi_tenant_catalogs/catalogs.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/catalogs.py b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/catalogs.py index 0003e4dcf..7c9f0859d 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/catalogs.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/catalogs.py @@ -1,6 +1,6 @@ """Catalogs extension.""" -from typing import Any, Dict, List, Literal, Optional, Type, Union +from typing import List, Literal, Optional, Type, Union import attr from fastapi import APIRouter, FastAPI, Query, Request @@ -223,16 +223,14 @@ async def _unlink_sub_catalog_wrapper( catalog_id=catalog_id, sub_catalog_id=sub_catalog_id, request=request ) - def register(self, app: FastAPI, settings: Optional[Dict[str, Any]] = None) -> None: + def register(self, app: FastAPI) -> None: """Register the extension with a FastAPI application. Args: app: target FastAPI application. - settings: extension settings. """ if self.client is None: raise ValueError("CatalogsExtension requires a client to be set") - self.settings = settings or {} self.router = APIRouter() # --- READ-ONLY ROUTES (Always Registered) --- From 7ed3deade3c3692384b445d00b17e0073f1367a4 Mon Sep 17 00:00:00 2001 From: jonhealy1 Date: Tue, 10 Feb 2026 20:01:56 +0800 Subject: [PATCH 25/40] remove collections from export --- .../extensions/third_party/multi_tenant_catalogs/__init__.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/__init__.py b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/__init__.py index 3e24496fc..0ad399839 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/__init__.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/__init__.py @@ -1,7 +1,5 @@ """Catalogs extension module.""" -from stac_pydantic.api.collections import Collections - from .catalogs import CATALOGS_CONFORMANCE_CLASSES, CatalogsExtension from .client import AsyncBaseCatalogsClient from .types import Catalogs, Children, ObjectUri @@ -10,7 +8,6 @@ "CatalogsExtension", "AsyncBaseCatalogsClient", "Catalogs", - "Collections", "Children", "ObjectUri", "CATALOGS_CONFORMANCE_CLASSES", From 53468c9c47fdbf68da9c64f163458707b4762852 Mon Sep 17 00:00:00 2001 From: jonhealy1 Date: Tue, 10 Feb 2026 20:17:13 +0800 Subject: [PATCH 26/40] make client required --- .../third_party/multi_tenant_catalogs/catalogs.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/catalogs.py b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/catalogs.py index 7c9f0859d..4da0c10c3 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/catalogs.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/catalogs.py @@ -47,12 +47,12 @@ class CatalogsExtension(ApiExtension): response_class: Response class for the extension. """ - client: AsyncBaseCatalogsClient = attr.ib(default=None) - settings: dict = attr.ib(default=attr.Factory(dict)) - enable_transactions: bool = attr.ib(default=False) - conformance_classes: List[str] = attr.ib(factory=list) - router: APIRouter = attr.ib(factory=APIRouter) - response_class: Type[Response] = attr.ib(default=JSONResponse) + client: AsyncBaseCatalogsClient = attr.ib(kw_only=True) + enable_transactions: bool = attr.ib(default=False, kw_only=True) + settings: dict = attr.ib(default=attr.Factory(dict), kw_only=True) + conformance_classes: List[str] = attr.ib(factory=list, kw_only=True) + router: APIRouter = attr.ib(factory=APIRouter, kw_only=True) + response_class: Type[Response] = attr.ib(default=JSONResponse, kw_only=True) def __attrs_post_init__(self): """Initialize conformance classes based on settings.""" @@ -229,8 +229,6 @@ def register(self, app: FastAPI) -> None: Args: app: target FastAPI application. """ - if self.client is None: - raise ValueError("CatalogsExtension requires a client to be set") self.router = APIRouter() # --- READ-ONLY ROUTES (Always Registered) --- From c15ddeb9888a78b94b1efa128e89b3cec6d2013d Mon Sep 17 00:00:00 2001 From: jonhealy1 Date: Tue, 10 Feb 2026 20:21:54 +0800 Subject: [PATCH 27/40] use pipe operator for model --- .../multi_tenant_catalogs/types.py | 5 ++- .../extensions/tests/test_catalogs.py | 40 ++++++++++++++----- 2 files changed, 32 insertions(+), 13 deletions(-) diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/types.py b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/types.py index f4d10c905..3903c4276 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/types.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/types.py @@ -1,9 +1,10 @@ """Catalogs extension types.""" -from typing import Any, Dict, List, Optional +from typing import List, Optional from pydantic import BaseModel from stac_pydantic.catalog import Catalog +from stac_pydantic.collection import Collection from stac_pydantic.links import Links from stac_pydantic.shared import StacBaseModel @@ -36,7 +37,7 @@ class Children(StacBaseModel): Returns a mixed list of Catalogs and Collections as children. """ - children: List[Dict[str, Any]] + children: List[Catalog | Collection] links: Links numberMatched: Optional[int] = None numberReturned: Optional[int] = None diff --git a/stac_fastapi/extensions/tests/test_catalogs.py b/stac_fastapi/extensions/tests/test_catalogs.py index 9e6e96155..dad3dce3e 100644 --- a/stac_fastapi/extensions/tests/test_catalogs.py +++ b/stac_fastapi/extensions/tests/test_catalogs.py @@ -276,21 +276,39 @@ async def get_catalog_children( **kwargs, ) -> Children: all_children = [ - { - "id": f"{catalog_id}-child-1", - "type": "Catalog", - "description": "Child catalog", - }, - { - "id": "collection-1", - "type": "Collection", - "description": "Child collection", - }, + Catalog( + id=f"{catalog_id}-child-1", + type="Catalog", + description="Child catalog", + stac_version="1.0.0", + links=[], + ), + Collection( + id="collection-1", + type="Collection", + description="Child collection", + stac_version="1.0.0", + license="proprietary", + extent={ + "spatial": {"bbox": [[-180, -90, 180, 90]]}, + "temporal": {"interval": [[None, None]]}, + }, + links=[], + ), ] # Filter by type if provided if type: - filtered_children = [child for child in all_children if child["type"] == type] + filtered_children = [ + child + for child in all_children + if ( + type == "Catalog" + and isinstance(child, Catalog) + and not isinstance(child, Collection) + ) + or (type == "Collection" and isinstance(child, Collection)) + ] else: filtered_children = all_children From 9053c0e11917692e8c2b57b54598ac8dec72a822 Mon Sep 17 00:00:00 2001 From: jonhealy1 Date: Tue, 10 Feb 2026 20:27:33 +0800 Subject: [PATCH 28/40] revert bulk transactions move --- docs/mkdocs.yml | 4 +--- .../index.md => bulk_transactions.md} | 0 .../bulk_transactions/bulk_transactions.md | 3 --- .../extensions/third_party/index.md | 2 +- .../bulk_transactions.py | 0 .../third_party/bulk_transactions/__init__.py | 17 ----------------- 6 files changed, 2 insertions(+), 24 deletions(-) rename docs/src/api/stac_fastapi/extensions/third_party/{bulk_transactions/index.md => bulk_transactions.md} (100%) delete mode 100644 docs/src/api/stac_fastapi/extensions/third_party/bulk_transactions/bulk_transactions.md rename stac_fastapi/extensions/stac_fastapi/extensions/third_party/{bulk_transactions => }/bulk_transactions.py (100%) delete mode 100644 stac_fastapi/extensions/stac_fastapi/extensions/third_party/bulk_transactions/__init__.py diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index caec1303d..2cf2ae4c1 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -74,9 +74,7 @@ nav: - transaction: api/stac_fastapi/extensions/core/transaction.md - third_party: - module: api/stac_fastapi/extensions/third_party/index.md - - bulk_transactions: - - module: api/stac_fastapi/extensions/third_party/bulk_transactions/index.md - - bulk_transactions: api/stac_fastapi/extensions/third_party/bulk_transactions/bulk_transactions.md + - bulk_transactions: api/stac_fastapi/extensions/third_party/bulk_transactions.md - multi_tenant_catalogs: - module: api/stac_fastapi/extensions/third_party/multi_tenant_catalogs/index.md - catalogs: api/stac_fastapi/extensions/third_party/multi_tenant_catalogs/catalogs.md diff --git a/docs/src/api/stac_fastapi/extensions/third_party/bulk_transactions/index.md b/docs/src/api/stac_fastapi/extensions/third_party/bulk_transactions.md similarity index 100% rename from docs/src/api/stac_fastapi/extensions/third_party/bulk_transactions/index.md rename to docs/src/api/stac_fastapi/extensions/third_party/bulk_transactions.md diff --git a/docs/src/api/stac_fastapi/extensions/third_party/bulk_transactions/bulk_transactions.md b/docs/src/api/stac_fastapi/extensions/third_party/bulk_transactions/bulk_transactions.md deleted file mode 100644 index 89f115eb4..000000000 --- a/docs/src/api/stac_fastapi/extensions/third_party/bulk_transactions/bulk_transactions.md +++ /dev/null @@ -1,3 +0,0 @@ -::: stac_fastapi.extensions.third_party.bulk_transactions.bulk_transactions - options: - show_source: true diff --git a/docs/src/api/stac_fastapi/extensions/third_party/index.md b/docs/src/api/stac_fastapi/extensions/third_party/index.md index b15de031c..4db94987a 100644 --- a/docs/src/api/stac_fastapi/extensions/third_party/index.md +++ b/docs/src/api/stac_fastapi/extensions/third_party/index.md @@ -4,5 +4,5 @@ Third Party Extensions submodule. ## Sub-modules -* [stac_fastapi.extensions.third_party.bulk_transactions](bulk_transactions/index.md) +* [stac_fastapi.extensions.third_party.bulk_transactions](bulk_transactions.md) * [stac_fastapi.extensions.third_party.multi_tenant_catalogs](multi_tenant_catalogs/index.md) diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/third_party/bulk_transactions/bulk_transactions.py b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/bulk_transactions.py similarity index 100% rename from stac_fastapi/extensions/stac_fastapi/extensions/third_party/bulk_transactions/bulk_transactions.py rename to stac_fastapi/extensions/stac_fastapi/extensions/third_party/bulk_transactions.py diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/third_party/bulk_transactions/__init__.py b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/bulk_transactions/__init__.py deleted file mode 100644 index d503fe5fc..000000000 --- a/stac_fastapi/extensions/stac_fastapi/extensions/third_party/bulk_transactions/__init__.py +++ /dev/null @@ -1,17 +0,0 @@ -"""Bulk transactions extension.""" - -from .bulk_transactions import ( - AsyncBaseBulkTransactionsClient, - BaseBulkTransactionsClient, - BulkTransactionExtension, - BulkTransactionMethod, - Items, -) - -__all__ = [ - "BulkTransactionExtension", - "AsyncBaseBulkTransactionsClient", - "BaseBulkTransactionsClient", - "BulkTransactionMethod", - "Items", -] From d31d8fc0f10373de731ca65a9ae1700ecd976277 Mon Sep 17 00:00:00 2001 From: jonhealy1 Date: Tue, 10 Feb 2026 20:30:25 +0800 Subject: [PATCH 29/40] clean bulk transactions --- .../extensions/third_party/bulk_transactions.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/third_party/bulk_transactions.py b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/bulk_transactions.py index 5aea8adba..aec905dff 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/third_party/bulk_transactions.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/bulk_transactions.py @@ -138,12 +138,3 @@ def register(self, app: FastAPI) -> None: ), ) app.include_router(router, tags=["Bulk Transaction Extension"]) - - -__all__ = [ - "BulkTransactionExtension", - "AsyncBaseBulkTransactionsClient", - "BaseBulkTransactionsClient", - "BulkTransactionMethod", - "Items", -] From 932068ae67191d3525590ba25c44b1e7f2ec5d12 Mon Sep 17 00:00:00 2001 From: jonhealy1 Date: Tue, 10 Feb 2026 23:05:17 +0800 Subject: [PATCH 30/40] use pipe syntax --- .../multi_tenant_catalogs/catalogs.py | 28 ++++----- .../multi_tenant_catalogs/client.py | 62 +++++++++---------- 2 files changed, 45 insertions(+), 45 deletions(-) diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/catalogs.py b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/catalogs.py index 4da0c10c3..2a650f80d 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/catalogs.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/catalogs.py @@ -1,6 +1,6 @@ """Catalogs extension.""" -from typing import List, Literal, Optional, Type, Union +from typing import List, Literal, Type import attr from fastapi import APIRouter, FastAPI, Query, Request @@ -65,16 +65,16 @@ async def get_catalog_collection_items( catalog_id: str, collection_id: str, request: Request, - bbox: Optional[str] = Query( + bbox: str | None = Query( None, description="Bounding box to filter items.", ), - datetime: Optional[str] = Query(None, description="Datetime to filter items"), + datetime: str | None = Query(None, description="Datetime to filter items"), limit: int = Query(10, ge=1, le=10000, description="Maximum number of items"), - token: Optional[str] = Query(None, description="Pagination token"), + token: str | None = Query(None, description="Pagination token"), ) -> ItemCollection: """Get items from a collection in a catalog with search support.""" - bbox_list: Optional[List[float]] = None + bbox_list: List[float] | None = None if bbox: bbox_tuple = str2bbox(bbox) if bbox_tuple: @@ -95,8 +95,8 @@ async def get_catalog_collection_items( async def _get_catalogs_wrapper( self, request: Request, - limit: Optional[int] = None, - token: Optional[str] = None, + limit: int | None = None, + token: str | None = None, ) -> Catalogs: return await self.client.get_catalogs(limit=limit, token=token, request=request) @@ -129,8 +129,8 @@ async def _get_sub_catalogs_wrapper( self, catalog_id: str, request: Request, - limit: Optional[int] = None, - token: Optional[str] = None, + limit: int | None = None, + token: str | None = None, ) -> Catalogs: return await self.client.get_sub_catalogs( catalog_id=catalog_id, limit=limit, token=token, request=request @@ -139,7 +139,7 @@ async def _get_sub_catalogs_wrapper( async def _create_sub_catalog_wrapper( self, catalog_id: str, - catalog: Union[Catalog, ObjectUri], + catalog: Catalog | ObjectUri, request: Request, ) -> Catalog: return await self.client.create_sub_catalog( @@ -149,7 +149,7 @@ async def _create_sub_catalog_wrapper( async def _create_catalog_collection_wrapper( self, catalog_id: str, - collection: Union[Collection, ObjectUri], + collection: Collection | ObjectUri, request: Request, ) -> Collection: return await self.client.create_catalog_collection( @@ -184,9 +184,9 @@ async def _get_catalog_children_wrapper( self, catalog_id: str, request: Request, - limit: Optional[int] = None, - token: Optional[str] = None, - type: Optional[Literal["Catalog", "Collection"]] = None, + limit: int | None = None, + token: str | None = None, + type: Literal["Catalog", "Collection"] | None = None, ) -> Children: return await self.client.get_catalog_children( catalog_id=catalog_id, diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/client.py b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/client.py index 3df4f796c..662ff7364 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/client.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/client.py @@ -2,7 +2,7 @@ import abc from datetime import datetime -from typing import List, Literal, Optional, Union +from typing import List, Literal import attr from fastapi import Request @@ -22,9 +22,9 @@ class AsyncBaseCatalogsClient(abc.ABC): @abc.abstractmethod async def get_catalogs( self, - limit: Optional[int] = None, - token: Optional[str] = None, - request: Optional[Request] = None, + limit: int | None = None, + token: str | None = None, + request: Request | None = None, **kwargs, ) -> Catalogs: """Get all catalogs with pagination support. @@ -41,7 +41,7 @@ async def get_catalogs( @abc.abstractmethod async def create_catalog( - self, catalog: Catalog, request: Optional[Request] = None, **kwargs + self, catalog: Catalog, request: Request | None = None, **kwargs ) -> Catalog: """Create a new catalog. @@ -56,7 +56,7 @@ async def create_catalog( @abc.abstractmethod async def get_catalog( - self, catalog_id: str, request: Optional[Request] = None, **kwargs + self, catalog_id: str, request: Request | None = None, **kwargs ) -> Catalog: """Get a specific catalog by ID. @@ -74,7 +74,7 @@ async def update_catalog( self, catalog_id: str, catalog: Catalog, - request: Optional[Request] = None, + request: Request | None = None, **kwargs, ) -> Catalog: """Update an existing catalog. @@ -93,7 +93,7 @@ async def update_catalog( async def delete_catalog( self, catalog_id: str, - request: Optional[Request] = None, + request: Request | None = None, **kwargs, ) -> None: """Delete a catalog. @@ -108,7 +108,7 @@ async def delete_catalog( async def get_catalog_collections( self, catalog_id: str, - request: Optional[Request] = None, + request: Request | None = None, **kwargs, ) -> Collections: """Get collections linked from a specific catalog. @@ -126,9 +126,9 @@ async def get_catalog_collections( async def get_sub_catalogs( self, catalog_id: str, - limit: Optional[int] = None, - token: Optional[str] = None, - request: Optional[Request] = None, + limit: int | None = None, + token: str | None = None, + request: Request | None = None, **kwargs, ) -> Catalogs: """Get all sub-catalogs of a specific catalog with pagination. @@ -148,8 +148,8 @@ async def get_sub_catalogs( async def create_sub_catalog( self, catalog_id: str, - catalog: Union[Catalog, ObjectUri], - request: Optional[Request] = None, + catalog: Catalog | ObjectUri, + request: Request | None = None, **kwargs, ) -> Catalog: """Create a new catalog or link an existing catalog as a sub-catalog. @@ -182,8 +182,8 @@ async def create_sub_catalog( async def create_catalog_collection( self, catalog_id: str, - collection: Union[Collection, ObjectUri], - request: Optional[Request] = None, + collection: Collection | ObjectUri, + request: Request | None = None, **kwargs, ) -> Collection: """Create a new collection or link an existing collection to catalog. @@ -209,7 +209,7 @@ async def get_catalog_collection( self, catalog_id: str, collection_id: str, - request: Optional[Request] = None, + request: Request | None = None, **kwargs, ) -> Collection: """Get a specific collection from a catalog. @@ -229,7 +229,7 @@ async def unlink_catalog_collection( self, catalog_id: str, collection_id: str, - request: Optional[Request] = None, + request: Request | None = None, **kwargs, ) -> None: """Unlink a collection from a catalog. @@ -249,11 +249,11 @@ async def get_catalog_collection_items( self, catalog_id: str, collection_id: str, - bbox: Optional[List[float]] = None, - datetime: Optional[Union[str, datetime]] = None, - limit: Optional[int] = 10, - token: Optional[str] = None, - request: Optional[Request] = None, + bbox: List[float] | None = None, + datetime: str | datetime | None = None, + limit: int | None = 10, + token: str | None = None, + request: Request | None = None, **kwargs, ) -> ItemCollection: """Get items from a collection in a catalog with search support. @@ -281,7 +281,7 @@ async def get_catalog_collection_item( catalog_id: str, collection_id: str, item_id: str, - request: Optional[Request] = None, + request: Request | None = None, **kwargs, ) -> Item: """Get a specific item from a collection in a catalog. @@ -301,10 +301,10 @@ async def get_catalog_collection_item( async def get_catalog_children( self, catalog_id: str, - limit: Optional[int] = None, - token: Optional[str] = None, - type: Optional[Literal["Catalog", "Collection"]] = None, - request: Optional[Request] = None, + limit: int | None = None, + token: str | None = None, + type: Literal["Catalog", "Collection"] | None = None, + request: Request | None = None, **kwargs, ) -> Children: """Get all children (Catalogs and Collections) of a specific catalog. @@ -323,7 +323,7 @@ async def get_catalog_children( @abc.abstractmethod async def get_catalog_conformance( - self, catalog_id: str, request: Optional[Request] = None, **kwargs + self, catalog_id: str, request: Request | None = None, **kwargs ) -> dict: """Get conformance classes specific to this sub-catalog. @@ -338,7 +338,7 @@ async def get_catalog_conformance( @abc.abstractmethod async def get_catalog_queryables( - self, catalog_id: str, request: Optional[Request] = None, **kwargs + self, catalog_id: str, request: Request | None = None, **kwargs ) -> dict: """Get queryable fields available for filtering in this sub-catalog. @@ -356,7 +356,7 @@ async def unlink_sub_catalog( self, catalog_id: str, sub_catalog_id: str, - request: Optional[Request] = None, + request: Request | None = None, **kwargs, ) -> None: """Unlink a sub-catalog from its parent. From 58ff4991ad76473154aac20c914aad57f4d1d8f4 Mon Sep 17 00:00:00 2001 From: jonhealy1 Date: Tue, 10 Feb 2026 23:09:29 +0800 Subject: [PATCH 31/40] extend to types --- .../third_party/multi_tenant_catalogs/types.py | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/types.py b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/types.py index 3903c4276..65b6e0b19 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/types.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/types.py @@ -1,6 +1,6 @@ """Catalogs extension types.""" -from typing import List, Optional +from typing import List from pydantic import BaseModel from stac_pydantic.catalog import Catalog @@ -20,15 +20,12 @@ class ObjectUri(BaseModel): class Catalogs(StacBaseModel): - """Catalogs endpoint response. - - Similar to Collections but for catalogs. - """ + """Catalogs endpoint response.""" catalogs: List[Catalog] links: Links - numberMatched: Optional[int] = None - numberReturned: Optional[int] = None + numberMatched: int | None = None + numberReturned: int | None = None class Children(StacBaseModel): @@ -39,5 +36,5 @@ class Children(StacBaseModel): children: List[Catalog | Collection] links: Links - numberMatched: Optional[int] = None - numberReturned: Optional[int] = None + numberMatched: int | None = None + numberReturned: int | None = None From ec5c13f8e3b0057913f68982e19a6a2d958b87c2 Mon Sep 17 00:00:00 2001 From: jonhealy1 Date: Sat, 28 Feb 2026 11:39:09 +0800 Subject: [PATCH 32/40] remove wrappers, update --- .../multi_tenant_catalogs/catalogs.py | 507 ++++++++---------- .../multi_tenant_catalogs/types.py | 127 ++++- 2 files changed, 355 insertions(+), 279 deletions(-) diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/catalogs.py b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/catalogs.py index 2a650f80d..0bf98ebb8 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/catalogs.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/catalogs.py @@ -1,9 +1,9 @@ """Catalogs extension.""" -from typing import List, Literal, Type +from typing import List, Type import attr -from fastapi import APIRouter, FastAPI, Query, Request +from fastapi import APIRouter, FastAPI from fastapi.responses import JSONResponse from stac_pydantic.api.collections import Collections from stac_pydantic.catalog import Catalog @@ -13,11 +13,25 @@ from starlette.responses import Response from starlette.status import HTTP_200_OK, HTTP_201_CREATED, HTTP_204_NO_CONTENT +from stac_fastapi.api.routes import create_async_endpoint from stac_fastapi.types.extension import ApiExtension -from stac_fastapi.types.search import str2bbox from .client import AsyncBaseCatalogsClient -from .types import Catalogs, Children, ObjectUri +from .types import ( + CatalogChildrenRequest, + CatalogCollectionItemsRequest, + CatalogCollectionItemUri, + CatalogCollectionUri, + Catalogs, + CatalogsGetRequest, + CatalogsUri, + Children, + CreateCatalogCollectionRequest, + CreateSubCatalogRequest, + SubCatalogsRequest, + UnlinkSubCatalogRequest, + UpdateCatalogRequest, +) CATALOGS_CONFORMANCE_CLASSES = [ "https://api.stacspec.org/v1.0.0/core", @@ -60,169 +74,6 @@ def __attrs_post_init__(self): if self.enable_transactions: self.conformance_classes.append(CATALOGS_TRANSACTION_CONFORMANCE_CLASS) - async def get_catalog_collection_items( - self, - catalog_id: str, - collection_id: str, - request: Request, - bbox: str | None = Query( - None, - description="Bounding box to filter items.", - ), - datetime: str | None = Query(None, description="Datetime to filter items"), - limit: int = Query(10, ge=1, le=10000, description="Maximum number of items"), - token: str | None = Query(None, description="Pagination token"), - ) -> ItemCollection: - """Get items from a collection in a catalog with search support.""" - bbox_list: List[float] | None = None - if bbox: - bbox_tuple = str2bbox(bbox) - if bbox_tuple: - bbox_list = list(bbox_tuple) - - return await self.client.get_catalog_collection_items( - catalog_id=catalog_id, - collection_id=collection_id, - bbox=bbox_list, - datetime=datetime, - limit=limit, - token=token, - request=request, - ) - - # --- WRAPPERS --- - - async def _get_catalogs_wrapper( - self, - request: Request, - limit: int | None = None, - token: str | None = None, - ) -> Catalogs: - return await self.client.get_catalogs(limit=limit, token=token, request=request) - - async def _create_catalog_wrapper( - self, catalog: Catalog, request: Request - ) -> Catalog: - return await self.client.create_catalog(catalog=catalog, request=request) - - async def _get_catalog_wrapper(self, catalog_id: str, request: Request) -> Catalog: - return await self.client.get_catalog(catalog_id=catalog_id, request=request) - - async def _update_catalog_wrapper( - self, catalog_id: str, catalog: Catalog, request: Request - ) -> Catalog: - return await self.client.update_catalog( - catalog_id=catalog_id, catalog=catalog, request=request - ) - - async def _delete_catalog_wrapper(self, catalog_id: str, request: Request) -> None: - return await self.client.delete_catalog(catalog_id=catalog_id, request=request) - - async def _get_catalog_collections_wrapper( - self, catalog_id: str, request: Request - ) -> Collections: - return await self.client.get_catalog_collections( - catalog_id=catalog_id, request=request - ) - - async def _get_sub_catalogs_wrapper( - self, - catalog_id: str, - request: Request, - limit: int | None = None, - token: str | None = None, - ) -> Catalogs: - return await self.client.get_sub_catalogs( - catalog_id=catalog_id, limit=limit, token=token, request=request - ) - - async def _create_sub_catalog_wrapper( - self, - catalog_id: str, - catalog: Catalog | ObjectUri, - request: Request, - ) -> Catalog: - return await self.client.create_sub_catalog( - catalog_id=catalog_id, catalog=catalog, request=request - ) - - async def _create_catalog_collection_wrapper( - self, - catalog_id: str, - collection: Collection | ObjectUri, - request: Request, - ) -> Collection: - return await self.client.create_catalog_collection( - catalog_id=catalog_id, collection=collection, request=request - ) - - async def _get_catalog_collection_wrapper( - self, catalog_id: str, collection_id: str, request: Request - ) -> Collection: - return await self.client.get_catalog_collection( - catalog_id=catalog_id, collection_id=collection_id, request=request - ) - - async def _unlink_catalog_collection_wrapper( - self, catalog_id: str, collection_id: str, request: Request - ) -> None: - return await self.client.unlink_catalog_collection( - catalog_id=catalog_id, collection_id=collection_id, request=request - ) - - async def _get_catalog_collection_item_wrapper( - self, catalog_id: str, collection_id: str, item_id: str, request: Request - ) -> Item: - return await self.client.get_catalog_collection_item( - catalog_id=catalog_id, - collection_id=collection_id, - item_id=item_id, - request=request, - ) - - async def _get_catalog_children_wrapper( - self, - catalog_id: str, - request: Request, - limit: int | None = None, - token: str | None = None, - type: Literal["Catalog", "Collection"] | None = None, - ) -> Children: - return await self.client.get_catalog_children( - catalog_id=catalog_id, - limit=limit, - token=token, - type=type, - request=request, - ) - - async def _get_catalog_conformance_wrapper( - self, catalog_id: str, request: Request - ) -> dict: - result = await self.client.get_catalog_conformance( - catalog_id=catalog_id, request=request - ) - # Merge extension conformance classes with client response - if "conformsTo" in result: - result["conformsTo"].extend(self.conformance_classes) - else: - result["conformsTo"] = self.conformance_classes - return result - - async def _get_catalog_queryables_wrapper( - self, catalog_id: str, request: Request - ) -> dict: - return await self.client.get_catalog_queryables( - catalog_id=catalog_id, request=request - ) - - async def _unlink_sub_catalog_wrapper( - self, catalog_id: str, sub_catalog_id: str, request: Request - ) -> None: - return await self.client.unlink_sub_catalog( - catalog_id=catalog_id, sub_catalog_id=sub_catalog_id, request=request - ) - def register(self, app: FastAPI) -> None: """Register the extension with a FastAPI application. @@ -230,90 +81,139 @@ def register(self, app: FastAPI) -> None: app: target FastAPI application. """ self.router = APIRouter() + self.register_read_endpoints() + if self.enable_transactions: + self.register_transaction_endpoints() + app.include_router(self.router, tags=["Catalogs"]) - # --- READ-ONLY ROUTES (Always Registered) --- + def register_read_endpoints(self) -> None: + """Register all GET endpoints using the async factory.""" + # GET /catalogs self.router.add_api_route( + name="Get All Catalogs", path="/catalogs", - endpoint=self._get_catalogs_wrapper, methods=["GET"], - response_model=Catalogs, + endpoint=create_async_endpoint(self.client.get_catalogs, CatalogsGetRequest), + response_model=Catalogs + if self.settings.get("enable_response_models", True) + else None, response_class=self.response_class, summary="Get All Catalogs", description="Returns a list of all catalogs in the database.", tags=["Catalogs"], ) + # GET /catalogs/{catalog_id} self.router.add_api_route( + name="Get Catalog", path="/catalogs/{catalog_id}", - endpoint=self._get_catalog_wrapper, methods=["GET"], - response_model=Catalog, + endpoint=create_async_endpoint(self.client.get_catalog, CatalogsUri), + response_model=Catalog + if self.settings.get("enable_response_models", True) + else None, response_class=self.response_class, summary="Get Catalog", description="Get a specific STAC catalog by ID.", tags=["Catalogs"], ) + # GET /catalogs/{catalog_id}/collections self.router.add_api_route( + name="Get Catalog Collections", path="/catalogs/{catalog_id}/collections", - endpoint=self._get_catalog_collections_wrapper, methods=["GET"], - response_model=Collections, + endpoint=create_async_endpoint( + self.client.get_catalog_collections, CatalogsUri + ), + response_model=Collections + if self.settings.get("enable_response_models", True) + else None, response_class=self.response_class, summary="Get Catalog Collections", description="Get collections linked from a specific catalog.", tags=["Catalogs"], ) + # GET /catalogs/{catalog_id}/collections/{collection_id} self.router.add_api_route( + name="Get Catalog Collection", path="/catalogs/{catalog_id}/collections/{collection_id}", - endpoint=self._get_catalog_collection_wrapper, methods=["GET"], - response_model=Collection, + endpoint=create_async_endpoint( + self.client.get_catalog_collection, CatalogCollectionUri + ), + response_model=Collection + if self.settings.get("enable_response_models", True) + else None, response_class=self.response_class, summary="Get Catalog Collection", description="Get a specific collection from a catalog.", tags=["Catalogs"], ) + # GET /catalogs/{catalog_id}/collections/{collection_id}/items self.router.add_api_route( + name="Get Catalog Collection Items", path="/catalogs/{catalog_id}/collections/{collection_id}/items", - endpoint=self.get_catalog_collection_items, methods=["GET"], - response_model=ItemCollection, + endpoint=create_async_endpoint( + self.client.get_catalog_collection_items, CatalogCollectionItemsRequest + ), + response_model=ItemCollection + if self.settings.get("enable_response_models", True) + else None, response_class=self.response_class, summary="Get Catalog Collection Items", description="Get items from a collection in a catalog.", tags=["Catalogs"], ) + # GET /catalogs/{catalog_id}/collections/{collection_id}/items/{item_id} self.router.add_api_route( + name="Get Catalog Collection Item", path="/catalogs/{catalog_id}/collections/{collection_id}/items/{item_id}", - endpoint=self._get_catalog_collection_item_wrapper, methods=["GET"], - response_model=Item, + endpoint=create_async_endpoint( + self.client.get_catalog_collection_item, CatalogCollectionItemUri + ), + response_model=Item + if self.settings.get("enable_response_models", True) + else None, response_class=self.response_class, summary="Get Catalog Collection Item", description="Get a specific item from a collection in a catalog.", tags=["Catalogs"], ) + # GET /catalogs/{catalog_id}/catalogs self.router.add_api_route( + name="Get Catalog Sub-Catalogs", path="/catalogs/{catalog_id}/catalogs", - endpoint=self._get_sub_catalogs_wrapper, methods=["GET"], - response_model=Catalogs, + endpoint=create_async_endpoint( + self.client.get_sub_catalogs, SubCatalogsRequest + ), + response_model=Catalogs + if self.settings.get("enable_response_models", True) + else None, response_class=self.response_class, summary="Get Catalog Sub-Catalogs", description="Get sub-catalogs linked from a specific catalog.", tags=["Catalogs"], ) + # GET /catalogs/{catalog_id}/children self.router.add_api_route( + name="Get Catalog Children", path="/catalogs/{catalog_id}/children", - endpoint=self._get_catalog_children_wrapper, methods=["GET"], - response_model=Children, + endpoint=create_async_endpoint( + self.client.get_catalog_children, CatalogChildrenRequest + ), + response_model=Children + if self.settings.get("enable_response_models", True) + else None, response_class=self.response_class, summary="Get Catalog Children", description=( @@ -322,10 +222,12 @@ def register(self, app: FastAPI) -> None: tags=["Catalogs"], ) + # GET /catalogs/{catalog_id}/conformance self.router.add_api_route( + name="Get Catalog Conformance", path="/catalogs/{catalog_id}/conformance", - endpoint=self._get_catalog_conformance_wrapper, methods=["GET"], + endpoint=create_async_endpoint(self._get_catalog_conformance, CatalogsUri), response_class=self.response_class, summary="Get Catalog Conformance", description="Get conformance classes specific to this sub-catalog.", @@ -335,10 +237,14 @@ def register(self, app: FastAPI) -> None: }, ) + # GET /catalogs/{catalog_id}/queryables self.router.add_api_route( + name="Get Catalog Queryables", path="/catalogs/{catalog_id}/queryables", - endpoint=self._get_catalog_queryables_wrapper, methods=["GET"], + endpoint=create_async_endpoint( + self.client.get_catalog_queryables, CatalogsUri + ), response_class=self.response_class, summary="Get Catalog Queryables", description=( @@ -349,95 +255,142 @@ def register(self, app: FastAPI) -> None: responses={HTTP_200_OK: {"description": "Queryable fields for the catalog"}}, ) - # --- TRANSACTION ROUTES (Conditionally Registered) --- - if self.enable_transactions: - self.router.add_api_route( - path="/catalogs", - endpoint=self._create_catalog_wrapper, - methods=["POST"], - response_model=Catalog, - response_class=self.response_class, - status_code=HTTP_201_CREATED, - summary="Create Catalog", - description="Create a new STAC catalog.", - tags=["Catalogs"], - ) - - self.router.add_api_route( - path="/catalogs/{catalog_id}", - endpoint=self._update_catalog_wrapper, - methods=["PUT"], - response_model=Catalog, - response_class=self.response_class, - summary="Update Catalog", - description="Update an existing STAC catalog.", - tags=["Catalogs"], - ) - - self.router.add_api_route( - path="/catalogs/{catalog_id}", - endpoint=self._delete_catalog_wrapper, - methods=["DELETE"], - response_class=self.response_class, - status_code=HTTP_204_NO_CONTENT, - summary="Delete Catalog", - description="Delete a catalog.", - tags=["Catalogs"], - ) - - self.router.add_api_route( - path="/catalogs/{catalog_id}/collections", - endpoint=self._create_catalog_collection_wrapper, - methods=["POST"], - response_model=Collection, - response_class=self.response_class, - status_code=HTTP_201_CREATED, - summary="Create Catalog Collection", - description="Create a new collection and link it to a specific catalog.", - tags=["Catalogs"], - ) - - self.router.add_api_route( - path="/catalogs/{catalog_id}/collections/{collection_id}", - endpoint=self._unlink_catalog_collection_wrapper, - methods=["DELETE"], - response_class=self.response_class, - status_code=HTTP_204_NO_CONTENT, - summary="Unlink Collection from Catalog", - description=( - "Removes the link between the catalog and collection. " - "The Collection data is NOT deleted." - ), - tags=["Catalogs"], - ) - - self.router.add_api_route( - path="/catalogs/{catalog_id}/catalogs", - endpoint=self._create_sub_catalog_wrapper, - methods=["POST"], - response_model=Catalog, - response_class=self.response_class, - status_code=HTTP_201_CREATED, - summary="Create Catalog Sub-Catalog", - description=( - "Create a new catalog or link an existing catalog as a " - "sub-catalog of a specific catalog." - ), - tags=["Catalogs"], - ) - - self.router.add_api_route( - path="/catalogs/{catalog_id}/catalogs/{sub_catalog_id}", - endpoint=self._unlink_sub_catalog_wrapper, - methods=["DELETE"], - response_class=self.response_class, - status_code=HTTP_204_NO_CONTENT, - summary="Unlink Sub-Catalog", - description=( - "Unlink a sub-catalog from its parent. " - "Does not delete the sub-catalog." - ), - tags=["Catalogs"], - ) + def register_transaction_endpoints(self) -> None: + """Register POST/PUT/DELETE endpoints.""" + # POST /catalogs + self.router.add_api_route( + name="Create Catalog", + path="/catalogs", + methods=["POST"], + status_code=HTTP_201_CREATED, + endpoint=create_async_endpoint(self.client.create_catalog, Catalog), + response_model=Catalog + if self.settings.get("enable_response_models", True) + else None, + response_class=self.response_class, + summary="Create Catalog", + description="Create a new STAC catalog.", + tags=["Catalogs"], + ) - app.include_router(self.router, tags=["Catalogs"]) + # PUT /catalogs/{catalog_id} + self.router.add_api_route( + name="Update Catalog", + path="/catalogs/{catalog_id}", + methods=["PUT"], + endpoint=create_async_endpoint( + self.client.update_catalog, UpdateCatalogRequest + ), + response_model=Catalog + if self.settings.get("enable_response_models", True) + else None, + response_class=self.response_class, + summary="Update Catalog", + description="Update an existing STAC catalog.", + tags=["Catalogs"], + ) + + # DELETE /catalogs/{catalog_id} + self.router.add_api_route( + name="Delete Catalog", + path="/catalogs/{catalog_id}", + methods=["DELETE"], + status_code=HTTP_204_NO_CONTENT, + endpoint=create_async_endpoint(self.client.delete_catalog, CatalogsUri), + response_class=self.response_class, + summary="Delete Catalog", + description="Delete a catalog.", + tags=["Catalogs"], + ) + + # POST /catalogs/{catalog_id}/collections + self.router.add_api_route( + name="Create Catalog Collection", + path="/catalogs/{catalog_id}/collections", + methods=["POST"], + status_code=HTTP_201_CREATED, + endpoint=create_async_endpoint( + self.client.create_catalog_collection, CreateCatalogCollectionRequest + ), + response_model=Collection + if self.settings.get("enable_response_models", True) + else None, + response_class=self.response_class, + summary="Create Catalog Collection", + description="Create a new collection and link it to a specific catalog.", + tags=["Catalogs"], + ) + + # DELETE /catalogs/{catalog_id}/collections/{collection_id} + self.router.add_api_route( + name="Unlink Collection from Catalog", + path="/catalogs/{catalog_id}/collections/{collection_id}", + methods=["DELETE"], + status_code=HTTP_204_NO_CONTENT, + endpoint=create_async_endpoint( + self.client.unlink_catalog_collection, CatalogCollectionUri + ), + response_class=self.response_class, + summary="Unlink Collection from Catalog", + description=( + "Removes the link between the catalog and collection. " + "The Collection data is NOT deleted." + ), + tags=["Catalogs"], + ) + + # POST /catalogs/{catalog_id}/catalogs + self.router.add_api_route( + name="Create Catalog Sub-Catalog", + path="/catalogs/{catalog_id}/catalogs", + methods=["POST"], + status_code=HTTP_201_CREATED, + endpoint=create_async_endpoint( + self.client.create_sub_catalog, CreateSubCatalogRequest + ), + response_model=Catalog + if self.settings.get("enable_response_models", True) + else None, + response_class=self.response_class, + summary="Create Catalog Sub-Catalog", + description=( + "Create a new catalog or link an existing catalog as a " + "sub-catalog of a specific catalog." + ), + tags=["Catalogs"], + ) + + # DELETE /catalogs/{catalog_id}/catalogs/{sub_catalog_id} + self.router.add_api_route( + name="Unlink Sub-Catalog", + path="/catalogs/{catalog_id}/catalogs/{sub_catalog_id}", + methods=["DELETE"], + status_code=HTTP_204_NO_CONTENT, + endpoint=create_async_endpoint( + self.client.unlink_sub_catalog, UnlinkSubCatalogRequest + ), + response_class=self.response_class, + summary="Unlink Sub-Catalog", + description=( + "Unlink a sub-catalog from its parent. " + "Does not delete the sub-catalog." + ), + tags=["Catalogs"], + ) + + async def _get_catalog_conformance( + self, catalog_id: str, request=None, **kwargs + ) -> dict: + """Get conformance classes specific to this sub-catalog. + + Merges client response with extension conformance classes. + """ + result = await self.client.get_catalog_conformance( + catalog_id=catalog_id, request=request + ) + # Merge extension conformance classes with client response + if "conformsTo" in result: + result["conformsTo"].extend(self.conformance_classes) + else: + result["conformsTo"] = self.conformance_classes + return result diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/types.py b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/types.py index 65b6e0b19..23eca850c 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/types.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/types.py @@ -1,12 +1,17 @@ """Catalogs extension types.""" -from typing import List +from typing import List, Literal, Optional, Union +import attr +from fastapi import Body, Path, Query from pydantic import BaseModel from stac_pydantic.catalog import Catalog from stac_pydantic.collection import Collection from stac_pydantic.links import Links -from stac_pydantic.shared import StacBaseModel +from stac_pydantic.shared import BBox, StacBaseModel +from typing_extensions import Annotated + +from stac_fastapi.types.search import APIRequest, _bbox_converter class ObjectUri(BaseModel): @@ -19,6 +24,124 @@ class ObjectUri(BaseModel): id: str +# --- Uri Request Models for create_async_endpoint factory --- + + +@attr.s +class CatalogsUri(APIRequest): + """Base for catalog-specific endpoints.""" + + catalog_id: Annotated[str, Path(description="Catalog ID")] = attr.ib() + + +@attr.s +class CatalogsGetRequest(APIRequest): + """Parameters for the root /catalogs endpoint.""" + + limit: Annotated[ + Optional[int], + Query(ge=1, le=1000, description="Maximum number of catalogs to return"), + ] = attr.ib(default=10) + token: Annotated[Optional[str], Query(description="Pagination token")] = attr.ib( + default=None + ) + + +@attr.s +class CatalogCollectionUri(CatalogsUri): + """Combines catalog_id and collection_id.""" + + collection_id: Annotated[str, Path(description="Collection ID")] = attr.ib() + + +@attr.s +class CatalogCollectionItemUri(CatalogCollectionUri): + """Combines catalog_id, collection_id, and item_id.""" + + item_id: Annotated[str, Path(description="Item ID")] = attr.ib() + + +@attr.s +class CatalogCollectionItemsRequest(CatalogCollectionUri): + """Parameters for /catalogs/{catalog_id}/collections/{collection_id}/items.""" + + bbox: Annotated[ + Optional[BBox], + Query(description="Bounding box to filter items [minx, miny, maxx, maxy]"), + ] = attr.ib(default=None, converter=lambda x: _bbox_converter(x) if x else None) + datetime: Annotated[ + Optional[str], Query(description="Datetime to filter items") + ] = attr.ib(default=None) + limit: Annotated[ + Optional[int], + Query(ge=1, le=10000, description="Maximum number of items to return"), + ] = attr.ib(default=10) + token: Annotated[Optional[str], Query(description="Pagination token")] = attr.ib( + default=None + ) + + +@attr.s +class SubCatalogsRequest(CatalogsUri): + """Parameters for /catalogs/{catalog_id}/catalogs.""" + + limit: Annotated[ + Optional[int], + Query(ge=1, le=1000, description="Maximum number of sub-catalogs to return"), + ] = attr.ib(default=10) + token: Annotated[Optional[str], Query(description="Pagination token")] = attr.ib( + default=None + ) + + +@attr.s +class CatalogChildrenRequest(CatalogsUri): + """Parameters for /catalogs/{catalog_id}/children.""" + + limit: Annotated[ + Optional[int], + Query(ge=1, le=1000, description="Maximum number of children to return"), + ] = attr.ib(default=10) + token: Annotated[Optional[str], Query(description="Pagination token")] = attr.ib( + default=None + ) + type: Annotated[ + Optional[Literal["Catalog", "Collection"]], + Query(description="Filter by resource type"), + ] = attr.ib(default=None) + + +# --- Request Models with Body for Transaction Endpoints --- + + +@attr.s +class UpdateCatalogRequest(CatalogsUri): + """Update catalog with body.""" + + catalog: Annotated[Catalog, Body()] = attr.ib(default=None) + + +@attr.s +class CreateCatalogCollectionRequest(CatalogsUri): + """Create catalog collection with body.""" + + collection: Annotated[Union[Collection, ObjectUri], Body()] = attr.ib(default=None) + + +@attr.s +class CreateSubCatalogRequest(CatalogsUri): + """Create sub-catalog with body.""" + + catalog: Annotated[Union[Catalog, ObjectUri], Body()] = attr.ib(default=None) + + +@attr.s +class UnlinkSubCatalogRequest(CatalogsUri): + """Unlink sub-catalog request.""" + + sub_catalog_id: Annotated[str, Path(description="Sub-Catalog ID")] = attr.ib() + + class Catalogs(StacBaseModel): """Catalogs endpoint response.""" From 9c01d0ec87345b6d06275ce710789d738b90be1d Mon Sep 17 00:00:00 2001 From: jonhealy1 Date: Sat, 28 Feb 2026 11:49:52 +0800 Subject: [PATCH 33/40] clean up --- .../third_party/multi_tenant_catalogs/types.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/types.py b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/types.py index 23eca850c..519468066 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/types.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/types.py @@ -68,7 +68,7 @@ class CatalogCollectionItemsRequest(CatalogCollectionUri): bbox: Annotated[ Optional[BBox], Query(description="Bounding box to filter items [minx, miny, maxx, maxy]"), - ] = attr.ib(default=None, converter=lambda x: _bbox_converter(x) if x else None) + ] = attr.ib(default=None, converter=lambda x: _bbox_converter(x)) datetime: Annotated[ Optional[str], Query(description="Datetime to filter items") ] = attr.ib(default=None) @@ -115,30 +115,34 @@ class CatalogChildrenRequest(CatalogsUri): @attr.s -class UpdateCatalogRequest(CatalogsUri): +class UpdateCatalogRequest(APIRequest): """Update catalog with body.""" + catalog_id: Annotated[str, Path(description="Catalog ID")] = attr.ib() catalog: Annotated[Catalog, Body()] = attr.ib(default=None) @attr.s -class CreateCatalogCollectionRequest(CatalogsUri): +class CreateCatalogCollectionRequest(APIRequest): """Create catalog collection with body.""" + catalog_id: Annotated[str, Path(description="Catalog ID")] = attr.ib() collection: Annotated[Union[Collection, ObjectUri], Body()] = attr.ib(default=None) @attr.s -class CreateSubCatalogRequest(CatalogsUri): +class CreateSubCatalogRequest(APIRequest): """Create sub-catalog with body.""" + catalog_id: Annotated[str, Path(description="Catalog ID")] = attr.ib() catalog: Annotated[Union[Catalog, ObjectUri], Body()] = attr.ib(default=None) @attr.s -class UnlinkSubCatalogRequest(CatalogsUri): +class UnlinkSubCatalogRequest(APIRequest): """Unlink sub-catalog request.""" + catalog_id: Annotated[str, Path(description="Catalog ID")] = attr.ib() sub_catalog_id: Annotated[str, Path(description="Sub-Catalog ID")] = attr.ib() From 89b1f5aedbd503472ed2ac34f8dc554041b33896 Mon Sep 17 00:00:00 2001 From: jonhealy1 Date: Sat, 28 Feb 2026 12:00:29 +0800 Subject: [PATCH 34/40] use native types --- .../multi_tenant_catalogs/catalogs.py | 4 +-- .../multi_tenant_catalogs/client.py | 4 +-- .../multi_tenant_catalogs/types.py | 32 +++++++++---------- 3 files changed, 20 insertions(+), 20 deletions(-) diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/catalogs.py b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/catalogs.py index 0bf98ebb8..f4b7e6caa 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/catalogs.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/catalogs.py @@ -1,6 +1,6 @@ """Catalogs extension.""" -from typing import List, Type +from typing import Type import attr from fastapi import APIRouter, FastAPI @@ -64,7 +64,7 @@ class CatalogsExtension(ApiExtension): client: AsyncBaseCatalogsClient = attr.ib(kw_only=True) enable_transactions: bool = attr.ib(default=False, kw_only=True) settings: dict = attr.ib(default=attr.Factory(dict), kw_only=True) - conformance_classes: List[str] = attr.ib(factory=list, kw_only=True) + conformance_classes: list[str] = attr.ib(factory=list, kw_only=True) router: APIRouter = attr.ib(factory=APIRouter, kw_only=True) response_class: Type[Response] = attr.ib(default=JSONResponse, kw_only=True) diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/client.py b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/client.py index 662ff7364..e496b0563 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/client.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/client.py @@ -2,7 +2,7 @@ import abc from datetime import datetime -from typing import List, Literal +from typing import Literal import attr from fastapi import Request @@ -249,7 +249,7 @@ async def get_catalog_collection_items( self, catalog_id: str, collection_id: str, - bbox: List[float] | None = None, + bbox: list[float] | None = None, datetime: str | datetime | None = None, limit: int | None = 10, token: str | None = None, diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/types.py b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/types.py index 519468066..d1486db87 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/types.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/types.py @@ -1,6 +1,6 @@ """Catalogs extension types.""" -from typing import List, Literal, Optional, Union +from typing import Literal import attr from fastapi import Body, Path, Query @@ -39,10 +39,10 @@ class CatalogsGetRequest(APIRequest): """Parameters for the root /catalogs endpoint.""" limit: Annotated[ - Optional[int], + int | None, Query(ge=1, le=1000, description="Maximum number of catalogs to return"), ] = attr.ib(default=10) - token: Annotated[Optional[str], Query(description="Pagination token")] = attr.ib( + token: Annotated[str | None, Query(description="Pagination token")] = attr.ib( default=None ) @@ -66,17 +66,17 @@ class CatalogCollectionItemsRequest(CatalogCollectionUri): """Parameters for /catalogs/{catalog_id}/collections/{collection_id}/items.""" bbox: Annotated[ - Optional[BBox], + BBox | None, Query(description="Bounding box to filter items [minx, miny, maxx, maxy]"), ] = attr.ib(default=None, converter=lambda x: _bbox_converter(x)) datetime: Annotated[ - Optional[str], Query(description="Datetime to filter items") + str | None, Query(description="Datetime to filter items") ] = attr.ib(default=None) limit: Annotated[ - Optional[int], + int | None, Query(ge=1, le=10000, description="Maximum number of items to return"), ] = attr.ib(default=10) - token: Annotated[Optional[str], Query(description="Pagination token")] = attr.ib( + token: Annotated[str | None, Query(description="Pagination token")] = attr.ib( default=None ) @@ -86,10 +86,10 @@ class SubCatalogsRequest(CatalogsUri): """Parameters for /catalogs/{catalog_id}/catalogs.""" limit: Annotated[ - Optional[int], + int | None, Query(ge=1, le=1000, description="Maximum number of sub-catalogs to return"), ] = attr.ib(default=10) - token: Annotated[Optional[str], Query(description="Pagination token")] = attr.ib( + token: Annotated[str | None, Query(description="Pagination token")] = attr.ib( default=None ) @@ -99,14 +99,14 @@ class CatalogChildrenRequest(CatalogsUri): """Parameters for /catalogs/{catalog_id}/children.""" limit: Annotated[ - Optional[int], + int | None, Query(ge=1, le=1000, description="Maximum number of children to return"), ] = attr.ib(default=10) - token: Annotated[Optional[str], Query(description="Pagination token")] = attr.ib( + token: Annotated[str | None, Query(description="Pagination token")] = attr.ib( default=None ) type: Annotated[ - Optional[Literal["Catalog", "Collection"]], + Literal["Catalog", "Collection"] | None, Query(description="Filter by resource type"), ] = attr.ib(default=None) @@ -127,7 +127,7 @@ class CreateCatalogCollectionRequest(APIRequest): """Create catalog collection with body.""" catalog_id: Annotated[str, Path(description="Catalog ID")] = attr.ib() - collection: Annotated[Union[Collection, ObjectUri], Body()] = attr.ib(default=None) + collection: Annotated[Collection | ObjectUri, Body()] = attr.ib(default=None) @attr.s @@ -135,7 +135,7 @@ class CreateSubCatalogRequest(APIRequest): """Create sub-catalog with body.""" catalog_id: Annotated[str, Path(description="Catalog ID")] = attr.ib() - catalog: Annotated[Union[Catalog, ObjectUri], Body()] = attr.ib(default=None) + catalog: Annotated[Catalog | ObjectUri, Body()] = attr.ib(default=None) @attr.s @@ -149,7 +149,7 @@ class UnlinkSubCatalogRequest(APIRequest): class Catalogs(StacBaseModel): """Catalogs endpoint response.""" - catalogs: List[Catalog] + catalogs: list[Catalog] links: Links numberMatched: int | None = None numberReturned: int | None = None @@ -161,7 +161,7 @@ class Children(StacBaseModel): Returns a mixed list of Catalogs and Collections as children. """ - children: List[Catalog | Collection] + children: list[Catalog | Collection] links: Links numberMatched: int | None = None numberReturned: int | None = None From fee26d52f69cbd21a3ea4dc515c1cc833795abd2 Mon Sep 17 00:00:00 2001 From: jonhealy1 Date: Sat, 28 Feb 2026 12:17:01 +0800 Subject: [PATCH 35/40] allow starlette response --- .../multi_tenant_catalogs/__init__.py | 33 ++++++++++++++- .../multi_tenant_catalogs/catalogs.py | 5 ++- .../multi_tenant_catalogs/client.py | 40 ++++++++++++------- .../multi_tenant_catalogs/types.py | 24 ++++++----- 4 files changed, 76 insertions(+), 26 deletions(-) diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/__init__.py b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/__init__.py index 0ad399839..cc2a52686 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/__init__.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/__init__.py @@ -1,14 +1,43 @@ """Catalogs extension module.""" from .catalogs import CATALOGS_CONFORMANCE_CLASSES, CatalogsExtension -from .client import AsyncBaseCatalogsClient -from .types import Catalogs, Children, ObjectUri +from .client import AsyncBaseCatalogsClient, BaseCatalogsClient +from .types import ( + CatalogChildrenRequest, + CatalogCollectionItemsRequest, + CatalogCollectionItemUri, + CatalogCollectionUri, + Catalogs, + CatalogsGetRequest, + CatalogsUri, + Children, + CreateCatalogCollectionRequest, + CreateCatalogRequest, + CreateSubCatalogRequest, + ObjectUri, + SubCatalogsRequest, + UnlinkSubCatalogRequest, + UpdateCatalogRequest, +) __all__ = [ "CatalogsExtension", "AsyncBaseCatalogsClient", + "BaseCatalogsClient", "Catalogs", "Children", "ObjectUri", "CATALOGS_CONFORMANCE_CLASSES", + "CatalogsUri", + "CatalogsGetRequest", + "CatalogCollectionUri", + "CatalogCollectionItemUri", + "CatalogCollectionItemsRequest", + "SubCatalogsRequest", + "CatalogChildrenRequest", + "CreateCatalogRequest", + "UpdateCatalogRequest", + "CreateCatalogCollectionRequest", + "CreateSubCatalogRequest", + "UnlinkSubCatalogRequest", ] diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/catalogs.py b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/catalogs.py index f4b7e6caa..461abb349 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/catalogs.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/catalogs.py @@ -27,6 +27,7 @@ CatalogsUri, Children, CreateCatalogCollectionRequest, + CreateCatalogRequest, CreateSubCatalogRequest, SubCatalogsRequest, UnlinkSubCatalogRequest, @@ -263,7 +264,9 @@ def register_transaction_endpoints(self) -> None: path="/catalogs", methods=["POST"], status_code=HTTP_201_CREATED, - endpoint=create_async_endpoint(self.client.create_catalog, Catalog), + endpoint=create_async_endpoint( + self.client.create_catalog, CreateCatalogRequest + ), response_model=Catalog if self.settings.get("enable_response_models", True) else None, diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/client.py b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/client.py index e496b0563..84fb9334a 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/client.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/client.py @@ -11,6 +11,7 @@ from stac_pydantic.collection import Collection from stac_pydantic.item import Item from stac_pydantic.item_collection import ItemCollection +from starlette.responses import Response from .types import Catalogs, Children, ObjectUri @@ -26,7 +27,7 @@ async def get_catalogs( token: str | None = None, request: Request | None = None, **kwargs, - ) -> Catalogs: + ) -> Catalogs | Response: """Get all catalogs with pagination support. Args: @@ -42,7 +43,7 @@ async def get_catalogs( @abc.abstractmethod async def create_catalog( self, catalog: Catalog, request: Request | None = None, **kwargs - ) -> Catalog: + ) -> Catalog | Response: """Create a new catalog. Args: @@ -57,7 +58,7 @@ async def create_catalog( @abc.abstractmethod async def get_catalog( self, catalog_id: str, request: Request | None = None, **kwargs - ) -> Catalog: + ) -> Catalog | Response: """Get a specific catalog by ID. Args: @@ -76,7 +77,7 @@ async def update_catalog( catalog: Catalog, request: Request | None = None, **kwargs, - ) -> Catalog: + ) -> Catalog | Response: """Update an existing catalog. Args: @@ -110,7 +111,7 @@ async def get_catalog_collections( catalog_id: str, request: Request | None = None, **kwargs, - ) -> Collections: + ) -> Collections | Response: """Get collections linked from a specific catalog. Args: @@ -130,7 +131,7 @@ async def get_sub_catalogs( token: str | None = None, request: Request | None = None, **kwargs, - ) -> Catalogs: + ) -> Catalogs | Response: """Get all sub-catalogs of a specific catalog with pagination. Args: @@ -151,7 +152,7 @@ async def create_sub_catalog( catalog: Catalog | ObjectUri, request: Request | None = None, **kwargs, - ) -> Catalog: + ) -> Catalog | Response: """Create a new catalog or link an existing catalog as a sub-catalog. Supports two modes: @@ -185,7 +186,7 @@ async def create_catalog_collection( collection: Collection | ObjectUri, request: Request | None = None, **kwargs, - ) -> Collection: + ) -> Collection | Response: """Create a new collection or link an existing collection to catalog. Supports two modes: @@ -211,7 +212,7 @@ async def get_catalog_collection( collection_id: str, request: Request | None = None, **kwargs, - ) -> Collection: + ) -> Collection | Response: """Get a specific collection from a catalog. Args: @@ -255,7 +256,7 @@ async def get_catalog_collection_items( token: str | None = None, request: Request | None = None, **kwargs, - ) -> ItemCollection: + ) -> ItemCollection | Response: """Get items from a collection in a catalog with search support. Multiple filters are combined using AND logic. If both bbox and datetime @@ -283,7 +284,7 @@ async def get_catalog_collection_item( item_id: str, request: Request | None = None, **kwargs, - ) -> Item: + ) -> Item | Response: """Get a specific item from a collection in a catalog. Args: @@ -306,7 +307,7 @@ async def get_catalog_children( type: Literal["Catalog", "Collection"] | None = None, request: Request | None = None, **kwargs, - ) -> Children: + ) -> Children | Response: """Get all children (Catalogs and Collections) of a specific catalog. Args: @@ -324,7 +325,7 @@ async def get_catalog_children( @abc.abstractmethod async def get_catalog_conformance( self, catalog_id: str, request: Request | None = None, **kwargs - ) -> dict: + ) -> dict | Response: """Get conformance classes specific to this sub-catalog. Args: @@ -339,7 +340,7 @@ async def get_catalog_conformance( @abc.abstractmethod async def get_catalog_queryables( self, catalog_id: str, request: Request | None = None, **kwargs - ) -> dict: + ) -> dict | Response: """Get queryable fields available for filtering in this sub-catalog. Args: @@ -367,3 +368,14 @@ async def unlink_sub_catalog( request: Optional FastAPI request object. """ ... + + +@attr.s +class BaseCatalogsClient(abc.ABC): + """Defines a synchronous pattern for implementing the STAC catalogs extension. + + This is the base class for synchronous catalog client implementations. + For async implementations, use AsyncBaseCatalogsClient instead. + """ + + pass diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/types.py b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/types.py index d1486db87..b53afd3ab 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/types.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/types.py @@ -68,7 +68,10 @@ class CatalogCollectionItemsRequest(CatalogCollectionUri): bbox: Annotated[ BBox | None, Query(description="Bounding box to filter items [minx, miny, maxx, maxy]"), - ] = attr.ib(default=None, converter=lambda x: _bbox_converter(x)) + ] = attr.ib( + default=None, + converter=lambda x: _bbox_converter(x) if x is not None else None, + ) datetime: Annotated[ str | None, Query(description="Datetime to filter items") ] = attr.ib(default=None) @@ -115,34 +118,37 @@ class CatalogChildrenRequest(CatalogsUri): @attr.s -class UpdateCatalogRequest(APIRequest): +class CreateCatalogRequest(APIRequest): + """Create catalog with body.""" + + catalog: Annotated[Catalog, Body()] = attr.ib(default=None) + + +@attr.s +class UpdateCatalogRequest(CatalogsUri): """Update catalog with body.""" - catalog_id: Annotated[str, Path(description="Catalog ID")] = attr.ib() catalog: Annotated[Catalog, Body()] = attr.ib(default=None) @attr.s -class CreateCatalogCollectionRequest(APIRequest): +class CreateCatalogCollectionRequest(CatalogsUri): """Create catalog collection with body.""" - catalog_id: Annotated[str, Path(description="Catalog ID")] = attr.ib() collection: Annotated[Collection | ObjectUri, Body()] = attr.ib(default=None) @attr.s -class CreateSubCatalogRequest(APIRequest): +class CreateSubCatalogRequest(CatalogsUri): """Create sub-catalog with body.""" - catalog_id: Annotated[str, Path(description="Catalog ID")] = attr.ib() catalog: Annotated[Catalog | ObjectUri, Body()] = attr.ib(default=None) @attr.s -class UnlinkSubCatalogRequest(APIRequest): +class UnlinkSubCatalogRequest(CatalogsUri): """Unlink sub-catalog request.""" - catalog_id: Annotated[str, Path(description="Catalog ID")] = attr.ib() sub_catalog_id: Annotated[str, Path(description="Sub-Catalog ID")] = attr.ib() From 6cbcb0a513aef676c852d50640a1eb13b733a251 Mon Sep 17 00:00:00 2001 From: jonhealy1 Date: Sat, 28 Feb 2026 12:28:48 +0800 Subject: [PATCH 36/40] update tests, use native types --- .../extensions/tests/test_catalogs.py | 138 +++++++++++++----- 1 file changed, 105 insertions(+), 33 deletions(-) diff --git a/stac_fastapi/extensions/tests/test_catalogs.py b/stac_fastapi/extensions/tests/test_catalogs.py index dad3dce3e..a1acbbdb5 100644 --- a/stac_fastapi/extensions/tests/test_catalogs.py +++ b/stac_fastapi/extensions/tests/test_catalogs.py @@ -1,13 +1,16 @@ """Tests for the Catalogs extension.""" +from collections.abc import Iterator from datetime import datetime -from typing import Iterator, List, Optional, Union import pytest +from fastapi import Request +from stac_pydantic.api.collections import Collections from stac_pydantic.catalog import Catalog from stac_pydantic.collection import Collection from stac_pydantic.item import Item from stac_pydantic.item_collection import ItemCollection +from starlette.responses import Response from starlette.testclient import TestClient from stac_fastapi.api.app import StacApi @@ -49,7 +52,13 @@ def item_collection(self, *args, **kwargs): class DummyCatalogsClient(AsyncBaseCatalogsClient): """Dummy catalogs client for testing.""" - async def get_catalogs(self, limit: int = None, token: str = None, **kwargs): + async def get_catalogs( + self, + limit: int | None = None, + token: str | None = None, + request: Request | None = None, + **kwargs, + ) -> Catalogs | Response: return Catalogs( catalogs=[ Catalog( @@ -72,7 +81,9 @@ async def get_catalogs(self, limit: int = None, token: str = None, **kwargs): numberReturned=2, ) - async def create_catalog(self, catalog: Catalog, **kwargs): + async def create_catalog( + self, catalog: Catalog, request: Request | None = None, **kwargs + ) -> Catalog | Response: return Catalog( type="Catalog", id=catalog.id, @@ -81,7 +92,9 @@ async def create_catalog(self, catalog: Catalog, **kwargs): links=[], ) - async def get_catalog(self, catalog_id: str, **kwargs): + async def get_catalog( + self, catalog_id: str, request: Request | None = None, **kwargs + ) -> Catalog | Response: return Catalog( type="Catalog", id=catalog_id, @@ -90,7 +103,13 @@ async def get_catalog(self, catalog_id: str, **kwargs): links=[], ) - async def update_catalog(self, catalog_id: str, catalog: Catalog, **kwargs): + async def update_catalog( + self, + catalog_id: str, + catalog: Catalog, + request: Request | None = None, + **kwargs, + ) -> Catalog | Response: return Catalog( type="Catalog", id=catalog_id, @@ -99,10 +118,14 @@ async def update_catalog(self, catalog_id: str, catalog: Catalog, **kwargs): links=[], ) - async def delete_catalog(self, catalog_id: str, **kwargs): + async def delete_catalog( + self, catalog_id: str, request: Request | None = None, **kwargs + ) -> None: return None - async def get_catalog_collections(self, catalog_id: str, **kwargs): + async def get_catalog_collections( + self, catalog_id: str, request: Request | None = None, **kwargs + ) -> Collections | Response: return { "collections": [ { @@ -135,8 +158,13 @@ async def get_catalog_collections(self, catalog_id: str, **kwargs): } async def get_sub_catalogs( - self, catalog_id: str, limit: int = None, token: str = None, **kwargs - ): + self, + catalog_id: str, + limit: int | None = None, + token: str | None = None, + request: Request | None = None, + **kwargs, + ) -> Catalogs | Response: return Catalogs( catalogs=[ Catalog( @@ -153,8 +181,12 @@ async def get_sub_catalogs( ) async def create_sub_catalog( - self, catalog_id: str, catalog: Union[Catalog, ObjectUri], **kwargs - ): + self, + catalog_id: str, + catalog: Catalog | ObjectUri, + request: Request | None = None, + **kwargs, + ) -> Catalog | Response: catalog_id_val = catalog.id description = None @@ -172,8 +204,12 @@ async def create_sub_catalog( ) async def create_catalog_collection( - self, catalog_id: str, collection: Union[Collection, ObjectUri], **kwargs - ): + self, + catalog_id: str, + collection: Collection | ObjectUri, + request: Request | None = None, + **kwargs, + ) -> Collection | Response: collection_id_val = collection.id description = None @@ -194,7 +230,13 @@ async def create_catalog_collection( links=[], ) - async def get_catalog_collection(self, catalog_id: str, collection_id: str, **kwargs): + async def get_catalog_collection( + self, + catalog_id: str, + collection_id: str, + request: Request | None = None, + **kwargs, + ) -> Collection | Response: return Collection( type="Collection", id=collection_id, @@ -208,20 +250,25 @@ async def get_catalog_collection(self, catalog_id: str, collection_id: str, **kw ) async def unlink_catalog_collection( - self, catalog_id: str, collection_id: str, **kwargs - ): + self, + catalog_id: str, + collection_id: str, + request: Request | None = None, + **kwargs, + ) -> None: return None async def get_catalog_collection_items( self, catalog_id: str, collection_id: str, - bbox: Optional[List[float]] = None, - datetime: Optional[Union[str, datetime]] = None, - limit: Optional[int] = 10, - token: Optional[str] = None, + bbox: list[float] | None = None, + datetime: str | datetime | None = None, + limit: int | None = 10, + token: str | None = None, + request: Request | None = None, **kwargs, - ): + ) -> ItemCollection | Response: features = [ Item( type="Feature", @@ -254,8 +301,13 @@ async def get_catalog_collection_items( ) async def get_catalog_collection_item( - self, catalog_id: str, collection_id: str, item_id: str, **kwargs - ): + self, + catalog_id: str, + collection_id: str, + item_id: str, + request: Request | None = None, + **kwargs, + ) -> Item | Response: return Item( type="Feature", id=item_id, @@ -270,11 +322,12 @@ async def get_catalog_collection_item( async def get_catalog_children( self, catalog_id: str, - limit: int = None, - token: str = None, - type: str = None, + limit: int | None = None, + token: str | None = None, + type: str | None = None, + request: Request | None = None, **kwargs, - ) -> Children: + ) -> Children | Response: all_children = [ Catalog( id=f"{catalog_id}-child-1", @@ -319,7 +372,9 @@ async def get_catalog_children( numberReturned=len(filtered_children), ) - async def get_catalog_conformance(self, catalog_id: str, **kwargs): + async def get_catalog_conformance( + self, catalog_id: str, request: Request | None = None, **kwargs + ) -> dict | Response: return { "conformsTo": [ "https://api.stacspec.org/v1.0.0/core", @@ -327,7 +382,9 @@ async def get_catalog_conformance(self, catalog_id: str, **kwargs): ] } - async def get_catalog_queryables(self, catalog_id: str, **kwargs): + async def get_catalog_queryables( + self, catalog_id: str, request: Request | None = None, **kwargs + ) -> dict | Response: return { "queryables": [ {"name": "datetime", "type": "string"}, @@ -335,7 +392,13 @@ async def get_catalog_queryables(self, catalog_id: str, **kwargs): ] } - async def unlink_sub_catalog(self, catalog_id: str, sub_catalog_id: str, **kwargs): + async def unlink_sub_catalog( + self, + catalog_id: str, + sub_catalog_id: str, + request: Request | None = None, + **kwargs, + ) -> None: return None @@ -361,7 +424,11 @@ def client( settings=settings, client=core_client, extensions=[ - CatalogsExtension(client=catalogs_client, enable_transactions=True), + CatalogsExtension( + client=catalogs_client, + enable_transactions=True, + settings=settings.model_dump(), + ), ], ) with TestClient(api.app) as test_client: @@ -378,7 +445,11 @@ def client_readonly( settings=settings, client=core_client, extensions=[ - CatalogsExtension(client=catalogs_client, enable_transactions=False), + CatalogsExtension( + client=catalogs_client, + enable_transactions=False, + settings=settings.model_dump(), + ), ], ) with TestClient(api.app) as test_client: @@ -640,7 +711,8 @@ def test_get_catalog_collection_items_invalid_bbox_string(client: TestClient) -> response = client.get( "/catalogs/test-catalog-1/collections/test-collection/items", params=params ) - # str2bbox raises HTTPException(400) internally on ValueError + # The _bbox_converter in CatalogCollectionItemsRequest triggers a 400 error + # on invalid strings assert response.status_code == 400 assert "invalid bbox" in response.json()["detail"] From 61f13c06a4428d6dd4833246c40206a8f648daeb Mon Sep 17 00:00:00 2001 From: jonhealy1 Date: Sat, 28 Feb 2026 12:34:44 +0800 Subject: [PATCH 37/40] ci pre-commit update --- .../third_party/multi_tenant_catalogs/catalogs.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/catalogs.py b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/catalogs.py index 461abb349..4a9ff546c 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/catalogs.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/catalogs.py @@ -384,16 +384,14 @@ def register_transaction_endpoints(self) -> None: async def _get_catalog_conformance( self, catalog_id: str, request=None, **kwargs ) -> dict: - """Get conformance classes specific to this sub-catalog. - - Merges client response with extension conformance classes. - """ + """Merge client response with extension conformance classes.""" result = await self.client.get_catalog_conformance( catalog_id=catalog_id, request=request ) # Merge extension conformance classes with client response - if "conformsTo" in result: - result["conformsTo"].extend(self.conformance_classes) - else: - result["conformsTo"] = self.conformance_classes + if isinstance(result, dict): + if "conformsTo" in result: + result["conformsTo"].extend(self.conformance_classes) + else: + result["conformsTo"] = self.conformance_classes return result From 38abbf0dd3509e50a1e0ba861cecfd1352baee43 Mon Sep 17 00:00:00 2001 From: jonhealy1 Date: Sat, 28 Feb 2026 12:43:24 +0800 Subject: [PATCH 38/40] optional Response --- .../extensions/third_party/multi_tenant_catalogs/catalogs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/catalogs.py b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/catalogs.py index 4a9ff546c..e6d53a7ed 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/catalogs.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/catalogs.py @@ -383,7 +383,7 @@ def register_transaction_endpoints(self) -> None: async def _get_catalog_conformance( self, catalog_id: str, request=None, **kwargs - ) -> dict: + ) -> dict | Response: """Merge client response with extension conformance classes.""" result = await self.client.get_catalog_conformance( catalog_id=catalog_id, request=request From 453a5019d4bb2510659ac1a416c9a6f276309672 Mon Sep 17 00:00:00 2001 From: jonhealy1 Date: Mon, 2 Mar 2026 20:20:42 +0800 Subject: [PATCH 39/40] update conformance classes --- .../extensions/third_party/multi_tenant_catalogs/catalogs.py | 4 ++-- stac_fastapi/extensions/tests/test_catalogs.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/catalogs.py b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/catalogs.py index e6d53a7ed..38b0c7044 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/catalogs.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/catalogs.py @@ -36,13 +36,13 @@ CATALOGS_CONFORMANCE_CLASSES = [ "https://api.stacspec.org/v1.0.0/core", - "https://api.stacspec.org/v1.0.0-beta.1/multi-tenant-catalogs", + "https://api.stacspec.org/v1.0.0-beta.2/multi-tenant-catalogs", "https://api.stacspec.org/v1.0.0-rc.2/children", "https://api.stacspec.org/v1.0.0-rc.2/children#type-filter", ] CATALOGS_TRANSACTION_CONFORMANCE_CLASS = ( - "https://api.stacspec.org/v1.0.0-beta.1/multi-tenant-catalogs/transaction" + "https://api.stacspec.org/v1.0.0-beta.2/multi-tenant-catalogs/transaction" ) diff --git a/stac_fastapi/extensions/tests/test_catalogs.py b/stac_fastapi/extensions/tests/test_catalogs.py index a1acbbdb5..ecf6b9392 100644 --- a/stac_fastapi/extensions/tests/test_catalogs.py +++ b/stac_fastapi/extensions/tests/test_catalogs.py @@ -831,7 +831,7 @@ def test_readonly_conformance_excludes_transaction_class( data = response.json() assert "conformsTo" in data transaction_class = ( - "https://api.stacspec.org/v1.0.0-beta.1/multi-tenant-catalogs/transaction" + "https://api.stacspec.org/v1.0.0-beta.2/multi-tenant-catalogs/transaction" ) assert transaction_class not in data["conformsTo"] @@ -843,6 +843,6 @@ def test_enabled_conformance_includes_transaction_class(client: TestClient) -> N data = response.json() assert "conformsTo" in data transaction_class = ( - "https://api.stacspec.org/v1.0.0-beta.1/multi-tenant-catalogs/transaction" + "https://api.stacspec.org/v1.0.0-beta.2/multi-tenant-catalogs/transaction" ) assert transaction_class in data["conformsTo"] From 861036d795d8fdded1fad6c2d6a4167af3bb92a2 Mon Sep 17 00:00:00 2001 From: jonhealy1 Date: Sun, 8 Mar 2026 12:04:12 +0800 Subject: [PATCH 40/40] update version, offer guidance on links --- .../extensions/third_party/__init__.py | 2 ++ .../multi_tenant_catalogs/catalogs.py | 4 ++-- .../third_party/multi_tenant_catalogs/client.py | 16 ++++++++++++++++ stac_fastapi/extensions/tests/test_catalogs.py | 6 +++--- 4 files changed, 23 insertions(+), 5 deletions(-) diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/third_party/__init__.py b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/__init__.py index ab165951c..ff1f40ec8 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/third_party/__init__.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/__init__.py @@ -3,6 +3,7 @@ from .bulk_transactions import BulkTransactionExtension from .multi_tenant_catalogs import ( AsyncBaseCatalogsClient, + BaseCatalogsClient, Catalogs, CatalogsExtension, Children, @@ -13,6 +14,7 @@ "BulkTransactionExtension", "CatalogsExtension", "AsyncBaseCatalogsClient", + "BaseCatalogsClient", "Catalogs", "Children", "ObjectUri", diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/catalogs.py b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/catalogs.py index 38b0c7044..d2954bb57 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/catalogs.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/catalogs.py @@ -36,13 +36,13 @@ CATALOGS_CONFORMANCE_CLASSES = [ "https://api.stacspec.org/v1.0.0/core", - "https://api.stacspec.org/v1.0.0-beta.2/multi-tenant-catalogs", + "https://api.stacspec.org/v1.0.0-beta.3/multi-tenant-catalogs", "https://api.stacspec.org/v1.0.0-rc.2/children", "https://api.stacspec.org/v1.0.0-rc.2/children#type-filter", ] CATALOGS_TRANSACTION_CONFORMANCE_CLASS = ( - "https://api.stacspec.org/v1.0.0-beta.2/multi-tenant-catalogs/transaction" + "https://api.stacspec.org/v1.0.0-beta.3/multi-tenant-catalogs/transaction" ) diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/client.py b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/client.py index 84fb9334a..15aa29b72 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/client.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/client.py @@ -61,6 +61,11 @@ async def get_catalog( ) -> Catalog | Response: """Get a specific catalog by ID. + Note on Links: To support Directed Acyclic Graphs (DAGs) and poly-hierarchy, + implementations SHOULD dynamically generate `rel="parent"` links for EVERY parent + this catalog belongs to, and `rel="child"` links for all immediate sub-catalogs + and collections. Static linking is highly discouraged. + Args: catalog_id: The ID of the catalog to retrieve. request: Optional FastAPI request object. @@ -114,6 +119,12 @@ async def get_catalog_collections( ) -> Collections | Response: """Get collections linked from a specific catalog. + Note on Links: To preserve contextual breadcrumbs in UI clients + (e.g., STAC Browser), the `rel="parent"` link of the returned + Collections object, as well as the parent links of the individual + collections within it, SHOULD point exclusively back to the specific + `catalog_id` requested. + Args: catalog_id: The ID of the catalog. request: Optional FastAPI request object. @@ -215,6 +226,11 @@ async def get_catalog_collection( ) -> Collection | Response: """Get a specific collection from a catalog. + Note on Links: To preserve contextual breadcrumb navigation, the + `rel="parent"` link of the returned Collection SHOULD point + exclusively back to the specific `catalog_id` requested, rather than + listing all of the collection's potential parents. + Args: catalog_id: The ID of the catalog. collection_id: The ID of the collection. diff --git a/stac_fastapi/extensions/tests/test_catalogs.py b/stac_fastapi/extensions/tests/test_catalogs.py index ecf6b9392..2b9db3307 100644 --- a/stac_fastapi/extensions/tests/test_catalogs.py +++ b/stac_fastapi/extensions/tests/test_catalogs.py @@ -378,7 +378,7 @@ async def get_catalog_conformance( return { "conformsTo": [ "https://api.stacspec.org/v1.0.0/core", - "https://api.stacspec.org/v1.0.0-beta.1/multi-tenant-catalogs", + "https://api.stacspec.org/v1.0.0-beta.3/multi-tenant-catalogs", ] } @@ -831,7 +831,7 @@ def test_readonly_conformance_excludes_transaction_class( data = response.json() assert "conformsTo" in data transaction_class = ( - "https://api.stacspec.org/v1.0.0-beta.2/multi-tenant-catalogs/transaction" + "https://api.stacspec.org/v1.0.0-beta.3/multi-tenant-catalogs/transaction" ) assert transaction_class not in data["conformsTo"] @@ -843,6 +843,6 @@ def test_enabled_conformance_includes_transaction_class(client: TestClient) -> N data = response.json() assert "conformsTo" in data transaction_class = ( - "https://api.stacspec.org/v1.0.0-beta.2/multi-tenant-catalogs/transaction" + "https://api.stacspec.org/v1.0.0-beta.3/multi-tenant-catalogs/transaction" ) assert transaction_class in data["conformsTo"]