diff --git a/CHANGES.md b/CHANGES.md index 9bb2d8ad3..9fb06789c 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.1] - 2026-02-10 ### Fixed diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 531255f0a..2cf2ae4c1 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -75,6 +75,11 @@ nav: - third_party: - module: api/stac_fastapi/extensions/third_party/index.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 + - 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/third_party/index.md b/docs/src/api/stac_fastapi/extensions/third_party/index.md index 3ac6c5e1d..4db94987a 100644 --- a/docs/src/api/stac_fastapi/extensions/third_party/index.md +++ b/docs/src/api/stac_fastapi/extensions/third_party/index.md @@ -5,3 +5,4 @@ Third Party Extensions submodule. ## Sub-modules * [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/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/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/multi_tenant_catalogs/__init__.py b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/__init__.py new file mode 100644 index 000000000..cc2a52686 --- /dev/null +++ b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/__init__.py @@ -0,0 +1,43 @@ +"""Catalogs extension module.""" + +from .catalogs import CATALOGS_CONFORMANCE_CLASSES, CatalogsExtension +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 new file mode 100644 index 000000000..d2954bb57 --- /dev/null +++ b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/catalogs.py @@ -0,0 +1,397 @@ +"""Catalogs extension.""" + +from typing import Type + +import attr +from fastapi import APIRouter, FastAPI +from fastapi.responses import JSONResponse +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.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 .client import AsyncBaseCatalogsClient +from .types import ( + CatalogChildrenRequest, + CatalogCollectionItemsRequest, + CatalogCollectionItemUri, + CatalogCollectionUri, + Catalogs, + CatalogsGetRequest, + CatalogsUri, + Children, + CreateCatalogCollectionRequest, + CreateCatalogRequest, + CreateSubCatalogRequest, + SubCatalogsRequest, + UnlinkSubCatalogRequest, + UpdateCatalogRequest, +) + +CATALOGS_CONFORMANCE_CLASSES = [ + "https://api.stacspec.org/v1.0.0/core", + "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.3/multi-tenant-catalogs/transaction" +) + + +@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. + 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. + """ + + 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.""" + self.conformance_classes = CATALOGS_CONFORMANCE_CLASSES.copy() + if self.enable_transactions: + self.conformance_classes.append(CATALOGS_TRANSACTION_CONFORMANCE_CLASS) + + def register(self, app: FastAPI) -> None: + """Register the extension with a FastAPI application. + + Args: + 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"]) + + 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", + methods=["GET"], + 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}", + methods=["GET"], + 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", + methods=["GET"], + 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}", + methods=["GET"], + 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", + methods=["GET"], + 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}", + methods=["GET"], + 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", + methods=["GET"], + 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", + methods=["GET"], + 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=( + "Retrieve all children (Catalogs and Collections) of this catalog." + ), + tags=["Catalogs"], + ) + + # GET /catalogs/{catalog_id}/conformance + self.router.add_api_route( + name="Get Catalog Conformance", + path="/catalogs/{catalog_id}/conformance", + 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.", + tags=["Catalogs"], + responses={ + HTTP_200_OK: {"description": "Conformance classes for the catalog"} + }, + ) + + # GET /catalogs/{catalog_id}/queryables + self.router.add_api_route( + name="Get Catalog Queryables", + path="/catalogs/{catalog_id}/queryables", + methods=["GET"], + endpoint=create_async_endpoint( + self.client.get_catalog_queryables, CatalogsUri + ), + response_class=self.response_class, + summary="Get Catalog Queryables", + description=( + "Get queryable fields available for filtering in this " + "sub-catalog (Filter Extension)." + ), + tags=["Catalogs"], + responses={HTTP_200_OK: {"description": "Queryable fields for the catalog"}}, + ) + + 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, CreateCatalogRequest + ), + 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"], + ) + + # 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 | Response: + """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 isinstance(result, dict): + 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/client.py b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/client.py new file mode 100644 index 000000000..15aa29b72 --- /dev/null +++ b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/client.py @@ -0,0 +1,397 @@ +"""Catalogs extension clients.""" + +import abc +from datetime import datetime +from typing import Literal + +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 +from stac_pydantic.item import Item +from stac_pydantic.item_collection import ItemCollection +from starlette.responses import Response + +from .types import Catalogs, Children, ObjectUri + + +@attr.s +class AsyncBaseCatalogsClient(abc.ABC): + """Defines an async pattern for implementing the STAC catalogs extension.""" + + @abc.abstractmethod + async def get_catalogs( + self, + limit: int | None = None, + token: str | None = None, + request: Request | None = None, + **kwargs, + ) -> Catalogs | Response: + """Get all catalogs with pagination support. + + 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. + """ + ... + + @abc.abstractmethod + async def create_catalog( + self, catalog: Catalog, request: Request | None = None, **kwargs + ) -> Catalog | Response: + """Create a new catalog. + + Args: + catalog: The catalog to create. + request: Optional FastAPI request object. + + Returns: + The created catalog. + """ + ... + + @abc.abstractmethod + async def get_catalog( + self, catalog_id: str, request: Request | None = None, **kwargs + ) -> 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. + + Returns: + The requested catalog. + """ + ... + + @abc.abstractmethod + async def update_catalog( + self, + catalog_id: str, + catalog: Catalog, + request: Request | None = None, + **kwargs, + ) -> Catalog | Response: + """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. + """ + ... + + @abc.abstractmethod + async def delete_catalog( + self, + catalog_id: str, + request: Request | None = 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, + request: Request | None = None, + **kwargs, + ) -> 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. + + Returns: + Collections object containing collections linked from the catalog. + """ + ... + + @abc.abstractmethod + async def get_sub_catalogs( + self, + catalog_id: str, + limit: int | None = None, + token: str | None = None, + request: Request | None = None, + **kwargs, + ) -> Catalogs | Response: + """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. + request: Optional FastAPI request object. + + Returns: + A Catalogs response containing sub-catalogs with pagination links. + """ + ... + + @abc.abstractmethod + async def create_sub_catalog( + self, + catalog_id: str, + catalog: Catalog | ObjectUri, + request: Request | None = None, + **kwargs, + ) -> Catalog | Response: + """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). + request: Optional FastAPI request object. + + Returns: + The created or linked catalog. + """ + ... + + @abc.abstractmethod + async def create_catalog_collection( + self, + catalog_id: str, + collection: Collection | ObjectUri, + request: Request | None = None, + **kwargs, + ) -> Collection | Response: + """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). + request: Optional FastAPI request object. + + Returns: + The created or linked collection. + """ + ... + + @abc.abstractmethod + async def get_catalog_collection( + self, + catalog_id: str, + collection_id: str, + request: Request | None = None, + **kwargs, + ) -> 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. + request: Optional FastAPI request object. + + Returns: + The requested collection. + """ + ... + + @abc.abstractmethod + async def unlink_catalog_collection( + self, + catalog_id: str, + collection_id: str, + request: Request | None = 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. + request: Optional FastAPI request object. + """ + ... + + @abc.abstractmethod + async def get_catalog_collection_items( + self, + catalog_id: str, + collection_id: str, + 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: + """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. + request: Optional FastAPI request object. + + 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, + request: Request | None = None, + **kwargs, + ) -> Item | Response: + """Get a specific item from a collection in a catalog. + + Args: + catalog_id: The ID of the catalog. + collection_id: The ID of the collection. + item_id: The ID of the item. + request: Optional FastAPI request object. + + Returns: + The requested item. + """ + ... + + @abc.abstractmethod + async def get_catalog_children( + self, + catalog_id: str, + limit: int | None = None, + token: str | None = None, + type: Literal["Catalog", "Collection"] | None = None, + request: Request | None = None, + **kwargs, + ) -> Children | Response: + """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). + request: Optional FastAPI request object. + + Returns: + Children object containing children and pagination links. + """ + ... + + @abc.abstractmethod + async def get_catalog_conformance( + self, catalog_id: str, request: Request | None = None, **kwargs + ) -> dict | Response: + """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. + """ + ... + + @abc.abstractmethod + async def get_catalog_queryables( + self, catalog_id: str, request: Request | None = None, **kwargs + ) -> dict | Response: + """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). + """ + ... + + @abc.abstractmethod + async def unlink_sub_catalog( + self, + catalog_id: str, + sub_catalog_id: str, + request: Request | None = 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. + """ + ... + + +@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 new file mode 100644 index 000000000..b53afd3ab --- /dev/null +++ b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/multi_tenant_catalogs/types.py @@ -0,0 +1,173 @@ +"""Catalogs extension types.""" + +from typing import Literal + +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 BBox, StacBaseModel +from typing_extensions import Annotated + +from stac_fastapi.types.search import APIRequest, _bbox_converter + + +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 + + +# --- 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[ + int | None, + Query(ge=1, le=1000, description="Maximum number of catalogs to return"), + ] = attr.ib(default=10) + token: Annotated[str | None, 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[ + BBox | None, + Query(description="Bounding box to filter items [minx, miny, maxx, maxy]"), + ] = 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) + limit: Annotated[ + int | None, + Query(ge=1, le=10000, description="Maximum number of items to return"), + ] = attr.ib(default=10) + token: Annotated[str | None, Query(description="Pagination token")] = attr.ib( + default=None + ) + + +@attr.s +class SubCatalogsRequest(CatalogsUri): + """Parameters for /catalogs/{catalog_id}/catalogs.""" + + limit: Annotated[ + int | None, + Query(ge=1, le=1000, description="Maximum number of sub-catalogs to return"), + ] = attr.ib(default=10) + token: Annotated[str | None, Query(description="Pagination token")] = attr.ib( + default=None + ) + + +@attr.s +class CatalogChildrenRequest(CatalogsUri): + """Parameters for /catalogs/{catalog_id}/children.""" + + limit: Annotated[ + int | None, + Query(ge=1, le=1000, description="Maximum number of children to return"), + ] = attr.ib(default=10) + token: Annotated[str | None, Query(description="Pagination token")] = attr.ib( + default=None + ) + type: Annotated[ + Literal["Catalog", "Collection"] | None, + Query(description="Filter by resource type"), + ] = attr.ib(default=None) + + +# --- Request Models with Body for Transaction Endpoints --- + + +@attr.s +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: Annotated[Catalog, Body()] = attr.ib(default=None) + + +@attr.s +class CreateCatalogCollectionRequest(CatalogsUri): + """Create catalog collection with body.""" + + collection: Annotated[Collection | ObjectUri, Body()] = attr.ib(default=None) + + +@attr.s +class CreateSubCatalogRequest(CatalogsUri): + """Create sub-catalog with body.""" + + catalog: Annotated[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.""" + + catalogs: list[Catalog] + links: Links + numberMatched: int | None = None + numberReturned: int | None = None + + +class Children(StacBaseModel): + """Children endpoint response. + + Returns a mixed list of Catalogs and Collections as children. + """ + + children: list[Catalog | Collection] + links: Links + numberMatched: int | None = None + numberReturned: int | None = None diff --git a/stac_fastapi/extensions/tests/test_catalogs.py b/stac_fastapi/extensions/tests/test_catalogs.py new file mode 100644 index 000000000..2b9db3307 --- /dev/null +++ b/stac_fastapi/extensions/tests/test_catalogs.py @@ -0,0 +1,848 @@ +"""Tests for the Catalogs extension.""" + +from collections.abc import Iterator +from datetime import datetime + +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 +from stac_fastapi.extensions.third_party import CatalogsExtension +from stac_fastapi.extensions.third_party.multi_tenant_catalogs.client import ( + AsyncBaseCatalogsClient, +) +from stac_fastapi.extensions.third_party.multi_tenant_catalogs.types import ( + Catalogs, + Children, + ObjectUri, +) +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(AsyncBaseCatalogsClient): + """Dummy catalogs client for testing.""" + + async def get_catalogs( + self, + limit: int | None = None, + token: str | None = None, + request: Request | None = None, + **kwargs, + ) -> Catalogs | Response: + 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, + ) + + async def create_catalog( + self, catalog: Catalog, request: Request | None = None, **kwargs + ) -> Catalog | Response: + return Catalog( + type="Catalog", + id=catalog.id, + description=catalog.description or "Created catalog", + stac_version="1.0.0", + links=[], + ) + + async def get_catalog( + self, catalog_id: str, request: Request | None = None, **kwargs + ) -> Catalog | Response: + return Catalog( + type="Catalog", + id=catalog_id, + description=f"Catalog {catalog_id}", + stac_version="1.0.0", + links=[], + ) + + async def update_catalog( + self, + catalog_id: str, + catalog: Catalog, + request: Request | None = None, + **kwargs, + ) -> Catalog | Response: + return Catalog( + type="Catalog", + id=catalog_id, + description=catalog.description or f"Updated {catalog_id}", + stac_version="1.0.0", + links=[], + ) + + 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, request: Request | None = None, **kwargs + ) -> Collections | Response: + 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", + }, + ], + } + + async def get_sub_catalogs( + self, + catalog_id: str, + limit: int | None = None, + token: str | None = None, + request: Request | None = None, + **kwargs, + ) -> Catalogs | Response: + 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, + ) + + async def create_sub_catalog( + self, + catalog_id: str, + catalog: Catalog | ObjectUri, + request: Request | None = None, + **kwargs, + ) -> Catalog | Response: + 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_val, + description=description, + stac_version="1.0.0", + links=[], + ) + + async def create_catalog_collection( + self, + catalog_id: str, + collection: Collection | ObjectUri, + request: Request | None = None, + **kwargs, + ) -> Collection | Response: + 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_val, + description=description, + extent={ + "spatial": {"bbox": [[-180, -90, 180, 90]]}, + "temporal": {"interval": [[None, None]]}, + }, + license="proprietary", + links=[], + ) + + 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, + description=f"Collection {collection_id} in {catalog_id}", + extent={ + "spatial": {"bbox": [[-180, -90, 180, 90]]}, + "temporal": {"interval": [[None, None]]}, + }, + license="proprietary", + links=[], + ) + + async def unlink_catalog_collection( + 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: 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", + 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=features, + links=[], + ) + + async def get_catalog_collection_item( + self, + catalog_id: str, + collection_id: str, + item_id: str, + request: Request | None = None, + **kwargs, + ) -> Item | Response: + 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={}, + ) + + async def get_catalog_children( + self, + catalog_id: str, + limit: int | None = None, + token: str | None = None, + type: str | None = None, + request: Request | None = None, + **kwargs, + ) -> Children | Response: + all_children = [ + 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 ( + type == "Catalog" + and isinstance(child, Catalog) + and not isinstance(child, Collection) + ) + or (type == "Collection" and isinstance(child, Collection)) + ] + else: + filtered_children = all_children + + return Children( + children=filtered_children, + links=[], + numberMatched=len(filtered_children), + numberReturned=len(filtered_children), + ) + + 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", + "https://api.stacspec.org/v1.0.0-beta.3/multi-tenant-catalogs", + ] + } + + async def get_catalog_queryables( + self, catalog_id: str, request: Request | None = None, **kwargs + ) -> dict | Response: + return { + "queryables": [ + {"name": "datetime", "type": "string"}, + {"name": "platform", "type": "string"}, + ] + } + + async def unlink_sub_catalog( + self, + catalog_id: str, + sub_catalog_id: str, + request: Request | None = None, + **kwargs, + ) -> None: + 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 with transactions enabled.""" + settings = ApiSettings() + api = StacApi( + settings=settings, + client=core_client, + extensions=[ + CatalogsExtension( + client=catalogs_client, + enable_transactions=True, + settings=settings.model_dump(), + ), + ], + ) + 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, + settings=settings.model_dump(), + ), + ], + ) + 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_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( + "/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_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") + 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 + 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: + """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_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 + ) + # The _bbox_converter in CatalogCollectionItemsRequest triggers a 400 error + # on invalid strings + 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("/") + 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 + + +# --- 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.3/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.3/multi-tenant-catalogs/transaction" + ) + assert transaction_class in data["conformsTo"]