Skip to content

Commit a82b927

Browse files
committed
support STAC API Transactions
1 parent 014a0a6 commit a82b927

File tree

9 files changed

+481
-18
lines changed

9 files changed

+481
-18
lines changed

docs/stac.rst

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,14 @@ STAC support will render links as follows:
4141
* links that are enclosures will be encoded as STAC assets (in ``assets``)
4242
* all other links remain as record links (in ``links``)
4343

44+
Transactions
45+
^^^^^^^^^^^^
46+
47+
STAC Transactions are supported as per the following STAC API specifications:
48+
49+
* `STAC API - Transaction Extension Specification`_.
50+
* `STAC API - Collection Transaction Extension`_.
51+
4452
Request Examples
4553
----------------
4654

@@ -106,3 +114,5 @@ Request Examples
106114
http://localhost:8000/stac/collections/metadata:main/items/{itemId}
107115
108116
.. _`SpatioTemporal Asset Catalog API version v1.0.0`: https://github.com/radiantearth/stac-api-spec
117+
.. _`STAC API - Transaction Extension Specification`: https://github.com/stac-api-extensions/transaction
118+
.. _`STAC API - Collection Transaction Extension`: https://github.com/stac-api-extensions/collection-transaction

docs/transactions.rst

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,5 +114,46 @@ Harvesting
114114

115115
Harvesting is not yet supported via OGC API - Records.
116116

117+
Transactions using STAC API
118+
===========================
119+
120+
pycsw's STAC API support provides transactional capabilities via the `STAC API - Transaction Extension Specification`_ and `STAC API - Collection Transaction Extension`_ specifications,
121+
which follows RESTful patterns for insert/update/delete of resources.
122+
123+
Supported Resource Types
124+
------------------------
125+
126+
STAC Collections, Items and Item Collections are supported via OGC API - Records transactional workflow. Note that the HTTP ``Content-Type``
127+
header MUST be set to (i.e. ``application/json``).
128+
129+
Transaction operations
130+
----------------------
131+
132+
The below examples demonstrate transactional workflow using pycsw's OGC API - Records endpoint:
133+
134+
.. code-block:: bash
135+
136+
# insert STAC Item
137+
curl -v -H "Content-Type: application/json" -XPOST http://localhost:8000/stac/collections/metadata:main/items -d @fooitem.json
138+
139+
# update STAC Item
140+
curl -v -H "Content-Type: application/json" -XPUT http://localhost:8000/stac/collections/metadata:main/items/fooitem -d @fooitem.json
141+
142+
# delete STAC Item
143+
curl -v -XDELETE http://localhost:8000/stac/collections/metadata:main/items/fooitem
144+
145+
# insert STAC Item Collection
146+
curl -v -H "Content-Type: application/json" -XPOST http://localhost:8000/stac/collections/metadata:main/items -d @fooitemcollection.json
147+
148+
# insert STAC Collection
149+
curl -v -H "Content-Type: application/json" -XPOST http://localhost:8000/stac/collections -d @foocollection.json
150+
151+
# update STAC Collection
152+
curl -v -H "Content-Type: application/json" -XPUT http://localhost:8000/stac/collections/foocollection -d @foocollection.json
153+
154+
# delete STAC Collection
155+
curl -v -XDELETE http://localhost:8000/stac/collections/foocollection
117156
118157
.. _`OGC API - Features - Part 4: Create, Replace, Update and Delete`: https://docs.ogc.org/DRAFTS/20-002.html
158+
.. _`STAC API - Transaction Extension Specification`: https://github.com/stac-api-extensions/transaction
159+
.. _`STAC API - Collection Transaction Extension`: https://github.com/stac-api-extensions/collection-transaction

pycsw/core/repository.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ def __init__(self, database, context, app_root=None, table='records', repo_filte
147147
self.query_mappings = {
148148
'identifier': self.dataset.identifier,
149149
'type': self.dataset.type,
150+
'typename': self.dataset.typename,
150151
'parentidentifier': self.dataset.parentidentifier,
151152
'collections': self.dataset.parentidentifier,
152153
'updated': self.dataset.insert_date,

pycsw/stac/api.py

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,9 @@
6969
'https://api.stacspec.org/v1.0.0/item-search#filter',
7070
'https://api.stacspec.org/v1.0.0/item-search#free-text',
7171
'https://api.stacspec.org/v1.0.0-rc.1/collection-search',
72-
'https://api.stacspec.org/v1.0.0-rc.1/collection-search#free-text'
72+
'https://api.stacspec.org/v1.0.0-rc.1/collection-search#free-text',
73+
'https://api.stacspec.org/v1.0.0/collections/extensions/transaction',
74+
'https://api.stacspec.org/v1.0.0/ogcapi-features/extensions/transaction'
7375
]
7476

7577

@@ -321,11 +323,15 @@ def collection(self, headers_, args, collection='metadata:main'):
321323
if collection == 'metadata:main':
322324
collection_info = self.get_collection_info()
323325
else:
324-
virtual_collection = self.repository.query_ids([collection])[0]
325-
collection_info = self.get_collection_info(
326-
virtual_collection.identifier,
327-
dict(title=virtual_collection.title,
326+
try:
327+
virtual_collection = self.repository.query_ids([collection])[0]
328+
collection_info = self.get_collection_info(
329+
virtual_collection.identifier,
330+
dict(title=virtual_collection.title,
328331
description=virtual_collection.abstract))
332+
except IndexError:
333+
return self.get_exception(
334+
404, headers_, 'InvalidParameterValue', 'STAC collection not found')
329335

330336
response = collection_info
331337
url_base = f"{self.config['server']['url']}/collections/{collection}"
@@ -456,6 +462,11 @@ def item(self, headers_, args, collection, item):
456462
return self.get_exception(400, headers_, 'InvalidParameterValue', msg)
457463

458464
response = json.loads(response)
465+
466+
if 'id' not in response:
467+
return self.get_exception(
468+
404, headers_, 'InvalidParameterValue', 'item not found')
469+
459470
response = links2stacassets(collection, response)
460471

461472
return self.get_response(status, headers_, response)
@@ -497,6 +508,27 @@ def get_collection_info(self, collection_name: str = 'metadata:main',
497508
}]
498509
}
499510

511+
def manage_collection_item(self, headers_, action='create', item=None, data=None, collection=None):
512+
if action == 'create' and 'features' in data:
513+
LOGGER.debug('STAC Collection detected')
514+
515+
for feature in data['features']:
516+
data2 = feature
517+
if collection is not None:
518+
data2['collection'] = collection
519+
520+
headers, status, content = super().manage_collection_item(
521+
headers_=headers_, action='create', data=data2)
522+
523+
return self.get_response(201, headers_, {})
524+
525+
else: # default/super
526+
if collection is not None:
527+
data['collection'] = collection
528+
529+
return super().manage_collection_item(
530+
headers_=headers_, action=action, item=item, data=data)
531+
500532

501533
def links2stacassets(collection, record):
502534
LOGGER.debug('Transforming enclosure links to STAC assets')

pycsw/wsgi_flask.py

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -134,8 +134,8 @@ def conformance():
134134
return get_response(api_.conformance(dict(request.headers), request.args))
135135

136136

137-
@BLUEPRINT.route('/collections')
138-
@BLUEPRINT.route('/stac/collections')
137+
@BLUEPRINT.route('/collections', methods=['GET', 'POST'])
138+
@BLUEPRINT.route('/stac/collections', methods=['GET', 'POST'])
139139
def collections():
140140
"""
141141
OGC API collections endpoint
@@ -144,13 +144,19 @@ def collections():
144144
"""
145145

146146
if get_api_type(request.url_rule.rule) == 'stac-api':
147-
return get_response(stacapi.collections(dict(request.headers), request.args)) # noqa
147+
if request.method == 'POST':
148+
data = request.get_json(silent=True)
149+
return get_response(stacapi.manage_collection_item(dict(request.headers),
150+
'create', data=data))
151+
else:
152+
return get_response(stacapi.collections(dict(request.headers),
153+
request.args))
148154
else:
149155
return get_response(api_.collections(dict(request.headers), request.args))
150156

151157

152-
@BLUEPRINT.route('/collections/<collection>')
153-
@BLUEPRINT.route('/stac/collections/<collection>')
158+
@BLUEPRINT.route('/collections/<collection>', methods=['GET', 'PUT', 'DELETE'])
159+
@BLUEPRINT.route('/stac/collections/<collection>', methods=['GET', 'PUT', 'DELETE'])
154160
def collection(collection='metadata:main'):
155161
"""
156162
OGC API collection endpoint
@@ -161,8 +167,18 @@ def collection(collection='metadata:main'):
161167
"""
162168

163169
if get_api_type(request.url_rule.rule) == 'stac-api':
164-
return get_response(stacapi.collection(dict(request.headers),
165-
request.args, collection))
170+
if request.method == 'PUT':
171+
return get_response(
172+
stacapi.manage_collection_item(
173+
dict(request.headers), 'update', collection,
174+
data=request.get_json(silent=True)))
175+
elif request.method == 'DELETE':
176+
return get_response(
177+
stacapi.manage_collection_item(dict(request.headers),
178+
'delete', collection))
179+
else:
180+
return get_response(stacapi.collection(dict(request.headers),
181+
request.args, collection))
166182
else:
167183
return get_response(api_.collection(dict(request.headers),
168184
request.args, collection))
@@ -200,14 +216,22 @@ def items(collection='metadata:main'):
200216
:returns: HTTP response
201217
"""
202218

203-
if request.method == 'POST' and request.content_type not in [None, 'application/json']: # noqa
219+
if all([get_api_type(request.url_rule.rule) == 'ogcapi-records',
220+
request.method == 'POST',
221+
request.content_type not in [None, 'application/json']]):
222+
204223
data = None
205224
if request.content_type == 'application/geo+json': # JSON grammar
206225
data = request.get_json(silent=True)
207226
elif 'xml' in request.content_type: # XML grammar
208227
data = request.data
228+
209229
return get_response(api_.manage_collection_item(dict(request.headers),
210230
'create', data=data))
231+
elif request.method == 'POST' and get_api_type(request.url_rule.rule) == 'stac-api':
232+
data = request.get_json(silent=True)
233+
return get_response(stacapi.manage_collection_item(dict(request.headers),
234+
'create', data=data, collection=collection))
211235
else:
212236
if get_api_type(request.url_rule.rule) == 'stac-api':
213237
return get_response(stacapi.items(dict(request.headers),

tests/functionaltests/suites/oarec/test_oarec_functional.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ def test_queryables(config):
107107
assert content['$id'] == 'http://localhost/pycsw/oarec/collections/metadata:main/queryables' # noqa
108108
assert content['$schema'] == 'http://json-schema.org/draft/2019-09/schema'
109109

110-
assert len(content['properties']) == 13
110+
assert len(content['properties']) == 14
111111

112112
assert 'geometry' in content['properties']
113113
assert content['properties']['geometry']['$ref'] == 'https://geojson.org/schema/Polygon.json' # noqa

tests/functionaltests/suites/oarec/test_oarec_virtual_collections_functional.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ def test_queryables(config_virtual_collections):
6868
assert content['$id'] == 'http://localhost/pycsw/oarec/collections/metadata:main/queryables' # noqa
6969
assert content['$schema'] == 'http://json-schema.org/draft/2019-09/schema'
7070

71-
assert len(content['properties']) == 13
71+
assert len(content['properties']) == 14
7272

7373
assert 'geometry' in content['properties']
7474
assert content['properties']['geometry']['$ref'] == 'https://geojson.org/schema/Polygon.json' # noqa

0 commit comments

Comments
 (0)