From 1255dfb627bbc10e73f0c086e93a9b43ce4e5bd4 Mon Sep 17 00:00:00 2001 From: vincentsarago Date: Thu, 8 Feb 2024 10:33:11 +0100 Subject: [PATCH 1/3] patch temporal cmr query to allow RFC3339 datetime --- tests/test_dependencies.py | 149 ++++++++++++++++++++++++++++++++++++ titiler/cmr/dependencies.py | 55 +++++++------ 2 files changed, 176 insertions(+), 28 deletions(-) create mode 100644 tests/test_dependencies.py diff --git a/tests/test_dependencies.py b/tests/test_dependencies.py new file mode 100644 index 0000000..8196600 --- /dev/null +++ b/tests/test_dependencies.py @@ -0,0 +1,149 @@ +"""test titiler-pgstac dependencies.""" + +import pytest +from starlette.requests import Request + +from titiler.cmr import dependencies +from titiler.cmr.enums import MediaType +from titiler.cmr.errors import InvalidDatetime + + +def test_media_type(): + """test accept_media_type dependency.""" + assert ( + dependencies.accept_media_type( + "application/json;q=0.9, text/html;q=1.0", + [MediaType.json, MediaType.html], + ) + == MediaType.html + ) + + assert ( + dependencies.accept_media_type( + "application/json;q=0.9, text/html;q=0.8", + [MediaType.json, MediaType.html], + ) + == MediaType.json + ) + + # if no quality then default to 1.0 + assert ( + dependencies.accept_media_type( + "application/json;q=0.9, text/html", + [MediaType.json, MediaType.html], + ) + == MediaType.html + ) + + # Invalid Quality + assert ( + dependencies.accept_media_type( + "application/json;q=w, , text/html;q=0.1", + [MediaType.json, MediaType.html], + ) + == MediaType.html + ) + + assert ( + dependencies.accept_media_type( + "*", + [MediaType.json, MediaType.html], + ) + == MediaType.json + ) + + +def test_output_type(): + """test OutputType dependency.""" + req = Request( + { + "type": "http", + "client": None, + "query_string": "", + "headers": ((b"accept", b"application/json"),), + }, + None, + ) + assert ( + dependencies.OutputType( + req, + ) + == MediaType.json + ) + + req = Request( + { + "type": "http", + "client": None, + "query_string": "", + "headers": ((b"accept", b"text/html"),), + }, + None, + ) + assert ( + dependencies.OutputType( + req, + ) + == MediaType.html + ) + + req = Request( + {"type": "http", "client": None, "query_string": "", "headers": ()}, None + ) + assert not dependencies.OutputType(req) + + # FastAPI will parse the request first and inject `f=json` in the dependency + req = Request( + { + "type": "http", + "client": None, + "query_string": "f=json", + "headers": ((b"accept", b"text/html"),), + }, + None, + ) + assert dependencies.OutputType(req, f="json") == MediaType.json + + +@pytest.mark.parametrize( + "temporal,res", + [ + ("2018-02-12T09:00:00Z", ("2018-02-12", "2018-02-12")), + ("2018-02-12T09:00:00Z/", ("2018-02-12", None)), + ("2018-02-12T09:00:00Z/..", ("2018-02-12", None)), + ("/2018-02-12T09:00:00Z", (None, "2018-02-12")), + ("../2018-02-12T09:00:00Z", (None, "2018-02-12")), + ("2018-02-12T09:00:00Z/2019-02-12T09:00:00Z", ("2018-02-12", "2019-02-12")), + ], +) +def test_cmr_query(temporal, res): + """test cmr query dependency.""" + assert ( + dependencies.cmr_query(concept_id="something", temporal=temporal)["temporal"] + == res + ) + + +def test_cmr_query_more(): + """test cmr query dependency.""" + assert dependencies.cmr_query( + concept_id="something", + ) == {"concept_id": "something"} + + with pytest.raises(InvalidDatetime): + dependencies.cmr_query( + concept_id="something", + temporal="yo/yo/yo", + ) + + with pytest.raises(InvalidDatetime): + dependencies.cmr_query( + concept_id="something", + temporal="2019-02-12", + ) + + with pytest.raises(InvalidDatetime): + dependencies.cmr_query( + concept_id="something", + temporal="2019-02-12T09:00:00Z/2019-02-12", + ) diff --git a/titiler/cmr/dependencies.py b/titiler/cmr/dependencies.py index 9024b18..4a28409 100644 --- a/titiler/cmr/dependencies.py +++ b/titiler/cmr/dependencies.py @@ -1,9 +1,9 @@ """titiler-cmr dependencies.""" -from datetime import datetime from typing import Any, Dict, List, Literal, Optional, get_args -from fastapi import HTTPException, Query +from ciso8601 import parse_rfc3339 +from fastapi import Query from starlette.requests import Request from typing_extensions import Annotated @@ -87,12 +87,14 @@ def cmr_query( temporal: Annotated[ Optional[str], Query( - description="Either a date-time or an interval. Date and time expressions adhere to 'YYYY-MM-DD' format. Intervals may be bounded or half-bounded (double-dots at start or end).", + description="Either a date-time or an interval. Date and time expressions adhere to rfc3339 ('2020-06-01T09:00:00Z') format. Intervals may be bounded or half-bounded (double-dots at start or end).", openapi_examples={ - "A date-time": {"value": "2018-02-12"}, - "A bounded interval": {"value": "2018-02-12/2018-03-18"}, - "Half-bounded intervals (start)": {"value": "2018-02-12/.."}, - "Half-bounded intervals (end)": {"value": "../2018-03-18"}, + "A date-time": {"value": "2018-02-12T09:00:00Z"}, + "A bounded interval": { + "value": "2018-02-12T09:00:00Z/2018-03-18T09:00:00Z" + }, + "Half-bounded intervals (start)": {"value": "2018-02-12T09:00:00Z/.."}, + "Half-bounded intervals (end)": {"value": "../2018-03-18T09:00:00Z"}, }, ), ] = None, @@ -103,36 +105,33 @@ def cmr_query( if temporal: dt = temporal.split("/") if len(dt) > 2: - raise HTTPException(status_code=422, detail="Invalid temporal: {temporal}") - - start: Optional[str] - end: Optional[str] + raise InvalidDatetime("Invalid temporal: {temporal}") + dates: List[Optional[str]] = [None, None] if len(dt) == 1: - start = end = dt[0] + dates = [dt[0], dt[0]] else: - start = dt[0] if dt[0] not in ["..", ""] else None - end = dt[1] if dt[1] not in ["..", ""] else None + dates[0] = dt[0] if dt[0] not in ["..", ""] else None + dates[1] = dt[1] if dt[1] not in ["..", ""] else None + + # TODO: once https://github.com/nsidc/earthaccess/pull/451 is publish + # we can move to Datetime object instead of String + start: Optional[str] = None + end: Optional[str] = None - if start: + if dates[0]: try: - datetime.strptime( - start, "%Y-%m-%d" - ), f"Start datetime {start} not in form of 'YYYY-MM-DD'" - except ValueError as e: - raise InvalidDatetime( - f"Start datetime {start} not in form of 'YYYY-MM-DD'" - ) from e + start = parse_rfc3339(dates[0]).strftime("%Y-%m-%d") + except Exception as e: + raise InvalidDatetime(f"Start datetime {dates[0]} not valid.") from e - if end: + if dates[1]: try: - datetime.strptime( - end, "%Y-%m-%d" - ), f"Start datetime {start} not in form of 'YYYY-MM-DD'" - except ValueError as e: + end = parse_rfc3339(dates[1]).strftime("%Y-%m-%d") + except Exception as e: raise InvalidDatetime( - f"End datetime {end} not in form of 'YYYY-MM-DD'" + f"End datetime {dates[1]} not in form of 'YYYY-MM-DD'" ) from e query["temporal"] = (start, end) From 31c0942f50c4e349c2584d3f32d989c83c5d5ffb Mon Sep 17 00:00:00 2001 From: vincentsarago Date: Thu, 8 Feb 2024 10:35:56 +0100 Subject: [PATCH 2/3] update docs --- docs/src/API.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/src/API.md b/docs/src/API.md index 9d38d10..2c8beb5 100644 --- a/docs/src/API.md +++ b/docs/src/API.md @@ -26,7 +26,7 @@ This endpoint provides tiled data for specific geographical locations and times. - **Query Parameters:** - `concept_id` (string): The [concept ID](https://cmr.earthdata.nasa.gov/search/site/docs/search/api.html#c-concept-id) of the collection. **REQUIRED** - - `temporal` (string, optional): Either a date-time or an interval. Date and time expressions adhere to 'YYYY-MM-DD' format. Intervals may be bounded or half-bounded (double-dots at start or end) **RECOMMENDED** + - `temporal` (string, optional): Either a date-time or an interval. Date and time expressions adhere to rfc3339 '2018-02-12T09:00:00Z' format. Intervals may be bounded or half-bounded (double-dots at start or end) **RECOMMENDED** - `backend` (*rasterio* or *xarray*, optional): Backend to use in order to read the CMR dataset. Defaults to `rasterio` - `variable`* (string, optional): The variable of interest. `required` when using `xarray` backend - `time_slice`* (string, optional): The time for which data is requested, in ISO 8601 format @@ -51,7 +51,7 @@ This endpoint provides tiled data for specific geographical locations and times. ## Request Example -GET /tiles/WebMercatorQuad/1/2/3?backend=xarray&variable=temperature×tamp=2024-01-16T00:00:00Z&colormap=viridis&rescale=0,100&temporal=2024-01-16/2024-01-16&concept_id=C0000000000-YOCLOUD +GET /tiles/WebMercatorQuad/1/2/3?backend=xarray&variable=temperature×tamp=2024-01-16T00:00:00Z&colormap=viridis&rescale=0,100&temporal=2024-01-16T09:00:00Z&concept_id=C0000000000-YOCLOUD ## Responses From b93f590be7cfa9a50cd28d629a0041bc550b23a0 Mon Sep 17 00:00:00 2001 From: vincentsarago Date: Thu, 8 Feb 2024 10:36:37 +0100 Subject: [PATCH 3/3] update requirements --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index fa43e84..724384d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,7 @@ dependencies = [ "titiler.core>=0.17.0,<0.18", "titiler.mosaic>=0.17.0,<0.18", "rio_tiler[s3]>=6.4.0,<7.0", + "ciso8601~=2.3", "xarray", "rioxarray", "cftime",