Skip to content

Commit 0764eb2

Browse files
fix(time-travel): validate and at API boundary with tests
1 parent 89bf146 commit 0764eb2

2 files changed

Lines changed: 133 additions & 1 deletion

File tree

api/app/v1/endpoints/read/query_parameters.py

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,60 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15+
from datetime import datetime, timezone
16+
1517
from app import VERSIONING
16-
from fastapi import Depends, Query
18+
from dateutil.parser import isoparse
19+
from fastapi import Depends, HTTPException, Query, status
20+
21+
22+
def _validation_error(message):
23+
return HTTPException(
24+
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
25+
detail={
26+
"code": 422,
27+
"type": "error",
28+
"message": message,
29+
},
30+
)
31+
32+
33+
def _parse_iso_datetime(value, parameter_name):
34+
try:
35+
parsed = isoparse(value)
36+
except (TypeError, ValueError) as exc:
37+
raise _validation_error(
38+
f"Invalid {parameter_name}: expected an ISO 8601 datetime"
39+
) from exc
40+
41+
if parsed.tzinfo is None:
42+
parsed = parsed.replace(tzinfo=datetime.now().astimezone().tzinfo)
43+
44+
return parsed.astimezone(timezone.utc)
45+
46+
47+
def _validate_time_travel_params(as_of, from_to):
48+
if as_of and from_to:
49+
raise _validation_error("$as_of and $from_to cannot be used together")
50+
51+
if as_of:
52+
as_of_value = _parse_iso_datetime(as_of, "$as_of")
53+
if as_of_value > datetime.now(timezone.utc):
54+
raise _validation_error("$as_of value cannot be in the future")
55+
56+
if from_to:
57+
values = from_to.split("/", 1)
58+
if len(values) != 2 or not values[0] or not values[1]:
59+
raise _validation_error(
60+
"Invalid $from_to: expected format is $from_to=<start>/<end>"
61+
)
62+
63+
start = _parse_iso_datetime(values[0], "$from_to start")
64+
end = _parse_iso_datetime(values[1], "$from_to end")
65+
if start > end:
66+
raise _validation_error(
67+
"$from_to start cannot be greater than $from_to end"
68+
)
1769

1870

1971
class CommonQueryParams:
@@ -73,6 +125,7 @@ def __init__(
73125
self.filter = filter
74126
self.as_of = as_of
75127
self.from_to = from_to
128+
_validate_time_travel_params(as_of, from_to)
76129

77130

78131
def get_common_query_params(

test/unit/test_query_parameters.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
from datetime import datetime, timedelta, timezone
2+
3+
import pytest
4+
from fastapi import HTTPException
5+
6+
from app.v1.endpoints.read.query_parameters import CommonQueryParams
7+
8+
9+
def _future_datetime():
10+
return (datetime.now(timezone.utc) + timedelta(days=1)).isoformat().replace(
11+
"+00:00", "Z"
12+
)
13+
14+
15+
def test_common_query_params_accepts_valid_as_of():
16+
params = CommonQueryParams(as_of="2024-01-01T00:00:00Z")
17+
18+
assert params.as_of == "2024-01-01T00:00:00Z"
19+
20+
21+
def test_common_query_params_rejects_invalid_as_of():
22+
with pytest.raises(HTTPException) as exc_info:
23+
CommonQueryParams(as_of="not-a-date")
24+
25+
assert exc_info.value.status_code == 422
26+
assert "$as_of" in exc_info.value.detail["message"]
27+
28+
29+
def test_common_query_params_rejects_future_as_of():
30+
with pytest.raises(HTTPException) as exc_info:
31+
CommonQueryParams(as_of=_future_datetime())
32+
33+
assert exc_info.value.status_code == 422
34+
assert "future" in exc_info.value.detail["message"]
35+
36+
37+
def test_common_query_params_accepts_valid_from_to():
38+
params = CommonQueryParams(
39+
from_to="2024-01-01T00:00:00Z/2024-01-02T00:00:00Z"
40+
)
41+
42+
assert params.from_to == "2024-01-01T00:00:00Z/2024-01-02T00:00:00Z"
43+
44+
45+
@pytest.mark.parametrize(
46+
"from_to",
47+
[
48+
"2024-01-01T00:00:00Z",
49+
"/2024-01-02T00:00:00Z",
50+
"2024-01-01T00:00:00Z/",
51+
"not-a-date/2024-01-02T00:00:00Z",
52+
],
53+
)
54+
def test_common_query_params_rejects_invalid_from_to(from_to):
55+
with pytest.raises(HTTPException) as exc_info:
56+
CommonQueryParams(from_to=from_to)
57+
58+
assert exc_info.value.status_code == 422
59+
60+
61+
def test_common_query_params_rejects_reversed_from_to():
62+
with pytest.raises(HTTPException) as exc_info:
63+
CommonQueryParams(
64+
from_to="2024-01-02T00:00:00Z/2024-01-01T00:00:00Z"
65+
)
66+
67+
assert exc_info.value.status_code == 422
68+
assert "greater" in exc_info.value.detail["message"]
69+
70+
71+
def test_common_query_params_rejects_as_of_with_from_to():
72+
with pytest.raises(HTTPException) as exc_info:
73+
CommonQueryParams(
74+
as_of="2024-01-01T00:00:00Z",
75+
from_to="2024-01-01T00:00:00Z/2024-01-02T00:00:00Z",
76+
)
77+
78+
assert exc_info.value.status_code == 422
79+
assert "cannot be used together" in exc_info.value.detail["message"]

0 commit comments

Comments
 (0)