From 4d1269d6d9485f31da6516a0ceb7341d08f5b72d Mon Sep 17 00:00:00 2001 From: tjellicoe Date: Mon, 20 May 2024 12:09:27 +0100 Subject: [PATCH 1/4] Add collection search extension definition --- stac_fastapi/api/stac_fastapi/api/app.py | 65 ++++++++-- stac_fastapi/api/stac_fastapi/api/models.py | 25 ++++ stac_fastapi/api/tests/test_app.py | 77 ++++++++++- .../stac_fastapi/extensions/core/__init__.py | 2 + .../core/collectionSearch/__init__.py | 5 + .../core/collectionSearch/collectionSearch.py | 107 ++++++++++++++++ .../core/collectionSearch/request.py | 29 +++++ .../tests/test_collection_search.py | 121 ++++++++++++++++++ stac_fastapi/types/stac_fastapi/types/core.py | 39 +++++- .../types/stac_fastapi/types/search.py | 23 +++- 10 files changed, 474 insertions(+), 19 deletions(-) create mode 100644 stac_fastapi/extensions/stac_fastapi/extensions/core/collectionSearch/__init__.py create mode 100644 stac_fastapi/extensions/stac_fastapi/extensions/core/collectionSearch/collectionSearch.py create mode 100644 stac_fastapi/extensions/stac_fastapi/extensions/core/collectionSearch/request.py create mode 100644 stac_fastapi/extensions/tests/test_collection_search.py diff --git a/stac_fastapi/api/stac_fastapi/api/app.py b/stac_fastapi/api/stac_fastapi/api/app.py index ed9a1cc78..d91884969 100644 --- a/stac_fastapi/api/stac_fastapi/api/app.py +++ b/stac_fastapi/api/stac_fastapi/api/app.py @@ -8,6 +8,7 @@ from fastapi import APIRouter, FastAPI from fastapi.openapi.utils import get_openapi from fastapi.params import Depends +from pydantic import BaseModel from stac_pydantic import api from stac_pydantic.api.collections import Collections from stac_pydantic.api.version import STAC_API_VERSION @@ -29,11 +30,20 @@ from stac_fastapi.api.routes import Scope, add_route_dependencies, create_async_endpoint # TODO: make this module not depend on `stac_fastapi.extensions` -from stac_fastapi.extensions.core import FieldsExtension, TokenPaginationExtension +from stac_fastapi.extensions.core import ( + CollectionSearchExtension, + FieldsExtension, + TokenPaginationExtension, +) from stac_fastapi.types.config import ApiSettings, Settings 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.search import ( + BaseCollectionSearchGetRequest, + BaseCollectionSearchPostRequest, + BaseSearchGetRequest, + BaseSearchPostRequest, +) @attr.s @@ -121,6 +131,13 @@ class StacApi: ) route_dependencies: List[Tuple[List[Scope], List[Depends]]] = attr.ib(default=[]) + collections_get_request_model: Type[BaseCollectionSearchGetRequest] = attr.ib( + default=EmptyRequest + ) + collections_post_request_model: Type[BaseCollectionSearchPostRequest] = attr.ib( + default=BaseModel + ) + def get_extension(self, extension: Type[ApiExtension]) -> Optional[ApiExtension]: """Get an extension. @@ -284,27 +301,50 @@ def register_get_collections(self): Returns: None """ + collection_search_ext = self.get_extension(CollectionSearchExtension) self.router.add_api_route( name="Get Collections", path="/collections", response_model=( - Collections if self.settings.enable_response_models else None + (Collections if not collection_search_ext else None) + if self.settings.enable_response_models + else None ), - responses={ - 200: { - "content": { - MimeTypes.json.value: {}, - }, - "model": Collections, - }, - }, response_class=self.response_class, response_model_exclude_unset=True, response_model_exclude_none=True, methods=["GET"], - endpoint=create_async_endpoint(self.client.all_collections, EmptyRequest), + endpoint=create_async_endpoint( + self.client.all_collections, self.collections_get_request_model + ), ) + # def register_post_collections(self): + # """Register get collections endpoint (GET /collections). + + # Returns: + # None + # """ + # collection_search_ext = self.get_extension(CollectionSearchExtension) + # print("POST") + # print(self.collections_post_request_model) + # if not collection_search_ext: + # return + # self.router.add_api_route( + # name="Post Collections", + # path="/collections", + # response_model=( + # (Collections if not collection_search_ext else None) + # 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.post_all_collections, self.collections_post_request_model), + # ) + def register_get_collection(self): """Register get collection endpoint (GET /collection/{collection_id}). @@ -392,6 +432,7 @@ def register_core(self): self.register_post_search() self.register_get_search() self.register_get_collections() + # self.register_post_collections() self.register_get_collection() self.register_get_item_collection() diff --git a/stac_fastapi/api/stac_fastapi/api/models.py b/stac_fastapi/api/stac_fastapi/api/models.py index 2716fe7fb..77197c56d 100644 --- a/stac_fastapi/api/stac_fastapi/api/models.py +++ b/stac_fastapi/api/stac_fastapi/api/models.py @@ -80,6 +80,31 @@ def create_post_request_model( ) +def create_get_collections_request_model( + extensions, base_model: BaseSearchGetRequest = BaseSearchGetRequest +): + """Wrap create_request_model to create the GET request model.""" + return create_request_model( + "CollectionsGetRequest", + base_model=base_model, + extensions=extensions, + request_type="GET", + ) + + +def create_post_collections_request_model( + extensions, base_model: BaseSearchPostRequest = BaseSearchPostRequest +): + """Wrap create_request_model to create the POST request model.""" + + return create_request_model( + "CollectionsPostRequest", + base_model=base_model, + extensions=extensions, + request_type="POST", + ) + + @attr.s # type:ignore class CollectionUri(APIRequest): """Get or delete collection.""" diff --git a/stac_fastapi/api/tests/test_app.py b/stac_fastapi/api/tests/test_app.py index 9b4e0e828..21c5813a6 100644 --- a/stac_fastapi/api/tests/test_app.py +++ b/stac_fastapi/api/tests/test_app.py @@ -1,18 +1,30 @@ -from datetime import datetime +from datetime import datetime as datetimetype from typing import List, Optional, Union import pytest from fastapi.testclient import TestClient -from pydantic import ValidationError +from pydantic import BaseModel, ValidationError from stac_pydantic import api from stac_fastapi.api import app -from stac_fastapi.api.models import create_get_request_model, create_post_request_model +from stac_fastapi.api.models import ( + EmptyRequest, + create_get_collections_request_model, + create_get_request_model, + create_post_collections_request_model, + create_post_request_model, +) +from stac_fastapi.extensions.core.collectionSearch.collectionSearch import ( + CollectionSearchExtension, +) from stac_fastapi.extensions.core.filter.filter import FilterExtension from stac_fastapi.types import stac from stac_fastapi.types.config import ApiSettings from stac_fastapi.types.core import NumType -from stac_fastapi.types.search import BaseSearchPostRequest +from stac_fastapi.types.rfc3339 import str_to_interval +from stac_fastapi.types.search import ( + BaseSearchPostRequest, +) def test_client_response_type(TestCoreClient): @@ -132,7 +144,7 @@ def get_search( ids: Optional[List[str]] = None, bbox: Optional[List[NumType]] = None, intersects: Optional[str] = None, - datetime: Optional[Union[str, datetime]] = None, + datetime: Optional[Union[str, datetimetype]] = None, limit: Optional[int] = 10, filter: Optional[str] = None, filter_crs: Optional[str] = None, @@ -186,3 +198,58 @@ def get_search( assert get_search.status_code == 200, get_search.text assert post_search.status_code == 200, post_search.text + + +def test_collection_search_extension(TestCoreClient, collection_dict): + """Test if Collection Search Parameters are passed correctly.""" + + class CollectionSearchClient(TestCoreClient): + def all_collections( + self, + bbox: Optional[List[NumType]] = None, + datetime: Optional[Union[str, datetimetype]] = None, + limit: Optional[int] = 10, + **kwargs, + ) -> stac.Collections: + # Check if all collection search parameters are passed correctly + + assert bbox == (-180, -90, 180, 90) + assert datetime == str_to_interval("2024-01-01T00:00:00Z") + assert limit == 10 + + return stac.Collections( + collections=[stac.Collection(**collection_dict)], + links=[ + {"href": "test", "rel": "root"}, + {"href": "test", "rel": "self"}, + {"href": "test", "rel": "parent"}, + ], + ) + + collections_post_request_model = create_post_collections_request_model( + [CollectionSearchExtension()], BaseModel + ) + collections_get_request_model = create_get_collections_request_model( + [CollectionSearchExtension()], EmptyRequest + ) + + test_app = app.StacApi( + settings=ApiSettings(), + client=CollectionSearchClient(), + collections_get_request_model=collections_get_request_model, + collections_post_request_model=collections_post_request_model, + ) + + with TestClient(test_app.app) as client: + get_collections = client.get( + "/collections", + params={ + "bbox": "-180,-90,180,90", + "datetime": "2024-01-01T00:00:00Z", + "limit": 10, + }, + ) + + assert get_collections.status_code == 200, get_collections.text + print(get_collections.json()) + api.collections.Collections(**get_collections.json()) diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/__init__.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/__init__.py index 74f15ed0a..4fa472ac8 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/__init__.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/__init__.py @@ -1,5 +1,6 @@ """stac_api.extensions.core module.""" +from .collectionSearch import CollectionSearchExtension from .context import ContextExtension from .fields import FieldsExtension from .filter import FilterExtension @@ -17,4 +18,5 @@ "SortExtension", "TokenPaginationExtension", "TransactionExtension", + "CollectionSearchExtension", ) diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/collectionSearch/__init__.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/collectionSearch/__init__.py new file mode 100644 index 000000000..c1e0acb8f --- /dev/null +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/collectionSearch/__init__.py @@ -0,0 +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 new file mode 100644 index 000000000..d7ae48df9 --- /dev/null +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/collectionSearch/collectionSearch.py @@ -0,0 +1,107 @@ +# encoding: utf-8 +"""Collection Search Extension.""" +from enum import Enum +from typing import List, Type, Union + +import attr +from fastapi import APIRouter, FastAPI +from stac_pydantic.api.collections import Collections +from starlette.responses import JSONResponse, Response + +from stac_fastapi.api.routes import create_async_endpoint +from stac_fastapi.types.config import ApiSettings +from stac_fastapi.types.core import ( + AsyncBaseCollectionSearchClient, + BaseCollectionSearchClient, +) +from stac_fastapi.types.extension import ApiExtension +from stac_fastapi.types.search import ( + BaseCollectionSearchPostRequest, +) + +from .request import ( + CollectionSearchExtensionGetRequest, + CollectionSearchExtensionPostRequest, +) + + +class CollectionSearchConformanceClasses(str, Enum): + """Conformance classes for the Collection Search extension. + + See + https://github.com/stac-api-extensions/collection-search + """ + + 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 + collections via GET and POST: + GET /collection-search + POST /collection-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 + """ + + settings: ApiSettings = attr.ib(default=ApiSettings()) + + GET = CollectionSearchExtensionGetRequest + POST = CollectionSearchExtensionPostRequest + + collections_post_request_model: Type[BaseCollectionSearchPostRequest] = attr.ib( + default=BaseCollectionSearchPostRequest + ) + + client: Union[AsyncBaseCollectionSearchClient, BaseCollectionSearchClient] = attr.ib( + factory=BaseCollectionSearchClient + ) + + 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) + + def register(self, app: FastAPI) -> None: + """Register the extension with a FastAPI application. + + Args: + app: target FastAPI application. + + Returns: + None + """ + self.router.add_api_route( + name="Post Collections", + path="/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=["POST"], + endpoint=create_async_endpoint( + self.client.post_all_collections, self.collections_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 new file mode 100644 index 000000000..f0152fd69 --- /dev/null +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/collectionSearch/request.py @@ -0,0 +1,29 @@ +"""Collection Search extension request models.""" + +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 + + +@attr.s +class CollectionSearchExtensionGetRequest(APIRequest): + """Collection Search extension GET request model.""" + + 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): + """Collection Search extension POST request model.""" + + bbox: Optional[BBox] + datetime: Optional[DateTimeType] + limit: Optional[Limit] = Field(default=10) + # q: Optional[str] diff --git a/stac_fastapi/extensions/tests/test_collection_search.py b/stac_fastapi/extensions/tests/test_collection_search.py new file mode 100644 index 000000000..754d9059d --- /dev/null +++ b/stac_fastapi/extensions/tests/test_collection_search.py @@ -0,0 +1,121 @@ +from typing import Iterator + +import pytest +from stac_pydantic.api.collections import Collections +from stac_pydantic.shared import BBox +from starlette.testclient import TestClient + +from stac_fastapi.api.app import StacApi +from stac_fastapi.extensions.core import CollectionSearchExtension +from stac_fastapi.types.config import ApiSettings +from stac_fastapi.types.core import BaseCollectionSearchClient, BaseCoreClient +from stac_fastapi.types.rfc3339 import DateTimeType +from stac_fastapi.types.search import ( + BaseCollectionSearchPostRequest, +) +from stac_fastapi.types.stac import Item, ItemCollection + + +class DummyCoreClient(BaseCoreClient): + def all_collections(self, *args, **kwargs): + raise NotImplementedError + + 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 DummyCollectionSearchClient(BaseCollectionSearchClient): + """Defines a pattern for implementing the STAC collection search extension.""" + + def post_all_collections( + self, search_request: BaseCollectionSearchPostRequest, **kwargs + ) -> Collections: + assert search_request.bbox == BBox([-180, -90, 180, 90]) + assert search_request.datetime == DateTimeType("2024-01-01T00:00:00Z") + assert search_request.limit == 10 + + return Collections( + collections=[Collections()], + links=[ + {"href": "test", "rel": "root"}, + {"href": "test", "rel": "self"}, + {"href": "test", "rel": "parent"}, + ], + ) + + +def test_post_collection_search(client: TestClient) -> None: + post_collections = client.post( + "/collections", + json={}, + # "bbox": [-180,-90,180,90], + # "datetime": "2024-01-01T00:00:00Z", + # "limit": 10, + # }, + ) + assert post_collections.status_code == 200, post_collections.text + Collections(**post_collections.json()) + + +@pytest.fixture +def client( + core_client: DummyCoreClient, collection_search_client: DummyCollectionSearchClient +) -> Iterator[TestClient]: + settings = ApiSettings() + api = StacApi( + settings=settings, + client=core_client, + extensions=[ + CollectionSearchExtension(client=collection_search_client, settings=settings), + ], + ) + with TestClient(api.app) as client: + yield client + + +@pytest.fixture +def core_client() -> DummyCoreClient: + return DummyCoreClient() + + +@pytest.fixture +def collection_search_client() -> DummyCollectionSearchClient: + return DummyCollectionSearchClient() + + +@pytest.fixture +def item_collection(item: Item) -> ItemCollection: + return { + "type": "FeatureCollection", + "features": [item], + "links": [], + "context": None, + } + + +@pytest.fixture +def item() -> Item: + return { + "type": "Feature", + "stac_version": "1.0.0", + "stac_extensions": [], + "id": "test_item", + "geometry": {"type": "Point", "coordinates": [-105, 40]}, + "bbox": [-105, 40, -105, 40], + "properties": {}, + "links": [], + "assets": {}, + "collection": "test_collection", + } diff --git a/stac_fastapi/types/stac_fastapi/types/core.py b/stac_fastapi/types/stac_fastapi/types/core.py index fdf020b08..d49ca8ea0 100644 --- a/stac_fastapi/types/stac_fastapi/types/core.py +++ b/stac_fastapi/types/stac_fastapi/types/core.py @@ -20,7 +20,10 @@ 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 +from stac_fastapi.types.search import ( + BaseCollectionSearchPostRequest, + BaseSearchPostRequest, +) NumType = Union[float, int] StacType = Dict[str, Any] @@ -792,3 +795,37 @@ def get_queryables( "description": "Queryable names for the example STAC API Item Search filter.", "properties": {}, } + + +@attr.s +class AsyncBaseCollectionSearchClient(abc.ABC): + """Defines a pattern for implementing the STAC Collection Search extension.""" + + async def post_all_collections( + self, search_request: BaseCollectionSearchPostRequest, **kwargs + ) -> stac.Collections: + """Get all available collections. + + Called with `GET /collections`. + + Returns: + A list of collections. + """ + return stac.Collections(collections=[]) + + +@attr.s +class BaseCollectionSearchClient(abc.ABC): + """Defines a pattern for implementing the STAC Collection Search extension.""" + + async def post_all_collections( + self, search_request: BaseCollectionSearchPostRequest, **kwargs + ) -> stac.Collections: + """Get all available collections. + + Called with `GET /collections`. + + Returns: + A list of collections. + """ + return stac.Collections(collections=[]) diff --git a/stac_fastapi/types/stac_fastapi/types/search.py b/stac_fastapi/types/stac_fastapi/types/search.py index cf6647340..003c8ff40 100644 --- a/stac_fastapi/types/stac_fastapi/types/search.py +++ b/stac_fastapi/types/stac_fastapi/types/search.py @@ -6,7 +6,7 @@ from typing import Dict, List, Optional, Union import attr -from pydantic import PositiveInt +from pydantic import BaseModel, PositiveInt from pydantic.functional_validators import AfterValidator from stac_pydantic.api import Search from stac_pydantic.shared import BBox @@ -68,3 +68,24 @@ class BaseSearchPostRequest(Search): """Base arguments for POST Request.""" limit: Optional[Limit] = 10 + + +@attr.s +class BaseCollectionSearchGetRequest(APIRequest): + """Base arguments for Collection Search GET Request.""" + + 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) + + +class BaseCollectionSearchPostRequest(BaseModel): + """Search model. + + Replace base model in STAC-pydantic as it includes additional fields, not in the core + model. + """ + + bbox: Optional[BBox] + datetime: Optional[DateTimeType] + limit: Optional[Limit] = 10 From d4a0a264ec65b90e26d83879643ebf295cb5d46c Mon Sep 17 00:00:00 2001 From: tjellicoe Date: Mon, 20 May 2024 14:15:19 +0100 Subject: [PATCH 2/4] Added tests for collection search extension, also updated formatting --- CHANGES.md | 1 + stac_fastapi/api/stac_fastapi/api/app.py | 33 ++----------- stac_fastapi/api/stac_fastapi/api/version.py | 1 + stac_fastapi/api/tests/test_app.py | 1 - stac_fastapi/extensions/setup.py | 1 - .../core/collectionSearch/collectionSearch.py | 2 + .../stac_fastapi/extensions/version.py | 1 + .../tests/test_collection_search.py | 49 +++++++++++++++---- stac_fastapi/types/stac_fastapi/types/core.py | 1 - .../types/stac_fastapi/types/version.py | 1 + 10 files changed, 48 insertions(+), 43 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 940b7b844..a41037637 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -6,6 +6,7 @@ * switch from `fastapi` to `fastapi-slim` to avoid installing unwanted dependencies. ([#687](https://github.com/stac-utils/stac-fastapi/pull/687)) * replace Enum with `Literal` for `FilterLang`. ([#686](https://github.com/stac-utils/stac-fastapi/pull/686)) +* Added `collection-search` extension ### Removed diff --git a/stac_fastapi/api/stac_fastapi/api/app.py b/stac_fastapi/api/stac_fastapi/api/app.py index d91884969..762676c15 100644 --- a/stac_fastapi/api/stac_fastapi/api/app.py +++ b/stac_fastapi/api/stac_fastapi/api/app.py @@ -1,6 +1,5 @@ """Fastapi app creation.""" - from typing import Any, Dict, List, Optional, Tuple, Type, Union import attr @@ -319,32 +318,6 @@ def register_get_collections(self): ), ) - # def register_post_collections(self): - # """Register get collections endpoint (GET /collections). - - # Returns: - # None - # """ - # collection_search_ext = self.get_extension(CollectionSearchExtension) - # print("POST") - # print(self.collections_post_request_model) - # if not collection_search_ext: - # return - # self.router.add_api_route( - # name="Post Collections", - # path="/collections", - # response_model=( - # (Collections if not collection_search_ext else None) - # 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.post_all_collections, self.collections_post_request_model), - # ) - def register_get_collection(self): """Register get collection endpoint (GET /collection/{collection_id}). @@ -354,9 +327,9 @@ def register_get_collection(self): self.router.add_api_route( name="Get Collection", path="/collections/{collection_id}", - response_model=api.Collection - if self.settings.enable_response_models - else None, + response_model=( + api.Collection if self.settings.enable_response_models else None + ), responses={ 200: { "content": { diff --git a/stac_fastapi/api/stac_fastapi/api/version.py b/stac_fastapi/api/stac_fastapi/api/version.py index bf624bae8..1301d46cd 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__ = "3.0.0a0" diff --git a/stac_fastapi/api/tests/test_app.py b/stac_fastapi/api/tests/test_app.py index 21c5813a6..4aca88b17 100644 --- a/stac_fastapi/api/tests/test_app.py +++ b/stac_fastapi/api/tests/test_app.py @@ -251,5 +251,4 @@ def all_collections( ) assert get_collections.status_code == 200, get_collections.text - print(get_collections.json()) api.collections.Collections(**get_collections.json()) diff --git a/stac_fastapi/extensions/setup.py b/stac_fastapi/extensions/setup.py index 39bc59b3f..ae474a664 100644 --- a/stac_fastapi/extensions/setup.py +++ b/stac_fastapi/extensions/setup.py @@ -1,6 +1,5 @@ """stac_fastapi: extensions module.""" - from setuptools import find_namespace_packages, setup with open("README.md") as f: 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 d7ae48df9..7fe9b26ee 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/collectionSearch/collectionSearch.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/collectionSearch/collectionSearch.py @@ -91,6 +91,7 @@ def register(self, app: FastAPI) -> None: Returns: None """ + self.router.prefix = app.state.router_prefix self.router.add_api_route( name="Post Collections", path="/collections", @@ -105,3 +106,4 @@ def register(self, app: FastAPI) -> None: self.client.post_all_collections, self.collections_post_request_model ), ) + app.include_router(self.router) diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/version.py b/stac_fastapi/extensions/stac_fastapi/extensions/version.py index bf624bae8..1301d46cd 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__ = "3.0.0a0" diff --git a/stac_fastapi/extensions/tests/test_collection_search.py b/stac_fastapi/extensions/tests/test_collection_search.py index 754d9059d..61b05a91a 100644 --- a/stac_fastapi/extensions/tests/test_collection_search.py +++ b/stac_fastapi/extensions/tests/test_collection_search.py @@ -1,17 +1,22 @@ +import json from typing import Iterator import pytest +from pydantic import BaseModel from stac_pydantic.api.collections import Collections -from stac_pydantic.shared import BBox +from stac_pydantic.api.utils import link_factory from starlette.testclient import TestClient from stac_fastapi.api.app import StacApi +from stac_fastapi.api.models import create_post_collections_request_model from stac_fastapi.extensions.core import CollectionSearchExtension +from stac_fastapi.types import stac from stac_fastapi.types.config import ApiSettings from stac_fastapi.types.core import BaseCollectionSearchClient, BaseCoreClient -from stac_fastapi.types.rfc3339 import DateTimeType +from stac_fastapi.types.rfc3339 import parse_single_date from stac_fastapi.types.search import ( BaseCollectionSearchPostRequest, + str2bbox, ) from stac_fastapi.types.stac import Item, ItemCollection @@ -42,12 +47,29 @@ class DummyCollectionSearchClient(BaseCollectionSearchClient): def post_all_collections( self, search_request: BaseCollectionSearchPostRequest, **kwargs ) -> Collections: - assert search_request.bbox == BBox([-180, -90, 180, 90]) - assert search_request.datetime == DateTimeType("2024-01-01T00:00:00Z") + # Check inputs are parsed correctly + assert search_request.bbox == str2bbox("-180, -90, 180, 90") + assert search_request.datetime == parse_single_date("2024-01-01T00:00:00Z") assert search_request.limit == 10 + collection_links = link_factory.CollectionLinks("/", "test").create_links() return Collections( - collections=[Collections()], + collections=[ + stac.Collection( + { + "id": "test_collection", + "title": "Test Collection", + "description": "A test collection", + "keywords": ["test"], + "license": "proprietary", + "extent": { + "spatial": {"bbox": [[-180, -90, 180, 90]]}, + "temporal": {"interval": [["2000-01-01T00:00:00Z", None]]}, + }, + "links": collection_links, + } + ) + ], links=[ {"href": "test", "rel": "root"}, {"href": "test", "rel": "self"}, @@ -59,11 +81,13 @@ def post_all_collections( def test_post_collection_search(client: TestClient) -> None: post_collections = client.post( "/collections", - json={}, - # "bbox": [-180,-90,180,90], - # "datetime": "2024-01-01T00:00:00Z", - # "limit": 10, - # }, + content=json.dumps( + { + "bbox": [-180, -90, 180, 90], + "datetime": "2024-01-01T00:00:00Z", + "limit": 10, + } + ), ) assert post_collections.status_code == 200, post_collections.text Collections(**post_collections.json()) @@ -74,13 +98,18 @@ def client( core_client: DummyCoreClient, collection_search_client: DummyCollectionSearchClient ) -> Iterator[TestClient]: settings = ApiSettings() + collections_post_request_model = create_post_collections_request_model( + [CollectionSearchExtension()], BaseModel + ) api = StacApi( settings=settings, client=core_client, extensions=[ CollectionSearchExtension(client=collection_search_client, settings=settings), ], + collections_post_request_model=collections_post_request_model, ) + with TestClient(api.app) as client: yield client diff --git a/stac_fastapi/types/stac_fastapi/types/core.py b/stac_fastapi/types/stac_fastapi/types/core.py index d49ca8ea0..7f9c0fb00 100644 --- a/stac_fastapi/types/stac_fastapi/types/core.py +++ b/stac_fastapi/types/stac_fastapi/types/core.py @@ -1,6 +1,5 @@ """Base clients.""" - import abc from typing import Any, Dict, List, Optional, Union from urllib.parse import urljoin diff --git a/stac_fastapi/types/stac_fastapi/types/version.py b/stac_fastapi/types/stac_fastapi/types/version.py index bf624bae8..1301d46cd 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__ = "3.0.0a0" From 8abcffb8247392972802d461b855584cd1ca663c Mon Sep 17 00:00:00 2001 From: tjellicoe Date: Mon, 20 May 2024 14:31:39 +0100 Subject: [PATCH 3/4] Updated collection-search module name --- stac_fastapi/api/stac_fastapi/api/config.py | 1 + stac_fastapi/api/stac_fastapi/api/openapi.py | 8 ++++++++ stac_fastapi/api/tests/test_app.py | 2 +- .../extensions/stac_fastapi/extensions/core/__init__.py | 2 +- .../{collectionSearch => collection_search}/__init__.py | 2 +- .../collection_search.py} | 0 .../{collectionSearch => collection_search}/request.py | 0 7 files changed, 12 insertions(+), 3 deletions(-) rename stac_fastapi/extensions/stac_fastapi/extensions/core/{collectionSearch => collection_search}/__init__.py (56%) rename stac_fastapi/extensions/stac_fastapi/extensions/core/{collectionSearch/collectionSearch.py => collection_search/collection_search.py} (100%) rename stac_fastapi/extensions/stac_fastapi/extensions/core/{collectionSearch => collection_search}/request.py (100%) diff --git a/stac_fastapi/api/stac_fastapi/api/config.py b/stac_fastapi/api/stac_fastapi/api/config.py index 3918421ff..81b148293 100644 --- a/stac_fastapi/api/stac_fastapi/api/config.py +++ b/stac_fastapi/api/stac_fastapi/api/config.py @@ -18,6 +18,7 @@ class ApiExtensions(enum.Enum): query = "query" sort = "sort" transaction = "transaction" + collection_search = "collection-search" class AddOns(enum.Enum): diff --git a/stac_fastapi/api/stac_fastapi/api/openapi.py b/stac_fastapi/api/stac_fastapi/api/openapi.py index ab90ce425..8c0d4a5de 100644 --- a/stac_fastapi/api/stac_fastapi/api/openapi.py +++ b/stac_fastapi/api/stac_fastapi/api/openapi.py @@ -81,6 +81,14 @@ def custom_openapi(): "application/json" ]["schema"] = {"$ref": "#/components/schemas/ItemCollection"} + if settings.api_extension_is_enabled(ApiExtensions.collection_search): + openapi_schema["paths"]["/collections"]["get"]["responses"]["200"]["content"][ + "application/json" + ]["schema"] = {"$ref": "#/components/schemas/Collections"} + openapi_schema["paths"]["/collections"]["post"]["responses"]["200"][ + "content" + ]["application/json"]["schema"] = {"$ref": "#/components/schemas/Collections"} + app.openapi_schema = openapi_schema return app.openapi_schema diff --git a/stac_fastapi/api/tests/test_app.py b/stac_fastapi/api/tests/test_app.py index 4aca88b17..2dd2c3824 100644 --- a/stac_fastapi/api/tests/test_app.py +++ b/stac_fastapi/api/tests/test_app.py @@ -14,7 +14,7 @@ create_post_collections_request_model, create_post_request_model, ) -from stac_fastapi.extensions.core.collectionSearch.collectionSearch import ( +from stac_fastapi.extensions.core.collection_search.collection_search import ( CollectionSearchExtension, ) from stac_fastapi.extensions.core.filter.filter import FilterExtension diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/__init__.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/__init__.py index 4fa472ac8..2e5e0351d 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/__init__.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/__init__.py @@ -1,6 +1,6 @@ """stac_api.extensions.core module.""" -from .collectionSearch import CollectionSearchExtension +from .collection_search import CollectionSearchExtension from .context import ContextExtension from .fields import FieldsExtension from .filter import FilterExtension diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/collectionSearch/__init__.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/collection_search/__init__.py similarity index 56% rename from stac_fastapi/extensions/stac_fastapi/extensions/core/collectionSearch/__init__.py rename to stac_fastapi/extensions/stac_fastapi/extensions/core/collection_search/__init__.py index c1e0acb8f..7fd4e8e63 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/collectionSearch/__init__.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/collection_search/__init__.py @@ -1,5 +1,5 @@ """Filter extension module.""" -from .collectionSearch import CollectionSearchExtension +from .collection_search 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/collection_search/collection_search.py similarity index 100% rename from stac_fastapi/extensions/stac_fastapi/extensions/core/collectionSearch/collectionSearch.py rename to stac_fastapi/extensions/stac_fastapi/extensions/core/collection_search/collection_search.py diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/collectionSearch/request.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/collection_search/request.py similarity index 100% rename from stac_fastapi/extensions/stac_fastapi/extensions/core/collectionSearch/request.py rename to stac_fastapi/extensions/stac_fastapi/extensions/core/collection_search/request.py From d4dbba3cb8d593ab22f789322f9df465da0fd848 Mon Sep 17 00:00:00 2001 From: tjellicoe Date: Mon, 20 May 2024 14:33:01 +0100 Subject: [PATCH 4/4] Removed commented line --- stac_fastapi/api/stac_fastapi/api/app.py | 1 - 1 file changed, 1 deletion(-) diff --git a/stac_fastapi/api/stac_fastapi/api/app.py b/stac_fastapi/api/stac_fastapi/api/app.py index 762676c15..0fe94f262 100644 --- a/stac_fastapi/api/stac_fastapi/api/app.py +++ b/stac_fastapi/api/stac_fastapi/api/app.py @@ -405,7 +405,6 @@ def register_core(self): self.register_post_search() self.register_get_search() self.register_get_collections() - # self.register_post_collections() self.register_get_collection() self.register_get_item_collection()