diff --git a/CHANGES.md b/CHANGES.md index 816e4a7da..0d23d55cf 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,10 @@ ## [Unreleased] +## Changed + +* use `string` type instead of python `datetime.datetime` for datetime parameter in `BaseSearchGetRequest`, `ItemCollectionUri` and `BaseCollectionSearchGetRequest` GET models + ## [3.0.5] - 2025-01-10 ### Removed diff --git a/stac_fastapi/api/stac_fastapi/api/models.py b/stac_fastapi/api/stac_fastapi/api/models.py index d2e06abca..581ec6201 100644 --- a/stac_fastapi/api/stac_fastapi/api/models.py +++ b/stac_fastapi/api/stac_fastapi/api/models.py @@ -9,14 +9,15 @@ from typing_extensions import Annotated from stac_fastapi.types.extension import ApiExtension -from stac_fastapi.types.rfc3339 import DateTimeType from stac_fastapi.types.search import ( APIRequest, BaseSearchGetRequest, BaseSearchPostRequest, + DatetimeMixin, + DateTimeQueryType, Limit, _bbox_converter, - _datetime_converter, + _validate_datetime, ) try: @@ -110,7 +111,7 @@ class EmptyRequest(APIRequest): @attr.s -class ItemCollectionUri(APIRequest): +class ItemCollectionUri(APIRequest, DatetimeMixin): """Get item collection.""" collection_id: Annotated[str, Path(description="Collection ID")] = attr.ib() @@ -121,9 +122,7 @@ class ItemCollectionUri(APIRequest): ), ] = attr.ib(default=10) bbox: Optional[BBox] = attr.ib(default=None, converter=_bbox_converter) - datetime: Optional[DateTimeType] = attr.ib( - default=None, converter=_datetime_converter - ) + datetime: DateTimeQueryType = attr.ib(default=None, validator=_validate_datetime) class GeoJSONResponse(JSONResponse): diff --git a/stac_fastapi/api/tests/test_app.py b/stac_fastapi/api/tests/test_app.py index 3c214a952..106bde389 100644 --- a/stac_fastapi/api/tests/test_app.py +++ b/stac_fastapi/api/tests/test_app.py @@ -1,4 +1,3 @@ -from datetime import datetime from typing import List, Optional, Union import attr @@ -141,7 +140,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[str] = None, limit: Optional[int] = 10, filter: Optional[str] = None, filter_crs: Optional[str] = None, @@ -221,7 +220,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[str] = None, limit: Optional[int] = 10, **kwargs, ) -> stac.ItemCollection: @@ -247,7 +246,7 @@ def item_collection( self, collection_id: str, bbox: Optional[List[Union[float, int]]] = None, - datetime: Optional[Union[str, datetime]] = None, + datetime: Optional[str] = None, limit: int = 10, token: str = None, **kwargs, @@ -392,6 +391,7 @@ def test_client_datetime_input_params(): class FakeClient(BaseCoreClient): def post_search(self, search_request: BaseSearchPostRequest, **kwargs): + assert isinstance(search_request.datetime, str) return search_request.datetime def get_search( @@ -400,10 +400,11 @@ 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[str] = None, limit: Optional[int] = 10, **kwargs, ): + assert isinstance(datetime, str) return datetime def get_item(self, item_id: str, collection_id: str, **kwargs) -> stac.Item: @@ -419,7 +420,7 @@ def item_collection( self, collection_id: str, bbox: Optional[List[Union[float, int]]] = None, - datetime: Optional[Union[str, datetime]] = None, + datetime: Optional[str] = None, limit: int = 10, token: str = None, **kwargs, @@ -439,6 +440,13 @@ def item_collection( "datetime": "2020-01-01T00:00:00.00001Z", }, ) + get_search_zero = client.get( + "/search", + params={ + "collections": ["test"], + "datetime": "2020-01-01T00:00:00.0000Z", + }, + ) post_search = client.post( "/search", json={ @@ -446,9 +454,20 @@ def item_collection( "datetime": "2020-01-01T00:00:00.00001Z", }, ) + post_search_zero = client.post( + "/search", + json={ + "collections": ["test"], + "datetime": "2020-01-01T00:00:00.0000Z", + }, + ) assert get_search.status_code == 200, get_search.text - assert get_search.json() == "2020-01-01T00:00:00.000010+00:00" + assert get_search.json() == "2020-01-01T00:00:00.00001Z" + assert get_search_zero.status_code == 200, get_search_zero.text + assert get_search_zero.json() == "2020-01-01T00:00:00.0000Z" assert post_search.status_code == 200, post_search.text assert post_search.json() == "2020-01-01T00:00:00.00001Z" + assert post_search_zero.status_code == 200, post_search_zero.text + assert post_search_zero.json() == "2020-01-01T00:00:00.0000Z" diff --git a/stac_fastapi/api/tests/test_models.py b/stac_fastapi/api/tests/test_models.py index 5c4fdc8c6..0c25e7f3d 100644 --- a/stac_fastapi/api/tests/test_models.py +++ b/stac_fastapi/api/tests/test_models.py @@ -35,12 +35,24 @@ def test_create_get_request_model(): assert model.collections == ["test1", "test2"] assert model.filter_crs == "epsg:4326" - d = model.datetime + d = model.start_date assert d.microsecond == 10 + assert not model.end_date + model = request_model( + datetime="2020-01-01T00:00:00.00001Z/2020-01-02T00:00:00.00001Z", + ) + assert model.start_date + assert model.end_date + + # invalid datetime format with pytest.raises(HTTPException): request_model(datetime="yo") + # Wrong order + with pytest.raises(HTTPException): + request_model(datetime="2020-01-02T00:00:00.00001Z/2020-01-01T00:00:00.00001Z") + app = FastAPI() @app.get("/test") @@ -92,6 +104,9 @@ def test_create_post_request_model(filter_val, passes): assert model.filter == filter_val assert model.datetime == "2020-01-01T00:00:00.00001Z" + with pytest.raises(ValidationError): + request_model(datetime="yo") + @pytest.mark.parametrize( "sortby,passes", diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/collection_search/request.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/collection_search/request.py index 0bc6d22e3..0e8eb8824 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/collection_search/request.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/collection_search/request.py @@ -10,23 +10,22 @@ 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, + DatetimeMixin, + DateTimeQueryType, Limit, _bbox_converter, - _datetime_converter, + _validate_datetime, ) @attr.s -class BaseCollectionSearchGetRequest(APIRequest): +class BaseCollectionSearchGetRequest(APIRequest, DatetimeMixin): """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 - ) + datetime: DateTimeQueryType = attr.ib(default=None, validator=_validate_datetime) limit: Annotated[ Optional[Limit], Query( @@ -38,8 +37,26 @@ class BaseCollectionSearchGetRequest(APIRequest): class BaseCollectionSearchPostRequest(BaseModel): """Collection-Search POST model.""" - bbox: Optional[BBox] = None - datetime: Optional[str] = None + bbox: Optional[BBox] = Field( + default=None, + description="Only return items intersecting this bounding box. Mutually exclusive with **intersects**.", # noqa: E501 + json_schema_extra={ + "example": [-175.05, -85.05, 175.05, 85.05], + }, + ) + datetime: Optional[str] = Field( + default=None, + description="""Only return items that have a temporal property that intersects this value.\n +Either a date-time or an interval, open or closed. Date and time expressions adhere to RFC 3339. Open intervals are expressed using double-dots.""", # noqa: E501 + json_schema_extra={ + "examples": { + "datetime": {"value": "2018-02-12T23:20:50Z"}, + "closed-interval": {"value": "2018-02-12T00:00:00Z/2018-03-18T12:31:12Z"}, + "open-interval-from": {"value": "2018-02-12T00:00:00Z/.."}, + "open-interval-to": {"value": "../2018-03-18T12:31:12Z"}, + }, + }, + ) 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 diff --git a/stac_fastapi/extensions/tests/test_collection_search.py b/stac_fastapi/extensions/tests/test_collection_search.py index 8f54f1537..3b7bcfc2a 100644 --- a/stac_fastapi/extensions/tests/test_collection_search.py +++ b/stac_fastapi/extensions/tests/test_collection_search.py @@ -120,10 +120,7 @@ def test_collection_search_extension_default(): assert response.is_success, response.json() response_dict = response.json() assert [-175.05, -85.05, 175.05, 85.05] == response_dict["bbox"] - assert [ - "2020-06-13T13:00:00+00:00", - "2020-06-13T14:00:00+00:00", - ] == response_dict["datetime"] + assert "2020-06-13T13:00:00Z/2020-06-13T14:00:00Z" == response_dict["datetime"] assert 100 == response_dict["limit"] @@ -211,10 +208,7 @@ def test_collection_search_extension_models(): assert response.is_success, response.json() response_dict = response.json() assert [-175.05, -85.05, 175.05, 85.05] == response_dict["bbox"] - assert [ - "2020-06-13T13:00:00+00:00", - "2020-06-13T14:00:00+00:00", - ] == response_dict["datetime"] + assert "2020-06-13T13:00:00Z/2020-06-13T14:00:00Z" == response_dict["datetime"] assert 100 == response_dict["limit"] assert ["EO", "Earth Observation"] == response_dict["q"] assert "id='item_id' AND collection='collection_id'" == response_dict["filter"] diff --git a/stac_fastapi/types/stac_fastapi/types/core.py b/stac_fastapi/types/stac_fastapi/types/core.py index cadc56500..d7178781c 100644 --- a/stac_fastapi/types/stac_fastapi/types/core.py +++ b/stac_fastapi/types/stac_fastapi/types/core.py @@ -19,7 +19,6 @@ from stac_fastapi.types.conformance import BASE_CONFORMANCE_CLASSES 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 __all__ = [ @@ -497,7 +496,7 @@ def get_search( ids: Optional[List[str]] = None, bbox: Optional[BBox] = None, intersects: Optional[Geometry] = None, - datetime: Optional[DateTimeType] = None, + datetime: Optional[str] = None, limit: Optional[int] = 10, **kwargs, ) -> stac.ItemCollection: @@ -555,7 +554,7 @@ def item_collection( self, collection_id: str, bbox: Optional[BBox] = None, - datetime: Optional[DateTimeType] = None, + datetime: Optional[str] = None, limit: int = 10, token: str = None, **kwargs, @@ -733,7 +732,7 @@ async def get_search( ids: Optional[List[str]] = None, bbox: Optional[BBox] = None, intersects: Optional[Geometry] = None, - datetime: Optional[DateTimeType] = None, + datetime: Optional[str] = None, limit: Optional[int] = 10, **kwargs, ) -> stac.ItemCollection: @@ -791,7 +790,7 @@ async def item_collection( self, collection_id: str, bbox: Optional[BBox] = None, - datetime: Optional[DateTimeType] = None, + datetime: Optional[str] = None, limit: int = 10, token: str = None, **kwargs, diff --git a/stac_fastapi/types/stac_fastapi/types/search.py b/stac_fastapi/types/stac_fastapi/types/search.py index cfa8baf9b..19f05caca 100644 --- a/stac_fastapi/types/stac_fastapi/types/search.py +++ b/stac_fastapi/types/stac_fastapi/types/search.py @@ -1,7 +1,7 @@ """stac_fastapi.types.search module. """ - +from datetime import datetime as dt from typing import Dict, List, Optional, Union import attr @@ -83,22 +83,9 @@ def _bbox_converter( return str2bbox(val) -def _datetime_converter( - val: Annotated[ - Optional[str], - Query( - description="""Only return items that have a temporal property that intersects this value.\n -Either a date-time or an interval, open or closed. Date and time expressions adhere to RFC 3339. Open intervals are expressed using double-dots.""", # noqa: E501 - openapi_examples={ - "datetime": {"value": "2018-02-12T23:20:50Z"}, - "closed-interval": {"value": "2018-02-12T00:00:00Z/2018-03-18T12:31:12Z"}, - "open-interval-from": {"value": "2018-02-12T00:00:00Z/.."}, - "open-interval-to": {"value": "../2018-03-18T12:31:12Z"}, - }, - ), - ] = None, -): - return str_to_interval(val) +def _validate_datetime(instance, attribute, value): + """Validate Datetime.""" + _ = str_to_interval(value) # Be careful: https://github.com/samuelcolvin/pydantic/issues/1423#issuecomment-642797287 @@ -106,6 +93,21 @@ def _datetime_converter( Limit = Annotated[PositiveInt, AfterValidator(crop)] +DateTimeQueryType = Annotated[ + Optional[str], + Query( + description="""Only return items that have a temporal property that intersects this value.\n +Either a date-time or an interval, open or closed. Date and time expressions adhere to RFC 3339. Open intervals are expressed using double-dots.""", # noqa: E501 + openapi_examples={ + "datetime": {"value": "2018-02-12T23:20:50Z"}, + "closed-interval": {"value": "2018-02-12T00:00:00Z/2018-03-18T12:31:12Z"}, + "open-interval-from": {"value": "2018-02-12T00:00:00Z/.."}, + "open-interval-to": {"value": "../2018-03-18T12:31:12Z"}, + }, + ), +] + + @attr.s class APIRequest: """Generic API Request base class.""" @@ -117,7 +119,36 @@ def kwargs(self) -> Dict: @attr.s -class BaseSearchGetRequest(APIRequest): +class DatetimeMixin: + """Datetime Mixin.""" + + datetime: DateTimeQueryType = attr.ib(default=None, validator=_validate_datetime) + + def parse_datetime(self) -> Optional[DateTimeType]: + """Return Datetime objects.""" + return str_to_interval(self.datetime) + + @property + def start_date(self) -> Optional[dt]: + """Start Date.""" + parsed = self.parse_datetime() + if parsed is None: + return None + + return parsed[0] if isinstance(parsed, tuple) else parsed + + @property + def end_date(self) -> Optional[dt]: + """End Date.""" + parsed = self.parse_datetime() + if parsed is None: + return None + + return parsed[1] if isinstance(parsed, tuple) else None + + +@attr.s +class BaseSearchGetRequest(APIRequest, DatetimeMixin): """Base arguments for GET Request.""" collections: Optional[List[str]] = attr.ib( @@ -170,9 +201,7 @@ class BaseSearchGetRequest(APIRequest): }, ), ] = attr.ib(default=None) - datetime: Optional[DateTimeType] = attr.ib( - default=None, converter=_datetime_converter - ) + datetime: DateTimeQueryType = attr.ib(default=None, validator=_validate_datetime) limit: Annotated[ Optional[Limit], Query(