Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.

### Fixed

- Ensure datetime filter uses nanosecond precision (6 digits) instead of millisecond (3 digits) and truncation of after 3 miliseconds, and enforce 1970-2262 date boundaries to prevent Elasticsearch/OpenSearch resolution errors. [#529](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/pull/529)

### Removed

### Updated
Expand Down
30 changes: 24 additions & 6 deletions stac_fastapi/core/stac_fastapi/core/datetime_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,27 +15,45 @@ def format_datetime_range(date_str: str) -> str:
Returns:
str: A string formatted as 'YYYY-MM-DDTHH:MM:SSZ/YYYY-MM-DDTHH:MM:SSZ', with '..' used if any element is None.
"""
MIN_DATE_NANOS = datetime(1970, 1, 1, tzinfo=timezone.utc)
MAX_DATE_NANOS = datetime(2262, 4, 11, 23, 47, 16, 854775, tzinfo=timezone.utc)

def normalize(dt):
"""Normalize datetime string and preserve millisecond precision."""
dt = dt.strip()
if not dt or dt == "..":
return ".."
dt_obj = rfc3339_str_to_datetime(dt)
dt_utc = dt_obj.astimezone(timezone.utc)
return dt_utc.isoformat(timespec="milliseconds").replace("+00:00", "Z")
dt_utc = rfc3339_str_to_datetime(dt).astimezone(timezone.utc)
if dt_utc < MIN_DATE_NANOS:
dt_utc = MIN_DATE_NANOS
if dt_utc > MAX_DATE_NANOS:
dt_utc = MAX_DATE_NANOS
return dt_utc.isoformat(timespec="auto").replace("+00:00", "Z")

if not isinstance(date_str, str):
return "../.."
return f"{MIN_DATE_NANOS.isoformat(timespec='auto').replace('+00:00','Z')}/{MAX_DATE_NANOS.isoformat(timespec='auto').replace('+00:00','Z')}"

if "/" not in date_str:
return f"{normalize(date_str)}/{normalize(date_str)}"

try:
start, end = date_str.split("/", 1)
except Exception:
return "../.."
return f"{normalize(start)}/{normalize(end)}"
return f"{MIN_DATE_NANOS.isoformat(timespec='auto').replace('+00:00','Z')}/{MAX_DATE_NANOS.isoformat(timespec='auto').replace('+00:00','Z')}"

normalized_start = normalize(start)
normalized_end = normalize(end)

if normalized_start == "..":
normalized_start = MIN_DATE_NANOS.isoformat(timespec="auto").replace(
"+00:00", "Z"
)
if normalized_end == "..":
normalized_end = MAX_DATE_NANOS.isoformat(timespec="auto").replace(
"+00:00", "Z"
)

return f"{normalized_start}/{normalized_end}"


# Borrowed from pystac - https://github.com/stac-utils/pystac/blob/f5e4cf4a29b62e9ef675d4a4dac7977b09f53c8f/pystac/utils.py#L370-L394
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import re
from datetime import date
from datetime import datetime as datetime_type
from datetime import timezone
from typing import Dict, Optional, Union

from stac_fastapi.types.rfc3339 import DateTimeType
Expand Down Expand Up @@ -37,6 +38,8 @@ def return_date(
always containing 'gte' and 'lte' keys.
"""
result: Dict[str, Optional[str]] = {"gte": None, "lte": None}
MIN_DATE_NANOS = datetime_type(1970, 1, 1, tzinfo=timezone.utc)
MAX_DATE_NANOS = datetime_type(2262, 4, 11, 23, 47, 16, 854775, tzinfo=timezone.utc)

if interval is None:
return result
Expand All @@ -45,28 +48,55 @@ def return_date(
if "/" in interval:
parts = interval.split("/")
result["gte"] = (
parts[0] if parts[0] != ".." else datetime_type.min.isoformat() + "Z"
parts[0] if parts[0] != ".." else MIN_DATE_NANOS.isoformat() + "Z"
)
result["lte"] = (
parts[1]
if len(parts) > 1 and parts[1] != ".."
else datetime_type.max.isoformat() + "Z"
else MAX_DATE_NANOS.isoformat() + "Z"
)
else:
converted_time = interval if interval != ".." else None
result["gte"] = result["lte"] = converted_time
return result

if isinstance(interval, datetime_type):
datetime_iso = interval.isoformat()
dt_utc = (
interval.astimezone(timezone.utc)
if interval.tzinfo
else interval.replace(tzinfo=timezone.utc)
)
if dt_utc < MIN_DATE_NANOS:
dt_utc = MIN_DATE_NANOS
elif dt_utc > MAX_DATE_NANOS:
dt_utc = MAX_DATE_NANOS
datetime_iso = dt_utc.isoformat()
result["gte"] = result["lte"] = datetime_iso
elif isinstance(interval, tuple):
start, end = interval
# Ensure datetimes are converted to UTC and formatted with 'Z'
if start:
result["gte"] = start.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
start_utc = (
start.astimezone(timezone.utc)
if start.tzinfo
else start.replace(tzinfo=timezone.utc)
)
if start_utc < MIN_DATE_NANOS:
start_utc = MIN_DATE_NANOS
elif start_utc > MAX_DATE_NANOS:
start_utc = MAX_DATE_NANOS
result["gte"] = start_utc.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
if end:
result["lte"] = end.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
end_utc = (
end.astimezone(timezone.utc)
if end.tzinfo
else end.replace(tzinfo=timezone.utc)
)
if end_utc < MIN_DATE_NANOS:
end_utc = MIN_DATE_NANOS
elif end_utc > MAX_DATE_NANOS:
end_utc = MAX_DATE_NANOS
result["lte"] = end_utc.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"

return result

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ class Geometry(Protocol): # noqa
"type": "object",
"properties": {
# Common https://github.com/radiantearth/stac-spec/blob/master/item-spec/common-metadata.md
"datetime": {"type": "date"},
"datetime": {"type": "date_nanos"},
"start_datetime": {"type": "date"},
"end_datetime": {"type": "date"},
"created": {"type": "date"},
Expand Down
8 changes: 4 additions & 4 deletions stac_fastapi/tests/api/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -608,10 +608,10 @@ async def test_datetime_bad_interval(app_client, txn_client, ctx):
await create_item(txn_client, third_item)

dt_formats = [
"1920-02-04T12:30:22+00:00/1920-02-06T12:30:22+00:00",
"1920-02-04T12:30:22.00Z/1920-02-06T12:30:22.00Z",
"1920-02-04T12:30:22Z/1920-02-06T12:30:22Z",
"1920-02-04T12:30:22.00+00:00/1920-02-06T12:30:22.00+00:00",
"1970-02-04T12:30:22+00:00/1970-02-06T12:30:22+00:00",
"1970-02-04T12:30:22.00Z/1970-02-06T12:30:22.00Z",
"1970-02-04T12:30:22Z/1970-02-06T12:30:22Z",
"1970-02-04T12:30:22.00+00:00/1970-02-06T12:30:22.00+00:00",
]

for dt in dt_formats:
Expand Down