diff --git a/stac_fastapi/api/stac_fastapi/api/app.py b/stac_fastapi/api/stac_fastapi/api/app.py index 7ad0c96f5..f9834c82d 100644 --- a/stac_fastapi/api/stac_fastapi/api/app.py +++ b/stac_fastapi/api/stac_fastapi/api/app.py @@ -7,7 +7,7 @@ from fastapi import APIRouter, FastAPI from fastapi.openapi.utils import get_openapi from fastapi.params import Depends -from stac_pydantic import Collection, Item, ItemCollection +from stac_pydantic import Catalog, Collection, Item, ItemCollection from stac_pydantic.api import ConformanceClasses, LandingPage from stac_pydantic.api.collections import Collections from stac_pydantic.version import STAC_VERSION @@ -16,6 +16,7 @@ from stac_fastapi.api.errors import DEFAULT_STATUS_CODES, add_exception_handlers from stac_fastapi.api.middleware import CORSMiddleware, ProxyHeaderMiddleware from stac_fastapi.api.models import ( + CatalogUri, CollectionUri, EmptyRequest, GeoJSONResponse, @@ -32,6 +33,7 @@ from stac_fastapi.types.core import AsyncBaseCoreClient, BaseCoreClient from stac_fastapi.types.extension import ApiExtension from stac_fastapi.types.search import BaseSearchGetRequest, BaseSearchPostRequest +from stac_fastapi.types.stac import Catalogs @attr.s @@ -138,9 +140,9 @@ def register_landing_page(self): self.router.add_api_route( name="Landing Page", path="/", - response_model=LandingPage - if self.settings.enable_response_models - else None, + response_model=( + LandingPage if self.settings.enable_response_models else None + ), response_class=self.response_class, response_model_exclude_unset=False, response_model_exclude_none=True, @@ -157,9 +159,9 @@ def register_conformance_classes(self): self.router.add_api_route( name="Conformance Classes", path="/conformance", - response_model=ConformanceClasses - if self.settings.enable_response_models - else None, + response_model=( + ConformanceClasses if self.settings.enable_response_models else None + ), response_class=self.response_class, response_model_exclude_unset=True, response_model_exclude_none=True, @@ -175,7 +177,7 @@ def register_get_item(self): """ self.router.add_api_route( name="Get Item", - path="/collections/{collection_id}/items/{item_id}", + path="/catalogs/{catalog_id}collections/{collection_id}/items/{item_id}", response_model=Item if self.settings.enable_response_models else None, response_class=GeoJSONResponse, response_model_exclude_unset=True, @@ -194,9 +196,11 @@ def register_post_search(self): self.router.add_api_route( name="Search", path="/search", - response_model=(ItemCollection if not fields_ext else None) - if self.settings.enable_response_models - else None, + response_model=( + (ItemCollection if not fields_ext else None) + if self.settings.enable_response_models + else None + ), response_class=GeoJSONResponse, response_model_exclude_unset=True, response_model_exclude_none=True, @@ -216,9 +220,11 @@ def register_get_search(self): self.router.add_api_route( name="Search", path="/search", - response_model=(ItemCollection if not fields_ext else None) - if self.settings.enable_response_models - else None, + response_model=( + (ItemCollection if not fields_ext else None) + if self.settings.enable_response_models + else None + ), response_class=GeoJSONResponse, response_model_exclude_unset=True, response_model_exclude_none=True, @@ -237,9 +243,9 @@ def register_get_collections(self): self.router.add_api_route( name="Get Collections", path="/collections", - response_model=Collections - if self.settings.enable_response_models - else None, + response_model=( + Collections if self.settings.enable_response_models else None + ), response_class=self.response_class, response_model_exclude_unset=True, response_model_exclude_none=True, @@ -247,15 +253,53 @@ def register_get_collections(self): endpoint=create_async_endpoint(self.client.all_collections, EmptyRequest), ) + def register_get_catalogs(self): + """Register get catalogs endpoint (GET /catalogs). + + Returns: + None + """ + self.router.add_api_route( + name="Get Catalogs", + path="/catalogs", + response_model=Catalogs if self.settings.enable_response_models else None, + response_class=self.response_class, + response_model_exclude_unset=True, + response_model_exclude_none=True, + methods=["GET"], + endpoint=create_async_endpoint(self.client.all_catalogs, EmptyRequest), + ) + + def register_get_catalog_collections(self): + """Register get catalogs collections endpoint (GET /catalogs/{catalog_id}/collections). + + Returns: + None + """ + self.router.add_api_route( + name="Get CatalogCollections", + path="/catalogs/{catalog_id}/collections", + response_model=( + Collections if self.settings.enable_response_models else None + ), + response_class=self.response_class, + response_model_exclude_unset=True, + response_model_exclude_none=True, + methods=["GET"], + endpoint=create_async_endpoint( + self.client.get_catalog_collections, CatalogUri + ), + ) + def register_get_collection(self): - """Register get collection endpoint (GET /collection/{collection_id}). + """Register get collection endpoint (GET /catalogs/{catalog_id}/collection/{collection_id}). Returns: None """ self.router.add_api_route( name="Get Collection", - path="/collections/{collection_id}", + path="/catalogs/{catalog_id}/collections/{collection_id}", response_model=Collection if self.settings.enable_response_models else None, response_class=self.response_class, response_model_exclude_unset=True, @@ -264,6 +308,23 @@ def register_get_collection(self): endpoint=create_async_endpoint(self.client.get_collection, CollectionUri), ) + def register_get_catalog(self): + """Register get catalog endpoint (GET /catalogs/{catalog_id}). + + Returns: + None + """ + self.router.add_api_route( + name="Get Catalog", + path="/catalogs/{catalog_id}", + response_model=Catalog if self.settings.enable_response_models else None, + response_class=self.response_class, + response_model_exclude_unset=True, + response_model_exclude_none=True, + methods=["GET"], + endpoint=create_async_endpoint(self.client.get_catalog, CatalogUri), + ) + def register_get_item_collection(self): """Register get item collection endpoint (GET /collection/{collection_id}/items). @@ -282,10 +343,10 @@ def register_get_item_collection(self): ) self.router.add_api_route( name="Get ItemCollection", - path="/collections/{collection_id}/items", - response_model=ItemCollection - if self.settings.enable_response_models - else None, + path="/catalogs/{catalog_id}/collections/{collection_id}/items", + response_model=( + ItemCollection if self.settings.enable_response_models else None + ), response_class=GeoJSONResponse, response_model_exclude_unset=True, response_model_exclude_none=True, @@ -316,8 +377,11 @@ def register_core(self): self.register_post_search() self.register_get_search() self.register_get_collections() + self.register_get_catalogs() self.register_get_collection() + self.register_get_catalog() self.register_get_item_collection() + self.register_get_catalog_collections() def customize_openapi(self) -> Optional[Dict[str, Any]]: """Customize openapi schema.""" diff --git a/stac_fastapi/api/stac_fastapi/api/config.py b/stac_fastapi/api/stac_fastapi/api/config.py index b4e2c8620..81b148293 100644 --- a/stac_fastapi/api/stac_fastapi/api/config.py +++ b/stac_fastapi/api/stac_fastapi/api/config.py @@ -1,4 +1,5 @@ """Application settings.""" + import enum @@ -17,10 +18,10 @@ class ApiExtensions(enum.Enum): query = "query" sort = "sort" transaction = "transaction" + collection_search = "collection-search" class AddOns(enum.Enum): """Enumeration of available third party add ons.""" bulk_transaction = "bulk-transaction" - collection_search = "collection-search" diff --git a/stac_fastapi/api/stac_fastapi/api/middleware.py b/stac_fastapi/api/stac_fastapi/api/middleware.py index 3ed67d6c9..2ba3ef570 100644 --- a/stac_fastapi/api/stac_fastapi/api/middleware.py +++ b/stac_fastapi/api/stac_fastapi/api/middleware.py @@ -1,4 +1,5 @@ """Api middleware.""" + import re import typing from http.client import HTTP_PORT, HTTPS_PORT diff --git a/stac_fastapi/api/stac_fastapi/api/models.py b/stac_fastapi/api/stac_fastapi/api/models.py index 53f376aa0..7186ba837 100644 --- a/stac_fastapi/api/stac_fastapi/api/models.py +++ b/stac_fastapi/api/stac_fastapi/api/models.py @@ -10,7 +10,7 @@ from stac_pydantic.shared import BBox from stac_fastapi.types.extension import ApiExtension -from stac_fastapi.types.rfc3339 import DateTimeType +from stac_fastapi.types.rfc3339 import DateTimeType, str_to_interval from stac_fastapi.types.search import ( APIRequest, BaseSearchGetRequest, @@ -49,9 +49,11 @@ def create_request_model( for k, v in model.__fields__.items(): field_info = v.field_info body = Body( - None - if isinstance(field_info.default, UndefinedType) - else field_info.default, + ( + None + if isinstance(field_info.default, UndefinedType) + else field_info.default + ), default_factory=field_info.default_factory, alias=field_info.alias, alias_priority=field_info.alias_priority, @@ -101,7 +103,14 @@ def create_post_request_model( @attr.s # type:ignore -class CollectionUri(APIRequest): +class CatalogUri(APIRequest): + """Delete catalog.""" + + catalog_id: str = attr.ib(default=Path(..., description="Catalog ID")) + + +@attr.s # type:ignore +class CollectionUri(CatalogUri): """Delete collection.""" collection_id: str = attr.ib(default=Path(..., description="Collection ID")) @@ -127,7 +136,7 @@ class ItemCollectionUri(CollectionUri): limit: int = attr.ib(default=10) bbox: Optional[BBox] = attr.ib(default=None, converter=str2bbox) - datetime: Optional[DateTimeType] = attr.ib(default=None) + datetime: Optional[DateTimeType] = attr.ib(default=None, converter=str_to_interval) class POSTTokenPagination(BaseModel): diff --git a/stac_fastapi/api/stac_fastapi/api/openapi.py b/stac_fastapi/api/stac_fastapi/api/openapi.py index a38a70bae..84d72dab1 100644 --- a/stac_fastapi/api/stac_fastapi/api/openapi.py +++ b/stac_fastapi/api/stac_fastapi/api/openapi.py @@ -1,4 +1,5 @@ """openapi.""" + import warnings from fastapi import FastAPI @@ -43,9 +44,9 @@ async def patched_openapi_endpoint(req: Request) -> Response: # Get the response from the old endpoint function response: JSONResponse = await old_endpoint(req) # Update the content type header in place - response.headers[ - "content-type" - ] = "application/vnd.oai.openapi+json;version=3.0" + response.headers["content-type"] = ( + "application/vnd.oai.openapi+json;version=3.0" + ) # Return the updated response return response diff --git a/stac_fastapi/api/stac_fastapi/api/version.py b/stac_fastapi/api/stac_fastapi/api/version.py index 1b1206cd3..863e3198e 100644 --- a/stac_fastapi/api/stac_fastapi/api/version.py +++ b/stac_fastapi/api/stac_fastapi/api/version.py @@ -1,2 +1,3 @@ """Library version.""" + __version__ = "2.5.2" diff --git a/stac_fastapi/api/tests/benchmarks.py b/stac_fastapi/api/tests/benchmarks.py index ad73d2424..fe625700e 100644 --- a/stac_fastapi/api/tests/benchmarks.py +++ b/stac_fastapi/api/tests/benchmarks.py @@ -12,6 +12,7 @@ collection_links = link_factory.CollectionLinks("/", "test").create_links() item_links = link_factory.ItemLinks("/", "test", "test").create_links() +catalog_links = link_factory.CatalogLinks("/", "test").create_links() collections = [ @@ -62,7 +63,9 @@ def get_search( ) -> stac_types.ItemCollection: raise NotImplementedError - def get_item(self, item_id: str, collection_id: str, **kwargs) -> stac_types.Item: + def get_item( + self, item_id: str, collection_id: str, catalog_id: str, **kwargs + ) -> stac_types.Item: raise NotImplementedError def all_collections(self, **kwargs) -> stac_types.Collections: @@ -75,9 +78,27 @@ def all_collections(self, **kwargs) -> stac_types.Collections: ], ) - def get_collection(self, collection_id: str, **kwargs) -> stac_types.Collection: + def all_catalogs(self, **kwargs) -> stac_types.Catalogs: + return stac_types.Catalogs( + catalogs=catalogs, + links=[ + {"href": "test", "rel": "root"}, + {"href": "test", "rel": "self"}, + {"href": "test", "rel": "parent"}, + ], + ) + + def get_collection( + self, catalog_id: str, collection_id: str, **kwargs + ) -> stac_types.Collection: return collections[0] + def get_catalog_collection(self, catalog_id: str, **kwargs) -> stac_types.Catalogs: + return collections[0] + + def get_catalog(self, catalog_id: str, **kwargs) -> stac_types.Catalog: + return catalogs[0] + def item_collection( self, collection_id: str, diff --git a/stac_fastapi/api/tests/test_api.py b/stac_fastapi/api/tests/test_api.py index 91b50371e..4680d4660 100644 --- a/stac_fastapi/api/tests/test_api.py +++ b/stac_fastapi/api/tests/test_api.py @@ -86,23 +86,17 @@ def test_add_route_dependencies_after_building_api(self): class DummyCoreClient(core.BaseCoreClient): - def all_collections(self, *args, **kwargs): - ... + def all_collections(self, *args, **kwargs): ... - def get_collection(self, *args, **kwargs): - ... + def get_collection(self, *args, **kwargs): ... - def get_item(self, *args, **kwargs): - ... + def get_item(self, *args, **kwargs): ... - def get_search(self, *args, **kwargs): - ... + def get_search(self, *args, **kwargs): ... - def post_search(self, *args, **kwargs): - ... + def post_search(self, *args, **kwargs): ... - def item_collection(self, *args, **kwargs): - ... + def item_collection(self, *args, **kwargs): ... class DummyTransactionsClient(core.BaseTransactionsClient): @@ -126,6 +120,15 @@ def update_collection(self, *args, **kwargs): def delete_collection(self, *args, **kwargs): return "dummy response" + def create_catalog(self, *args, **kwargs): + return "dummy response" + + def update_catalog(self, *args, **kwargs): + return "dummy response" + + def delete_catalog(self, *args, **kwargs): + return "dummy response" + def must_be_bob( credentials: security.HTTPBasicCredentials = Depends(security.HTTPBasic()), diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/__init__.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/__init__.py index be608101c..8864d554b 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/__init__.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/__init__.py @@ -1,12 +1,14 @@ """stac_api.extensions.core module.""" + +from .collectionSearch import CollectionSearchExtension from .context import ContextExtension +from .discoverySearch import DiscoverySearchExtension from .fields import FieldsExtension from .filter import FilterExtension from .pagination import PaginationExtension, TokenPaginationExtension from .query import QueryExtension from .sort import SortExtension from .transaction import TransactionExtension -from .collectionSearch import CollectionSearchExtension __all__ = ( "ContextExtension", @@ -18,4 +20,5 @@ "TokenPaginationExtension", "TransactionExtension", "CollectionSearchExtension", + "DiscoverySearchExtension", ) diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/collectionSearch/__init__.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/collectionSearch/__init__.py index 21f35e8ca..c1e0acb8f 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/collectionSearch/__init__.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/collectionSearch/__init__.py @@ -1,6 +1,5 @@ """Filter extension module.""" - from .collectionSearch import CollectionSearchExtension __all__ = ["CollectionSearchExtension"] diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/collectionSearch/collectionSearch.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/collectionSearch/collectionSearch.py index defaa5702..75de8388f 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/collectionSearch/collectionSearch.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/collectionSearch/collectionSearch.py @@ -6,15 +6,20 @@ import attr from fastapi import APIRouter, FastAPI from starlette.responses import JSONResponse, Response -from stac_pydantic.api.collections import Collections -from stac_fastapi.api.models import JSONSchemaResponse from stac_fastapi.api.routes import create_async_endpoint from stac_fastapi.types.core import AsyncCollectionSearchClient, CollectionSearchClient from stac_fastapi.types.extension import ApiExtension -from stac_fastapi.types.search import BaseCollectionSearchGetRequest, BaseCollectionSearchPostRequest +from stac_fastapi.types.search import ( + BaseCollectionSearchGetRequest, + BaseCollectionSearchPostRequest, +) + +from .request import ( + CollectionSearchExtensionGetRequest, + CollectionSearchExtensionPostRequest, +) -from .request import CollectionSearchExtensionGetRequest, CollectionSearchExtensionPostRequest class CollectionSearchConformanceClasses(str, Enum): """Conformance classes for the Collection Search extension. @@ -26,12 +31,16 @@ class CollectionSearchConformanceClasses(str, Enum): CORE = "https://api.stacspec.org/v1.0.0-rc.1/core" COLLECTION_SEARCH = "https://api.stacspec.org/v1.0.0-rc.1/collection-search" SIMPLE_QUERY = "http://www.opengis.net/spec/ogcapi-common-2/1.0/conf/simple-query" + COLLECTION_SEARCH_FREE_TEXT = ( + "https://api.stacspec.org/v1.0.0-rc.1/collection-search#free-text" + ) + @attr.s class CollectionSearchExtension(ApiExtension): """CollectionSearch Extension. - The collection search extension adds two endpoints which allow searching of + The collection search extension adds two endpoints which allow searching of collections via GET and POST: GET /collection-search POST /collection-search @@ -44,30 +53,31 @@ class CollectionSearchExtension(ApiExtension): client: Collection Search endpoint logic conformance_classes: Conformance classes provided by the extension """ - + GET = CollectionSearchExtensionGetRequest POST = CollectionSearchExtensionPostRequest collection_search_get_request_model: Type[BaseCollectionSearchGetRequest] = attr.ib( default=BaseCollectionSearchGetRequest ) - collection_search_post_request_model: Type[BaseCollectionSearchPostRequest] = attr.ib( - default=BaseCollectionSearchPostRequest + collection_search_post_request_model: Type[BaseCollectionSearchPostRequest] = ( + attr.ib(default=BaseCollectionSearchPostRequest) ) - + client: Union[AsyncCollectionSearchClient, CollectionSearchClient] = attr.ib( factory=CollectionSearchClient ) - + conformance_classes: List[str] = attr.ib( default=[ CollectionSearchConformanceClasses.CORE, CollectionSearchConformanceClasses.COLLECTION_SEARCH, CollectionSearchConformanceClasses.SIMPLE_QUERY, + CollectionSearchConformanceClasses.COLLECTION_SEARCH_FREE_TEXT, ] ) router: APIRouter = attr.ib(factory=APIRouter) - response_class: Type[Response] = attr.ib(default=JSONResponse) + response_class: Type[Response] = attr.ib(default=JSONResponse) def register(self, app: FastAPI) -> None: """Register the extension with a FastAPI application. @@ -88,7 +98,8 @@ def register(self, app: FastAPI) -> None: response_model_exclude_none=True, methods=["GET"], endpoint=create_async_endpoint( - self.client.get_collection_search, self.collection_search_get_request_model + self.client.get_collection_search, + self.collection_search_get_request_model, ), ) @@ -101,7 +112,8 @@ def register(self, app: FastAPI) -> None: response_model_exclude_none=True, methods=["POST"], endpoint=create_async_endpoint( - self.client.post_collection_search, self.collection_search_post_request_model + self.client.post_collection_search, + self.collection_search_post_request_model, ), ) diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/collectionSearch/request.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/collectionSearch/request.py index f159d6a87..de48d90d6 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/collectionSearch/request.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/collectionSearch/request.py @@ -1,15 +1,14 @@ """Collection Search extension request models.""" -from enum import Enum -from typing import Any, Dict, Optional, List +from typing import Optional import attr from pydantic import BaseModel, Field from stac_pydantic.shared import BBox +from stac_fastapi.types.rfc3339 import DateTimeType, str_to_interval from stac_fastapi.types.search import APIRequest, Limit, str2bbox -from stac_fastapi.types.rfc3339 import DateTimeType, str_to_interval @attr.s class CollectionSearchExtensionGetRequest(APIRequest): @@ -18,6 +17,7 @@ class CollectionSearchExtensionGetRequest(APIRequest): bbox: Optional[BBox] = attr.ib(default=None, converter=str2bbox) datetime: Optional[DateTimeType] = attr.ib(default=None, converter=str_to_interval) limit: Optional[int] = attr.ib(default=10) + q: Optional[str] = attr.ib(default=None) class CollectionSearchExtensionPostRequest(BaseModel): @@ -26,3 +26,4 @@ class CollectionSearchExtensionPostRequest(BaseModel): bbox: Optional[BBox] datetime: Optional[DateTimeType] limit: Optional[Limit] = Field(default=10) + q: Optional[str] diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/discoverySearch/__init__.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/discoverySearch/__init__.py new file mode 100644 index 000000000..41eba4784 --- /dev/null +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/discoverySearch/__init__.py @@ -0,0 +1,5 @@ +"""Filter extension module.""" + +from .discoverySearch import DiscoverySearchExtension + +__all__ = ["DiscoverySearchExtension"] diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/discoverySearch/discoverySearch.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/discoverySearch/discoverySearch.py new file mode 100644 index 000000000..1688cbee8 --- /dev/null +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/discoverySearch/discoverySearch.py @@ -0,0 +1,97 @@ +# encoding: utf-8 +"""Collection Search Extension.""" +from typing import List, Type, Union + +import attr +from fastapi import APIRouter, FastAPI +from starlette.responses import JSONResponse, Response + +from stac_fastapi.api.routes import create_async_endpoint +from stac_fastapi.types.core import AsyncDiscoverySearchClient, DiscoverySearchClient +from stac_fastapi.types.extension import ApiExtension +from stac_fastapi.types.search import ( + BaseDiscoverySearchGetRequest, + BaseDiscoverySearchPostRequest, +) + +from .request import ( + DiscoverySearchExtensionGetRequest, + DiscoverySearchExtensionPostRequest, +) + + +@attr.s +class DiscoverySearchExtension(ApiExtension): + """DiscoverySearch Extension. + + The collection search extension adds two endpoints which allow searching of + collections via GET and POST: + GET /discovery-search + POST /discovery-search + + https://github.com/stac-api-extensions/collection-search + + Attributes: + search_get_request_model: Get request model for collection search + search_post_request_model: Post request model for collection search + client: Collection Search endpoint logic + conformance_classes: Conformance classes provided by the extension + """ + + GET = DiscoverySearchExtensionGetRequest + POST = DiscoverySearchExtensionPostRequest + + discovery_search_get_request_model: Type[BaseDiscoverySearchGetRequest] = attr.ib( + default=BaseDiscoverySearchGetRequest + ) + discovery_search_post_request_model: Type[BaseDiscoverySearchPostRequest] = attr.ib( + default=BaseDiscoverySearchPostRequest + ) + + client: Union[AsyncDiscoverySearchClient, DiscoverySearchClient] = attr.ib( + factory=DiscoverySearchClient + ) + + conformance_classes: List[str] = attr.ib(default=[]) + router: APIRouter = attr.ib(factory=APIRouter) + response_class: Type[Response] = attr.ib(default=JSONResponse) + + def register(self, app: FastAPI) -> None: + """Register the extension with a FastAPI application. + + Args: + app: target FastAPI application. + + Returns: + None + """ + self.router.prefix = app.state.router_prefix + self.router.add_api_route( + name="Discovery Search", + path="/discovery-search", + response_model=None, + response_class=self.response_class, + response_model_exclude_unset=True, + response_model_exclude_none=True, + methods=["GET"], + endpoint=create_async_endpoint( + self.client.get_discovery_search, + self.discovery_search_get_request_model, + ), + ) + + self.router.add_api_route( + name="Discovery Search", + path="/discovery-search", + response_model=None, + response_class=self.response_class, + response_model_exclude_unset=True, + response_model_exclude_none=True, + methods=["POST"], + endpoint=create_async_endpoint( + self.client.post_discovery_search, + self.discovery_search_post_request_model, + ), + ) + + app.include_router(self.router, tags=["Discovery Search Extension"]) diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/discoverySearch/request.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/discoverySearch/request.py new file mode 100644 index 000000000..f70fc76a3 --- /dev/null +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/discoverySearch/request.py @@ -0,0 +1,23 @@ +"""Discovery Search extension request models.""" + +from typing import Optional + +import attr +from pydantic import BaseModel, Field + +from stac_fastapi.types.search import APIRequest, Limit + + +@attr.s +class DiscoverySearchExtensionGetRequest(APIRequest): + """Discovery Search extension GET request model.""" + + q: Optional[str] = attr.ib(default=None) + limit: Optional[int] = attr.ib(default=10) + + +class DiscoverySearchExtensionPostRequest(BaseModel): + """Discovery Search extension POST request model.""" + + q: Optional[str] + limit: Optional[Limit] = Field(default=10) diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/fields/__init__.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/fields/__init__.py index b9a246b63..087d01b7a 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/fields/__init__.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/fields/__init__.py @@ -1,6 +1,5 @@ """Fields extension module.""" - from .fields import FieldsExtension __all__ = ["FieldsExtension"] diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/fields/fields.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/fields/fields.py index df4cd44de..25b6fe252 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/fields/fields.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/fields/fields.py @@ -1,4 +1,5 @@ """Fields extension.""" + from typing import List, Optional, Set import attr diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/filter/__init__.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/filter/__init__.py index 78256bfd2..256f3e06e 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/filter/__init__.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/filter/__init__.py @@ -1,6 +1,5 @@ """Filter extension module.""" - from .filter import FilterExtension __all__ = ["FilterExtension"] diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/query/query.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/query/query.py index 3e85b406d..dcb162060 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/query/query.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/query/query.py @@ -1,4 +1,5 @@ """Query extension.""" + from typing import List, Optional import attr diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/sort/sort.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/sort/sort.py index 5dd96cfa6..4b27d8d0e 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/sort/sort.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/sort/sort.py @@ -1,4 +1,5 @@ """Sort extension.""" + from typing import List, Optional import attr diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/transaction.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/transaction.py index 0ebcc6194..c6e5ecc3e 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/transaction.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/transaction.py @@ -4,10 +4,10 @@ import attr from fastapi import APIRouter, Body, FastAPI -from stac_pydantic import Collection, Item +from stac_pydantic import Catalog, Collection, Item from starlette.responses import JSONResponse, Response -from stac_fastapi.api.models import CollectionUri, ItemUri +from stac_fastapi.api.models import CatalogUri, CollectionUri, ItemUri from stac_fastapi.api.routes import create_async_endpoint from stac_fastapi.types import stac as stac_types from stac_fastapi.types.config import ApiSettings @@ -24,6 +24,27 @@ class PostItem(CollectionUri): ) +@attr.s +class PostCatalog(CatalogUri): + """Create Item.""" + + catalog: Union[stac_types.Catalog] = attr.ib(default=Body(None)) + + +@attr.s +class PutCollection(CollectionUri): + """Update Collection.""" + + collection: Union[stac_types.Collection] = attr.ib(default=Body(None)) + + +@attr.s +class PostNewCollection(CatalogUri): + """Create Collection.""" + + collection: Union[stac_types.Collection] = attr.ib(default=Body(None)) + + @attr.s class PutItem(ItemUri): """Update Item.""" @@ -62,10 +83,10 @@ class TransactionExtension(ApiExtension): response_class: Type[Response] = attr.ib(default=JSONResponse) def register_create_item(self): - """Register create item endpoint (POST /collections/{collection_id}/items).""" + """Register create item endpoint (POST /catalogs/{catalog_id}/collections/{collection_id}/items).""" self.router.add_api_route( name="Create Item", - path="/collections/{collection_id}/items", + path="/catalogs/{catalog_id}/collections/{collection_id}/items", response_model=Item if self.settings.enable_response_models else None, response_class=self.response_class, response_model_exclude_unset=True, @@ -76,10 +97,10 @@ def register_create_item(self): def register_update_item(self): """Register update item endpoint (PUT - /collections/{collection_id}/items/{item_id}).""" + /catalogs/{catalog_id}/collections/{collection_id}/items/{item_id}).""" self.router.add_api_route( name="Update Item", - path="/collections/{collection_id}/items/{item_id}", + path="/catalogs/{catalog_id}/collections/{collection_id}/items/{item_id}", response_model=Item if self.settings.enable_response_models else None, response_class=self.response_class, response_model_exclude_unset=True, @@ -90,10 +111,10 @@ def register_update_item(self): def register_delete_item(self): """Register delete item endpoint (DELETE - /collections/{collection_id}/items/{item_id}).""" + /catalogs/{catalog_id}/collections/{collection_id}/items/{item_id}).""" self.router.add_api_route( name="Delete Item", - path="/collections/{collection_id}/items/{item_id}", + path="/catalogs/{catalog_id}/collections/{collection_id}/items/{item_id}", response_model=Item if self.settings.enable_response_models else None, response_class=self.response_class, response_model_exclude_unset=True, @@ -103,40 +124,40 @@ def register_delete_item(self): ) def register_create_collection(self): - """Register create collection endpoint (POST /collections).""" + """Register create collection endpoint (POST /catalogs/{catalog_id}/collections).""" self.router.add_api_route( name="Create Collection", - path="/collections", + path="/catalogs/{catalog_id}/collections/", response_model=Collection if self.settings.enable_response_models else None, response_class=self.response_class, response_model_exclude_unset=True, response_model_exclude_none=True, methods=["POST"], endpoint=create_async_endpoint( - self.client.create_collection, stac_types.Collection + self.client.create_collection, PostNewCollection ), ) def register_update_collection(self): - """Register update collection endpoint (PUT /collections/{collection_id}).""" + """Register update collection endpoint (PUT /catalogs/{catalog_id}/collections/{collection_id}).""" self.router.add_api_route( name="Update Collection", - path="/collections/{collection_id}", + path="/catalogs/{catalog_id}/collections/{collection_id}", response_model=Collection if self.settings.enable_response_models else None, response_class=self.response_class, response_model_exclude_unset=True, response_model_exclude_none=True, methods=["PUT"], endpoint=create_async_endpoint( - self.client.update_collection, stac_types.Collection + self.client.update_collection, PutCollection ), ) def register_delete_collection(self): - """Register delete collection endpoint (DELETE /collections/{collection_id}).""" + """Register delete collection endpoint (DELETE /catalogs/{catalog_id}/collections/{collection_id}).""" self.router.add_api_route( name="Delete Collection", - path="/collections/{collection_id}", + path="/catalogs/{catalog_id}/collections/{collection_id}", response_model=Collection if self.settings.enable_response_models else None, response_class=self.response_class, response_model_exclude_unset=True, @@ -147,6 +168,47 @@ def register_delete_collection(self): ), ) + def register_create_catalog(self): + """Register create catalog endpoint (POST /catalogs).""" + self.router.add_api_route( + name="Create Catalog", + path="/catalogs", + response_model=Catalog if self.settings.enable_response_models else None, + response_class=self.response_class, + response_model_exclude_unset=True, + response_model_exclude_none=True, + methods=["POST"], + endpoint=create_async_endpoint( + self.client.create_catalog, stac_types.Catalog + ), + ) + + def register_update_catalog(self): + """Register update collection endpoint (PUT /collections/{collection_id}).""" + self.router.add_api_route( + name="Update Catalog", + path="/catalogs/{catalog_id}", + response_model=Catalog if self.settings.enable_response_models else None, + response_class=self.response_class, + response_model_exclude_unset=True, + response_model_exclude_none=True, + methods=["PUT"], + endpoint=create_async_endpoint(self.client.update_catalog, PostCatalog), + ) + + def register_delete_catalog(self): + """Register delete collection endpoint (DELETE /catalogs/{catalog_id}).""" + self.router.add_api_route( + name="Delete Catalog", + path="/catalogs/{catalog_id}", + response_model=Catalog if self.settings.enable_response_models else None, + response_class=self.response_class, + response_model_exclude_unset=True, + response_model_exclude_none=True, + methods=["DELETE"], + endpoint=create_async_endpoint(self.client.delete_catalog, CatalogUri), + ) + def register(self, app: FastAPI) -> None: """Register the extension with a FastAPI application. @@ -161,6 +223,9 @@ def register(self, app: FastAPI) -> None: self.register_update_item() self.register_delete_item() self.register_create_collection() + self.register_create_catalog() self.register_update_collection() + self.register_update_catalog() self.register_delete_collection() + self.register_delete_catalog() app.include_router(self.router, tags=["Transaction Extension"]) 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 ab7349e60..d35c4c8f9 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/third_party/__init__.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/__init__.py @@ -1,4 +1,5 @@ """stac_api.extensions.third_party module.""" + from .bulk_transactions import BulkTransactionExtension __all__ = ("BulkTransactionExtension",) 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 9fa96ff2b..cfe906afc 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 @@ -1,4 +1,5 @@ """Bulk transactions extension.""" + import abc from enum import Enum from typing import Any, Dict, List, Optional, Union @@ -46,6 +47,7 @@ def _chunks(lst, n): @abc.abstractmethod def bulk_item_insert( self, + catalog_id: str, items: Items, chunk_size: Optional[int] = None, **kwargs, @@ -69,6 +71,7 @@ class AsyncBaseBulkTransactionsClient(abc.ABC): @abc.abstractmethod async def bulk_item_insert( self, + catalog_id: str, items: Items, **kwargs, ) -> str: @@ -109,9 +112,9 @@ class BulkTransactionExtension(ApiExtension): } """ - client: Union[ - AsyncBaseBulkTransactionsClient, BaseBulkTransactionsClient - ] = attr.ib() + client: Union[AsyncBaseBulkTransactionsClient, BaseBulkTransactionsClient] = ( + attr.ib() + ) conformance_classes: List[str] = attr.ib(default=list()) schema_href: Optional[str] = attr.ib(default=None) diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/version.py b/stac_fastapi/extensions/stac_fastapi/extensions/version.py index 1b1206cd3..863e3198e 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/version.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/version.py @@ -1,2 +1,3 @@ """Library version.""" + __version__ = "2.5.2" diff --git a/stac_fastapi/extensions/tests/test_transaction.py b/stac_fastapi/extensions/tests/test_transaction.py index fc5acc2cf..6ad3cf0f2 100644 --- a/stac_fastapi/extensions/tests/test_transaction.py +++ b/stac_fastapi/extensions/tests/test_transaction.py @@ -15,9 +15,15 @@ class DummyCoreClient(BaseCoreClient): def all_collections(self, *args, **kwargs): raise NotImplementedError + def all_catalogs(self, *args, **kwargs): + raise NotImplementedError + def get_collection(self, *args, **kwargs): raise NotImplementedError + def get_catalog_collections(self, *args, **kwargs): + raise NotImplementedError + def get_item(self, *args, **kwargs): raise NotImplementedError @@ -52,6 +58,15 @@ def update_collection(self, *args, **kwargs): def delete_collection(self, *args, **kwargs): raise NotImplementedError + def create_catalog(self, *args, **kwargs): + raise NotImplementedError + + def update_catalog(self, *args, **kwargs): + raise NotImplementedError + + def delete_catalog(self, *args, **kwargs): + raise NotImplementedError + def test_create_item(client: TestClient, item: Item) -> None: response = client.post("/collections/a-collection/items", content=json.dumps(item)) diff --git a/stac_fastapi/types/stac_fastapi/types/catalogs.py b/stac_fastapi/types/stac_fastapi/types/catalogs.py new file mode 100644 index 000000000..421346707 --- /dev/null +++ b/stac_fastapi/types/stac_fastapi/types/catalogs.py @@ -0,0 +1,20 @@ +from typing import List + +from pydantic import BaseModel +from stac_pydantic import Catalog +from stac_pydantic.api.links import Link + + +class Catalogs(BaseModel): + """ + http://docs.opengeospatial.org/is/17-069r3/17-069r3.html#_feature_collections_rootcollections + """ + + links: List[Link] + collections: List[Catalog] + + def to_dict(self, **kwargs): + return self.dict(by_alias=True, exclude_unset=True, **kwargs) + + def to_json(self, **kwargs): + return self.json(by_alias=True, exclude_unset=True, **kwargs) diff --git a/stac_fastapi/types/stac_fastapi/types/config.py b/stac_fastapi/types/stac_fastapi/types/config.py index 4b88c56a4..905f5ae55 100644 --- a/stac_fastapi/types/stac_fastapi/types/config.py +++ b/stac_fastapi/types/stac_fastapi/types/config.py @@ -1,4 +1,5 @@ """stac_fastapi.types.config module.""" + from typing import Optional, Set from pydantic import BaseSettings diff --git a/stac_fastapi/types/stac_fastapi/types/conformance.py b/stac_fastapi/types/stac_fastapi/types/conformance.py index 13836aaf5..840584c1b 100644 --- a/stac_fastapi/types/stac_fastapi/types/conformance.py +++ b/stac_fastapi/types/stac_fastapi/types/conformance.py @@ -1,4 +1,5 @@ """Conformance Classes.""" + from enum import Enum diff --git a/stac_fastapi/types/stac_fastapi/types/core.py b/stac_fastapi/types/stac_fastapi/types/core.py index e8d75b602..fc610477d 100644 --- a/stac_fastapi/types/stac_fastapi/types/core.py +++ b/stac_fastapi/types/stac_fastapi/types/core.py @@ -17,7 +17,11 @@ from stac_fastapi.types.extension import ApiExtension from stac_fastapi.types.requests import get_base_url from stac_fastapi.types.rfc3339 import DateTimeType -from stac_fastapi.types.search import BaseSearchPostRequest, BaseCollectionSearchPostRequest +from stac_fastapi.types.search import ( + BaseCollectionSearchPostRequest, + BaseDiscoverySearchPostRequest, + BaseSearchPostRequest, +) from stac_fastapi.types.stac import Conformance NumType = Union[float, int] @@ -52,7 +56,12 @@ def create_item( @abc.abstractmethod def update_item( - self, collection_id: str, item_id: str, item: stac_types.Item, **kwargs + self, + catalog_id: str, + collection_id: str, + item_id: str, + item: stac_types.Item, + **kwargs, ) -> Optional[Union[stac_types.Item, Response]]: """Perform a complete update on an existing item. @@ -72,7 +81,7 @@ def update_item( @abc.abstractmethod def delete_item( - self, item_id: str, collection_id: str, **kwargs + self, item_id: str, collection_id: str, catalog_id: str, **kwargs ) -> Optional[Union[stac_types.Item, Response]]: """Delete an item from a collection. @@ -89,7 +98,7 @@ def delete_item( @abc.abstractmethod def create_collection( - self, collection: stac_types.Collection, **kwargs + self, catalog_id: str, collection: stac_types.Collection, **kwargs ) -> Optional[Union[stac_types.Collection, Response]]: """Create a new collection. @@ -103,6 +112,22 @@ def create_collection( """ ... + @abc.abstractmethod + def create_catalog( + self, catalog: stac_types.Catalog, **kwargs + ) -> Optional[Union[stac_types.Catalog, Response]]: + """Create a new catalog. + + Called with `POST /catalogs`. + + Args: + catalog: the catalog + + Returns: + The catalog that was created. + """ + ... + @abc.abstractmethod def update_collection( self, collection_id: str, collection: stac_types.Collection, **kwargs @@ -125,7 +150,7 @@ def update_collection( @abc.abstractmethod def delete_collection( - self, collection_id: str, **kwargs + self, catalog_id: str, collection_id: str, **kwargs ) -> Optional[Union[stac_types.Collection, Response]]: """Delete a collection. @@ -166,7 +191,12 @@ async def create_item( @abc.abstractmethod async def update_item( - self, collection_id: str, item_id: str, item: stac_types.Item, **kwargs + self, + catalog_id: str, + collection_id: str, + item_id: str, + item: stac_types.Item, + **kwargs, ) -> Optional[Union[stac_types.Item, Response]]: """Perform a complete update on an existing item. @@ -185,7 +215,7 @@ async def update_item( @abc.abstractmethod async def delete_item( - self, item_id: str, collection_id: str, **kwargs + self, item_id: str, collection_id: str, catalog_id: str, **kwargs ) -> Optional[Union[stac_types.Item, Response]]: """Delete an item from a collection. @@ -218,11 +248,15 @@ async def create_collection( @abc.abstractmethod async def update_collection( - self, collection_id: str, collection: stac_types.Collection, **kwargs + self, + catalog_id: str, + collection_id: str, + collection: stac_types.Collection, + **kwargs, ) -> Optional[Union[stac_types.Collection, Response]]: """Perform a complete update on an existing collection. - Called with `PUT /collections/{collection_id}`. It is expected that this item + Called with `PUT /catalogs/{catalog_id}/collections/{collection_id}`. It is expected that this item already exists. The update should do a diff against the saved collection and perform any necessary updates. Partial updates are not supported by the transactions extension. @@ -238,11 +272,11 @@ async def update_collection( @abc.abstractmethod async def delete_collection( - self, collection_id: str, **kwargs + self, catalog_id: str, collection_id: str, **kwargs ) -> Optional[Union[stac_types.Collection, Response]]: """Delete a collection. - Called with `DELETE /collections/{collection_id}` + Called with `DELETE /catalogs/{catalog_id}/collections/{collection_id}` Args: collection_id: id of the collection. @@ -475,7 +509,9 @@ def get_search( ... @abc.abstractmethod - def get_item(self, item_id: str, collection_id: str, **kwargs) -> stac_types.Item: + def get_item( + self, item_id: str, collection_id: str, catalog_id: str, **kwargs + ) -> stac_types.Item: """Get item by id. Called with `GET /collections/{collection_id}/items/{item_id}`. @@ -501,10 +537,23 @@ def all_collections(self, **kwargs) -> stac_types.Collections: ... @abc.abstractmethod - def get_collection(self, collection_id: str, **kwargs) -> stac_types.Collection: + def all_catalogs(self, **kwargs) -> stac_types.Catalogs: + """Get all available catalogs. + + Called with `GET /catalogs`. + + Returns: + A list of catalogs. + """ + ... + + @abc.abstractmethod + def get_collection( + self, catalog_id: str, collection_id: str, **kwargs + ) -> stac_types.Collection: """Get collection by id. - Called with `GET /collections/{collection_id}`. + Called with `GET /catalogs/{catalog_id}/collections/{collection_id}`. Args: collection_id: Id of the collection. @@ -514,6 +563,36 @@ def get_collection(self, collection_id: str, **kwargs) -> stac_types.Collection: """ ... + @abc.abstractmethod + def get_catalog(self, catalog_id: str, **kwargs) -> stac_types.Catalog: + """Get catalog by id. + + Called with `GET /catalogs/{catalog_id}`. + + Args: + catalog_id: Id of the catalog. + + Returns: + Catalog. + """ + ... + + @abc.abstractmethod + def get_catalog_collections( + self, catalog_id: str, **kwargs + ) -> stac_types.Collections: + """Get collections by catalog id. + + Called with `GET /catalogs/{catalog_id}/collections`. + + Args: + catalog_id: Id of the catalog. + + Returns: + Collections. + """ + ... + @abc.abstractmethod def item_collection( self, @@ -599,14 +678,26 @@ async def landing_page(self, **kwargs) -> stac_types.LandingPage: ) # Add Collections links - collections = await self.all_collections(request=kwargs["request"]) - for collection in collections["collections"]: + # collections = await self.all_collections(request=kwargs["request"]) + # for collection in collections["collections"]: + # landing_page["links"].append( + # { + # "rel": Relations.child.value, + # "type": MimeTypes.json.value, + # "title": collection.get("title") or collection.get("id"), + # "href": urljoin(base_url, f"collections/{collection['id']}"), + # } + # ) + + # Add Collections links + catalogs = await self.all_catalogs(request=kwargs["request"]) + for catalog in catalogs["collections"]: landing_page["links"].append( { "rel": Relations.child.value, "type": MimeTypes.json.value, - "title": collection.get("title") or collection.get("id"), - "href": urljoin(base_url, f"collections/{collection['id']}"), + "title": catalog.get("title") or catalog.get("id"), + "href": urljoin(base_url, f"collections/{catalog['id']}"), } ) @@ -684,11 +775,11 @@ async def get_search( @abc.abstractmethod async def get_item( - self, item_id: str, collection_id: str, **kwargs + self, item_id: str, collection_id: str, catalog_id: str, **kwargs ) -> stac_types.Item: """Get item by id. - Called with `GET /collections/{collection_id}/items/{item_id}`. + Called with `GET /catalogs/{catalog_id}/collections/{collection_id}/items/{item_id}`. Args: item_id: Id of the item. @@ -710,13 +801,24 @@ async def all_collections(self, **kwargs) -> stac_types.Collections: """ ... + @abc.abstractmethod + async def all_catalogs(self, **kwargs) -> stac_types.Catalogs: + """Get all available catalogs. + + Called with `GET /catalogs`. + + Returns: + A list of catalogs. + """ + ... + @abc.abstractmethod async def get_collection( self, collection_id: str, **kwargs ) -> stac_types.Collection: """Get collection by id. - Called with `GET /collections/{collection_id}`. + Called with `GET /catalogs/{catalog_id}/collections/{collection_id}`. Args: collection_id: Id of the collection. @@ -726,6 +828,20 @@ async def get_collection( """ ... + @abc.abstractmethod + async def get_catalog(self, catalog_id: str, **kwargs) -> stac_types.Catalog: + """Get catalog by id. + + Called with `GET /catalogs/{catalog_id}`. + + Args: + catalog_id: Id of the catalog. + + Returns: + Catalog. + """ + ... + @abc.abstractmethod async def item_collection( self, @@ -738,7 +854,7 @@ async def item_collection( ) -> stac_types.ItemCollection: """Get all items from a specific collection. - Called with `GET /collections/{collection_id}/items` + Called with `GET /catalogs/{catalog_id}/collections/{collection_id}/items` Args: collection_id: id of the collection. @@ -801,11 +917,14 @@ def get_queryables( "description": "Queryable names for the example STAC API Item Search filter.", "properties": {}, } - + + @attr.s class AsyncCollectionSearchClient(abc.ABC): """Defines a pattern for implementing the STAC Collection Search extension.""" + extensions: List[ApiExtension] = attr.ib(default=attr.Factory(list)) + @abc.abstractmethod async def post_collection_search( self, search_request: BaseCollectionSearchPostRequest, **kwargs @@ -828,6 +947,7 @@ async def get_collection_search( bbox: Optional[BBox] = None, datetime: Optional[DateTimeType] = None, limit: Optional[int] = 10, + q: Optional[str] = None, **kwargs, ) -> stac_types.Collections: """Cross catalog search (GET) of collections. @@ -866,6 +986,7 @@ def get_collection_search( bbox: Optional[BBox] = None, datetime: Optional[DateTimeType] = None, limit: Optional[int] = 10, + q: Optional[str] = None, **kwargs, ) -> stac_types.Collections: """Cross catalog search (GET) of collections. @@ -875,4 +996,80 @@ def get_collection_search( Returns: A tuple of (collections, next pagination token if any). """ - ... \ No newline at end of file + ... + + +@attr.s +class AsyncDiscoverySearchClient(abc.ABC): + """Defines a pattern for implementing the STAC Collection Search extension.""" + + extensions: List[ApiExtension] = attr.ib(default=attr.Factory(list)) + + @abc.abstractmethod + async def post_discovery_search( + self, search_request: BaseDiscoverySearchPostRequest, **kwargs + ) -> stac_types.ItemCollection: + """Cross catalog search (POST) of collections. + + Called with `POST /collection-search`. + + Args: + search_request: search request parameters. + + Returns: + A tuple of (collections, next pagination token if any). + """ + ... + + @abc.abstractmethod + async def get_discovery_search( + self, + q: Optional[str] = None, + limit: Optional[int] = 10, + **kwargs, + ) -> stac_types.Collections: + """Cross catalog search (GET) of collections. + + Called with `GET /collection-search`. + + Returns: + A tuple of (collections, next pagination token if any). + """ + ... + + +@attr.s +class DiscoverySearchClient(abc.ABC): + """Defines a pattern for implementing the STAC Collection Search extension.""" + + @abc.abstractmethod + def post_discovery_search( + self, search_request: BaseDiscoverySearchPostRequest, **kwargs + ) -> stac_types.Collections: + """Cross catalog search (POST) of collections. + + Called with `POST /collection-search`. + + Args: + search_request: search request parameters. + + Returns: + A tuple of (collections, next pagination token if any). + """ + ... + + @abc.abstractmethod + def get_discovery_search( + self, + q: Optional[str] = None, + limit: Optional[int] = 10, + **kwargs, + ) -> stac_types.Collections: + """Cross catalog search (GET) of collections. + + Called with `GET /collection-search`. + + Returns: + A tuple of (collections, next pagination token if any). + """ + ... diff --git a/stac_fastapi/types/stac_fastapi/types/extension.py b/stac_fastapi/types/stac_fastapi/types/extension.py index 732a907bf..55a4a123c 100644 --- a/stac_fastapi/types/stac_fastapi/types/extension.py +++ b/stac_fastapi/types/stac_fastapi/types/extension.py @@ -1,4 +1,5 @@ """Base api extension.""" + import abc from typing import List, Optional diff --git a/stac_fastapi/types/stac_fastapi/types/links.py b/stac_fastapi/types/stac_fastapi/types/links.py index 28f05d6c0..426ce1189 100644 --- a/stac_fastapi/types/stac_fastapi/types/links.py +++ b/stac_fastapi/types/stac_fastapi/types/links.py @@ -1,7 +1,7 @@ """Link helpers.""" from typing import Any, Dict, List -from urllib.parse import urljoin +from urllib.parse import urljoin, urlsplit import attr from stac_pydantic.links import Relations @@ -11,6 +11,8 @@ # Instead they are dynamically generated when querying the database using the # classes defined below INFERRED_LINK_RELS = ["self", "item", "parent", "collection", "root", "items"] +# Attempt to maintain "root" links to later determine relationships to catalogues +# INFERRED_LINK_RELS = ["self", "item", "parent", "collection", "items"] def filter_links(links: List[Dict]) -> List[Dict]: @@ -22,6 +24,10 @@ def resolve_links(links: list, base_url: str) -> List[Dict]: """Convert relative links to absolute links.""" filtered_links = filter_links(links) for link in filtered_links: + if "http://" in link["href"] or "https://" in link["href"]: + split_url = urlsplit(link["href"]) + link["href"] = split_url.path + # link["href"].replace(split_url.scheme, "").replace(split_url.netloc, "") link.update({"href": urljoin(base_url, link["href"])}) return filtered_links @@ -30,6 +36,7 @@ def resolve_links(links: list, base_url: str) -> List[Dict]: class BaseLinks: """Create inferred links common to collections and items.""" + catalog_id: str = attr.ib() collection_id: str = attr.ib() base_url: str = attr.ib() @@ -47,7 +54,10 @@ def self(self) -> Dict[str, Any]: return dict( rel=Relations.self, type=MimeTypes.json, - href=urljoin(self.base_url, f"collections/{self.collection_id}"), + href=urljoin( + self.base_url, + f"catalogs/{self.catalog_id}/collections/{self.collection_id}", + ), ) def parent(self) -> Dict[str, Any]: @@ -59,12 +69,56 @@ def items(self) -> Dict[str, Any]: return dict( rel="items", type=MimeTypes.geojson, - href=urljoin(self.base_url, f"collections/{self.collection_id}/items"), + href=urljoin( + self.base_url, + f"catalogs/{self.catalog_id}/collections/{self.collection_id}/items", + ), ) def create_links(self) -> List[Dict[str, Any]]: """Return all inferred links.""" - return [self.self(), self.parent(), self.items(), self.root()] + # We use predefined root here to identify the catalog containing this dataset + return [ + self.self(), + self.parent(), + self.items(), + self.root(), + ] # get rid of root here to remove generated value + + +@attr.s +class BaseCatalogLinks: + """Create inferred links common to catalogs.""" + + base_url: str = attr.ib() + catalog_id: str = attr.ib() + + def root(self) -> Dict[str, Any]: + """Return the catalog root.""" + return dict(rel=Relations.root, type=MimeTypes.json, href=self.base_url) + + +@attr.s +class CatalogLinks(BaseCatalogLinks): + """Create inferred links specific to catalogs.""" + + def self(self) -> Dict[str, Any]: + """Create the `self` link.""" + return dict( + rel=Relations.self, + type=MimeTypes.json, + href=urljoin(self.base_url, f"catalogs/{self.catalog_id}"), + ) + + def parent(self) -> Dict[str, Any]: + """Create the `parent` link.""" + return dict(rel=Relations.parent, type=MimeTypes.json, href=self.base_url) + + def create_links(self) -> List[Dict[str, Any]]: + """Return all inferred links.""" + # We use predefined root here to identify the catalog containing this dataset + # No items for catalogues + return [self.self(), self.parent(), self.root()] @attr.s @@ -80,7 +134,7 @@ def self(self) -> Dict[str, Any]: type=MimeTypes.geojson, href=urljoin( self.base_url, - f"collections/{self.collection_id}/items/{self.item_id}", + f"catalogs/{self.catalog_id}/collections/{self.collection_id}/items/{self.item_id}", ), ) @@ -89,7 +143,10 @@ def parent(self) -> Dict[str, Any]: return dict( rel=Relations.parent, type=MimeTypes.json, - href=urljoin(self.base_url, f"collections/{self.collection_id}"), + href=urljoin( + self.base_url, + f"catalogs/{self.catalog_id}/collections/{self.collection_id}", + ), ) def collection(self) -> Dict[str, Any]: @@ -97,7 +154,10 @@ def collection(self) -> Dict[str, Any]: return dict( rel=Relations.collection, type=MimeTypes.json, - href=urljoin(self.base_url, f"collections/{self.collection_id}"), + href=urljoin( + self.base_url, + f"catalogs/{self.catalog_id}/collections/{self.collection_id}", + ), ) def create_links(self) -> List[Dict[str, Any]]: diff --git a/stac_fastapi/types/stac_fastapi/types/rfc3339.py b/stac_fastapi/types/stac_fastapi/types/rfc3339.py index b1d40999e..1a8c2d0d6 100644 --- a/stac_fastapi/types/stac_fastapi/types/rfc3339.py +++ b/stac_fastapi/types/stac_fastapi/types/rfc3339.py @@ -1,4 +1,5 @@ """rfc3339.""" + import re from datetime import datetime, timezone from typing import Optional, Tuple, Union diff --git a/stac_fastapi/types/stac_fastapi/types/search.py b/stac_fastapi/types/stac_fastapi/types/search.py index 46831c056..c060e838a 100644 --- a/stac_fastapi/types/stac_fastapi/types/search.py +++ b/stac_fastapi/types/stac_fastapi/types/search.py @@ -106,6 +106,7 @@ class BaseSearchGetRequest(APIRequest): """Base arguments for GET Request.""" collections: Optional[str] = attr.ib(default=None, converter=str2list) + catalogs: Optional[str] = attr.ib(default=None, converter=str2list) ids: Optional[str] = attr.ib(default=None, converter=str2list) bbox: Optional[BBox] = attr.ib(default=None, converter=str2bbox) intersects: Optional[str] = attr.ib(default=None, converter=str2list) @@ -125,6 +126,7 @@ class BaseSearchPostRequest(BaseModel): """ collections: Optional[List[str]] + catalogs: Optional[List[str]] ids: Optional[List[str]] bbox: Optional[BBox] intersects: Optional[ @@ -219,7 +221,7 @@ def spatial_filter(self) -> Optional[_GeometryBase]: if self.intersects: return self.intersects return - + @attr.s class BaseCollectionSearchGetRequest(APIRequest): @@ -228,8 +230,9 @@ class BaseCollectionSearchGetRequest(APIRequest): bbox: Optional[BBox] = attr.ib(default=None, converter=str2bbox) datetime: Optional[DateTimeType] = attr.ib(default=None, converter=str_to_interval) limit: Optional[int] = attr.ib(default=10) - - + q: Optional[str] = attr.ib(default=None) + + class BaseCollectionSearchPostRequest(BaseModel): """Search model. @@ -240,6 +243,7 @@ class BaseCollectionSearchPostRequest(BaseModel): bbox: Optional[BBox] datetime: Optional[DateTimeType] limit: Optional[Limit] = Field(default=10) + q: Optional[str] @property def start_date(self) -> Optional[datetime]: @@ -312,3 +316,22 @@ def spatial_filter(self) -> Optional[_GeometryBase]: if self.intersects: return self.intersects return + + +@attr.s +class BaseDiscoverySearchGetRequest(APIRequest): + """Base arguments for Collection Search GET Request.""" + + q: Optional[str] = attr.ib(default=None) + limit: Optional[int] = attr.ib(default=10) + + +class BaseDiscoverySearchPostRequest(BaseModel): + """Search model. + + Replace base model in STAC-pydantic as it includes additional fields, not in the core + model. + """ + + q: Optional[str] + limit: Optional[Limit] = Field(default=10) diff --git a/stac_fastapi/types/stac_fastapi/types/stac.py b/stac_fastapi/types/stac_fastapi/types/stac.py index 51bb6e652..aaaa0b4ca 100644 --- a/stac_fastapi/types/stac_fastapi/types/stac.py +++ b/stac_fastapi/types/stac_fastapi/types/stac.py @@ -1,4 +1,5 @@ """STAC types.""" + import sys from typing import Any, Dict, List, Literal, Optional, Union @@ -56,6 +57,7 @@ class Collection(Catalog, total=False): extent: Dict[str, Any] summaries: Dict[str, Any] assets: Dict[str, Any] + # links: List[Dict[str, Any]] class Item(TypedDict, total=False): @@ -90,3 +92,23 @@ class Collections(TypedDict, total=False): collections: List[Collection] links: List[Dict[str, Any]] + + +class Catalogs(TypedDict, total=False): + """All collections endpoint. + + https://github.com/radiantearth/stac-api-spec/tree/master/collections + """ + + catalogs: List[Catalog] + links: List[Dict[str, Any]] + + +class CatalogsAndCollections(TypedDict, total=False): + """All catalogues and collections endpoint. + + https://github.com/radiantearth/stac-api-spec/tree/master/collections + """ + + catalogs_and_collections: List[Union[Catalog, Collection]] + links: List[Dict[str, Any]] diff --git a/stac_fastapi/types/stac_fastapi/types/version.py b/stac_fastapi/types/stac_fastapi/types/version.py index 1b1206cd3..863e3198e 100644 --- a/stac_fastapi/types/stac_fastapi/types/version.py +++ b/stac_fastapi/types/stac_fastapi/types/version.py @@ -1,2 +1,3 @@ """Library version.""" + __version__ = "2.5.2"