Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

patch temporal cmr query to allow RFC3339 datetime #16

Merged
merged 3 commits into from
Feb 8, 2024
Merged
Show file tree
Hide file tree
Changes from all 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
4 changes: 2 additions & 2 deletions docs/src/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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&timestamp=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&timestamp=2024-01-16T00:00:00Z&colormap=viridis&rescale=0,100&temporal=2024-01-16T09:00:00Z&concept_id=C0000000000-YOCLOUD


## Responses
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
149 changes: 149 additions & 0 deletions tests/test_dependencies.py
Original file line number Diff line number Diff line change
@@ -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",
)
55 changes: 27 additions & 28 deletions titiler/cmr/dependencies.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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,
Expand All @@ -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)
Expand Down
Loading