Skip to content

Commit 86e5b78

Browse files
authored
Implement OGC API Features Part 4 transactions for Senosorthings provider (#1911)
* Support OGC API - Features Part 4 for SensorThings API Provider * Use configured scheme and netloc * Fix flake8 Update test_sensorthings_provider.py * Add graceful failure on startup * Update copyright year * Use PUT
1 parent 2472fc3 commit 86e5b78

3 files changed

Lines changed: 125 additions & 11 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ parameters.
3131
`Parquet`_,✅/✅,results/hits,✅,✅,❌,✅,❌,❌,✅
3232
`PostgreSQL`_,✅/✅,results/hits,✅,✅,✅,✅,✅,✅,✅
3333
`SQLiteGPKG`_,✅/❌,results/hits,✅,❌,❌,✅,❌,❌,✅
34-
`SensorThings API`_,✅/✅,results/hits,✅,✅,✅,✅,❌,,✅
34+
`SensorThings API`_,✅/✅,results/hits,✅,✅,✅,✅,❌,,✅
3535
`Socrata`_,✅/✅,results/hits,✅,✅,✅,✅,❌,❌,✅
3636
`TinyDB`_,✅/✅,results/hits,✅,✅,✅,✅,❌,✅,✅
3737

pygeoapi/provider/sensorthings.py

Lines changed: 77 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
# Authors: Benjamin Webb <benjamin.miller.webb@gmail.com>
44
# Authors: Tom Kralidis <tomkralidis@gmail.com>
55
#
6-
# Copyright (c) 2024 Benjamin Webb
6+
# Copyright (c) 2025 Benjamin Webb
77
# Copyright (c) 2022 Tom Kralidis
88
#
99
# Permission is hereby granted, free of charge, to any person
@@ -32,13 +32,16 @@
3232
from json.decoder import JSONDecodeError
3333
import logging
3434
from requests import Session
35+
from requests.exceptions import ConnectionError
3536
from urllib.parse import urlparse
3637

3738
from pygeoapi.config import get_config
3839
from pygeoapi.provider.base import (
39-
BaseProvider, ProviderQueryError, ProviderConnectionError)
40+
BaseProvider, ProviderQueryError, ProviderConnectionError,
41+
ProviderInvalidDataError)
4042
from pygeoapi.util import (
41-
url_join, get_provider_default, crs_transform, get_base_url)
43+
url_join, get_provider_default, crs_transform, get_base_url,
44+
get_typed_value)
4245

4346
LOGGER = logging.getLogger(__name__)
4447

@@ -101,12 +104,16 @@ def get_fields(self):
101104
:returns: dict of fields
102105
"""
103106
if not self._fields:
104-
r = self._get_response(self._url, {'$top': 1})
105107
try:
108+
r = self._get_response(self._url, {'$top': 1})
106109
results = r['value'][0]
107110
except IndexError:
108111
LOGGER.warning('could not get fields; returning empty set')
109112
return {}
113+
except (ConnectionError, ProviderConnectionError):
114+
msg = f'Unable to contact SensorThings endpoint at {self._url}'
115+
LOGGER.error(msg)
116+
raise ProviderConnectionError(msg)
110117

111118
for (n, v) in results.items():
112119
if isinstance(v, (int, float)) or \
@@ -155,6 +162,65 @@ def get(self, identifier, **kwargs):
155162
response = self._get_response(f'{self._url}({identifier})')
156163
return self._make_feature(response)
157164

165+
def create(self, item):
166+
"""
167+
Create a new item
168+
169+
:param item: `dict` of new item
170+
171+
:returns: identifier of created item
172+
"""
173+
response = self.http.post(self._url, json=item)
174+
175+
if response.status_code == 201:
176+
location = response.headers.get("Location")
177+
iotid = location[location.find("(")+1:location.find(")")]
178+
179+
LOGGER.debug(f'Feature created with @iot.id: {iotid}')
180+
return get_typed_value(iotid)
181+
else:
182+
msg = f"Failed to create item: {response.text}"
183+
raise ProviderInvalidDataError(msg)
184+
185+
def update(self, identifier, item):
186+
"""
187+
Updates an existing item
188+
189+
:param identifier: feature id
190+
:param item: `dict` of partial or full item
191+
192+
:returns: `bool` of update result
193+
"""
194+
id = f"'{identifier}'" \
195+
if isinstance(identifier, str) else str(identifier)
196+
LOGGER.debug(f'Updating @iot.id: {id}')
197+
response = self.http.put(f"{self._url}({id})", json=item)
198+
199+
if response.status_code == 200:
200+
return True
201+
else:
202+
msg = f'Failed to update item: {response.text}'
203+
raise ProviderConnectionError(msg)
204+
205+
def delete(self, identifier):
206+
"""
207+
Deletes an existing item
208+
209+
:param identifier: item id
210+
211+
:returns: `bool` of deletion result
212+
"""
213+
id = f"'{identifier}'" \
214+
if isinstance(identifier, str) else str(identifier)
215+
LOGGER.debug(f'Deleting @iot.id: {id}')
216+
response = self.http.delete(f"{self._url}({id})")
217+
218+
if response.status_code == 200:
219+
return True
220+
else:
221+
msg = f"Failed to delete item: {response.text}"
222+
raise ProviderConnectionError(msg)
223+
158224
def _load(self, offset=0, limit=10, resulttype='results',
159225
bbox=[], datetime_=None, properties=[], sortby=[],
160226
select_properties=[], skip_geometry=False, q=None):
@@ -208,13 +274,13 @@ def _load(self, offset=0, limit=10, resulttype='results',
208274
v = response.get('value')
209275
while len(v) < limit:
210276
try:
211-
LOGGER.debug('Fetching next set of values')
212-
next_ = response['@iot.nextLink']
213-
214277
# Ensure we only use provided network location
215-
next_ = next_.replace(urlparse(next_).netloc,
216-
urlparse(self.data).netloc)
278+
next_ = urlparse(response['@iot.nextLink'])._replace(
279+
scheme=self.parsed_url.scheme,
280+
netloc=self.parsed_url.netloc
281+
).geturl()
217282

283+
LOGGER.debug('Fetching next set of values')
218284
response = self._get_response(next_)
219285
v.extend(response['value'])
220286
except (ProviderConnectionError, KeyError):
@@ -517,6 +583,8 @@ def _generate_mappings(self, provider_def: dict):
517583
self._url = self.data
518584
self.data = self._url.rstrip(f'/{self.entity}')
519585

586+
self.parsed_url = urlparse(self.data)
587+
520588
# Default id
521589
if self.id_field:
522590
LOGGER.debug(f'Using id field: {self.id_field}')

tests/test_sensorthings_provider.py

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
#
33
# Authors: Benjamin Webb <bwebb@lincolninst.edu>
44
#
5-
# Copyright (c) 2024 Benjamin Webb
5+
# Copyright (c) 2025 Benjamin Webb
66
#
77
# Permission is hereby granted, free of charge, to any person
88
# obtaining a copy of this software and associated documentation
@@ -44,6 +44,27 @@ def config():
4444
}
4545

4646

47+
@pytest.fixture()
48+
def post_body():
49+
return {
50+
'@iot.id': 121,
51+
'name': 'Temperature Datastream',
52+
'description': 'Datastream for measuring temperature in Celsius.',
53+
'observationType': 'http://www.opengis.net/def/observationType/OGC-OM/2.0/OM_Measurement', # noqa
54+
'unitOfMeasurement': {
55+
'name': 'Degree Celsius',
56+
'symbol': 'degC',
57+
'definition': 'http://www.qudt.org/qudt/owl/1.0.0/unit/Instances.html#DegreeCelsius' # noqa
58+
},
59+
'Thing': {'@iot.id': 2},
60+
'ObservedProperty': {'@iot.id': 3},
61+
'Sensor': {'@iot.id': 5},
62+
'properties': {
63+
'uri': 'https://geoconnex.us/test/datastream'
64+
}
65+
}
66+
67+
4768
def test_query_datastreams(config):
4869
p = SensorThingsProvider(config)
4970
fields = p.get_fields()
@@ -162,3 +183,28 @@ def test_custom_expand(config):
162183
assert 'Observations' in fields
163184
assert 'ObservedProperty' not in fields
164185
assert 'Sensor' not in fields
186+
187+
188+
def test_transactions(config, post_body):
189+
p = SensorThingsProvider(config)
190+
results = p.query(resulttype='hits')
191+
assert results['numberMatched'] == 120
192+
193+
id = p.create(post_body)
194+
assert id == 121
195+
results = p.query(resulttype='hits')
196+
assert results['numberMatched'] == 121
197+
198+
datastream = p.get(121)
199+
assert datastream['properties']['name'] == 'Temperature Datastream'
200+
201+
post_body['name'] = 'Temperature'
202+
result = p.update(id, post_body)
203+
assert result is True
204+
205+
datastream = p.get(121)
206+
assert datastream['properties']['name'] == 'Temperature'
207+
208+
assert p.delete(id) is True
209+
results = p.query(resulttype='hits')
210+
assert results['numberMatched'] == 120

0 commit comments

Comments
 (0)