Repository: stac-utils/stac-fastapi
Pull request #780 (2025-01-14 22:08:49)

keep datetime as string type in GET Request models
**Related Issue(s):**

- https://github.com/stac-utils/stac-fastapi/issues/778

**Description:**

This PR change the `type` for the datetime parameter in GET request for `/search`, `/collection` and `/items` endpoints.

with this change, The GET and POST request models behave similarly by setting type for `string`. 


Patch:
diff --git a/CHANGES.md b/CHANGES.md
index 816e4a7..0d23d55 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 d2e06ab..581ec62 100644
--- a/stac_fastapi/api/stac_fastapi/api/models.py
+++ b/stac_fastapi/api/stac_fastapi/api/models.py
@@ -9,14 +9,15 @@ from stac_pydantic.shared import BBox
 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/extensions/stac_fastapi/extensions/core/collection_search/request.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/collection_search/request.py
index 0bc6d22..0e8eb88 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.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,
+    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/types/stac_fastapi/types/core.py b/stac_fastapi/types/stac_fastapi/types/core.py
index cadc565..d717878 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.config import ApiSettings
 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 @@ class BaseCoreClient(LandingPageMixin, abc.ABC):
         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 @@ class BaseCoreClient(LandingPageMixin, abc.ABC):
         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 @@ class AsyncBaseCoreClient(LandingPageMixin, abc.ABC):
         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 @@ class AsyncBaseCoreClient(LandingPageMixin, abc.ABC):
         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 cfa8baf..19f05ca 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 @@ NumType = Union[float, int]
 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 @@ class APIRequest:
 
 
 @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(


Test patch:
diff --git a/stac_fastapi/api/tests/test_app.py b/stac_fastapi/api/tests/test_app.py
index 3c214a9..106bde3 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 test_filter_extension(validate, TestCoreClient, item_dict):
             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 test_fields_extension(validate, TestCoreClient, item_dict):
             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 test_fields_extension(validate, TestCoreClient, item_dict):
             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 test_client_datetime_input_params():
             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 test_client_datetime_input_params():
             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 test_client_datetime_input_params():
                 "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 test_client_datetime_input_params():
                 "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 5c4fdc8..0c25e7f 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/tests/test_collection_search.py b/stac_fastapi/extensions/tests/test_collection_search.py
index 8f54f15..3b7bcfc 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"]