diff --git a/xcube_geodb_openeo/api/context.py b/xcube_geodb_openeo/api/context.py index 3adde9b..0f5e612 100644 --- a/xcube_geodb_openeo/api/context.py +++ b/xcube_geodb_openeo/api/context.py @@ -20,6 +20,7 @@ # DEALINGS IN THE SOFTWARE. import datetime import importlib +import re from functools import cached_property from typing import Any, List from typing import Dict @@ -36,8 +37,7 @@ from ..core.vectorcube import VectorCube from ..core.vectorcube_provider import VectorCubeProvider from ..defaults import default_config, STAC_VERSION, STAC_EXTENSIONS, \ - STAC_MAX_ITEMS_LIMIT, DEFAULT_VC_CACHE_SIZE, \ - MAX_NUMBER_OF_GEOMETRIES_DISPLAYED + DEFAULT_VC_CACHE_SIZE class GeoDbContext(ApiContext): @@ -151,28 +151,45 @@ def get_collection_items( offset: int, bbox: Optional[Tuple[float, float, float, float]] = None) -> Dict: vector_cube = self.get_vector_cube(collection_id, bbox=bbox) - stac_features = [ - _get_vector_cube_item(base_url, vector_cube, feature) - for feature in vector_cube.load_features(limit, offset) - ] - - result = { - 'type': 'FeatureCollection', - 'features': stac_features, - 'timeStamp': _utc_now(), - 'numberMatched': vector_cube.feature_count, - 'numberReturned': len(stac_features) - } + stac_features = [] + for feature in vector_cube.load_features(limit, offset): + _fix_time(feature) + stac_features.append( + _get_vector_cube_item(base_url, vector_cube, feature) + ) + + result = {'type': 'FeatureCollection', 'features': stac_features, + 'timeStamp': _utc_now(), + 'numberMatched': vector_cube.feature_count, + 'numberReturned': len(stac_features), + 'links': [ + { + 'rel': 'self', + 'href': f'{base_url}/collections/' + f'{vector_cube.id}/items', + 'type': 'application/json' + }, + { + 'rel': 'root', + 'href': f'{base_url}', + 'type': 'application/json' + }, + { + 'rel': 'items', + 'href': f'{base_url}collections/' + f'{vector_cube.id}/items', + 'type': 'application/json' + }] + } if offset + limit < vector_cube.feature_count: new_offset = offset + limit - result['links'] = [ + result['links'].append( { 'rel': 'next', 'href': f'{base_url}/collections/{vector_cube.id}' f'/items?limit={limit}&offset={new_offset}' - }, - ] + }) return result @@ -197,6 +214,13 @@ def transform_bbox(self, collection_id: Tuple[str, str], def get_collections_links(limit: int, offset: int, url: str, collection_count: int): links = [] + root_url = url.replace('/collections', '') + root_link = {'rel': 'root', + 'href': f'{root_url}', + 'title': 'root'} + self_link = {'rel': 'self', + 'href': f'{url}', + 'title': 'self'} next_offset = offset + limit next_link = {'rel': 'next', 'href': f'{url}?limit={limit}&offset='f'{next_offset}', @@ -213,6 +237,8 @@ def get_collections_links(limit: int, offset: int, url: str, 'href': f'{url}?limit={limit}&offset='f'{last_offset}', 'title': 'last'} + links.append(root_link) + links.append(self_link) if next_offset < collection_count: links.append(next_link) if offset > 0: @@ -244,11 +270,23 @@ def _get_vector_cube_collection(base_url: str, 'links': [ { 'rel': 'self', - 'href': f'{base_url}/collections/{vector_cube_id}' + 'href': f'{base_url}/collections/{vector_cube_id}', + "type": "application/json" }, { 'rel': 'root', - 'href': f'{base_url}/collections/' + 'href': f'{base_url}', + "type": "application/json" + }, + { + 'rel': 'parent', + 'href': f'{base_url}/collections/', + "type": "application/json" + }, + { + 'rel': 'items', + 'href': f'{base_url}/collections/{vector_cube_id}/items', + "type": "application/json" } ] } @@ -267,8 +305,9 @@ def _get_vector_cube_collection(base_url: str, 'reference_system': srid } } - vector_cube_collection['summaries'] = metadata.get('summaries', {}), - + vector_cube_collection['summaries'] = (metadata['summaries'] + if 'summaries' in metadata + else {}) if 'version' in metadata: vector_cube_collection['version'] = metadata['version'] return vector_cube_collection @@ -281,12 +320,11 @@ def _get_vector_cube_item(base_url: str, vector_cube: VectorCube, feature_bbox = feature.get('bbox') feature_geometry = feature.get('geometry') feature_properties = feature.get('properties', {}) - feature_datetime = feature.get('datetime') \ - if 'datetime' in feature else None item = { 'stac_version': STAC_VERSION, - 'stac_extensions': STAC_EXTENSIONS, + 'stac_extensions': ['https://schemas.stacspec.org/v1.0.0/item-spec/' + 'json-schema/item.json'], 'type': 'Feature', 'id': feature_id, 'bbox': feature_bbox, @@ -298,12 +336,25 @@ def _get_vector_cube_item(base_url: str, vector_cube: VectorCube, 'rel': 'self', 'href': f'{base_url}/collections/' f'{collection_id}/items/{feature_id}' - } + }, + { + 'rel': 'root', + 'href': f'{base_url}', + 'type': 'application/json' + }, + { + 'rel': 'parent', + 'href': f'{base_url}/collections', + 'type': 'application/json' + }, + { + 'rel': 'collection', + 'href': f'{base_url}/collections/{collection_id}', + 'type': 'application/json' + } ], 'assets': {} } - if feature_datetime: - item['datetime'] = feature_datetime return item @@ -314,6 +365,34 @@ def _utc_now(): .replace(microsecond=0) \ .isoformat() + 'Z' + +def _fix_time(feature): + time_column = _get_col_name(feature, ['date', 'time', 'timestamp', + 'datetime']) + props = feature['properties'] + if time_column and time_column != 'datetime': + props['datetime'] = props[time_column] + del props[time_column] + if 'datetime' not in props: + props['datetime'] = (datetime.datetime + .strptime('19700101', '%Y%M%d') + .isoformat() + 'Z') + if re.match('^\d\d\d\d.\d\d.\d\d$', props['datetime']): + props['datetime'] = props['datetime'] + 'T00:00:00Z' + is_tz_aware = (props['datetime'].endswith('Z') + or props['datetime'].endswith('+00:00')) + if props['datetime'] and not is_tz_aware: + props['datetime'] = props['datetime'] + 'Z' + + +def _get_col_name(feature: Feature, possible_names: List[str]) \ + -> Optional[str]: + for key in feature['properties'].keys(): + if key in possible_names: + return key + return None + + class CollectionNotFoundException(Exception): pass diff --git a/xcube_geodb_openeo/api/routes.py b/xcube_geodb_openeo/api/routes.py index b9a34d5..54d8c82 100644 --- a/xcube_geodb_openeo/api/routes.py +++ b/xcube_geodb_openeo/api/routes.py @@ -24,10 +24,13 @@ import requests from openeo.internal.graph_building import PGNode +from xcube.constants import LOG from xcube.server.api import ApiError from xcube.server.api import ApiHandler +from xcube_geodb.core.geodb import GeoDBError from .api import api +from .context import _fix_time from ..backend import capabilities from ..backend import processes from ..defaults import STAC_DEFAULT_ITEMS_LIMIT, STAC_MAX_ITEMS_LIMIT, \ @@ -260,6 +263,7 @@ def get(self): @api.route('/collections/{collection_id}') +@api.route('/collections/{collection_id}/') class CollectionHandler(ApiHandler): """ Lists all information about a specific collection specified by the @@ -271,14 +275,20 @@ def get(self, collection_id: str): Lists the collection information. """ base_url = get_base_url(self.request) + if '~' not in collection_id: + self.response.set_status(404, + f'Collection {collection_id} does ' + f'not exist') + return db = collection_id.split('~')[0] name = collection_id.split('~')[1] collection = self.ctx.get_collection(base_url, (db, name), True) if collection: self.response.finish(collection) else: - self.response.set_status(404, f'Collection {collection_id} does ' - f'not exist') + self.response.set_status(404, + f'Collection {collection_id} does ' + f'not exist') @api.route('/collections/{collection_id}/items') @@ -310,7 +320,7 @@ def get(self, collection_id: str): name = collection_id.split('~')[1] items = self.ctx.get_collection_items(base_url, (db, name), limit, offset, bbox) - self.response.finish(items) + self.response.finish(items, content_type='application/geo+json') @api.route('/collections/{collection_id}/items/{item_id}') @@ -327,9 +337,30 @@ def get(self, collection_id: str, item_id: str): db = collection_id.split('~')[0] name = collection_id.split('~')[1] base_url = get_base_url(self.request) - feature = self.ctx.get_collection_item(base_url, (db, name), - feature_id) - self.response.finish(feature) + try: + feature = self.ctx.get_collection_item(base_url, (db, name), + feature_id) + except GeoDBError as e: + if 'does not exist' in e.args[0]: + LOG.warning(f'Not existing feature with id {feature_id} ' + f'requested.') + self.response.set_status(404, + f'Feature {feature_id} does ' + f'not exist') + return + + _fix_time(feature) + self.response.finish(feature, content_type='application/geo+json') + + +@api.route('/api.html') +class FeatureHandler(ApiHandler): + """ + Simply forwards to openapi.html + """ + + def get(self): + self.response._handler.redirect('/openapi.html', status=301) def _get_limit(request, default=sys.maxsize) -> int: @@ -350,4 +381,4 @@ def _get_bbox(request): bbox = str(request.get_query_arg('bbox')) return tuple(bbox.split(',')) else: - return None \ No newline at end of file + return None diff --git a/xcube_geodb_openeo/backend/capabilities.py b/xcube_geodb_openeo/backend/capabilities.py index beb590c..2a8f6dd 100644 --- a/xcube_geodb_openeo/backend/capabilities.py +++ b/xcube_geodb_openeo/backend/capabilities.py @@ -36,10 +36,14 @@ def get_root(config: Mapping[str, Any], base_url: str): 'api_version': API_VERSION, 'backend_version': __version__, 'stac_version': STAC_VERSION, - 'type': 'catalog', + 'type': 'Catalog', "id": config['geodb_openeo']['SERVER_ID'], "title": config['geodb_openeo']['SERVER_TITLE'], "description": config['geodb_openeo']['SERVER_DESCRIPTION'], + "conformsTo": [ + f'https://api.stacspec.org/v1.0.0/{part}' + for part in ['core', 'collections', 'ogcapi-features'] + ], 'endpoints': [ {'path': '/.well-known/openeo', 'methods': ['GET']}, {'path': '/file_formats', 'methods': ['GET']}, @@ -52,6 +56,12 @@ def get_root(config: Mapping[str, Any], base_url: str): 'methods': ['GET']}, ], "links": [ + { + "rel": "root", + "href": f"{base_url}/", + "type": "application/json", + "title": "this document" + }, { "rel": "self", "href": f"{base_url}/", @@ -60,7 +70,7 @@ def get_root(config: Mapping[str, Any], base_url: str): }, { "rel": "service-desc", - "href": f"{base_url}/api", + "href": f'{base_url}/openapi.json', "type": "application/vnd.oai.openapi+json;version=3.0", "title": "the API definition" }, diff --git a/xcube_geodb_openeo/core/geodb_datasource.py b/xcube_geodb_openeo/core/geodb_datasource.py index 2c71bb3..ed7c820 100644 --- a/xcube_geodb_openeo/core/geodb_datasource.py +++ b/xcube_geodb_openeo/core/geodb_datasource.py @@ -137,21 +137,24 @@ def load_features(self, limit: int = STAC_DEFAULT_ITEMS_LIMIT, bbox = gdf.bounds.iloc[i] props = dict(row[1]) geometry = props['geometry'] - id = props['id'] + feature_id = str(props['id']) del props['geometry'] del props['id'] - if with_stac_info: - props['bbox']: [f'{bbox["minx"]:.4f}', - f'{bbox["miny"]:.4f}', - f'{bbox["maxx"]:.4f}', - f'{bbox["maxy"]:.4f}'] - props['stac_version']: STAC_VERSION - props['stac_extensions']: STAC_EXTENSIONS - props['type']: 'Feature' + feature = Feature( - id=str(id), + id=feature_id, geometry=geometry, properties=props) + + if with_stac_info: + feature['bbox'] = [bbox['minx'], + bbox['miny'], + bbox['maxx'], + bbox['maxy']] + feature['stac_version'] = STAC_VERSION + feature['stac_extensions'] = STAC_EXTENSIONS + feature['type'] = 'Feature' + features.append(feature) LOG.debug('...done.') return features @@ -235,12 +238,12 @@ def get_metadata(self, full: bool = False) -> Dict: name, select=time_column, order=time_column, limit=1, database=db)[time_column][0] - earliest = dateutil.parser.parse(earliest).isoformat() + earliest = dateutil.parser.parse(earliest).isoformat() + 'Z' latest = self._geodb.get_collection_pg( name, select=time_column, order=f'{time_column} DESC', limit=1, database=db)[time_column][0] - latest = dateutil.parser.parse(latest).isoformat() + latest = dateutil.parser.parse(latest).isoformat() + 'Z' LOG.debug(f'...done.') else: earliest, latest = None, None @@ -249,15 +252,15 @@ def get_metadata(self, full: bool = False) -> Dict: 'title': f'{name}', 'extent': { 'spatial': { - 'bbox': [None] if not full else - self.get_vector_cube_bbox(), + 'bbox': [[-180, -90, 180, 90]] if not full else + [self.get_vector_cube_bbox()], }, 'temporal': { 'interval': [[earliest, latest]] } }, 'summaries': { - 'column_names': col_names + 'properties': col_names }, } return metadata diff --git a/xcube_geodb_openeo/defaults.py b/xcube_geodb_openeo/defaults.py index 7f33112..ed319a6 100644 --- a/xcube_geodb_openeo/defaults.py +++ b/xcube_geodb_openeo/defaults.py @@ -30,7 +30,7 @@ API_VERSION = '1.1.0' STAC_VERSION = '1.0.0' STAC_EXTENSIONS = \ - ['datacube', + ['https://stac-extensions.github.io/datacube/v2.2.0/schema.json', 'https://stac-extensions.github.io/version/v1.0.0/schema.json'] STAC_DEFAULT_ITEMS_LIMIT = 10