diff --git a/docs/source/publishing/stac.rst b/docs/source/publishing/stac.rst index 97a23c965..40cdb4443 100644 --- a/docs/source/publishing/stac.rst +++ b/docs/source/publishing/stac.rst @@ -1,7 +1,7 @@ .. _stac: Publishing files to a SpatioTemporal Asset Catalog -************************************************** +================================================== The `SpatioTemporal Asset Catalog (STAC)`_ family of specifications aim to standardize the way geospatial asset metadata is structured and queried. A "spatiotemporal asset" @@ -16,9 +16,11 @@ years, and is used in numerous production deployments. pygeoapi built-in providers to browse STAC catalogs are described below: +Static catalog +-------------- FileSystem Provider -=================== +^^^^^^^^^^^^^^^^^^^ The FileSystem Provider implements STAC as a geospatial file browser through the server's file system, supporting any level of file/directory nesting/hierarchy. @@ -27,7 +29,7 @@ Configuring STAC in pygeoapi is done by simply pointing the ``data`` provider pr to the given directory and specifying allowed file types: Connection examples -------------------- +******************* .. code-block:: yaml @@ -47,7 +49,7 @@ Connection examples pygeometa metadata control files --------------------------------- +******************************** pygeoapi's STAC filesystem functionality supports `pygeometa`_ MCF files residing in the same directory as data files. If an MCF file is found, it will be used @@ -57,13 +59,13 @@ pygeometa will generate the STAC item metadata from configuration and by reading the data's properties. Publishing ESRI Shapefiles --------------------------- +************************** ESRI Shapefile publishing requires to specify all required component file extensions (``.shp``, ``.shx``, ``.dbf``) with the provider ``file_types`` option. Data access examples --------------------- +******************** * STAC root page @@ -72,7 +74,7 @@ Data access examples From here, browse the filesystem accordingly. Azure Blob Storage Provider -=========================== +^^^^^^^^^^^^^^^^^^^^^^^^^^^ The AzureBlobStorage Provider implements STAC as a geospatial file browser through Azure Blob Storage, supporting any level of file/directory nesting/hierarchy. @@ -81,7 +83,7 @@ Configuring STAC in pygeoapi is done by simply pointing the ``data`` provider pr to the given container and specifying allowed file types: Connection examples -------------------- +******************* .. code-block:: yaml @@ -104,7 +106,7 @@ Connection examples Hateoas Provider -================ +^^^^^^^^^^^^^^^^ HATEOAS (Hypermedia as the Engine of Application State) is a way of implementing a REST application that allows the client to dynamically navigate to the appropriate resources @@ -149,7 +151,7 @@ So, the following rules must be respected: ------------- File examples -------------- +************* **Structure of the catalog.json file** @@ -213,7 +215,7 @@ The code above shows the root catalog. The sub-catalogs have an additional ``rel ------------------------------------- -**Structure of the collection.json file** +**Structure of the ``collection.json`` file** Collections are similar to Catalogs with extra fields. @@ -270,10 +272,9 @@ Collections are similar to Catalogs with extra fields. } +**Structure of the Item ``.json`` file** -**Structure of the Item .json file** - -The example below shows the content of a file named *arcticdem-frontiere-0.json*. +The example below shows the content of a file named ``arcticdem-frontiere-0.json``: .. code-block:: json @@ -351,14 +352,14 @@ The example below shows the content of a file named *arcticdem-frontiere-0.json* -HATEOAS Configuration ---------------------- +HATEOAS configuration +********************* Configuring HATEOAS STAC Provider in pygeoapi is done by simply pointing the ``data`` provider property -to the local directory or remote URL and specifying the root file name (catalog.json or collection.json) in the file_types property: +to the local directory or remote URL and specifying the root file name (``catalog.json`` or ``collection.json``) in the ``file_types`` property: Connection examples -------------------- +******************* .. code-block:: yaml @@ -380,6 +381,106 @@ Connection examples data: tests/stac file_types: catalog.json +STAC API +-------- + +`STAC API`_ support is provided as a wrapper on top of resources that have feature or record providers configured. + +To enable STAC API support, configure a resource with a feature or record provider, and set the resource ``type`` to ``stac-collection``: + +.. code-block:: yaml + + canada-metadata: + type: stac-collection + title: + en: Open Canada sample data + fr: Exemple de donn\u00e9es Canada Ouvert + description: + en: Sample metadata records from open.canada.ca + fr: Exemples d'enregistrements de m\u00e9tadonn\u00e9es sur ouvert.canada.ca + keywords: + en: + - canada + - open data + fr: + - canada + - donn\u00e9es ouvertes + links: + - type: text/html + rel: canonical + title: information + href: https://open.canada.ca/en/open-data + hreflang: en-CA + - type: text/html + rel: alternate + title: informations + href: https://ouvert.canada.ca/fr/donnees-ouvertes + hreflang: fr-CA + extents: + spatial: + bbox: [-180,-90,180,90] + crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84 + providers: + - type: record + name: TinyDBCatalogue + data: tests/data/open.canada.ca/sample-records.tinydb + id_field: externalId + time_field: created + title_field: title + +STAC API queries will search all feature or record based resources configured as ``stac-collection``. Results +are decorated with the required STAC elements (unless they already exist). + +.. note:: + + pygeoapi STAC API support is minimally designed to leverage the OGC API - Features and OGC API - Records + implementations. A typical setup would be a features or records backend of STAC Items. pygeoapi does not + add or implement any STAC Catalog/Item relationships beyond what is encoded in a STAC resource. + + +Data access examples +-------------------- + +* landing page + + * http://localhost:5000/stac-api + +* query all STAC resources + + * http://localhost:5000/stac-api/search + +* query features (spatial) + + * http://localhost:5000/stac-api/search?bbox=-142,52,-140,55 + +* paging + + * http://localhost:5000/stac-api/search?offset=10&limit=10 + +* query features (temporal) + + * http://localhost:5000/stac-api/search?datetime=2019-11-11T11:11:11Z/.. + * http://localhost:5000/stac-api/search?datetime=2018-11-11T11:11:11Z/2019-11-11T11:11:11Z + * http://localhost:5000/stac-api/search?datetime=../2019-11-11T11:11:11Z + +.. code-block:: bash + + # query features (spatial) + curl -X POST http://localhost:5000/stac-api/search \ + -H "Content-Type: application/json" \ + -d "{\"bbox\": [-142, 52, -140, 55]}" + + # paging + curl -X POST http://localhost:5000/stac-api/search \ + -H "Content-Type: application/json" \ + -d "{\"offset\": 10, \"limit\": 10}" + + # query features (temporal) + curl -X POST http://localhost:5000/stac-api/search \ + -H "Content-Type: application/json" \ + -d "{\"datetime\": \"2019-11-11T11:11:11Z/..\"}" + .. _`SpatioTemporal Asset Catalog (STAC)`: https://stacspec.org .. _`pygeometa`: https://geopython.github.io/pygeometa +.. _`STAC API`: https://github.com/radiantearth/stac-api-spec diff --git a/pygeoapi/api/__init__.py b/pygeoapi/api/__init__.py index 8439d215d..c530cc2d8 100644 --- a/pygeoapi/api/__init__.py +++ b/pygeoapi/api/__init__.py @@ -1667,7 +1667,11 @@ def evaluate_limit(requested: Union[None, int], server_limits: dict, LOGGER.debug('no limit requested; returning default') return default - requested2 = get_typed_value(requested) + if isinstance(requested, int): + requested2 = requested + else: + requested2 = get_typed_value(requested) + if not isinstance(requested2, int): raise ValueError('limit value should be an integer') diff --git a/pygeoapi/api/stac.py b/pygeoapi/api/stac.py index 87ebd7541..84069e575 100644 --- a/pygeoapi/api/stac.py +++ b/pygeoapi/api/stac.py @@ -8,7 +8,7 @@ # Ricardo Garcia Silva # Bernhard Mallinger # -# Copyright (c) 2024 Tom Kralidis +# Copyright (c) 2025 Tom Kralidis # Copyright (c) 2025 Francesco Bartoli # Copyright (c) 2022 John A Stevenson and Colin Blackburn # Copyright (c) 2023 Ricardo Garcia Silva @@ -37,20 +37,26 @@ # # ================================================================= - +from copy import deepcopy from http import HTTPStatus +import json import logging -from typing import Tuple +from typing import Any, Tuple, Union +from urllib.parse import urlencode + +from shapely import from_geojson from pygeoapi import l10n +from pygeoapi import api as ogc_api +from pygeoapi.api import itemtypes as itemtypes_api from pygeoapi.plugin import load_plugin from pygeoapi.provider.base import ( - ProviderConnectionError, ProviderNotFoundError + ProviderConnectionError, ProviderNotFoundError, ProviderTypeError ) from pygeoapi.util import ( - get_provider_by_type, to_json, filter_dict_by_key_value, - render_j2_template + filter_dict_by_key_value, get_current_datetime, get_provider_by_type, + render_j2_template, to_json ) from . import APIRequest, API, FORMAT_TYPES, F_JSON, F_HTML @@ -94,15 +100,26 @@ def get_stac_root(api: API, request: APIRequest) -> Tuple[dict, int, str]: 'type', 'stac-collection') for key, value in stac_collections.items(): + try: + _ = load_plugin('provider', get_provider_by_type( + value['providers'], 'stac')) + except ProviderTypeError: + LOGGER.debug('Not a STAC-based provider; skipping') + continue + content['links'].append({ 'rel': 'child', 'href': f'{stac_url}/{key}?f={F_JSON}', - 'type': FORMAT_TYPES[F_JSON] + 'type': FORMAT_TYPES[F_JSON], + 'title': key, + 'description': value['description'] }) content['links'].append({ 'rel': 'child', 'href': f'{stac_url}/{key}', - 'type': FORMAT_TYPES[F_HTML] + 'type': FORMAT_TYPES[F_HTML], + 'title': key, + 'description': value['description'] }) if request.format == F_HTML: # render @@ -217,6 +234,210 @@ def get_stac_path(api: API, request: APIRequest, return headers, HTTPStatus.OK, stac_data +def landing_page(api: API, + request: APIRequest) -> Tuple[dict, int, str]: + """ + Provide API landing page + + :param request: A request object + + :returns: tuple of headers, status code, content + """ + + request._format = F_JSON + + headers, status, content = ogc_api.landing_page(api, request) + + content = json.loads(content) + + content['id'] = 'pygeoapi-catalogue' + content['stac_version'] = '1.0.0' + content['conformsTo'] = [ + 'https://api.stacspec.org/v1.0.0/core', + 'https://api.stacspec.org/v1.0.0/item-search', + 'https://api.stacspec.org/v1.0.0/item-search#sort' + ] + content['type'] = 'Catalog' + + content['links'] = [{ + 'rel': request.get_linkrel(F_JSON), + 'type': FORMAT_TYPES[F_JSON], + 'title': l10n.translate('This document as JSON', request.locale), + 'href': f"{api.base_url}/stac-api?f={F_JSON}" + }, { + 'rel': 'root', + 'type': FORMAT_TYPES[F_JSON], + 'title': l10n.translate('This document as JSON', request.locale), + 'href': f"{api.base_url}/stac-api?f={F_JSON}" + }, { + 'rel': 'service-desc', + 'type': 'application/vnd.oai.openapi+json;version=3.0', + 'title': l10n.translate('The OpenAPI definition as JSON', request.locale), # noqa + 'href': f"{api.base_url}/openapi" + }, { + 'rel': 'service-doc', + 'type': FORMAT_TYPES[F_HTML], + 'title': l10n.translate('The OpenAPI definition as HTML', request.locale), # noqa + 'href': f"{api.base_url}/openapi?f={F_HTML}", + 'hreflang': api.default_locale + }, { + 'rel': 'search', + 'type': FORMAT_TYPES[F_JSON], + 'title': l10n.translate('STAC API search', request.locale), + 'href': f"{api.base_url}/stac-api//search?f={F_JSON}" + }] + + return headers, status, to_json(content, api.pretty_print) + + +def search(api: API, request: Union[APIRequest, Any]) -> Tuple[dict, int, str]: + """ + STAC API Queries stac-collection + + :param request: A request object + :param dataset: dataset name + + :returns: tuple of headers, status code, content + """ + + stac_api_collections = {} + + request._format = F_JSON + + headers = request.get_response_headers(**api.api_headers) + + LOGGER.debug('Checking for STAC collections') + collections = filter_dict_by_key_value(api.config['resources'], + 'type', 'stac-collection') + + if not collections: + return api.get_exception( + HTTPStatus.NOT_IMPLEMENTED, headers, F_JSON, 'NotImplemented', + 'No configured STAC searchable collection') + + LOGGER.debug('Checking for STAC collections with features or records') + for key, value in collections.items(): + found_collection = False + for fr in ['feature', 'record']: + try: + _ = get_provider_by_type(value['providers'], fr) + found_collection = True + break + except ProviderTypeError: + pass + + if found_collection: + stac_api_collections[key] = value + + if not stac_api_collections: + msg = 'No STAC API collections configured' + return api.get_exception(HTTPStatus.INTERNAL_SERVER_ERROR, headers, + request.format, 'NotApplicable', msg) + + if request.data: + LOGGER.debug('Intercepting STAC POST request into query args') + request_data = json.loads(request.data) + request_params = deepcopy(dict(request.params)) + + for qp in ['bbox', 'datetime', 'limit', 'offset']: + if qp in request_data: + if qp == 'bbox' and isinstance(request_data[qp], list): + request_params[qp] = ','.join(str(b) for b in request_data[qp]) # noqa + else: + request_params[qp] = request_data[qp] + + request._args = request_params + request._data = None + + stac_api_response = { + 'type': 'FeatureCollection', + 'features': [], + 'numberMatched': 0, + 'links': [] + } + + for key, value in stac_api_collections.items(): + api.config['resources'][key]['type'] = 'collection' + headers, status, content = itemtypes_api.get_collection_items( + api, request, key) + api.config['resources'][key]['type'] = 'stac-collection' + + if status != HTTPStatus.OK: + return headers, status, to_json(content, api.pretty_print) + + content = json.loads(content) + stac_api_response['numberMatched'] += content.get('numberMatched', 0) + + if len(content.get('features', [])) > 0: + for feature in content['features']: + if 'stac_version' not in feature: + feature['stac_version'] = '1.0.0' + feature['properties'].update(get_temporal(feature)) + stac_api_response['features'].append(feature) + + if feature.get('geometry') is not None and 'bbox' not in feature: # noqa + geom = from_geojson(json.dumps(feature['geometry'])) + feature['bbox'] = geom.bounds + + for la in ['links', 'assets']: + if feature.get(la) is None: + feature[la] = [] + + stac_api_response['numberReturned'] = len(stac_api_response['features']) + + stac_api_response['links'].append({ + 'rel': 'root', + 'type': FORMAT_TYPES[F_JSON], + 'title': l10n.translate('STAC API landing page', request.locale), + 'href': f"{api.base_url}/stac-api?f={F_JSON}" + }) + + LOGGER.debug('Generating paging links') + + next_link = False + prev_link = False + request_params = deepcopy(dict(request._args)) + limit = itemtypes_api.evaluate_limit( + request_params.get('limit'), + api.config['server'].get('limits', {}), {}) + offset = int(request_params.get('offset', 0)) + + if stac_api_response.get('numberMatched', -1) > (limit + offset): + next_link = True + elif len(stac_api_response['features']) == limit: + next_link = True + + if offset > 0: + prev_link = True + + if prev_link: + request_params['offset'] = max(0, offset - limit) + if request_params['offset'] == 0: + request_params.pop('offset') + + request_params_qs = urlencode(request_params) + + stac_api_response['links'].append({ + 'rel': 'prev', + 'type': FORMAT_TYPES[F_JSON], + 'title': l10n.translate('Items (prev)', request.locale), + 'href': f"{api.base_url}/stac-api/search?{request_params_qs}" + }) + + if next_link: + request_params['offset'] = offset + limit + request_params_qs = urlencode(request_params) + + stac_api_response['links'].append({ + 'rel': 'next', + 'type': FORMAT_TYPES[F_JSON], + 'title': l10n.translate('Items (next)', request.locale), + 'href': f"{api.base_url}/stac-api/search?{request_params_qs}" + }) + + return headers, HTTPStatus.OK, to_json(stac_api_response, api.pretty_print) + + def get_oas_30(cfg: dict, locale: str) -> tuple[list[dict[str, str]], dict[str, dict]]: # noqa """ Get OpenAPI fragments @@ -232,7 +453,7 @@ def get_oas_30(cfg: dict, locale: str) -> tuple[list[dict[str, str]], dict[str, 'type', 'stac-collection') paths = {} if stac_collections: - paths['/stac'] = { + paths['/stac/catalog'] = { 'get': { 'summary': 'SpatioTemporal Asset Catalog', 'description': 'SpatioTemporal Asset Catalog', @@ -246,3 +467,42 @@ def get_oas_30(cfg: dict, locale: str) -> tuple[list[dict[str, str]], dict[str, } } return [{'name': 'stac'}], {'paths': paths} + + +def get_temporal(feature: dict) -> dict: + """ + Helper function to try and derive a useful temporal + definition on a non-STAC item + + :param feature: `dict` of GeoJSON feature + + :returns: `dict` of `datetime` or `start_datetime` and `end_datetime` + """ + + value = {} + + datetime_ = feature['properties'].get('datetime') + start_datetime = feature['properties'].get('start_datetime') + end_datetime = feature['properties'].get('end_datetime') + + if datetime_ is None and None not in [start_datetime, end_datetime]: + LOGGER.debug('Temporal range partially exists') + elif datetime_ is not None: + LOGGER.debug('Temporal instant exists') + + LOGGER.debug('Attempting to derive temporal from GeoJSON feature') + LOGGER.debug(feature) + if feature.get('time') is not None: + if feature['time'].get('timestamp') is not None: + value['datetime'] = feature['time']['timestamp'] + if feature['time'].get('interval') is not None: + value['start_datetime'] = feature['time']['interval'][0] + value['end_datetime'] = feature['time']['interval'][1] + + if feature['properties'].get('created') is not None: + value['datetime'] = feature['properties']['created'] + + if not value: + value['datetime'] = get_current_datetime() + + return value diff --git a/pygeoapi/django_/urls.py b/pygeoapi/django_/urls.py index 21607ac80..37a19d9ef 100644 --- a/pygeoapi/django_/urls.py +++ b/pygeoapi/django_/urls.py @@ -262,6 +262,16 @@ def apply_slash_rule(url: str): apply_slash_rule('stac/'), views.stac_catalog_root, name='stac-catalog-root' + ), + path( + apply_slash_rule('stac-api/'), + views.stac_landing_page, + name='stac-landing-page' + ), + path( + apply_slash_rule('stac-api/search'), + views.stac_search, + name='stac-search' ) ] diff --git a/pygeoapi/django_/views.py b/pygeoapi/django_/views.py index 040f3b57b..620581f11 100644 --- a/pygeoapi/django_/views.py +++ b/pygeoapi/django_/views.py @@ -454,6 +454,30 @@ def get_collection_edr_query( ) +def stac_landing_page(request: HttpRequest) -> HttpResponse: + """ + STAC API landing page endpoint + + :request Django HTTP Request + + :returns: Django HTTP response + """ + + return execute_from_django(stac_api.landing_page, request) + + +def stac_search(request: HttpRequest) -> HttpResponse: + """ + STAC API search endpoint + + :request Django HTTP Request + + :returns: Django HTTP response + """ + + return execute_from_django(stac_api.search, request) + + def stac_catalog_root(request: HttpRequest) -> HttpResponse: """ STAC root endpoint diff --git a/pygeoapi/flask_app.py b/pygeoapi/flask_app.py index ac9f0caac..a0cc2ea16 100644 --- a/pygeoapi/flask_app.py +++ b/pygeoapi/flask_app.py @@ -513,6 +513,28 @@ def get_collection_edr_query(collection_id, instance_id=None, ) +@BLUEPRINT.route('/stac-api') +def stac_landing_page(): + """ + STAC API landing page endpoint + + :returns: HTTP response + """ + + return execute_from_flask(stac_api.landing_page, request) + + +@BLUEPRINT.route('/stac-api/search', methods=['GET', 'POST']) +def stac_search(): + """ + STAC API search endpoint + + :returns: HTTP response + """ + + return execute_from_flask(stac_api.search, request) + + @BLUEPRINT.route('/stac') def stac_catalog_root(): """ diff --git a/pygeoapi/provider/xarray_.py b/pygeoapi/provider/xarray_.py index d215c2200..1cdb39a62 100644 --- a/pygeoapi/provider/xarray_.py +++ b/pygeoapi/provider/xarray_.py @@ -344,6 +344,7 @@ def gen_covjson(self, metadata, data, fields): cj['domain']['domainType'] = 'PointSeries' if self.time_field is not None: + LOGGER.debug('Adding time axis') cj['domain']['axes']['t'] = { 'values': [str(v) for v in ( data[self.time_field].values @@ -360,6 +361,7 @@ def gen_covjson(self, metadata, data, fields): } }) + LOGGER.debug('Adding parameters') for key, value in selected_fields.items(): parameter = { 'type': 'Parameter', @@ -383,6 +385,7 @@ def gen_covjson(self, metadata, data, fields): try: for key, value in selected_fields.items(): + LOGGER.debug(f'Adding range {key}') cj['ranges'][key] = { 'type': 'NdArray', 'dataType': value['type'], @@ -404,6 +407,7 @@ def gen_covjson(self, metadata, data, fields): LOGGER.warning(err) raise ProviderQueryError('Invalid query parameter') + LOGGER.debug('Returning data') return cj def _get_coverage_properties(self): diff --git a/pygeoapi/starlette_app.py b/pygeoapi/starlette_app.py index 29b7feaf5..07e4ad14e 100644 --- a/pygeoapi/starlette_app.py +++ b/pygeoapi/starlette_app.py @@ -551,6 +551,30 @@ async def stac_catalog_path(request: Request): return await execute_from_starlette(stac_api.get_stac_path, request, path) +async def stac_landing_page(request: Request): + """ + STAC API landing page endpoint + + :param request: Starlette Request instance + + :returns: Starlette HTTP response + """ + + return execute_from_starlette(stac_api.landing_page, request) + + +async def stac_search(request: Request): + """ + STAC API search endpoint + + :param request: Starlette Request instance + + :returns: Starlette HTTP response + """ + + return execute_from_starlette(stac_api.search, request) + + async def admin_config(request: Request): """ Admin endpoint @@ -692,7 +716,9 @@ async def __call__(self, scope: Scope, Route('/collections', collections), Route('/collections/{collection_id:path}', collections), Route('/stac', stac_catalog_root), - Route('/stac/{path:path}', stac_catalog_path) + Route('/stac/{path:path}', stac_catalog_path), + Route('/stac-api', stac_landing_page), + Route('/stac-api/search', stac_search, methods=['GET', 'POST']) ] admin_routes = [ diff --git a/pygeoapi/templates/collections/collection.html b/pygeoapi/templates/collections/collection.html index aa128a53f..849764e44 100644 --- a/pygeoapi/templates/collections/collection.html +++ b/pygeoapi/templates/collections/collection.html @@ -128,7 +128,7 @@

{% trans %}Storage CRS{% endtrans %}

{% trans %}CRS{% endtrans %}: {{ data['storageCrs'] }}
  • - {% trans %}Epoch{% endtrans %}: {{ data['storageCrsCoordinateEpoch'] or '_(not specified)' }} + {% trans %}Epoch{% endtrans %}: {{ data['storageCrsCoordinateEpoch'] or '_(not specified)' }}
  • {% endif %} diff --git a/pygeoapi/templates/stac/collection.html b/pygeoapi/templates/stac/collection.html index 30037a30d..1ae7afaca 100644 --- a/pygeoapi/templates/stac/collection.html +++ b/pygeoapi/templates/stac/collection.html @@ -19,17 +19,19 @@

    Collections

    - {% for k, v in filter_dict_by_key_value(config['resources'], 'type', 'stac-collection').items() %} + {% for link in data['links'] %} + {% if link['rel'] == 'child' and link['type'] == 'application/json' %} - - {{ v['title'] | striptags | truncate }} + + {{ link['title'] | striptags | truncate }} - {{ v['description'] | striptags | truncate }} + {{ link['description'] | striptags | truncate }} + {% endif %} {% endfor %} diff --git a/tests/api/test_stac.py b/tests/api/test_stac.py new file mode 100644 index 000000000..dea4de7bb --- /dev/null +++ b/tests/api/test_stac.py @@ -0,0 +1,97 @@ +# ================================================================= +# +# Authors: Tom Kralidis +# +# Copyright (c) 2025 Tom Kralidis +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation +# files (the "Software"), to deal in the Software without +# restriction, including without limitation the rights to use, +# copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the +# Software is furnished to do so, subject to the following +# conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +# OTHER DEALINGS IN THE SOFTWARE. +# +# ================================================================= + +import json + +import pytest + +from pygeoapi.api import FORMAT_TYPES, F_JSON +from pygeoapi.api.stac import search, landing_page +from pygeoapi.util import yaml_load + +from tests.util import get_test_file_path, mock_api_request + + +@pytest.fixture() +def config(): + with open(get_test_file_path('pygeoapi-test-stac-api-config.yml')) as fh: + return yaml_load(fh) + + +def test_landing_page(config, api_): + req = mock_api_request() + rsp_headers, code, response = landing_page(api_, req) + response = json.loads(response) + + assert rsp_headers['Content-Type'] == 'application/json' == \ + FORMAT_TYPES[F_JSON] + + assert isinstance(response, dict) + assert 'links' in response + assert len(response['conformsTo']) == 3 + assert response['type'] == 'Catalog' + assert response['links'][0]['rel'] == 'self' + assert response['links'][0]['type'] == 'application/json' + assert response['links'][0]['href'] == 'http://localhost:5000/stac-api?f=json' # noqa + assert len(response['links']) == 5 + assert 'title' in response + assert response['title'] == 'pygeoapi default instance' + assert 'description' in response + assert response['description'] == 'pygeoapi provides an API to geospatial data' # noqa + + +@pytest.mark.parametrize('params,matched,returned', [ + ({}, 10, 10), + ({'bbox': '-142,52,-140,55'}, 6, 6), + ({'limit': '1'}, 10, 1), + ({'datetime': '2019-11-11T11:11:11Z/..'}, 6, 6), + ({'datetime': '2018-11-11T11:11:11Z/2019-11-11T11:11:11Z'}, 4, 4) +]) +def test_search(config, api_, params, matched, returned): + # test GET + req = mock_api_request(params) + rsp_headers, code, response = search(api_, req) + response = json.loads(response) + + assert response['numberMatched'] == matched + assert response['numberReturned'] == returned + + for feature in response['features']: + assert feature['stac_version'] == '1.0.0' + + # test POST + req = mock_api_request(data=params) + rsp_headers, code, response = search(api_, req) + response = json.loads(response) + + assert response['numberMatched'] == matched + assert response['numberReturned'] == returned + + for feature in response['features']: + assert feature['stac_version'] == '1.0.0' diff --git a/tests/pygeoapi-test-stac-api-config.yml b/tests/pygeoapi-test-stac-api-config.yml new file mode 100644 index 000000000..ad7124e78 --- /dev/null +++ b/tests/pygeoapi-test-stac-api-config.yml @@ -0,0 +1,188 @@ +# ================================================================= +# +# Authors: Tom Kralidis +# +# Copyright (c) 2025 Tom Kralidis +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation +# files (the "Software"), to deal in the Software without +# restriction, including without limitation the rights to use, +# copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the +# Software is furnished to do so, subject to the following +# conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +# OTHER DEALINGS IN THE SOFTWARE. +# +# ================================================================= + +server: + bind: + host: 0.0.0.0 + port: 5000 + url: http://localhost:5000/ + mimetype: application/json; charset=UTF-8 + encoding: utf-8 + gzip: false + languages: + # First language is the default language + - en-US + - fr-CA + cors: true + pretty_print: true + limits: + default_items: 10 + max_items: 10 + # templates: /path/to/templates + map: + url: https://tile.openstreetmap.org/{z}/{x}/{y}.png + attribution: '© OpenStreetMap contributors' + manager: + name: TinyDB + connection: /tmp/pygeoapi-test-process-manager.db + output_dir: /tmp + +logging: + level: DEBUG + #logfile: /tmp/pygeoapi.log + +metadata: + identification: + title: + en: pygeoapi default instance + fr: instance par défaut de pygeoapi + description: + en: pygeoapi provides an API to geospatial data + fr: pygeoapi fournit une API aux données géospatiales + keywords: + en: + - geospatial + - data + - api + fr: + - géospatiale + - données + - api + keywords_type: theme + terms_of_service: https://creativecommons.org/licenses/by/4.0/ + url: http://example.org + license: + name: CC-BY 4.0 license + url: https://creativecommons.org/licenses/by/4.0/ + provider: + name: Organization Name + url: https://pygeoapi.io + contact: + name: Lastname, Firstname + position: Position Title + address: Mailing Address + city: City + stateorprovince: Administrative Area + postalcode: Zip or Postal Code + country: Country + phone: +xx-xxx-xxx-xxxx + fax: +xx-xxx-xxx-xxxx + email: you@example.org + url: Contact URL + hours: Hours of Service + instructions: During hours of service. Off on weekends. + role: pointOfContact + +resources: + nhgf-stac-static: + type: stac-collection + title: NHGF STAC COLLECTION (Static) + description: SpatioTemporal Asset Catalog for the USGS Water Mission Area + links: + - type: application/json + rel: canonical + title: NHGF STAC Catalog + href: https://code.usgs.gov/wma/nhgf/stac/-/raw/main/catalog/catalog.json + hreflang: en-US + extents: + spatial: + bbox: [-180, -90, 180, 90] + crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84 + providers: + - type: stac + name: Hateoas + data: https://code.usgs.gov/wma/nhgf/stac/-/raw/main/catalog + file_types: catalog.json + + test-data: + type: stac-collection + title: pygeoapi test data + description: pygeoapi test data + keywords: + - poi + - portugal + links: + - type: text/html + rel: canonical + title: information + href: https://github.com/geopython/pygeoapi/tree/master/tests/data + hreflang: en-US + extents: + spatial: + bbox: [-180,-90,180,90] + crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84 + providers: + - type: stac + name: FileSystem + data: tests/data + file_types: + - .gpkg + - .sqlite + - .csv + - .grib2 + - .tif + - .shp + + canada-metadata: + type: stac-collection + title: + en: Open Canada sample data + fr: Exemple de donn\u00e9es Canada Ouvert + description: + en: Sample metadata records from open.canada.ca + fr: Exemples d'enregistrements de m\u00e9tadonn\u00e9es sur ouvert.canada.ca + keywords: + en: + - canada + - open data + fr: + - canada + - donn\u00e9es ouvertes + links: + - type: text/html + rel: canonical + title: information + href: https://open.canada.ca/en/open-data + hreflang: en-CA + - type: text/html + rel: alternate + title: informations + href: https://ouvert.canada.ca/fr/donnees-ouvertes + hreflang: fr-CA + extents: + spatial: + bbox: [-180,-90,180,90] + crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84 + providers: + - type: record + name: TinyDBCatalogue + data: tests/data/open.canada.ca/sample-records.tinydb + id_field: externalId + time_field: created + title_field: title