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

Issue 18 functionality to return all parameters when parameter-names are not given #30

Merged
merged 22 commits into from
Feb 12, 2024
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
13961d4
Feat: return all available observations from a station if parameter n…
fjugipe Feb 7, 2024
5cee99e
Feat: return all available observations from an area or position if p…
fjugipe Feb 7, 2024
2fb606a
Refactor: Add examples to API docs for datetime
fjugipe Feb 7, 2024
334c49e
Refactor: Error handling to get_datetime_range
fjugipe Feb 7, 2024
78744e8
Test: Add tests for /locations/{id} endpoint
fjugipe Feb 7, 2024
8d71fe0
CI: update GitHub actions with testing depencies
fjugipe Feb 7, 2024
89eaaf1
Refactor: Add error catching to coord conversions
fjugipe Feb 8, 2024
a0653f2
Test: add tests for area & position ednpoints
fjugipe Feb 8, 2024
89c59b8
Merge branch 'main' into issue-18-return-all
fjugipe Feb 8, 2024
e87e51a
Test: fix assertions with corrected attribute names
fjugipe Feb 8, 2024
14e9bc1
Refactor: Raise explicit errors with coordinates
fjugipe Feb 8, 2024
1ac4ad6
Refactor: Change assertions to check for single values
fjugipe Feb 9, 2024
b34ed81
Refactor: add utilities.py to tests and remove duplicate functions
fjugipe Feb 9, 2024
b732cdc
Refactor: Raise and except TypeErrors instead of assertions with inco…
fjugipe Feb 9, 2024
f1b1986
Refactor: Use Annotated instead of providing default with Query
fjugipe Feb 9, 2024
f700ccb
Fix: Return 400 instead of 422 as per EDR specification
fjugipe Feb 9, 2024
36e1a0e
Refactor: leave instrument out from obs_request when not needed inste…
fjugipe Feb 9, 2024
e71f049
Test: Exclude test from coverage
fjugipe Feb 9, 2024
caddad2
Refactor: simplify get_obs_request filter creation
fjugipe Feb 9, 2024
699ade2
Test: add a test for area endpoint without query parameters
fjugipe Feb 9, 2024
f678779
Refactor: use Annotated with endpoints for information & validation
fjugipe Feb 9, 2024
b20fd8d
Style: refactor area filter for consistency
fjugipe Feb 12, 2024
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
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ jobs:
pip install --upgrade pip
pip install pytest-timeout
pip install pytest-cov
pip install httpx
lukas-phaf marked this conversation as resolved.
Show resolved Hide resolved
pip install -r ./api/requirements.txt

- name: Copy Protobuf file to api directory and build
Expand Down
44 changes: 30 additions & 14 deletions api/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from datetime import timedelta
fjugipe marked this conversation as resolved.
Show resolved Hide resolved
from typing import Tuple

from fastapi import HTTPException
from google.protobuf.timestamp_pb2 import Timestamp
from pydantic import AwareDatetime
from pydantic import TypeAdapter
Expand All @@ -11,23 +12,38 @@ def get_datetime_range(datetime_string: str | None) -> Tuple[Timestamp, Timestam
if not datetime_string:
return None

errors = {}

lukas-phaf marked this conversation as resolved.
Show resolved Hide resolved
start_datetime, end_datetime = Timestamp(), Timestamp()
aware_datetime_type_adapter = TypeAdapter(AwareDatetime)
datetimes = tuple(value.strip() for value in datetime_string.split("/"))
if len(datetimes) == 1:
start_datetime.FromDatetime(aware_datetime_type_adapter.validate_python(datetimes[0]))
end_datetime.FromDatetime(
aware_datetime_type_adapter.validate_python(datetimes[0]) + timedelta(seconds=1)
) # HACK: Add one second so we get some data, as the store returns [start, end)
else:
if datetimes[0] != "..":

try:
datetimes = tuple(value.strip() for value in datetime_string.split("/"))
if len(datetimes) == 1:
start_datetime.FromDatetime(aware_datetime_type_adapter.validate_python(datetimes[0]))
end_datetime.FromDatetime(
aware_datetime_type_adapter.validate_python(datetimes[0]) + timedelta(seconds=1)
) # HACK: Add one second so we get some data, as the store returns [start, end)
else:
start_datetime.FromDatetime(datetime.min)
if datetimes[1] != "..":
# HACK add one second so that the end_datetime is included in the interval.
end_datetime.FromDatetime(aware_datetime_type_adapter.validate_python(datetimes[1]) + timedelta(seconds=1))
else:
end_datetime.FromDatetime(datetime.max)
if datetimes[0] != "..":
start_datetime.FromDatetime(aware_datetime_type_adapter.validate_python(datetimes[0]))
else:
start_datetime.FromDatetime(datetime.min)
if datetimes[1] != "..":
# HACK add one second so that the end_datetime is included in the interval.
end_datetime.FromDatetime(
aware_datetime_type_adapter.validate_python(datetimes[1]) + timedelta(seconds=1)
)
else:
end_datetime.FromDatetime(datetime.max)

if start_datetime.seconds > end_datetime.seconds:
errors["datetime"] = f"Invalid range: {datetimes[0]} > {datetimes[1]}"

except ValueError:
errors["datetime"] = f"Invalid format: {datetime_string}"

if errors:
raise HTTPException(status_code=422, detail=errors)
lukas-phaf marked this conversation as resolved.
Show resolved Hide resolved

return start_datetime, end_datetime
41 changes: 27 additions & 14 deletions api/routers/edr.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from covjson_pydantic.coverage import CoverageCollection
from dependencies import get_datetime_range
from fastapi import APIRouter
from fastapi import HTTPException
from fastapi import Path
from fastapi import Query
from geojson_pydantic import Feature
Expand Down Expand Up @@ -58,13 +59,13 @@ async def get_locations(bbox: str = Query(..., example="5.0,52.0,6.0,52.1")) ->
@router.get(
"/locations/{location_id}",
tags=["Collection data queries"],
response_model=Coverage,
response_model=Coverage | CoverageCollection,
response_model_exclude_none=True,
)
async def get_data_location_id(
location_id: str = Path(..., example="06260"),
parameter_name: str = Query(..., alias="parameter-name", example="dd,ff,rh,pp,tn"),
datetime: str | None = None,
parameter_name: str = Query(None, alias="parameter-name", example="dd,ff,rh,pp,tn"),
lukas-phaf marked this conversation as resolved.
Show resolved Hide resolved
datetime: str = Query(None, example="2022-12-31T00:00Z/2023-01-01T00:00Z"),
f: str = Query(default="covjson", alias="f", description="Specify return format."),
):
# TODO: There is no error handling of any kind at the moment!
Expand All @@ -73,7 +74,9 @@ async def get_data_location_id(
get_obs_request = dstore.GetObsRequest(
filter=dict(
platform=dstore.Strings(values=[location_id]),
instrument=dstore.Strings(values=list(map(str.strip, parameter_name.split(",")))),
instrument=dstore.Strings(
values=list(map(str.strip, parameter_name.split(","))) if parameter_name else "*"
),
lukas-phaf marked this conversation as resolved.
Show resolved Hide resolved
),
temporal_interval=dstore.TimeInterval(start=range[0], end=range[1]) if range else None,
)
Expand All @@ -89,13 +92,17 @@ async def get_data_location_id(
)
async def get_data_position(
coords: str = Query(..., example="POINT(5.179705 52.0988218)"),
parameter_name: str = Query(..., alias="parameter-name", example="dd,ff,rh,pp,tn"),
datetime: str | None = None,
parameter_name: str = Query(None, alias="parameter-name", example="dd,ff,rh,pp,tn"),
datetime: str = Query(None, example="2022-12-31T00:00Z/2023-01-01T00:00Z"),
f: str = Query(default="covjson", alias="f", description="Specify return format."),
):
point = wkt.loads(coords)
assert point.geom_type == "Point"
poly = buffer(point, 0.0001, quad_segs=1) # Roughly 10 meters around the point
try:
point = wkt.loads(coords)
assert point.geom_type == "Point"
lukas-phaf marked this conversation as resolved.
Show resolved Hide resolved
poly = buffer(point, 0.0001, quad_segs=1) # Roughly 10 meters around the point
except Exception:
raise HTTPException(status_code=422, detail={"coords": "Invalid coordinates: {}".format(coords)})

return await get_data_area(poly.wkt, parameter_name, datetime, f)


Expand All @@ -107,15 +114,21 @@ async def get_data_position(
)
async def get_data_area(
coords: str = Query(..., example="POLYGON((5.0 52.0, 6.0 52.0,6.0 52.1,5.0 52.1, 5.0 52.0))"),
parameter_name: str = Query(..., alias="parameter-name", example="dd,ff,rh,pp,tn"),
datetime: str | None = None,
parameter_name: str = Query(None, alias="parameter-name", example="dd,ff,rh,pp,tn"),
datetime: str = Query(None, example="2022-12-31T00:00Z/2023-01-01T00:00Z"),
f: str = Query(default="covjson", alias="f", description="Specify return format."),
):
poly = wkt.loads(coords)
assert poly.geom_type == "Polygon"
try:
poly = wkt.loads(coords)
assert poly.geom_type == "Polygon"
fjugipe marked this conversation as resolved.
Show resolved Hide resolved
except Exception:
raise HTTPException(status_code=422, detail={"coords": "Invalid coordinates: {}".format(coords)})

range = get_datetime_range(datetime)
get_obs_request = dstore.GetObsRequest(
filter=dict(instrument=dstore.Strings(values=list(map(str.strip, parameter_name.split(","))))),
filter=dict(
instrument=dstore.Strings(values=list(map(str.strip, parameter_name.split(","))) if parameter_name else "*")
),
spatial_area=dstore.Polygon(
points=[dstore.Point(lat=coord[1], lon=coord[0]) for coord in poly.exterior.coords]
),
Expand Down
186 changes: 186 additions & 0 deletions api/test/test_edr.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
import json
from unittest.mock import patch

import datastore_pb2 as dstore
import routers.edr as edr
from fastapi.testclient import TestClient
from google.protobuf.json_format import Parse
from main import app


client = TestClient(app)


def test_get_locations_id_with_single_parameter_query_without_format():
with patch("routers.edr.getObsRequest") as mock_getObsRequest:
test_data = load_json("test/test_data/test_single_proto.json")
compare_data = load_json("test/test_data/test_single_covjson.json")

mock_getObsRequest.return_value = create_mock_obs_response(test_data)

response = client.get(
"/collections/observations/locations/06260?"
+ "parameter-name=ff&datetime=2022-12-31T00:00:00Z/2022-12-31T01:00:00Z"
)

# Check that getObsRequest gets called with correct arguments given in query
mock_getObsRequest.assert_called_once()
assert "ff" in mock_getObsRequest.call_args[0][0].filter["instrument"].values
lukas-phaf marked this conversation as resolved.
Show resolved Hide resolved
assert "06260" in mock_getObsRequest.call_args[0][0].filter["platform"].values
lukas-phaf marked this conversation as resolved.
Show resolved Hide resolved
assert "2022-12-31 00:00:00" == mock_getObsRequest.call_args[0][
0
].temporal_interval.start.ToDatetime().strftime("%Y-%m-%d %H:%M:%S")
assert "2022-12-31 01:00:01" == mock_getObsRequest.call_args[0][0].temporal_interval.end.ToDatetime().strftime(
"%Y-%m-%d %H:%M:%S"
)

assert response.status_code == 200
assert response.json()["type"] == "Coverage"
assert response.json() == compare_data


def test_get_locations_id_without_parameter_names_query():
with patch("routers.edr.getObsRequest") as mock_getObsRequest:
test_data = load_json("test/test_data/test_multiple_proto.json")
compare_data = load_json("test/test_data/test_multiple_covjson.json")

mock_getObsRequest.return_value = create_mock_obs_response(test_data)

response = client.get("/collections/observations/locations/06260?f=covjson")

# Check that getObsRequest gets called with correct arguments when no parameter names are given
# in query
mock_getObsRequest.assert_called_once()
assert "*" in mock_getObsRequest.call_args[0][0].filter["instrument"].values

assert response.status_code == 200
assert response.json() == compare_data


def test_get_locations_id_with_incorrect_datetime_format():
response = client.get("/collections/observations/locations/06260?datetime=20221231T000000Z/20221231T010000Z")

assert response.status_code == 422
assert response.json() == {"detail": {"datetime": "Invalid format: 20221231T000000Z/20221231T010000Z"}}


def test_get_locations_id_with_incorrect_datetime_range():
response = client.get(
"/collections/observations/locations/06260?datetime=2024-12-31T00:00:00Z/2022-12-31T01:00:00Z"
)

assert response.status_code == 422
assert response.json() == {"detail": {"datetime": "Invalid range: 2024-12-31T00:00:00Z > 2022-12-31T01:00:00Z"}}


def test_get_locations_id_with_empty_response():
with patch("routers.edr.getObsRequest") as mock_getObsRequest:
test_data = load_json("test/test_data/test_empty_proto.json")

mock_getObsRequest.return_value = create_mock_obs_response(test_data)

response = client.get("/collections/observations/locations/10000?f=covjson")

assert response.status_code == 404
assert response.json() == {"detail": "No data found"}


def test_get_area_with_normal_query():
with patch("routers.edr.getObsRequest") as mock_getObsRequest:
test_data = load_json("test/test_data/test_coverages_proto.json")
compare_data = load_json("test/test_data/test_coverages_covjson.json")

mock_getObsRequest.return_value = create_mock_obs_response(test_data)

response = client.get(
"/collections/observations/area?coords=POLYGON((22.12 59.86, 24.39 60.41, "
" 24.39 60.41, 24.39 59.86, 22.12 59.86))"
"&parameter-name=TA_P1D_AVG&datetime=2022-12-31T00:00:00Z/2022-12-31T01:00:00Z"
)

# Check that getObsRequest gets called with correct arguments given in query
mock_getObsRequest.assert_called_once()
assert "TA_P1D_AVG" in mock_getObsRequest.call_args[0][0].filter["instrument"].values
assert len(mock_getObsRequest.call_args[0][0].spatial_area.points) == 5
assert 22.12 == mock_getObsRequest.call_args[0][0].spatial_area.points[0].lon
assert "2022-12-31 00:00:00" == mock_getObsRequest.call_args[0][
0
].temporal_interval.start.ToDatetime().strftime("%Y-%m-%d %H:%M:%S")
assert "2022-12-31 01:00:01" == mock_getObsRequest.call_args[0][0].temporal_interval.end.ToDatetime().strftime(
"%Y-%m-%d %H:%M:%S"
)

assert response.status_code == 200
assert response.json() == compare_data


def test_get_area_with_incorrect_coords():
response = client.get("/collections/observations/area?coords=POLYGON((22.12 59.86, 24.39 60.41))")

assert response.status_code == 422


def test_get_area_with_incorrect_geometry_type():
response = client.get("/collections/observations/area?coords=POINT(22.12 59.86)")

assert response.status_code == 422


def test_get_position_with_normal_query():
# Wrap the original get_data_area to a mock so we can assert against the call values
with patch("routers.edr.get_data_area", wraps=edr.get_data_area) as mock_get_data_area, patch(
"routers.edr.getObsRequest"
) as mock_getObsRequest:
test_data = load_json("test/test_data/test_coverages_proto.json")
compare_data = load_json("test/test_data/test_coverages_covjson.json")

mock_getObsRequest.return_value = create_mock_obs_response(test_data)

response = client.get(
"/collections/observations/position?coords=POINT(5.179705 52.0988218)"
"&parameter-name=TA_P1D_AVG&datetime=2022-12-31T00:00Z/2022-12-31T00:00Z"
)

mock_get_data_area.assert_called_once()
mock_get_data_area.assert_called_with(
"POLYGON ((5.179805 52.0988218, 5.179705 52.0987218, 5.1796050000000005 52.0988218, "
"5.179705 52.09892180000001, 5.179805 52.0988218))",
"TA_P1D_AVG",
"2022-12-31T00:00Z/2022-12-31T00:00Z",
"covjson",
)

assert response.status_code == 200
assert response.json() == compare_data


def test_get_position_with_incorrect_coords():
response = client.get("/collections/observations/position?coords=POINT(60.41)")

assert response.status_code == 422
assert response.json() == {"detail": {"coords": "Invalid coordinates: POINT(60.41)"}}


def test_get_position_with_incorrect_geometry_type():
response = client.get(
"/collections/observations/position?coords=POLYGON((22.12 59.86, 24.39 60.41, "
"24.39 60.41, 24.39 59.86, 22.12 59.86))"
)

assert response.status_code == 422
assert response.json() == {
"detail": {
"coords": "Invalid coordinates: POLYGON((22.12 59.86, 24.39 60.41, 24.39 60.41, 24.39 59.86, 22.12 59.86))"
}
}


def create_mock_obs_response(json_data):
response = dstore.GetObsResponse()
Parse(json.dumps(json_data), response)
return response


def load_json(file_path):
with open(file_path, "r") as file:
return json.load(file)
lukas-phaf marked this conversation as resolved.
Show resolved Hide resolved
Loading