diff --git a/api/app/v1/endpoints/read/query_parameters.py b/api/app/v1/endpoints/read/query_parameters.py index 09c59d5..86e4c72 100644 --- a/api/app/v1/endpoints/read/query_parameters.py +++ b/api/app/v1/endpoints/read/query_parameters.py @@ -12,8 +12,60 @@ # See the License for the specific language governing permissions and # limitations under the License. +from datetime import datetime, timezone + from app import VERSIONING -from fastapi import Depends, Query +from dateutil.parser import isoparse +from fastapi import Depends, HTTPException, Query, status + + +def _validation_error(message): + return HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail={ + "code": 422, + "type": "error", + "message": message, + }, + ) + + +def _parse_iso_datetime(value, parameter_name): + try: + parsed = isoparse(value) + except (TypeError, ValueError) as exc: + raise _validation_error( + f"Invalid {parameter_name}: expected an ISO 8601 datetime" + ) from exc + + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=datetime.now().astimezone().tzinfo) + + return parsed.astimezone(timezone.utc) + + +def _validate_time_travel_params(as_of, from_to): + if as_of and from_to: + raise _validation_error("$as_of and $from_to cannot be used together") + + if as_of: + as_of_value = _parse_iso_datetime(as_of, "$as_of") + if as_of_value > datetime.now(timezone.utc): + raise _validation_error("$as_of value cannot be in the future") + + if from_to: + values = from_to.split("/", 1) + if len(values) != 2 or not values[0] or not values[1]: + raise _validation_error( + "Invalid $from_to: expected format is $from_to=/" + ) + + start = _parse_iso_datetime(values[0], "$from_to start") + end = _parse_iso_datetime(values[1], "$from_to end") + if start > end: + raise _validation_error( + "$from_to start cannot be greater than $from_to end" + ) class CommonQueryParams: @@ -73,6 +125,7 @@ def __init__( self.filter = filter self.as_of = as_of self.from_to = from_to + _validate_time_travel_params(as_of, from_to) def get_common_query_params( diff --git a/test/unit/test_query_parameters.py b/test/unit/test_query_parameters.py new file mode 100644 index 0000000..c95e907 --- /dev/null +++ b/test/unit/test_query_parameters.py @@ -0,0 +1,79 @@ +from datetime import datetime, timedelta, timezone + +import pytest +from fastapi import HTTPException + +from app.v1.endpoints.read.query_parameters import CommonQueryParams + + +def _future_datetime(): + return (datetime.now(timezone.utc) + timedelta(days=1)).isoformat().replace( + "+00:00", "Z" + ) + + +def test_common_query_params_accepts_valid_as_of(): + params = CommonQueryParams(as_of="2024-01-01T00:00:00Z") + + assert params.as_of == "2024-01-01T00:00:00Z" + + +def test_common_query_params_rejects_invalid_as_of(): + with pytest.raises(HTTPException) as exc_info: + CommonQueryParams(as_of="not-a-date") + + assert exc_info.value.status_code == 422 + assert "$as_of" in exc_info.value.detail["message"] + + +def test_common_query_params_rejects_future_as_of(): + with pytest.raises(HTTPException) as exc_info: + CommonQueryParams(as_of=_future_datetime()) + + assert exc_info.value.status_code == 422 + assert "future" in exc_info.value.detail["message"] + + +def test_common_query_params_accepts_valid_from_to(): + params = CommonQueryParams( + from_to="2024-01-01T00:00:00Z/2024-01-02T00:00:00Z" + ) + + assert params.from_to == "2024-01-01T00:00:00Z/2024-01-02T00:00:00Z" + + +@pytest.mark.parametrize( + "from_to", + [ + "2024-01-01T00:00:00Z", + "/2024-01-02T00:00:00Z", + "2024-01-01T00:00:00Z/", + "not-a-date/2024-01-02T00:00:00Z", + ], +) +def test_common_query_params_rejects_invalid_from_to(from_to): + with pytest.raises(HTTPException) as exc_info: + CommonQueryParams(from_to=from_to) + + assert exc_info.value.status_code == 422 + + +def test_common_query_params_rejects_reversed_from_to(): + with pytest.raises(HTTPException) as exc_info: + CommonQueryParams( + from_to="2024-01-02T00:00:00Z/2024-01-01T00:00:00Z" + ) + + assert exc_info.value.status_code == 422 + assert "greater" in exc_info.value.detail["message"] + + +def test_common_query_params_rejects_as_of_with_from_to(): + with pytest.raises(HTTPException) as exc_info: + CommonQueryParams( + as_of="2024-01-01T00:00:00Z", + from_to="2024-01-01T00:00:00Z/2024-01-02T00:00:00Z", + ) + + assert exc_info.value.status_code == 422 + assert "cannot be used together" in exc_info.value.detail["message"]