Skip to content

Commit

Permalink
add POST - /collections collection-search (#739)
Browse files Browse the repository at this point in the history
* add POST - /collections collection-search

* fix
  • Loading branch information
vincentsarago authored Jul 25, 2024
1 parent 69dcee0 commit 4adcf0e
Show file tree
Hide file tree
Showing 7 changed files with 476 additions and 25 deletions.
4 changes: 2 additions & 2 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,12 @@
### Changed

* Add more openapi metadata in input models [#734](https://github.com/stac-utils/stac-fastapi/pull/734)
* Use same `Limit` (capped to `10_000`) for `/items` and `GET - /search` input models ([#737](https://github.com/stac-utils/stac-fastapi/pull/737))
* Use same `Limit` (capped to `10_000`) for `/items` and `GET - /search` input models ([#738](https://github.com/stac-utils/stac-fastapi/pull/738))

### Added

* Add Free-text Extension ([#655](https://github.com/stac-utils/stac-fastapi/pull/655))
* Add Collection-Search Extension ([#736](https://github.com/stac-utils/stac-fastapi/pull/736))
* Add Collection-Search Extension ([#736](https://github.com/stac-utils/stac-fastapi/pull/736), [#739](https://github.com/stac-utils/stac-fastapi/pull/739))

## [3.0.0b2] - 2024-07-09

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""stac_api.extensions.core module."""

from .aggregation import AggregationExtension
from .collection_search import CollectionSearchExtension
from .collection_search import CollectionSearchExtension, CollectionSearchPostExtension
from .context import ContextExtension
from .fields import FieldsExtension
from .filter import FilterExtension
Expand All @@ -24,4 +24,5 @@
"TokenPaginationExtension",
"TransactionExtension",
"CollectionSearchExtension",
"CollectionSearchPostExtension",
)
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
"""Collection-Search extension module."""

from .collection_search import CollectionSearchExtension, ConformanceClasses
from .collection_search import (
CollectionSearchExtension,
CollectionSearchPostExtension,
ConformanceClasses,
)

__all__ = ["CollectionSearchExtension", "ConformanceClasses"]
__all__ = [
"CollectionSearchExtension",
"CollectionSearchPostExtension",
"ConformanceClasses",
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"""collection-search extensions clients."""

import abc

import attr

from stac_fastapi.types import stac

from .request import BaseCollectionSearchPostRequest


@attr.s
class AsyncBaseCollectionSearchClient(abc.ABC):
"""Defines a pattern for implementing the STAC collection-search POST extension."""

@abc.abstractmethod
async def post_all_collections(
self,
search_request: BaseCollectionSearchPostRequest,
**kwargs,
) -> stac.ItemCollection:
"""Get all available collections.
Called with `POST /collections`.
Returns:
A list of collections.
"""
...


@attr.s
class BaseCollectionSearchClient(abc.ABC):
"""Defines a pattern for implementing the STAC collection-search POST extension."""

@abc.abstractmethod
def post_all_collections(
self, search_request: BaseCollectionSearchPostRequest, **kwargs
) -> stac.ItemCollection:
"""Get all available collections.
Called with `POST /collections`.
Returns:
A list of collections.
"""
...
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
"""Collection-Search extension."""

from enum import Enum
from typing import List, Optional
from typing import List, Optional, Union

import attr
from fastapi import FastAPI
from fastapi import APIRouter, FastAPI
from stac_pydantic.api.collections import Collections
from stac_pydantic.shared import MimeTypes

from stac_fastapi.api.models import GeoJSONResponse
from stac_fastapi.api.routes import create_async_endpoint
from stac_fastapi.types.config import ApiSettings
from stac_fastapi.types.extension import ApiExtension

from .request import CollectionSearchExtensionGetRequest
from .client import AsyncBaseCollectionSearchClient, BaseCollectionSearchClient
from .request import BaseCollectionSearchGetRequest, BaseCollectionSearchPostRequest


class ConformanceClasses(str, Enum):
Expand Down Expand Up @@ -46,7 +52,7 @@ class CollectionSearchExtension(ApiExtension):
the extension
"""

GET = CollectionSearchExtensionGetRequest
GET: BaseCollectionSearchGetRequest = attr.ib(default=BaseCollectionSearchGetRequest)
POST = None

conformance_classes: List[str] = attr.ib(
Expand All @@ -64,3 +70,65 @@ def register(self, app: FastAPI) -> None:
None
"""
pass


@attr.s
class CollectionSearchPostExtension(CollectionSearchExtension):
"""Collection-Search Extension.
Extents the collection-search extension with an additional
POST - /collections endpoint
NOTE: the POST - /collections endpoint can be conflicting with the
POST /collections endpoint registered for the Transaction extension.
https://github.com/stac-api-extensions/collection-search
Attributes:
conformance_classes (list): Defines the list of conformance classes for
the extension
"""

client: Union[AsyncBaseCollectionSearchClient, BaseCollectionSearchClient] = attr.ib()
settings: ApiSettings = attr.ib()
conformance_classes: List[str] = attr.ib(
default=[ConformanceClasses.COLLECTIONSEARCH, ConformanceClasses.BASIS]
)
schema_href: Optional[str] = attr.ib(default=None)
router: APIRouter = attr.ib(factory=APIRouter)

GET: BaseCollectionSearchGetRequest = attr.ib(default=BaseCollectionSearchGetRequest)
POST: BaseCollectionSearchPostRequest = attr.ib(
default=BaseCollectionSearchPostRequest
)

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="Collections",
path="/collections",
methods=["POST"],
response_model=(
Collections if self.settings.enable_response_models else None
),
responses={
200: {
"content": {
MimeTypes.json.value: {},
},
"model": Collections,
},
},
response_class=GeoJSONResponse,
endpoint=create_async_endpoint(self.client.post_all_collections, self.POST),
)
app.include_router(self.router)
Original file line number Diff line number Diff line change
@@ -1,27 +1,139 @@
"""Request models for the Collection-Search extension."""

from typing import Optional
from datetime import datetime as dt
from typing import List, Optional, Tuple, cast

import attr
from fastapi import Query
from pydantic import BaseModel, Field, field_validator
from stac_pydantic.api.search import SearchDatetime
from stac_pydantic.shared import BBox
from typing_extensions import Annotated

from stac_fastapi.types.rfc3339 import DateTimeType
from stac_fastapi.types.search import APIRequest, _bbox_converter, _datetime_converter
from stac_fastapi.types.search import (
APIRequest,
Limit,
_bbox_converter,
_datetime_converter,
)


@attr.s
class CollectionSearchExtensionGetRequest(APIRequest):
class BaseCollectionSearchGetRequest(APIRequest):
"""Basics additional Collection-Search parameters for the GET request."""

bbox: Optional[BBox] = attr.ib(default=None, converter=_bbox_converter)
datetime: Optional[DateTimeType] = attr.ib(
default=None, converter=_datetime_converter
)
limit: Annotated[
Optional[int],
Optional[Limit],
Query(
description="Limits the number of results that are included in each page of the response." # noqa: E501
),
] = attr.ib(default=10)


class BaseCollectionSearchPostRequest(BaseModel):
"""Collection-Search POST model."""

bbox: Optional[BBox] = None
datetime: Optional[str] = None
limit: Optional[Limit] = Field(
10,
description="Limits the number of results that are included in each page of the response (capped to 10_000).", # noqa: E501
)

# Private properties to store the parsed datetime values.
# Not part of the model schema.
_start_date: Optional[dt] = None
_end_date: Optional[dt] = None

# Properties to return the private values
@property
def start_date(self) -> Optional[dt]:
"""start date."""
return self._start_date

@property
def end_date(self) -> Optional[dt]:
"""end date."""
return self._end_date

@field_validator("bbox")
@classmethod
def validate_bbox(cls, v: BBox) -> BBox:
"""validate bbox."""
if v:
# Validate order
if len(v) == 4:
xmin, ymin, xmax, ymax = cast(Tuple[int, int, int, int], v)
else:
xmin, ymin, min_elev, xmax, ymax, max_elev = cast(
Tuple[int, int, int, int, int, int], v
)
if max_elev < min_elev:
raise ValueError(
"Maximum elevation must greater than minimum elevation"
)

if xmax < xmin:
raise ValueError(
"Maximum longitude must be greater than minimum longitude"
)

if ymax < ymin:
raise ValueError(
"Maximum longitude must be greater than minimum longitude"
)

# Validate against WGS84
if xmin < -180 or ymin < -90 or xmax > 180 or ymax > 90:
raise ValueError("Bounding box must be within (-180, -90, 180, 90)")

return v

@field_validator("datetime")
@classmethod
def validate_datetime(cls, value: str) -> str:
"""validate datetime."""
# Split on "/" and replace no value or ".." with None
values = [v if v and v != ".." else None for v in value.split("/")]

# If there are more than 2 dates, it's invalid
if len(values) > 2:
raise ValueError(
"""Invalid datetime range. Too many values.
Must match format: {begin_date}/{end_date}"""
)

# If there is only one date, duplicate to use for both start and end dates
if len(values) == 1:
values = [values[0], values[0]]

# Cast because pylance gets confused by the type adapter and annotated type
dates = cast(
List[Optional[dt]],
[
# Use the type adapter to validate the datetime strings,
# strict is necessary due to pydantic issues #8736 and #8762
SearchDatetime.validate_strings(v, strict=True) if v else None
for v in values
],
)

# If there is a start and end date,
# check that the start date is before the end date
if dates[0] and dates[1] and dates[0] > dates[1]:
raise ValueError(
"Invalid datetime range. Begin date after end date. "
"Must match format: {begin_date}/{end_date}"
)

# Store the parsed dates
cls._start_date = dates[0]
cls._end_date = dates[1]

# Return the original string value
return value
Loading

0 comments on commit 4adcf0e

Please sign in to comment.