Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions stac_fastapi/api/stac_fastapi/api/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ class ApiExtensions(enum.Enum):
query = "query"
sort = "sort"
transaction = "transaction"
collection_search = "collection-search"


class AddOns(enum.Enum):
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
"""stac_api.extensions.core module."""

from .context import ContextExtension
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",
Expand All @@ -17,4 +17,5 @@
"SortExtension",
"TokenPaginationExtension",
"TransactionExtension",
"CollectionSearchExtension",
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
"""Filter extension module."""


from .collectionSearch import CollectionSearchExtension

__all__ = ["CollectionSearchExtension"]
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
# 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 Response

from stac_fastapi.api.models import JSONSchemaResponse
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 (
BaseCollectionSearchGetRequest,
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"


@attr.s
class CollectionSearchExtension(ApiExtension):
"""CollectionSearch Extension.

The collection search extension adds two endpoints which allow searching of
collections via GET and POST, to avoid conflict with /collections endpoints
those used here are:
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
"""

GET = CollectionSearchExtensionGetRequest
POST = CollectionSearchExtensionPostRequest

client: Union[AsyncBaseCollectionSearchClient, BaseCollectionSearchClient] = attr.ib(
factory=BaseCollectionSearchClient
)
settings: ApiSettings = attr.ib(default=ApiSettings())

collection_search_get_request_model: Type[BaseCollectionSearchGetRequest] = attr.ib(
default=BaseCollectionSearchGetRequest
)
collection_search_post_request_model: Type[BaseCollectionSearchPostRequest] = attr.ib(
default=BaseCollectionSearchPostRequest
)

conformance_classes: List[str] = attr.ib(
default=[
CollectionSearchConformanceClasses.CORE,
CollectionSearchConformanceClasses.COLLECTION_SEARCH,
CollectionSearchConformanceClasses.SIMPLE_QUERY,
]
)
router: APIRouter = attr.ib(factory=APIRouter)
response_class: Type[Response] = attr.ib(default=JSONSchemaResponse)

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="Collection Search",
path="/collection-search",
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_collection_search,
self.collection_search_get_request_model,
),
)

self.router.add_api_route(
name="Collection Search",
path="/collection-search",
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_collection_search,
self.collection_search_post_request_model,
),
)

app.include_router(self.router, tags=["Collection Search Extension"])
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"""Collection Search extension request models."""

from enum import Enum
from typing import Any, Dict, Optional, List

import attr
from pydantic import BaseModel, Field
from stac_pydantic.shared import BBox

from stac_fastapi.types.search import APIRequest, Limit, str2bbox

from stac_fastapi.types.rfc3339 import DateTimeType, str_to_interval

@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)


class CollectionSearchExtensionPostRequest(BaseModel):
"""Collection Search extension POST request model."""

bbox: Optional[BBox]
datetime: Optional[DateTimeType]
limit: Optional[Limit] = Field(default=10)
122 changes: 122 additions & 0 deletions stac_fastapi/extensions/tests/test_collection_search.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
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 (
BaseCollectionSearchGetRequest,
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 get_collection_search(
self,
request: BaseCollectionSearchGetRequest,
bbox: BBox,
datetime: DateTimeType,
limit: int,
):
return Collections()

def post_collection_search(
self, request: BaseCollectionSearchPostRequest, *args, **kwargs
):
return Collections()


def test_get_collection_search(client: TestClient) -> None:
get_search = client.get("/collection-search", params={"limit": 10})
assert get_search.status_code == 200, get_search.text
Collections(**get_search.json())


def test_post_collection_search(client: TestClient) -> None:
post_search = client.post(
"/collection-search",
json={"limit": 10},
)
assert post_search.status_code == 200, post_search.text
Collections(**post_search.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",
}
Loading