Skip to content

Commit

Permalink
add support for STAC API - Collection Transaction Extension
Browse files Browse the repository at this point in the history
  • Loading branch information
tomkralidis committed Dec 14, 2024
1 parent 3edf0e8 commit 6e98aa8
Show file tree
Hide file tree
Showing 7 changed files with 203 additions and 20 deletions.
6 changes: 5 additions & 1 deletion docs/stac.rst
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,10 @@ STAC support will render links as follows:
Transactions
^^^^^^^^^^^^

STAC Transactions are supported as per the `STAC API - Transaction Extension Specification`_.
STAC Transactions are supported as per the following STAC API specifications:

* `STAC API - Transaction Extension Specification`_.
* `STAC API - Collection Transaction Extension`_.

Request Examples
----------------
Expand Down Expand Up @@ -112,3 +115,4 @@ Request Examples
.. _`SpatioTemporal Asset Catalog API version v1.0.0`: https://github.com/radiantearth/stac-api-spec
.. _`STAC API - Transaction Extension Specification`: https://github.com/stac-api-extensions/transaction
.. _`STAC API - Collection Transaction Extension`: https://github.com/stac-api-extensions/collection-transaction
19 changes: 16 additions & 3 deletions docs/transactions.rst
Original file line number Diff line number Diff line change
Expand Up @@ -117,13 +117,13 @@ Harvesting is not yet supported via OGC API - Records.
Transactions using STAC API
===========================

pycsw's STAC API support provides transactional capabilities via the `STAC API - Transaction Extension Specification`_ extension specification,
pycsw's STAC API support provides transactional capabilities via the `STAC API - Transaction Extension Specification`_ and `STAC API - Collection Transaction Extension`_ specifications,
which follows RESTful patterns for insert/update/delete of resources.

Supported Resource Types
------------------------

STAC Items and Item Collections are supported via OGC API - Records transactional workflow. Note that the HTTP ``Content-Type``
STAC Collections, Items and Item Collections are supported via OGC API - Records transactional workflow. Note that the HTTP ``Content-Type``
header MUST be set to (i.e. ``application/json``).

Transaction operations
Expand All @@ -139,8 +139,21 @@ The below examples demonstrate transactional workflow using pycsw's OGC API - Re
# update STAC Item
curl -v -H "Content-Type: application/json" -XPUT http://localhost:8000/stac/collections/metadata:main/items/fooitem -d @fooitem.json
# delete item
# delete STAC Item
curl -v -XDELETE http://localhost:8000/stac/collections/metadata:main/items/fooitem
# insert STAC Item Collection
curl -v -H "Content-Type: application/json" -XPOST http://localhost:8000/stac/collections/metadata:main/items -d @fooitemcollection.json
# insert STAC Collection
curl -v -H "Content-Type: application/json" -XPOST http://localhost:8000/stac/collections -d @foocollection.json
# update STAC Collection
curl -v -H "Content-Type: application/json" -XPUT http://localhost:8000/stac/collections/foocollection -d @foocollection.json
# delete STAC Collection
curl -v -XDELETE http://localhost:8000/stac/collections/foocollection
.. _`OGC API - Features - Part 4: Create, Replace, Update and Delete`: https://docs.ogc.org/DRAFTS/20-002.html
.. _`STAC API - Transaction Extension Specification`: https://github.com/stac-api-extensions/transaction
.. _`STAC API - Collection Transaction Extension`: https://github.com/stac-api-extensions/collection-transaction
1 change: 1 addition & 0 deletions pycsw/core/repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ def __init__(self, database, context, app_root=None, table='records', repo_filte
self.query_mappings = {
'identifier': self.dataset.identifier,
'type': self.dataset.type,
'typename': self.dataset.typename,
'parentidentifier': self.dataset.parentidentifier,
'collections': self.dataset.parentidentifier,
'updated': self.dataset.insert_date,
Expand Down
13 changes: 9 additions & 4 deletions pycsw/stac/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@
'https://api.stacspec.org/v1.0.0/item-search#free-text',
'https://api.stacspec.org/v1.0.0-rc.1/collection-search',
'https://api.stacspec.org/v1.0.0-rc.1/collection-search#free-text',
'https://api.stacspec.org/v1.0.0/collections/extensions/transaction',
'https://api.stacspec.org/v1.0.0/ogcapi-features/extensions/transaction'
]

Expand Down Expand Up @@ -322,11 +323,15 @@ def collection(self, headers_, args, collection='metadata:main'):
if collection == 'metadata:main':
collection_info = self.get_collection_info()
else:
virtual_collection = self.repository.query_ids([collection])[0]
collection_info = self.get_collection_info(
virtual_collection.identifier,
dict(title=virtual_collection.title,
try:
virtual_collection = self.repository.query_ids([collection])[0]
collection_info = self.get_collection_info(
virtual_collection.identifier,
dict(title=virtual_collection.title,
description=virtual_collection.abstract))
except IndexError:
return self.get_exception(
404, headers_, 'InvalidParameterValue', 'STAC collection not found')

response = collection_info
url_base = f"{self.config['server']['url']}/collections/{collection}"
Expand Down
30 changes: 23 additions & 7 deletions pycsw/wsgi_flask.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,8 +134,8 @@ def conformance():
return get_response(api_.conformance(dict(request.headers), request.args))


@BLUEPRINT.route('/collections')
@BLUEPRINT.route('/stac/collections')
@BLUEPRINT.route('/collections', methods=['GET', 'POST'])
@BLUEPRINT.route('/stac/collections', methods=['GET', 'POST'])
def collections():
"""
OGC API collections endpoint
Expand All @@ -144,13 +144,19 @@ def collections():
"""

if get_api_type(request.url_rule.rule) == 'stac-api':
return get_response(stacapi.collections(dict(request.headers), request.args)) # noqa
if request.method == 'POST':
data = request.get_json(silent=True)
return get_response(stacapi.manage_collection_item(dict(request.headers),
'create', data=data))
else:
return get_response(stacapi.collections(dict(request.headers),
request.args))
else:
return get_response(api_.collections(dict(request.headers), request.args))


@BLUEPRINT.route('/collections/<collection>')
@BLUEPRINT.route('/stac/collections/<collection>')
@BLUEPRINT.route('/collections/<collection>', methods=['GET', 'PUT', 'DELETE'])
@BLUEPRINT.route('/stac/collections/<collection>', methods=['GET', 'PUT', 'DELETE'])
def collection(collection='metadata:main'):
"""
OGC API collection endpoint
Expand All @@ -161,8 +167,18 @@ def collection(collection='metadata:main'):
"""

if get_api_type(request.url_rule.rule) == 'stac-api':
return get_response(stacapi.collection(dict(request.headers),
request.args, collection))
if request.method == 'PUT':
return get_response(
stacapi.manage_collection_item(
dict(request.headers), 'update', collection,
data=request.get_json(silent=True)))
elif request.method == 'DELETE':
return get_response(
stacapi.manage_collection_item(dict(request.headers),
'delete', collection))
else:
return get_response(stacapi.collection(dict(request.headers),
request.args, collection))
else:
return get_response(api_.collection(dict(request.headers),
request.args, collection))
Expand Down
65 changes: 65 additions & 0 deletions tests/functionaltests/suites/stac_api/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,71 @@ def config():
}


@pytest.fixture()
def sample_collection():
yield {
'assets': {
'6072a0ee-0fff-4755-9cc7-660711de9b35': {
'href': 'https://api.up42.com/v2/assets/6072a0ee-0fff-4755-9cc7-660711de9b35',
'title': 'Original Delivery',
'roles': [
'data',
'original'
],
'type': 'application/zip'
}
},
'links': [
{
'rel': 'self',
'href': 'https://api.up42.dev/catalog/hosts/oneatlas/stac/search'
}
],
'stac_extensions': [
'https://api.up42.com/stac-extensions/up42-order/v1.0.0/schema.json'
],
'title': 'ORT_SPOT7_20190922_094920500_000',
'description': 'High-resolution 1.5m SPOT images acquired daily on a global basis. The datasets are available starting from 2012.',
'keywords': [
'berlin',
'optical'
],
'license': 'proprietary',
'providers': [
{
'name': 'Airbus',
'roles': [
'producer'
],
'url': 'https://www.airbus.com'
}
],
'extent': {
'spatial': {
'bbox': [
[
-86.07022916666666,
11.900145833333333,
-86.05072916666667,
11.942270833333334
]
]
},
'temporal': {
'interval': [
[
'2017-01-01T00:00:00Z',
'2021-12-31T00:00:00Z'
]
]
}
},
'stac_version': '1.0.0',
'type': 'Collection',
'id': '123e4567-e89b-12d3-a456-426614174000'
}


@pytest.fixture()
def sample_item():
yield {
Expand Down
89 changes: 84 additions & 5 deletions tests/functionaltests/suites/stac_api/test_stac_api_functional.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ def test_landing_page(config):

assert content['stac_version'] == '1.0.0'
assert content['type'] == 'Catalog'
assert len(content['conformsTo']) == 19
assert len(content['conformsTo']) == 20
assert len(content['keywords']) == 3


Expand All @@ -70,7 +70,7 @@ def test_conformance(config):
assert headers['Content-Type'] == 'application/json'
assert status == 200

assert len(content['conformsTo']) == 19
assert len(content['conformsTo']) == 20

conformances = [
'http://www.opengis.net/spec/ogcapi-common-2/1.0/conf/simple-query',
Expand Down Expand Up @@ -118,7 +118,7 @@ def test_queryables(config):
assert content['$id'] == 'http://localhost/pycsw/oarec/stac/collections/metadata:main/queryables' # noqa
assert content['$schema'] == 'http://json-schema.org/draft/2019-09/schema'

assert len(content['properties']) == 13
assert len(content['properties']) == 14

assert 'geometry' in content['properties']
assert content['properties']['geometry']['$ref'] == 'https://geojson.org/schema/Polygon.json' # noqa
Expand Down Expand Up @@ -235,7 +235,8 @@ def test_item(config):
assert status == 400


def test_json_transaction(config, sample_item, sample_item_collection):
def test_json_transaction(config, sample_collection, sample_item,
sample_item_collection):
api = STACAPI(config)
request_headers = {
'Content-Type': 'application/json'
Expand Down Expand Up @@ -270,7 +271,7 @@ def test_json_transaction(config, sample_item, sample_item_collection):
'20201211_223832_CS2')[2])

assert content['id'] == '20201211_223832_CS2'
assert content['properties']['datetime'] == '2021-12-11T22:38:32.125000Z'
assert content['properties']['datetime'] == sample_item['properties']['datetime']
assert content['collection'] == 'metadata:main'

# delete item
Expand Down Expand Up @@ -302,3 +303,81 @@ def test_json_transaction(config, sample_item, sample_item_collection):
matched = json.loads(content)['numberMatched']

assert matched == 14

# delete items from item collection
headers, status, content = api.manage_collection_item(
request_headers, 'delete', item='20201211_223832_CS2')

assert status == 200

headers, status, content = api.manage_collection_item(
request_headers, 'delete', item='20201212_223832_CS2')

assert status == 200

collection_id = '123e4567-e89b-12d3-a456-426614174000'

# insert collection
headers, status, content = api.manage_collection_item(
request_headers, 'create', data=sample_collection)

assert status == 201

# test that collection is in repository
headers, status, content = api.collections({}, {'f': 'json'})
content = json.loads(content)

collection_found = False

for collection in content['collections']:
if collection['id'] == collection_id:
collection_found = True

assert collection_found

headers, status, content = api.collection({}, {'f': 'json'}, collection=collection_id)

content = json.loads(content)

assert content['id'] == collection_id

assert content['title'] == 'ORT_SPOT7_20190922_094920500_000'

# update collection
sample_collection['title'] = 'test title update'

headers, status, content = api.manage_collection_item(
request_headers, 'update', item=collection_id,
data=sample_collection, collection='metadata:main')

assert status == 204

headers, status, content = api.collection({}, {'f': 'json'}, collection=collection_id)

content = json.loads(content)

assert content['title'] == sample_collection['title']

# test that item is in repository
content = json.loads(api.item({}, {}, 'metadata:main',
'20201211_223832_CS2')[2])

# delete collection
headers, status, content = api.manage_collection_item(
request_headers, 'delete', item=collection_id)

content = json.loads(content)

assert status == 200

# test that collection is not in repository
headers, status, content = api.collections({}, {'f': 'json'})
content = json.loads(content)

collection_found = False

for collection in content['collections']:
if collection['id'] == collection_id:
collection_found = True

assert not collection_found

0 comments on commit 6e98aa8

Please sign in to comment.