Repository: stac-utils/stac-fastapi
Pull request #670 (2024-04-23 18:11:54)

return 400 for datetime errors
**Related Issue(s):**

- #

**Description:**

**PR Checklist:**

- [x] `pre-commit` hooks pass locally
- [x] Tests pass (run `make test`)
- [x] Documentation has been updated to reflect changes, if applicable, and docs build successfully (run `make docs`)
- [x] Changes are added to the [CHANGELOG](https://github.com/stac-utils/stac-fastapi/blob/main/CHANGES.md).


vincentsarago: FYI: this might gets revisited after https://github.com/stac-utils/stac-pydantic/pull/135 

Patch:
diff --git a/CHANGES.md b/CHANGES.md
index 57498b2..0f057de 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -2,6 +2,10 @@
 
 ## [Unreleased]
 
+### Fixed
+
+* Return 400 for datetime errors ([#670](https://github.com/stac-utils/stac-fastapi/pull/670))
+
 ## [2.5.3] - 2024-04-23
 
 ### Fixed 
diff --git a/stac_fastapi/types/stac_fastapi/types/rfc3339.py b/stac_fastapi/types/stac_fastapi/types/rfc3339.py
index b1d4099..1277c99 100644
--- a/stac_fastapi/types/stac_fastapi/types/rfc3339.py
+++ b/stac_fastapi/types/stac_fastapi/types/rfc3339.py
@@ -4,6 +4,7 @@ from datetime import datetime, timezone
 from typing import Optional, Tuple, Union
 
 import iso8601
+from fastapi import HTTPException
 from pystac.utils import datetime_to_str
 
 RFC33339_PATTERN = (
@@ -45,53 +46,74 @@ def rfc3339_str_to_datetime(s: str) -> datetime:
     return iso8601.parse_date(s)
 
 
-def str_to_interval(interval: Optional[str]) -> Optional[DateTimeType]:
-    """Extract a tuple of datetimes from an interval string.
+def parse_single_date(date_str: str) -> datetime:
+    """
+    Parse a single RFC3339 date string into a datetime object.
+
+    Args:
+        date_str (str): A string representing the date in RFC3339 format.
+
+    Returns:
+        datetime: A datetime object parsed from the date_str.
+
+    Raises:
+        ValueError: If the date_str is empty or contains the placeholder '..'.
+    """
+    if ".." in date_str or not date_str:
+        raise ValueError("Invalid date format.")
+    return rfc3339_str_to_datetime(date_str)
+
 
-    Interval strings are defined by
-    OGC API - Features Part 1 for the datetime query parameter value. These follow the
-    form '1985-04-12T23:20:50.52Z/1986-04-12T23:20:50.52Z', and allow either the start
-    or end (but not both) to be open-ended with '..' or ''.
+def str_to_interval(interval: Optional[str]) -> Optional[DateTimeType]:
+    """
+    Extract a tuple of datetime objects from an interval string defined by the OGC API.
+    The interval can either be a single datetime or a range with start and end datetime.
 
     Args:
-        interval (str or None): The interval string to convert to a tuple of
-        datetime.datetime objects, or None if no datetime is specified.
+        interval (Optional[str]): The interval string to convert to datetime objects,
+        or None if no datetime is specified.
 
     Returns:
-        Optional[DateTimeType]: A tuple of datetime.datetime objects or None if
-        input is None.
+        Optional[DateTimeType]: A tuple of datetime.datetime objects or
+        None if input is None.
 
     Raises:
-        ValueError: If the string is not a valid interval string and not None.
+        HTTPException: If the string is not valid for various reasons such as being empty,
+        having more than one slash, or if date formats are invalid.
     """
     if interval is None:
         return None
 
     if not interval:
-        raise ValueError("Empty interval string is invalid.")
+        raise HTTPException(status_code=400, detail="Empty interval string is invalid.")
 
     values = interval.split("/")
-    if len(values) == 1:
-        # Single date for == date case
-        return rfc3339_str_to_datetime(values[0])
-    elif len(values) > 2:
-        raise ValueError(
-            f"Interval string '{interval}' contains more than one forward slash."
+    if len(values) > 2:
+        raise HTTPException(
+            status_code=400,
+            detail="Interval string contains more than one forward slash.",
         )
 
-    start = None
-    end = None
-    if values[0] not in ["..", ""]:
-        start = rfc3339_str_to_datetime(values[0])
-    if values[1] not in ["..", ""]:
-        end = rfc3339_str_to_datetime(values[1])
+    try:
+        start = parse_single_date(values[0]) if values[0] not in ["..", ""] else None
+        end = (
+            parse_single_date(values[1])
+            if len(values) > 1 and values[1] not in ["..", ""]
+            else None
+        )
+    except (ValueError, iso8601.ParseError) as e:
+        raise HTTPException(status_code=400, detail=str(e))
 
     if start is None and end is None:
-        raise ValueError("Double open-ended intervals are not allowed.")
+        raise HTTPException(
+            status_code=400, detail="Double open-ended intervals are not allowed."
+        )
     if start is not None and end is not None and start > end:
-        raise ValueError("Start datetime cannot be before end datetime.")
-    else:
-        return start, end
+        raise HTTPException(
+            status_code=400, detail="Start datetime cannot be before end datetime."
+        )
+
+    return start, end
 
 
 def now_in_utc() -> datetime:


Test patch:
diff --git a/stac_fastapi/types/tests/test_rfc3339.py b/stac_fastapi/types/tests/test_rfc3339.py
index 23f6242..8d83dbb 100644
--- a/stac_fastapi/types/tests/test_rfc3339.py
+++ b/stac_fastapi/types/tests/test_rfc3339.py
@@ -1,6 +1,7 @@
 from datetime import timezone
 
 import pytest
+from fastapi import HTTPException
 
 from stac_fastapi.types.rfc3339 import (
     now_in_utc,
@@ -86,8 +87,11 @@ def test_parse_valid_str_to_datetime(test_input):
 
 @pytest.mark.parametrize("test_input", invalid_intervals)
 def test_parse_invalid_interval_to_datetime(test_input):
-    with pytest.raises(ValueError):
+    with pytest.raises(HTTPException) as exc_info:
         str_to_interval(test_input)
+    assert (
+        exc_info.value.status_code == 400
+    ), "Should return a 400 status code for invalid intervals"
 
 
 @pytest.mark.parametrize("test_input", valid_intervals)