Skip to content

Commit e84a56c

Browse files
authored
add bbox support for CSV and GeoJSON feature providers (#2058)
* add bbox support for CSV and GeoJSON providers * update API tests * updates based on OAFeat Req 24C
1 parent 8087db5 commit e84a56c

6 files changed

Lines changed: 81 additions & 10 deletions

File tree

docs/source/data-publishing/ogcapi-features.rst

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,11 @@ parameters.
1919
:header: Provider, property filters/display, resulttype, bbox, datetime, sortby, skipGeometry, domains, CQL, transactions, crs
2020
:align: left
2121

22-
`CSV`_,✅/✅,results/hits,,❌,❌,✅,❌,❌,❌,✅
22+
`CSV`_,✅/✅,results/hits,,❌,❌,✅,❌,❌,❌,✅
2323
`Elasticsearch`_,✅/✅,results/hits,✅,✅,✅,✅,✅,✅,✅,✅
2424
`ERDDAP Tabledap Service`_,❌/❌,results/hits,✅,✅,❌,❌,❌,❌,❌,✅
2525
`ESRI Feature Service`_,✅/✅,results/hits,✅,✅,✅,✅,❌,❌,❌,✅
26-
`GeoJSON`_,✅/✅,results/hits,,❌,❌,✅,❌,❌,❌,✅
26+
`GeoJSON`_,✅/✅,results/hits,,❌,❌,✅,❌,❌,❌,✅
2727
`MongoDB`_,✅/❌,results,✅,✅,✅,✅,❌,❌,❌,✅
2828
`MySQL`_,✅/✅,results/hits,✅,✅,✅,✅,❌,✅,✅,✅
2929
`OGR`_,✅/❌,results/hits,✅,❌,❌,✅,❌,❌,❌,✅

pygeoapi/provider/csv_.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@
3232
import itertools
3333
import logging
3434

35+
from shapely import box, Point
36+
3537
from pygeoapi.provider.base import (BaseProvider, ProviderInvalidQueryError,
3638
ProviderItemNotFoundError,
3739
ProviderQueryError)
@@ -99,6 +101,7 @@ def _load(self, offset=0, limit=10, resulttype='results',
99101
:param limit: number of records to return (default 10)
100102
:param datetime_: temporal (datestamp or extent)
101103
:param resulttype: return results or hit limit (default results)
104+
:param bbox: bounding box [minx,miny,maxx,maxy]
102105
:param properties: list of tuples (name, value)
103106
:param select_properties: list of property names
104107
:param skip_geometry: bool of whether to skip geometry (default False)
@@ -120,6 +123,7 @@ def _load(self, offset=0, limit=10, resulttype='results',
120123
with open(self.data) as ff:
121124
LOGGER.debug('Serializing DictReader')
122125
data_ = csv.DictReader(ff)
126+
123127
if properties:
124128
for prop in properties:
125129
if prop[0] not in data_.fieldnames:
@@ -131,10 +135,17 @@ def _load(self, offset=0, limit=10, resulttype='results',
131135
lambda p: all(
132136
[p[prop[0]] == prop[1] for prop in properties]), data_)
133137

138+
if bbox:
139+
LOGGER.debug('processing bbox parameter')
140+
data_ = filter(
141+
lambda f: all(
142+
[self._intersects(f, bbox)]), data_)
143+
134144
if resulttype == 'hits':
135145
LOGGER.debug('Returning hits only')
136146
feature_collection['numberMatched'] = len(list(data_))
137147
return feature_collection
148+
138149
LOGGER.debug('Slicing CSV rows')
139150
for row in itertools.islice(data_, 0, None):
140151
try:
@@ -193,6 +204,24 @@ def _load(self, offset=0, limit=10, resulttype='results',
193204

194205
return feature_collection
195206

207+
def _intersects(self, data, bbox):
208+
"""
209+
Helper function to evaluate point geometry intersection with a bbox
210+
211+
:param geometry: `dict` of CSV row
212+
:param bbox: `list` of bbox
213+
214+
:returns: `bool` of whether point geometry intersects with bbox
215+
"""
216+
217+
if None in [data.get(self.geometry_x), data.get(self.geometry_y)]:
218+
return True
219+
220+
point = Point(data[self.geometry_x], data[self.geometry_y])
221+
bbox2 = box(*bbox)
222+
223+
return bbox2.intersects(point)
224+
196225
@crs_transform
197226
def query(self, offset=0, limit=10, resulttype='results',
198227
bbox=[], datetime_=None, properties=[], sortby=[],
@@ -215,7 +244,7 @@ def query(self, offset=0, limit=10, resulttype='results',
215244
"""
216245

217246
return self._load(offset, limit, resulttype,
218-
properties=properties,
247+
bbox=bbox, properties=properties,
219248
select_properties=select_properties,
220249
skip_geometry=skip_geometry)
221250

pygeoapi/provider/geojson.py

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@
3333
import os
3434
import uuid
3535

36+
from shapely import box, from_geojson
37+
3638
from pygeoapi.provider.base import BaseProvider, ProviderItemNotFoundError
3739
from pygeoapi.util import crs_transform
3840

@@ -96,7 +98,8 @@ def get_fields(self):
9698

9799
return self._fields
98100

99-
def _load(self, skip_geometry=None, properties=[], select_properties=[]):
101+
def _load(self, bbox=[], skip_geometry=None, properties=[],
102+
select_properties=[]):
100103
"""Load and validate the source GeoJSON file
101104
at self.data
102105
@@ -121,6 +124,12 @@ def _load(self, skip_geometry=None, properties=[], select_properties=[]):
121124
data['features'] = [f for f in data['features'] if \
122125
all([str(f['properties'][p[0]]) == str(p[1]) for p in properties])] # noqa
123126

127+
# filter by bbox if set
128+
if bbox:
129+
LOGGER.debug('processing bbox parameter')
130+
data['features'] = [f for f in data['features'] if \
131+
self._intersects(f['geometry'], bbox)] # noqa
132+
124133
# All features must have ids, TODO must be unique strings
125134
for i in data['features']:
126135
if 'id' not in i and self.id_field in i['properties']:
@@ -132,6 +141,24 @@ def _load(self, skip_geometry=None, properties=[], select_properties=[]):
132141
if k in set(self.properties) | set(select_properties)} # noqa
133142
return data
134143

144+
def _intersects(self, geometry, bbox):
145+
"""
146+
Helper function to evaluate feature geometry intersection with a bbox
147+
148+
:param geometry: `dict` of GeoJSON geometry
149+
:param bbox: `list` of bbox
150+
151+
:returns: `bool` of whether geometry intersects with bbox
152+
"""
153+
154+
if geometry is None:
155+
return True
156+
157+
bbox2 = box(*bbox)
158+
geometry2 = from_geojson(json.dumps(geometry))
159+
160+
return geometry2.intersects(bbox2)
161+
135162
@crs_transform
136163
def query(self, offset=0, limit=10, resulttype='results',
137164
bbox=[], datetime_=None, properties=[], sortby=[],
@@ -154,7 +181,8 @@ def query(self, offset=0, limit=10, resulttype='results',
154181
"""
155182

156183
# TODO filter by bbox without resorting to third-party libs
157-
data = self._load(skip_geometry=skip_geometry, properties=properties,
184+
data = self._load(bbox=bbox, skip_geometry=skip_geometry,
185+
properties=properties,
158186
select_properties=select_properties)
159187

160188
data['numberMatched'] = len(data['features'])

tests/api/test_itemtypes.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -258,7 +258,7 @@ def test_get_collection_items(config, api_):
258258
req = mock_api_request({
259259
'offset': '1',
260260
'limit': '1',
261-
'bbox': '-180,90,180,90'
261+
'bbox': '-180,-90,180,90'
262262
})
263263
rsp_headers, code, response = get_collection_items(api_, req, 'obs')
264264
features = json.loads(response)
@@ -267,16 +267,16 @@ def test_get_collection_items(config, api_):
267267

268268
links = features['links']
269269
assert len(links) == 6
270-
assert '/collections/obs/items?f=json&limit=1&bbox=-180,90,180,90' in \
270+
assert '/collections/obs/items?f=json&limit=1&bbox=-180,-90,180,90' in \
271271
links[0]['href']
272272
assert links[0]['rel'] == 'self'
273-
assert '/collections/obs/items?f=jsonld&limit=1&bbox=-180,90,180,90' in \
273+
assert '/collections/obs/items?f=jsonld&limit=1&bbox=-180,-90,180,90' in \
274274
links[1]['href']
275275
assert links[1]['rel'] == 'alternate'
276-
assert '/collections/obs/items?f=html&limit=1&bbox=-180,90,180,90' in \
276+
assert '/collections/obs/items?f=html&limit=1&bbox=-180,-90,180,90' in \
277277
links[2]['href']
278278
assert links[2]['rel'] == 'alternate'
279-
assert '/collections/obs/items?offset=0&limit=1&bbox=-180,90,180,90' \
279+
assert '/collections/obs/items?offset=0&limit=1&bbox=-180,-90,180,90' \
280280
in links[3]['href']
281281
assert links[3]['rel'] == 'prev'
282282
assert '/collections/obs' in links[4]['href']

tests/test_csv__provider.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,10 @@ def test_query(config):
136136
results = p.query()
137137
assert len(results['features'][0]['properties']) == 2
138138

139+
p = CSVProvider(config)
140+
results = p.query(bbox=[-75, 45, -64, 55])
141+
assert len(results['features'][0]['properties']) == 2
142+
139143

140144
def test_get_invalid_property(config):
141145
"""Testing query for an invalid property name"""

tests/test_geojson_provider.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,16 @@ def test_query(fixture, config):
9494
assert results['numberMatched'] == 1
9595
assert results['numberReturned'] == 1
9696

97+
results = p.query(bbox=[-154, 33, -120, 55])
98+
assert len(results['features']) == 0
99+
assert results['numberMatched'] == 0
100+
assert results['numberReturned'] == 0
101+
102+
results = p.query(bbox=[120, 10, 126, 11])
103+
assert len(results['features']) == 1
104+
assert results['numberMatched'] == 1
105+
assert results['numberReturned'] == 1
106+
97107

98108
def test_get(fixture, config):
99109
p = GeoJSONProvider(config)

0 commit comments

Comments
 (0)