From 682d4298aac102dbe2ea12acdafe895774473d86 Mon Sep 17 00:00:00 2001 From: vincentsarago Date: Tue, 14 Jan 2025 18:06:35 +0100 Subject: [PATCH 1/5] keep datetime as string type in GET Request models --- CHANGES.md | 4 ++ stac_fastapi/api/stac_fastapi/api/models.py | 41 +++++++++++-- stac_fastapi/api/tests/test_app.py | 33 +++++++--- stac_fastapi/api/tests/test_models.py | 3 +- .../core/collection_search/request.py | 60 ++++++++++++++++--- stac_fastapi/types/stac_fastapi/types/core.py | 9 ++- .../types/stac_fastapi/types/search.py | 59 +++++++++++------- 7 files changed, 161 insertions(+), 48 deletions(-) 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..71dd7b7e7 100644 --- a/stac_fastapi/api/stac_fastapi/api/models.py +++ b/stac_fastapi/api/stac_fastapi/api/models.py @@ -1,5 +1,6 @@ """Api request/response models.""" +from datetime import datetime as dt from typing import List, Optional, Type, Union import attr @@ -9,14 +10,13 @@ from typing_extensions import Annotated from stac_fastapi.types.extension import ApiExtension -from stac_fastapi.types.rfc3339 import DateTimeType +from stac_fastapi.types.rfc3339 import str_to_interval from stac_fastapi.types.search import ( APIRequest, BaseSearchGetRequest, BaseSearchPostRequest, Limit, _bbox_converter, - _datetime_converter, ) try: @@ -121,9 +121,40 @@ 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: 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.ib(default=None) + + @datetime.validator + def validate_datetime(self, attribute, value): + """Validate Datetime.""" + _ = str_to_interval(value) + + @property + def start_date(self) -> Optional[dt]: + """Start Date.""" + if self.datetime is None: + return self.datetime + interval = str_to_interval(self.datetime) + return interval if isinstance(interval, dt) else interval[0] + + @property + def end_date(self) -> Optional[dt]: + """End Date.""" + if self.datetime is None: + return self.datetime + interval = str_to_interval(self.datetime) + return interval[1] if isinstance(interval, tuple) else None 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..c411562e6 100644 --- a/stac_fastapi/api/tests/test_models.py +++ b/stac_fastapi/api/tests/test_models.py @@ -35,8 +35,9 @@ 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 with pytest.raises(HTTPException): request_model(datetime="yo") 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..75bd8a7e7 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,12 +10,11 @@ from stac_pydantic.shared import BBox from typing_extensions import Annotated -from stac_fastapi.types.rfc3339 import DateTimeType +from stac_fastapi.types.rfc3339 import str_to_interval from stac_fastapi.types.search import ( APIRequest, Limit, _bbox_converter, - _datetime_converter, ) @@ -24,9 +23,19 @@ 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 - ) + datetime: 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.ib(default=None) limit: Annotated[ Optional[Limit], Query( @@ -34,12 +43,49 @@ class BaseCollectionSearchGetRequest(APIRequest): ), ] = attr.ib(default=10) + @datetime.validator + def validate_datetime(self, attribute, value): + """Validate Datetime.""" + _ = str_to_interval(value) + + @property + def start_date(self) -> Optional[dt]: + """Start Date.""" + if self.datetime is None: + return self.datetime + interval = str_to_interval(self.datetime) + return interval if isinstance(interval, dt) else interval[0] + + @property + def end_date(self) -> Optional[dt]: + """End Date.""" + if self.datetime is None: + return self.datetime + interval = str_to_interval(self.datetime) + return interval[1] if isinstance(interval, tuple) else None + class BaseCollectionSearchPostRequest(BaseModel): """Collection-Search POST model.""" - bbox: Optional[BBox] = None - datetime: Optional[str] = None + bbox: Optional[BBox] = Field( + 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( + 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/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..977ede8a1 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 @@ -12,7 +12,7 @@ from stac_pydantic.shared import BBox from typing_extensions import Annotated -from stac_fastapi.types.rfc3339 import DateTimeType, str_to_interval +from stac_fastapi.types.rfc3339 import str_to_interval def crop(v: PositiveInt) -> PositiveInt: @@ -83,24 +83,6 @@ 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) - - # Be careful: https://github.com/samuelcolvin/pydantic/issues/1423#issuecomment-642797287 NumType = Union[float, int] Limit = Annotated[PositiveInt, AfterValidator(crop)] @@ -170,9 +152,19 @@ class BaseSearchGetRequest(APIRequest): }, ), ] = attr.ib(default=None) - datetime: Optional[DateTimeType] = attr.ib( - default=None, converter=_datetime_converter - ) + datetime: 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.ib(default=None) limit: Annotated[ Optional[Limit], Query( @@ -180,6 +172,27 @@ class BaseSearchGetRequest(APIRequest): ), ] = attr.ib(default=10) + @datetime.validator + def validate_datetime(self, attribute, value): + """Validate Datetime.""" + _ = str_to_interval(value) + + @property + def start_date(self) -> Optional[dt]: + """Start Date.""" + if self.datetime is None: + return self.datetime + interval = str_to_interval(self.datetime) + return interval if isinstance(interval, dt) else interval[0] + + @property + def end_date(self) -> Optional[dt]: + """End Date.""" + if self.datetime is None: + return self.datetime + interval = str_to_interval(self.datetime) + return interval[1] if isinstance(interval, tuple) else None + class BaseSearchPostRequest(Search): """Base arguments for POST Request.""" From 61ecf8534730f1b285fe642023a5c0185d184f7d Mon Sep 17 00:00:00 2001 From: vincentsarago Date: Tue, 14 Jan 2025 23:14:46 +0100 Subject: [PATCH 2/5] fix --- .../extensions/core/collection_search/request.py | 2 ++ .../extensions/tests/test_collection_search.py | 10 ++-------- 2 files changed, 4 insertions(+), 8 deletions(-) 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 75bd8a7e7..c71458057 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 @@ -69,12 +69,14 @@ class BaseCollectionSearchPostRequest(BaseModel): """Collection-Search POST model.""" 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={ 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"] From ea5457381a112e31fab87f216b58f3633f68e76d Mon Sep 17 00:00:00 2001 From: vincentsarago Date: Wed, 15 Jan 2025 11:33:44 +0100 Subject: [PATCH 3/5] use mixin --- stac_fastapi/api/stac_fastapi/api/models.py | 42 ++-------- .../core/collection_search/request.py | 41 ++-------- .../types/stac_fastapi/types/search.py | 80 +++++++++++-------- 3 files changed, 55 insertions(+), 108 deletions(-) diff --git a/stac_fastapi/api/stac_fastapi/api/models.py b/stac_fastapi/api/stac_fastapi/api/models.py index 71dd7b7e7..581ec6201 100644 --- a/stac_fastapi/api/stac_fastapi/api/models.py +++ b/stac_fastapi/api/stac_fastapi/api/models.py @@ -1,6 +1,5 @@ """Api request/response models.""" -from datetime import datetime as dt from typing import List, Optional, Type, Union import attr @@ -10,13 +9,15 @@ from typing_extensions import Annotated from stac_fastapi.types.extension import ApiExtension -from stac_fastapi.types.rfc3339 import str_to_interval from stac_fastapi.types.search import ( APIRequest, BaseSearchGetRequest, BaseSearchPostRequest, + DatetimeMixin, + DateTimeQueryType, Limit, _bbox_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,40 +122,7 @@ class ItemCollectionUri(APIRequest): ), ] = attr.ib(default=10) bbox: Optional[BBox] = attr.ib(default=None, converter=_bbox_converter) - datetime: 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.ib(default=None) - - @datetime.validator - def validate_datetime(self, attribute, value): - """Validate Datetime.""" - _ = str_to_interval(value) - - @property - def start_date(self) -> Optional[dt]: - """Start Date.""" - if self.datetime is None: - return self.datetime - interval = str_to_interval(self.datetime) - return interval if isinstance(interval, dt) else interval[0] - - @property - def end_date(self) -> Optional[dt]: - """End Date.""" - if self.datetime is None: - return self.datetime - interval = str_to_interval(self.datetime) - return interval[1] if isinstance(interval, tuple) else None + datetime: DateTimeQueryType = attr.ib(default=None, validator=_validate_datetime) class GeoJSONResponse(JSONResponse): 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 c71458057..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,32 +10,22 @@ from stac_pydantic.shared import BBox from typing_extensions import Annotated -from stac_fastapi.types.rfc3339 import str_to_interval from stac_fastapi.types.search import ( APIRequest, + DatetimeMixin, + DateTimeQueryType, Limit, _bbox_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: 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.ib(default=None) + datetime: DateTimeQueryType = attr.ib(default=None, validator=_validate_datetime) limit: Annotated[ Optional[Limit], Query( @@ -43,27 +33,6 @@ class BaseCollectionSearchGetRequest(APIRequest): ), ] = attr.ib(default=10) - @datetime.validator - def validate_datetime(self, attribute, value): - """Validate Datetime.""" - _ = str_to_interval(value) - - @property - def start_date(self) -> Optional[dt]: - """Start Date.""" - if self.datetime is None: - return self.datetime - interval = str_to_interval(self.datetime) - return interval if isinstance(interval, dt) else interval[0] - - @property - def end_date(self) -> Optional[dt]: - """End Date.""" - if self.datetime is None: - return self.datetime - interval = str_to_interval(self.datetime) - return interval[1] if isinstance(interval, tuple) else None - class BaseCollectionSearchPostRequest(BaseModel): """Collection-Search POST model.""" diff --git a/stac_fastapi/types/stac_fastapi/types/search.py b/stac_fastapi/types/stac_fastapi/types/search.py index 977ede8a1..b998b2fd1 100644 --- a/stac_fastapi/types/stac_fastapi/types/search.py +++ b/stac_fastapi/types/stac_fastapi/types/search.py @@ -83,6 +83,11 @@ def _bbox_converter( return str2bbox(val) +def _validate_datetime(instance, attribute, value): + """Validate Datetime.""" + _ = str_to_interval(value) + + # Be careful: https://github.com/samuelcolvin/pydantic/issues/1423#issuecomment-642797287 NumType = Union[float, int] Limit = Annotated[PositiveInt, AfterValidator(crop)] @@ -98,8 +103,46 @@ def kwargs(self) -> Dict: return self.__dict__ +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 DatetimeMixin: + """Datetime Mixin.""" + + datetime: DateTimeQueryType = attr.ib(default=None, validator=_validate_datetime) + + @property + def start_date(self) -> Optional[dt]: + """Start Date.""" + if self.datetime is None: + return self.datetime + interval = str_to_interval(self.datetime) + return interval if isinstance(interval, dt) else interval[0] + + @property + def end_date(self) -> Optional[dt]: + """End Date.""" + if self.datetime is None: + return self.datetime + interval = str_to_interval(self.datetime) + return interval[1] if isinstance(interval, tuple) else None + + @attr.s -class BaseSearchGetRequest(APIRequest): +class BaseSearchGetRequest(APIRequest, DatetimeMixin): """Base arguments for GET Request.""" collections: Optional[List[str]] = attr.ib( @@ -152,19 +195,7 @@ class BaseSearchGetRequest(APIRequest): }, ), ] = attr.ib(default=None) - datetime: 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.ib(default=None) + datetime: DateTimeQueryType = attr.ib(default=None, validator=_validate_datetime) limit: Annotated[ Optional[Limit], Query( @@ -172,27 +203,6 @@ class BaseSearchGetRequest(APIRequest): ), ] = attr.ib(default=10) - @datetime.validator - def validate_datetime(self, attribute, value): - """Validate Datetime.""" - _ = str_to_interval(value) - - @property - def start_date(self) -> Optional[dt]: - """Start Date.""" - if self.datetime is None: - return self.datetime - interval = str_to_interval(self.datetime) - return interval if isinstance(interval, dt) else interval[0] - - @property - def end_date(self) -> Optional[dt]: - """End Date.""" - if self.datetime is None: - return self.datetime - interval = str_to_interval(self.datetime) - return interval[1] if isinstance(interval, tuple) else None - class BaseSearchPostRequest(Search): """Base arguments for POST Request.""" From 6e27662c30c00842bb028f3dcab8c561c515e331 Mon Sep 17 00:00:00 2001 From: vincentsarago Date: Wed, 15 Jan 2025 11:34:51 +0100 Subject: [PATCH 4/5] reorder --- .../types/stac_fastapi/types/search.py | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/stac_fastapi/types/stac_fastapi/types/search.py b/stac_fastapi/types/stac_fastapi/types/search.py index b998b2fd1..21d1fe307 100644 --- a/stac_fastapi/types/stac_fastapi/types/search.py +++ b/stac_fastapi/types/stac_fastapi/types/search.py @@ -93,16 +93,6 @@ def _validate_datetime(instance, attribute, value): Limit = Annotated[PositiveInt, AfterValidator(crop)] -@attr.s -class APIRequest: - """Generic API Request base class.""" - - def kwargs(self) -> Dict: - """Transform api request params into format which matches the signature of the - endpoint.""" - return self.__dict__ - - DateTimeQueryType = Annotated[ Optional[str], Query( @@ -118,6 +108,16 @@ def kwargs(self) -> Dict: ] +@attr.s +class APIRequest: + """Generic API Request base class.""" + + def kwargs(self) -> Dict: + """Transform api request params into format which matches the signature of the + endpoint.""" + return self.__dict__ + + @attr.s class DatetimeMixin: """Datetime Mixin.""" From 5878dddf59343e7e701a488d3dde29b7d0aac95e Mon Sep 17 00:00:00 2001 From: vincentsarago Date: Wed, 15 Jan 2025 12:47:15 +0100 Subject: [PATCH 5/5] add parse_datetime --- stac_fastapi/api/tests/test_models.py | 14 +++++++++++ .../types/stac_fastapi/types/search.py | 24 ++++++++++++------- 2 files changed, 29 insertions(+), 9 deletions(-) diff --git a/stac_fastapi/api/tests/test_models.py b/stac_fastapi/api/tests/test_models.py index c411562e6..0c25e7f3d 100644 --- a/stac_fastapi/api/tests/test_models.py +++ b/stac_fastapi/api/tests/test_models.py @@ -39,9 +39,20 @@ def test_create_get_request_model(): 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") @@ -93,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/types/stac_fastapi/types/search.py b/stac_fastapi/types/stac_fastapi/types/search.py index 21d1fe307..19f05caca 100644 --- a/stac_fastapi/types/stac_fastapi/types/search.py +++ b/stac_fastapi/types/stac_fastapi/types/search.py @@ -12,7 +12,7 @@ from stac_pydantic.shared import BBox from typing_extensions import Annotated -from stac_fastapi.types.rfc3339 import str_to_interval +from stac_fastapi.types.rfc3339 import DateTimeType, str_to_interval def crop(v: PositiveInt) -> PositiveInt: @@ -124,21 +124,27 @@ class DatetimeMixin: 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.""" - if self.datetime is None: - return self.datetime - interval = str_to_interval(self.datetime) - return interval if isinstance(interval, dt) else interval[0] + 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.""" - if self.datetime is None: - return self.datetime - interval = str_to_interval(self.datetime) - return interval[1] if isinstance(interval, tuple) else None + parsed = self.parse_datetime() + if parsed is None: + return None + + return parsed[1] if isinstance(parsed, tuple) else None @attr.s