Skip to content

Commit e5c652d

Browse files
authored
feat: Accept POST request (#105)
* Add post support for position queries * Improve perfoormance of multipoint reprojection * Add Area query POST support, update README * Fix up tests * Some cleanup
1 parent 91731a6 commit e5c652d

7 files changed

Lines changed: 544 additions & 63 deletions

File tree

README.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ rest = xpublish.Rest(
5252

5353
This package attempts to follow [the spec](https://docs.ogc.org/is/19-086r6/19-086r6.html) where reasonable, adding functionality where the value is demonstrable.
5454

55+
> **Note:** `POST` is supported on `/position` and `/area` as a non-spec extension so that requests with large geometries (many points, complex polygons) can submit them in the request body instead of being limited by URL length. All selection parameters (`datetime`, `z`, `parameter-name`, `crs`, `f`, `method`) are still passed as query string parameters. See the per-query tables below for supported body content types.
56+
5557
### [collections](https://docs.ogc.org/is/19-086r6/19-086r6.html#_e55ba0f5-8f24-4f1b-a7e3-45775e39ef2e) and Resource Paths Support
5658

5759
`xpublish-edr` does not currently support the `/collections/{collectionId}/query` path template described in the spec. Instead the path resource appears as `/{dataset_id}/edr/{query}`. This is because of the path structure of xpublish. In the future, if `xpublish` supports [`DataTree`](https://docs.xarray.dev/en/stable/generated/xarray.DataTree.html) it could provide a path to supporting the spec compliant `collections` resource path.
@@ -64,27 +66,29 @@ This package attempts to follow [the spec](https://docs.ogc.org/is/19-086r6/19-0
6466

6567
| Query | Compliant | Comments
6668
| ------------- | ------------- | ------------- |
67-
| `coords` || |
69+
| `coords` || Required for `GET`; for `POST` the points are read from the request body |
6870
| `z` || |
6971
| `datetime` || |
7072
| `parameter-name` || |
7173
| `crs` || Requires a CF compliant [grid mapping](https://cf-xarray.readthedocs.io/en/latest/grid_mappings.html) on the target dataset. Default is `EPSG:4326` |
7274
| `f` || Supports `cf_covjson`, `csv`, `geojson` `netcdf`, `parquet` |
7375
| `method` || Optional: controls data selection. Use "nearest" for nearest neighbor selection, or "linear" for interpolated selection. Uses `nearest` if not specified |
76+
| `POST` body || Non-spec extension. Supported content types: `text/csv` (columns `x`/`y`, `lon`/`lat`, or `longitude`/`latitude`); `application/geo+json` (Point, MultiPoint, Feature, FeatureCollection, or GeometryCollection) |
7477

7578
> Any additional query parameters are assumed to be additional selections to make on the dimensions/coordinates. These queries will use the specified selections `method`.
7679
7780
[8.2.3 Area query](https://docs.ogc.org/is/19-086r6/19-086r6.html#_c92d1888-dc80-454f-8452-e2f070b90dcd)
7881

7982
| Query | Compliant | Comments
8083
| ------------- | ------------- | ------------- |
81-
| `coords` || Only `POLYGON` supported currently |
84+
| `coords` || `POLYGON` and `MULTIPOLYGON` supported. Required for `GET`; for `POST` the polygon is read from the request body |
8285
| `z` || |
8386
| `datetime` || |
8487
| `parameter-name` || |
8588
| `crs` || Requires a CF compliant [grid mapping](https://cf-xarray.readthedocs.io/en/latest/grid_mappings.html) on the target dataset. Default is `EPSG:4326` |
8689
| `f` || Supports `cf_covjson`, `csv`, `geojson` `netcdf`, `parquet` |
8790
| `method` || Optional: controls data selection. Use "nearest" for nearest neighbor selection, or "linear" for interpolated selection. Uses `nearest` if not specified |
91+
| `POST` body || Non-spec extension. Supported content types: `application/geo+json` (Polygon, MultiPolygon, Feature, FeatureCollection, or GeometryCollection); `application/wkt` / `text/plain` (raw WKT Polygon or MultiPolygon) |
8892

8993
> `method` is not applicable for the coordinates of area queries, only for selecting datetime, z, or additional dimensions.
9094

tests/test_cf_router.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import json
12
from io import BytesIO
23

34
import cf_xarray # noqa: F401
@@ -615,6 +616,45 @@ def test_cf_multiple_position_csv(cf_client):
615616
assert key in csv_data[0], f"column {key} should be in the header"
616617

617618

619+
def test_cf_post_position_csv_body(cf_client):
620+
response = cf_client.post(
621+
"/datasets/air/edr/position",
622+
content="lon,lat\n202,43\n205,45\n",
623+
headers={"content-type": "text/csv"},
624+
)
625+
assert response.status_code == 200, response.text
626+
axes = response.json()["domain"]["axes"]
627+
assert axes["x"] == {"values": [202.5, 205.0]}
628+
assert axes["y"] == {"values": [42.5, 45.0]}
629+
630+
631+
def test_cf_post_position_geojson_body(cf_client):
632+
body = {
633+
"type": "FeatureCollection",
634+
"features": [
635+
{
636+
"type": "Feature",
637+
"geometry": {"type": "Point", "coordinates": [202, 43]},
638+
"properties": {},
639+
},
640+
{
641+
"type": "Feature",
642+
"geometry": {"type": "Point", "coordinates": [205, 45]},
643+
"properties": {},
644+
},
645+
],
646+
}
647+
response = cf_client.post(
648+
"/datasets/air/edr/position",
649+
json=body,
650+
headers={"content-type": "application/geo+json"},
651+
)
652+
assert response.status_code == 200, response.text
653+
axes = response.json()["domain"]["axes"]
654+
assert axes["x"] == {"values": [202.5, 205.0]}
655+
assert axes["y"] == {"values": [42.5, 45.0]}
656+
657+
618658
def test_cf_area_query(cf_client, cf_air_dataset):
619659
coords = "POLYGON((201 41, 201 49, 209 49, 209 41, 201 41))"
620660
response = cf_client.get(f"/datasets/air/edr/area?coords={coords}&f=cf_covjson")
@@ -716,6 +756,56 @@ def test_cf_area_geojson_query(cf_client, cf_air_dataset):
716756
assert len(features) == 36, "There should be 36 data points"
717757

718758

759+
@pytest.mark.parametrize(
760+
"content_type,body,expected_status",
761+
[
762+
(
763+
"application/geo+json",
764+
json.dumps(
765+
{
766+
"type": "Feature",
767+
"geometry": {
768+
"type": "Polygon",
769+
"coordinates": [
770+
[[201, 41], [201, 49], [209, 49], [209, 41], [201, 41]],
771+
],
772+
},
773+
"properties": {},
774+
},
775+
),
776+
200,
777+
),
778+
(
779+
"application/wkt",
780+
"POLYGON((201 41, 201 49, 209 49, 209 41, 201 41))",
781+
200,
782+
),
783+
("application/geo+json", "", 422),
784+
(
785+
"application/geo+json",
786+
json.dumps({"type": "Point", "coordinates": [202, 43]}),
787+
422,
788+
),
789+
],
790+
ids=["geojson_feature", "wkt", "empty_body", "point_rejected"],
791+
)
792+
def test_cf_post_area_body(cf_client, content_type, body, expected_status):
793+
response = cf_client.post(
794+
"/datasets/air/edr/area",
795+
content=body,
796+
headers={"content-type": content_type},
797+
)
798+
assert response.status_code == expected_status, response.text
799+
if expected_status == 200:
800+
axes = response.json()["domain"]["axes"]
801+
assert axes["x"] == {
802+
"values": [202.5, 205.0, 207.5, 202.5, 205.0, 207.5, 202.5, 205.0, 207.5],
803+
}
804+
assert axes["y"] == {
805+
"values": [47.5, 47.5, 47.5, 45.0, 45.0, 45.0, 42.5, 42.5, 42.5],
806+
}
807+
808+
719809
def test_cf_area_nc_query(cf_client, cf_air_dataset):
720810
coords = "POLYGON((201 41, 201 49, 209 49, 209 41, 201 41))"
721811
response = cf_client.get(f"/datasets/air/edr/area?coords={coords}&f=nc")

tests/test_select.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -208,7 +208,7 @@ def test_select_query_error(regular_xy_dataset):
208208
parameters="air",
209209
)
210210

211-
with pytest.raises(TypeError):
211+
with pytest.raises(ValueError, match="Invalid datetime"):
212212
query.select(regular_xy_dataset, {})
213213

214214
query = EDRPositionQuery(

xpublish_edr/geometry/common.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,12 @@
66
from functools import lru_cache, partial
77
from typing import Union
88

9+
import numpy as np
910
import pyproj
1011
import rioxarray # noqa
12+
import shapely
1113
import xarray as xr
1214
from shapely import Geometry
13-
from shapely.ops import transform
1415

1516
VECTORIZED_DIM = "pts"
1617

@@ -93,7 +94,13 @@ def project_geometry(ds: xr.Dataset, geometry_crs: str, geometry: Geometry) -> G
9394
crs_from=geometry_crs,
9495
crs_to=data_crs,
9596
)
96-
return transform(transformer.transform, geometry)
97+
98+
def _transform(coords: np.ndarray) -> np.ndarray:
99+
"""Vectorized callback for shapely.transform: project all coords in one call."""
100+
x, y = transformer.transform(coords[:, 0], coords[:, 1])
101+
return np.column_stack([x, y])
102+
103+
return shapely.transform(geometry, _transform)
97104

98105

99106
def project_bbox(

0 commit comments

Comments
 (0)